diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml index 9fa69ff1a3..3285271265 100644 --- a/.github/workflows/postgres.yml +++ b/.github/workflows/postgres.yml @@ -98,10 +98,14 @@ jobs: - name: 'Yarn Install' run: | yarn install - + # Setup the database - name: 'Setup Test DB' run: bundle exec rake db:setup RAILS_ENV=test + + # Migrate + - name: 'Migrate DB' + run: bundle exec rake db:migrate RAILS_ENV=test # Compile the assets - name: 'Compile Assets' diff --git a/Gemfile b/Gemfile index 0e80988c9a..16131f2edc 100644 --- a/Gemfile +++ b/Gemfile @@ -1,119 +1,140 @@ -source 'https://rubygems.org' +# frozen_string_literal: true -ruby '>= 2.4.0' +source "https://rubygems.org" + +ruby ">= 2.4.0" # ------------------------------------------------ # RAILS -# Full-stack web application framework. (http://www.rubyonrails.org) # Full-stack web application framework. (http://rubyonrails.org) -gem 'rails', '~> 4.2.11.1' +gem "rails", "~> 4.2.11.1" # TODO: See if pegging gems is still necessary after migrating to Rails 5 -gem 'sprockets', '~> 3.2' +gem "sprockets", "~> 3.2" -# Rake is a Make-like program implemented in Ruby (https://github.com/ruby/rake) +# Rake is a Make-like program implemented in Ruby +# (https://github.com/ruby/rake) gem "rake" -# Tools for creating, working with, and running Rails applications. (http://www.rubyonrails.org) -# Tools for creating, working with, and running Rails applications. (http://rubyonrails.org) -gem 'railties' +# Tools for creating, working with, and running Rails applications. +# (http://www.rubyonrails.org) +gem "railties" # GEMS ADDED TO HELP HANDLE RAILS MIGRATION FROM 3.x to 4.2 # THESE GEMS HELP SUPPORT DEPRACATED FUNCTIONALITY AND WILL LOSE SUPPORT IN # FUTURE VERSIONS WE SHOULD CONSIDER BRINGING THE CODE UP TO DATE INSTEAD -# A set of Rails responders to dry up your application (http://github.com/plataformatec/responders) -gem 'responders', '~> 2.0' +# A set of Rails responders to dry up your application +# (http://github.com/plataformatec/responders) +gem "responders", "~> 2.0" group :rollbar, optional: true do - gem 'rollbar' + # Rollbar-gem is the SDK for Ruby apps and includes support for apps using + # Rails, Sinatra, Rack, plain Ruby, and other frameworks. + gem "rollbar" end # ------------------------------------------------ # DATABASE/SERVER group :mysql do - # A simple, fast Mysql library for Ruby, binding to libmysql (http://github.com/brianmario/mysql2) - # A simple, fast Mysql library for Ruby, binding to libmysql (https://github.com/brianmario/mysql2) - gem 'mysql2', '~> 0.4.10' + # A simple, fast Mysql library for Ruby, binding to libmysql + # (http://github.com/brianmario/mysql2) + gem "mysql2", "~> 0.4.10" end group :pgsql do # Pg is the Ruby interface to the {PostgreSQL # RDBMS}[http://www.postgresql.org/](https://bitbucket.org/ged/ruby-pg) - # Pg is the Ruby interface to the {PostgreSQL RDBMS}[http://www.postgresql.org/] (https://bitbucket.org/ged/ruby-pg) - gem 'pg', '~> 0.19.0' + # Pg is the Ruby interface to the {PostgreSQL RDBMS} + # [http://www.postgresql.org/] (https://bitbucket.org/ged/ruby-pg) + gem "pg", "~> 0.19.0" end group :thin do # A thin and fast web server (http://code.macournoyer.com/thin/) - gem 'thin' + gem "thin" end group :puma do - # Puma is a simple, fast, threaded, and highly concurrent HTTP 1.1 server for Ruby/Rack applications (http://puma.io) - gem 'puma', group: :puma + # Puma is a simple, fast, threaded, and highly concurrent HTTP 1.1 server + # for Ruby/Rack applications (http://puma.io) + gem "puma", group: :puma end # Bit fields for ActiveRecord (https://github.com/pboling/flag_shih_tzu) -gem 'flag_shih_tzu', '~> 0.3.23' # Allows for bitfields in activereccord +gem "flag_shih_tzu", "~> 0.3.23" # Allows for bitfields in activereccord # Pinned here because we're using a private method in Role.rb # if this gets updated, check this method still exists # ------------------------------------------------ # JSON DSL - USED BY API -# Create JSON structures via a Builder-style DSL (https://github.com/rails/jbuilder) -gem 'jbuilder', '~> 2.6.0' +# Create JSON structures via a Builder-style DSL +# (https://github.com/rails/jbuilder) +gem "jbuilder", "~> 2.6.0" # ------------------------------------------------ # USERS # devise for user authentication -# Flexible authentication solution for Rails with Warden (https://github.com/plataformatec/devise) -gem 'devise', ">= 4.7.1" +# Flexible authentication solution for Rails with Warden +# (https://github.com/plataformatec/devise) +gem "devise", ">= 4.7.1" -# An invitation strategy for Devise (https://github.com/scambra/devise_invitable) -gem 'devise_invitable' +# An invitation strategy for Devise +# (https://github.com/scambra/devise_invitable) +gem "devise_invitable" -# A generalized Rack framework for multiple-provider authentication. (https://github.com/omniauth/omniauth) -gem 'omniauth' +# A generalized Rack framework for multiple-provider authentication. +# (https://github.com/omniauth/omniauth) +gem "omniauth" # OmniAuth Shibboleth strategies for OmniAuth 1.x -gem 'omniauth-shibboleth' +gem "omniauth-shibboleth" -# ORCID OAuth 2.0 Strategy for OmniAuth 1.0 (https://github.com/datacite/omniauth-orcid) -gem 'omniauth-orcid' +# ORCID OAuth 2.0 Strategy for OmniAuth 1.0 +# (https://github.com/datacite/omniauth-orcid) +gem "omniauth-orcid" -# This gem provides a mitigation against CVE-2015-9284 (Cross-Site Request Forgery on the request phase -# when using OmniAuth gem with a Ruby on Rails application) by implementing a CSRF token verifier that -# directly uses ActionController::RequestForgeryProtection code from Rails. +# This gem provides a mitigation against CVE-2015-9284 (Cross-Site Request +# Forgery on the request phase when using OmniAuth gem with a Ruby on Rails +# application) by implementing a CSRF token verifier that directly uses +# ActionController::RequestForgeryProtection code from Rails. # https://nvd.nist.gov/vuln/detail/CVE-2015-9284 gem "omniauth-rails_csrf_protection" -# Pure Ruby implementation of Array#dig and Hash#dig for Ruby < 2.3. (https://github.com/Invoca/ruby_dig) -gem 'ruby_dig' # for omniauth-orcid +# A ruby implementation of the RFC 7519 OAuth JSON Web Token (JWT) standard. +gem 'jwt' + +# Pure Ruby implementation of Array#dig and Hash#dig for Ruby < 2.3. +# (https://github.com/Invoca/ruby_dig) +gem "ruby_dig" # for omniauth-orcid # Gems for repository integration # OO authorization for Rails (https://github.com/elabs/pundit) # OO authorization for Rails (https://github.com/varvet/pundit) -gem 'pundit' +gem "pundit" # ------------------------------------------------ # SETTINGS FOR TEMPLATES AND PLANS (FONTS, COLUMN LAYOUTS, ETC) -# Ruby gem to handle settings for ActiveRecord instances by storing them as serialized Hash in a separate database table. Namespaces and defaults included. (https://github.com/ledermann/rails-settings) -gem 'ledermann-rails-settings' +# Ruby gem to handle settings for ActiveRecord instances by storing them as +# serialized Hash in a separate database table. Namespaces and defaults +# included. (https://github.com/ledermann/rails-settings) +gem "ledermann-rails-settings" # ------------------------------------------------ # VIEWS -# Gem providing simple Contact Us functionality with a Rails 3+ Engine. (https://github.com/jdutil/contact_us) -gem 'contact_us' # COULD BE EASILY REPLACED WITH OUR OWN CODE +# Gem providing simple Contact Us functionality with a Rails 3+ Engine. +# (https://github.com/jdutil/contact_us) +gem "contact_us" # COULD BE EASILY REPLACED WITH OUR OWN CODE # Helpers for the reCAPTCHA API (http://github.com/ambethia/recaptcha) -gem 'recaptcha' +gem "recaptcha" -# Ideal gem for handling attachments in Rails, Sinatra and Rack applications. (http://github.com/markevans/dragonfly) -gem 'dragonfly' +# Ideal gem for handling attachments in Rails, Sinatra and Rack applications. +# (http://github.com/markevans/dragonfly) +gem "dragonfly" # ------------------------------------------------ # Start DMPTool customization @@ -129,29 +150,33 @@ group :aws do end - -# bootstrap-sass is a Sass-powered version of Bootstrap 3, ready to drop right into your Sass powered applications. (https://github.com/twbs/bootstrap-sass) -gem 'bootstrap-sass', '~> 3.4.1' +# bootstrap-sass is a Sass-powered version of Bootstrap 3, ready to drop +# right into your Sass powered applications. +# (https://github.com/twbs/bootstrap-sass) +gem "bootstrap-sass", "~> 3.4.1" # This is required for Font-Awesome, but not used as the main sass compiler -# Sass adapter for the Rails asset pipeline. (https://github.com/rails/sass-rails) +# Sass adapter for the Rails asset pipeline. +# (https://github.com/rails/sass-rails) gem "sass-rails", require: false # Integrate SassC-Ruby into Rails. (https://github.com/sass/sassc-rails) gem "sassc-rails" # Font-Awesome SASS (https://github.com/FortAwesome/font-awesome-sass) -gem 'font-awesome-sass', '~> 4.2.0' +gem "font-awesome-sass", "~> 4.2.0" -# Use webpack to manage app-like JavaScript modules in Rails (https://github.com/rails/webpacker) -gem 'webpacker', '~> 3.5' +# Use webpack to manage app-like JavaScript modules in Rails +# (https://github.com/rails/webpacker) +gem "webpacker", "~> 3.5" -# Parse CSS and add vendor prefixes to CSS rules using values from the Can I Use website. (https://github.com/ai/autoprefixer-rails) +# Parse CSS and add vendor prefixes to CSS rules using values from the Can +# I Use website. (https://github.com/ai/autoprefixer-rails) gem "autoprefixer-rails" # Minimal embedded v8 for Ruby (https://github.com/discourse/mini_racer) -gem 'mini_racer' +gem "mini_racer" # ------------------------------------------------ # EXPORTING @@ -166,34 +191,45 @@ gem 'wkhtmltopdf-binary', '0.12.4' # End DMPTool customization # ------------------------------------------------ -# PDF generator (from HTML) gem for Ruby on Rails (https://github.com/mileszs/wicked_pdf) -gem 'wicked_pdf', '~> 1.1.0' +# PDF generator (from HTML) gem for Ruby on Rails +# (https://github.com/mileszs/wicked_pdf) +gem "wicked_pdf", "~> 1.1.0" -# This simple gem allows you to create MS Word docx documents from simple html documents. This makes it easy to create dynamic reports and forms that can be downloaded by your users as simple MS Word docx files. (http://github.com/karnov/htmltoword) -gem 'htmltoword', '1.1.0' +# This simple gem allows you to create MS Word docx documents from simple +# html documents. This makes it easy to create dynamic reports and forms +# that can be downloaded by your users as simple MS Word docx files. +# (http://github.com/karnov/htmltoword) +gem "htmltoword", "1.1.0" # Filename sanitization for Ruby. This is useful when you generate filenames for downloads from user input gem 'zaru' # ------------------------------------------------ # INTERNATIONALIZATION -# Simple FastGettext Rails integration. (http://github.com/grosser/gettext_i18n_rails) -gem 'gettext_i18n_rails' +# Simple FastGettext Rails integration. +# (http://github.com/grosser/gettext_i18n_rails) +gem "gettext_i18n_rails" -# Extends gettext_i18n_rails making your .po files available to client side javascript as JSON (https://github.com/webhippie/gettext_i18n_rails_js) -gem 'gettext_i18n_rails_js' +# Extends gettext_i18n_rails making your .po files available to client side +# javascript as JSON (https://github.com/webhippie/gettext_i18n_rails_js) +gem "gettext_i18n_rails_js" -# Gettext is a pure Ruby libary and tools to localize messages. (http://ruby-gettext.github.com/) -gem 'gettext', require: false, group: :development +# Gettext is a pure Ruby libary and tools to localize messages. +# (http://ruby-gettext.github.com/) +gem "gettext", require: false, group: :development # ------------------------------------------------ # PAGINATION -# A pagination engine plugin for Rails 4+ and other modern frameworks (https://github.com/kaminari/kaminari) -gem 'kaminari' +# A pagination engine plugin for Rails 4+ and other modern frameworks +# (https://github.com/kaminari/kaminari) +gem "kaminari" -gem 'api-pagination' +# Paginate in your headers, not in your response body. This follows the +# proposed RFC-8288 standard for Web linking. +gem "api-pagination" -# Following best practices from http://12factor.net run a maintainable, clean, and scalable app on Rails (https://github.com/heroku/rails_12factor) +# Following best practices from http://12factor.net run a maintainable, +# clean, and scalable app on Rails (https://github.com/heroku/rails_12factor) gem "rails_12factor", group: [:production] # Autoload dotenv in Rails. (https://github.com/bkeepers/dotenv) @@ -205,23 +241,27 @@ gem 'activerecord-session_store' # UTILITIES gem 'parallel' +gem 'httparty' + # ------------------------------------------------ # ENVIRONMENT SPECIFIC DEPENDENCIES group :development, :test do - # Ruby fast debugger - base + CLI (http://github.com/deivid-rodriguez/byebug) + # Ruby fast debugger - base + CLI + # (http://github.com/deivid-rodriguez/byebug) gem "byebug" # RSpec for Rails (https://github.com/rspec/rspec-rails) gem "rspec-rails" - # factory_bot_rails provides integration between factory_bot and rails 3 or newer (http://github.com/thoughtbot/factory_bot_rails) - # factory_bot_rails provides integration between factory_bot and rails 3 or newer (https://github.com/thoughtbot/factory_bot_rails) + # factory_bot_rails provides integration between factory_bot and rails 3 + # or newer (http://github.com/thoughtbot/factory_bot_rails) gem "factory_bot_rails" # Easily generate fake data (https://github.com/stympy/faker) gem "faker" - # the instafailing RSpec progress bar formatter (https://github.com/thekompanee/fuubar) + # the instafailing RSpec progress bar formatter + # (https://github.com/thekompanee/fuubar) gem "fuubar" # Guard keeps an eye on your file modifications (http://guardgem.org) @@ -236,16 +276,21 @@ group :development, :test do end group :test do - # Library for stubbing HTTP requests in Ruby. (http://github.com/bblimke/webmock) - gem 'webmock' + # Library for stubbing HTTP requests in Ruby. + # (http://github.com/bblimke/webmock) + gem "webmock" - # Code coverage for Ruby 1.9+ with a powerful configuration library and automatic merging of coverage across test suites (http://github.com/colszowka/simplecov) - gem 'simplecov', require: false + # Code coverage for Ruby 1.9+ with a powerful configuration library and + # automatic merging of coverage across test suites + # (http://github.com/colszowka/simplecov) + gem "simplecov", require: false - # Strategies for cleaning databases. Can be used to ensure a clean state for testing. (http://github.com/DatabaseCleaner/database_cleaner) - gem 'database_cleaner', require: false + # Strategies for cleaning databases. Can be used to ensure a clean state + # for testing. (http://github.com/DatabaseCleaner/database_cleaner) + gem "database_cleaner", require: false - # Making tests easy on the fingers and eyes (https://github.com/thoughtbot/shoulda) + # Making tests easy on the fingers and eyes + # (https://github.com/thoughtbot/shoulda) gem "shoulda", require: false # Mocking and stubbing library (http://gofreerange.com/mocha/docs) @@ -254,17 +299,29 @@ group :test do # Rails application preloader (https://github.com/rails/spring) gem "spring" - # rspec command for spring (https://github.com/jonleighton/spring-commands-rspec) + # rspec command for spring + # (https://github.com/jonleighton/spring-commands-rspec) gem "spring-commands-rspec" - # Capybara aims to simplify the process of integration testing Rack applications, such as Rails, Sinatra or Merb (https://github.com/teamcapybara/capybara) + # Capybara aims to simplify the process of integration testing Rack + # applications, such as Rails, Sinatra or Merb + # (https://github.com/teamcapybara/capybara) gem "capybara" - # Automatically create snapshots when Cucumber steps fail with Capybara and Rails (http://github.com/mattheworiordan/capybara-screenshot) + # Automatically create snapshots when Cucumber steps fail with Capybara + # and Rails (http://github.com/mattheworiordan/capybara-screenshot) gem "capybara-screenshot" - gem 'webdrivers', '~> 3.0' + # Browser integration tests are expensive. We can mock external requests + # in our tests, but once a browser is involved, we lose control. + gem "capybara-webmock" + # Run Selenium tests more easily with automatic installation and updates + # for all supported webdrivers. + gem "webdrivers", "~> 3.0" + + # RSpec::CollectionMatchers lets you express expected outcomes on + # collections of an object in an example. gem "rspec-collection_matchers" # A set of RSpec matchers for testing Pundit authorisation policies. @@ -272,51 +329,60 @@ group :test do end group :ci, :development do - # Security vulnerability scanner for Ruby on Rails. (http://brakemanscanner.org) + # Security vulnerability scanner for Ruby on Rails. + # (http://brakemanscanner.org) gem "brakeman" - # Automatic Ruby code style checking tool. (https://github.com/rubocop-hq/rubocop) - # Rubocop style checks for DMP Roadmap projects. (https://github.com/DMPRoadmap/rubocop-DMP_Roadmap) + # Automatic Ruby code style checking tool. + # (https://github.com/rubocop-hq/rubocop) + # Rubocop style checks for DMP Roadmap projects. + # (https://github.com/DMPRoadmap/rubocop-DMP_Roadmap) gem "rubocop-dmp_roadmap", ">= 1.1.0" - # Helper gem to require bundler-audit (http://github.com/stewartmckee/bundle-audit) + # Helper gem to require bundler-audit + # (http://github.com/stewartmckee/bundle-audit) gem "bundle-audit" end group :development do - - # Simple Progress Bar for output to a terminal (http://github.com/paul/progress_bar) + # Simple Progress Bar for output to a terminal + # (http://github.com/paul/progress_bar) gem "progress_bar", require: false # A collection of text algorithms (http://github.com/threedaymonk/text) gem "text", require: false - # Better error page for Rails and other Rack apps (https://github.com/charliesome/better_errors) - # Better error page for Rails and other Rack apps (https://github.com/BetterErrors/better_errors) + # Better error page for Rails and other Rack apps + # (https://github.com/charliesome/better_errors) gem "better_errors" - # Retrieve the binding of a method's caller. Can also retrieve bindings even further up the stack. (http://github.com/banister/binding_of_caller) + # Retrieve the binding of a method's caller. Can also retrieve bindings + # even further up the stack. (http://github.com/banister/binding_of_caller) gem "binding_of_caller" - # A debugging tool for your Ruby on Rails applications. (https://github.com/rails/web-console) - gem 'web-console' + # A debugging tool for your Ruby on Rails applications. + # (https://github.com/rails/web-console) + gem "web-console" # Profiles loading speed for rack applications. (http://miniprofiler.com) - gem 'rack-mini-profiler' + gem "rack-mini-profiler" - # Annotates Rails Models, routes, fixtures, and others based on the database schema. (http://github.com/ctran/annotate_models) + # Annotates Rails Models, routes, fixtures, and others based on the + # database schema. (http://github.com/ctran/annotate_models) gem "annotate" - # Add comments to your Gemfile with each dependency's description. (https://github.com/ivantsepp/annotate_gem) + # Add comments to your Gemfile with each dependency's description. + # (https://github.com/ivantsepp/annotate_gem) gem "annotate_gem" - # help to kill N+1 queries and unused eager loading. (https://github.com/flyerhzm/bullet) + # help to kill N+1 queries and unused eager loading. + # (https://github.com/flyerhzm/bullet) gem "bullet" - # Documentation tool for consistent and usable documentation in Ruby. (http://yardoc.org) + # Documentation tool for consistent and usable documentation in Ruby. + # (http://yardoc.org) gem "yard" # TomDoc for YARD (http://rubyworks.github.com/yard-tomdoc) gem "yard-tomdoc" - end diff --git a/Gemfile.lock b/Gemfile.lock index b330e6beb9..e3051cf7be 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,34 +1,34 @@ GEM remote: https://rubygems.org/ specs: - actionmailer (4.2.11.1) - actionpack (= 4.2.11.1) - actionview (= 4.2.11.1) - activejob (= 4.2.11.1) + actionmailer (4.2.11.3) + actionpack (= 4.2.11.3) + actionview (= 4.2.11.3) + activejob (= 4.2.11.3) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 1.0, >= 1.0.5) - actionpack (4.2.11.1) - actionview (= 4.2.11.1) - activesupport (= 4.2.11.1) + actionpack (4.2.11.3) + actionview (= 4.2.11.3) + activesupport (= 4.2.11.3) rack (~> 1.6) rack-test (~> 0.6.2) rails-dom-testing (~> 1.0, >= 1.0.5) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (4.2.11.1) - activesupport (= 4.2.11.1) + actionview (4.2.11.3) + activesupport (= 4.2.11.3) builder (~> 3.1) erubis (~> 2.7.0) rails-dom-testing (~> 1.0, >= 1.0.5) rails-html-sanitizer (~> 1.0, >= 1.0.3) - activejob (4.2.11.1) - activesupport (= 4.2.11.1) + activejob (4.2.11.3) + activesupport (= 4.2.11.3) globalid (>= 0.3.0) - activemodel (4.2.11.1) - activesupport (= 4.2.11.1) + activemodel (4.2.11.3) + activesupport (= 4.2.11.3) builder (~> 3.1) - activerecord (4.2.11.1) - activemodel (= 4.2.11.1) - activesupport (= 4.2.11.1) + activerecord (4.2.11.3) + activemodel (= 4.2.11.3) + activesupport (= 4.2.11.3) arel (~> 6.0) activerecord-session_store (1.1.3) actionpack (>= 4.0) @@ -36,7 +36,7 @@ GEM multi_json (~> 1.11, >= 1.11.2) rack (>= 1.5.2, < 3) railties (>= 4.0) - activesupport (4.2.11.1) + activesupport (4.2.11.3) i18n (~> 0.7) minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) @@ -52,11 +52,11 @@ GEM bundler (>= 1.1) api-pagination (4.8.2) arel (6.0.4) - ast (2.4.0) + ast (2.4.1) autoprefixer-rails (9.7.6) execjs bcrypt (3.1.13) - better_errors (2.6.0) + better_errors (2.7.1) coderay (>= 1.0.0) erubi (>= 1.0.0) rack (>= 0.9.0) @@ -65,28 +65,27 @@ GEM bootstrap-sass (3.4.1) autoprefixer-rails (>= 5.2.1) sassc (>= 2.0.0) - brakeman (4.8.1) + brakeman (4.8.2) builder (3.2.4) bullet (6.1.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundle-audit (0.1.0) bundler-audit - bundler-audit (0.6.1) - bundler (>= 1.2.0, < 3) - thor (~> 0.18) - byebug (11.1.1) - capistrano (3.13.0) + bundler-audit (0.3.0) + bundler (~> 1.2) + byebug (11.1.3) + capistrano (3.14.1) airbrussh (>= 1.0.0) i18n rake (>= 10.0.0) sshkit (>= 1.9.0) capistrano-bundler (1.6.0) capistrano (~> 3.1) - capistrano-rails (1.4.0) + capistrano-rails (1.5.0) capistrano (~> 3.1) capistrano-bundler (~> 1.1) - capybara (3.32.1) + capybara (3.32.2) addressable mini_mime (>= 0.1.3) nokogiri (~> 1.8) @@ -97,8 +96,13 @@ GEM capybara-screenshot (1.0.24) capybara (>= 1.0, < 4) launchy + capybara-webmock (0.5.5) + capybara (>= 2.4, < 4) + rack (>= 1.4) + rack-proxy (>= 0.6.0) + selenium-webdriver (~> 3.0) childprocess (3.0.0) - coderay (1.1.2) + coderay (1.1.3) concurrent-ruby (1.1.6) contact_us (1.2.0) rails (>= 4.2.0) @@ -106,9 +110,9 @@ GEM safe_yaml (~> 1.0.0) crass (1.0.6) daemons (1.3.1) - database_cleaner (1.8.4) + database_cleaner (1.8.5) debug_inspector (0.0.3) - devise (4.7.1) + devise (4.7.2) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) @@ -135,19 +139,19 @@ GEM eventmachine (1.2.7) excon (0.73.0) execjs (2.7.0) - factory_bot (5.1.2) + factory_bot (5.2.0) activesupport (>= 4.2.0) - factory_bot_rails (5.1.1) - factory_bot (~> 5.1.0) + factory_bot_rails (5.2.0) + factory_bot (~> 5.2.0) railties (>= 4.2.0) faker (2.2.1) i18n (>= 0.8) faraday (1.0.1) multipart-post (>= 1.2, < 3) - fast_gettext (2.0.2) - ffi (1.12.2) + fast_gettext (2.0.3) + ffi (1.13.1) flag_shih_tzu (0.3.23) - fog-aws (3.6.2) + fog-aws (3.6.5) fog-core (~> 2.1) fog-json (~> 1.1) fog-xml (~> 0.1) @@ -202,27 +206,29 @@ GEM actionpack nokogiri rubyzip (>= 1.0) + httparty (0.18.1) + mime-types (~> 3.0) + multi_xml (>= 0.5.2) i18n (0.9.5) concurrent-ruby (~> 1.0) ipaddress (0.8.3) - jaro_winkler (1.5.4) jbuilder (2.6.4) activesupport (>= 3.0.0) multi_json (>= 1.2) json (2.3.0) jwt (2.2.1) - kaminari (1.2.0) + kaminari (1.2.1) activesupport (>= 4.1.0) - kaminari-actionview (= 1.2.0) - kaminari-activerecord (= 1.2.0) - kaminari-core (= 1.2.0) - kaminari-actionview (1.2.0) + kaminari-actionview (= 1.2.1) + kaminari-activerecord (= 1.2.1) + kaminari-core (= 1.2.1) + kaminari-actionview (1.2.1) actionview - kaminari-core (= 1.2.0) - kaminari-activerecord (1.2.0) + kaminari-core (= 1.2.1) + kaminari-activerecord (1.2.1) activerecord - kaminari-core (= 1.2.0) - kaminari-core (1.2.0) + kaminari-core (= 1.2.1) + kaminari-core (1.2.1) launchy (2.5.0) addressable (~> 2.7) ledermann-rails-settings (2.5.0) @@ -235,27 +241,27 @@ GEM loofah (2.5.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) - lumberjack (1.2.4) + lumberjack (1.2.5) mail (2.7.1) mini_mime (>= 0.1.1) method_source (1.0.0) mime-types (3.3.1) mime-types-data (~> 3.2015) - mime-types-data (3.2019.1009) + mime-types-data (3.2020.0512) mini_mime (1.0.2) mini_portile2 (2.4.0) - mini_racer (0.2.9) - libv8 (>= 6.9.411) - minitest (5.14.0) + mini_racer (0.2.14) + libv8 (> 7.3) + minitest (5.14.1) mocha (1.11.2) multi_json (1.14.1) multi_xml (0.6.0) multipart-post (2.1.1) mysql2 (0.4.10) nenv (0.3.0) - net-scp (2.0.0) - net-ssh (>= 2.6.5, < 6.0.0) - net-ssh (5.2.0) + net-scp (3.0.0) + net-ssh (>= 2.6.5, < 7.0.0) + net-ssh (6.1.0) nio4r (2.5.2) nokogiri (1.10.9) mini_portile2 (~> 2.4.0) @@ -285,7 +291,7 @@ GEM options (2.3.2) orm_adapter (0.5.0) parallel (1.19.1) - parser (2.7.1.0) + parser (2.7.1.3) ast (~> 2.4.0) pg (0.19.0) po_to_json (1.0.1) @@ -293,33 +299,33 @@ GEM progress_bar (1.3.1) highline (>= 1.6, < 3) options (~> 2.3.0) - pry (0.13.0) + pry (0.13.1) coderay (~> 1.1) method_source (~> 1.0) - public_suffix (4.0.4) - puma (4.3.3) + public_suffix (4.0.5) + puma (4.3.5) nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) pundit-matchers (1.6.0) rspec-rails (>= 3.0.0) rack (1.6.13) - rack-mini-profiler (2.0.1) + rack-mini-profiler (2.0.2) rack (>= 1.2.0) rack-proxy (0.6.5) rack rack-test (0.6.3) rack (>= 1.0) - rails (4.2.11.1) - actionmailer (= 4.2.11.1) - actionpack (= 4.2.11.1) - actionview (= 4.2.11.1) - activejob (= 4.2.11.1) - activemodel (= 4.2.11.1) - activerecord (= 4.2.11.1) - activesupport (= 4.2.11.1) + rails (4.2.11.3) + actionmailer (= 4.2.11.3) + actionpack (= 4.2.11.3) + actionview (= 4.2.11.3) + activejob (= 4.2.11.3) + activemodel (= 4.2.11.3) + activerecord (= 4.2.11.3) + activesupport (= 4.2.11.3) bundler (>= 1.3.0, < 2.0) - railties (= 4.2.11.1) + railties (= 4.2.11.3) sprockets-rails rails-deprecated_sanitizer (1.0.3) activesupport (>= 4.2.0.alpha) @@ -334,39 +340,39 @@ GEM rails_stdout_logging rails_serve_static_assets (0.0.5) rails_stdout_logging (0.0.5) - railties (4.2.11.1) - actionpack (= 4.2.11.1) - activesupport (= 4.2.11.1) + railties (4.2.11.3) + actionpack (= 4.2.11.3) + activesupport (= 4.2.11.3) rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) rainbow (3.0.0) rake (13.0.1) - rb-fsevent (0.10.3) + rb-fsevent (0.10.4) rb-inotify (0.10.1) ffi (~> 1.0) recaptcha (5.5.0) json - regexp_parser (1.7.0) + regexp_parser (1.7.1) responders (2.4.1) actionpack (>= 4.2.0, < 6.0) railties (>= 4.2.0, < 6.0) rexml (3.2.4) - rollbar (2.24.0) + rollbar (2.25.1) rspec (3.9.0) rspec-core (~> 3.9.0) rspec-expectations (~> 3.9.0) rspec-mocks (~> 3.9.0) rspec-collection_matchers (1.2.0) rspec-expectations (>= 2.99.0.beta1) - rspec-core (3.9.1) - rspec-support (~> 3.9.1) - rspec-expectations (3.9.1) + rspec-core (3.9.2) + rspec-support (~> 3.9.3) + rspec-expectations (3.9.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.9.0) rspec-mocks (3.9.1) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.9.0) - rspec-rails (4.0.0) + rspec-rails (4.0.1) actionpack (>= 4.2) activesupport (>= 4.2) railties (>= 4.2) @@ -374,31 +380,34 @@ GEM rspec-expectations (~> 3.9) rspec-mocks (~> 3.9) rspec-support (~> 3.9) - rspec-support (3.9.2) - rubocop (0.81.0) - jaro_winkler (~> 1.5.1) + rspec-support (3.9.3) + rubocop (0.85.1) parallel (~> 1.10) parser (>= 2.7.0.1) rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.7) rexml + rubocop-ast (>= 0.0.3) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 2.0) + rubocop-ast (0.0.3) + parser (>= 2.7.0.1) rubocop-dmp_roadmap (1.1.2) rubocop (>= 0.58.2) rubocop-rails_config (>= 0.2.2) rubocop-rspec (>= 1.27.0) - rubocop-performance (1.5.2) + rubocop-performance (1.6.1) rubocop (>= 0.71.0) - rubocop-rails (2.5.1) - activesupport + rubocop-rails (2.6.0) + activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 0.72.0) + rubocop (>= 0.82.0) rubocop-rails_config (0.9.1) railties (>= 3.0) rubocop (~> 0.77) rubocop-performance (~> 1.3) rubocop-rails (~> 2.0) - rubocop-rspec (1.38.1) + rubocop-rspec (1.40.0) rubocop (>= 0.68.1) ruby-progressbar (1.10.1) ruby_dig (0.0.2) @@ -411,7 +420,7 @@ GEM rb-inotify (~> 0.9, >= 0.9.7) sass-rails (6.0.0) sassc-rails (~> 2.1, >= 2.1.1) - sassc (2.2.1) + sassc (2.4.0) ffi (~> 1.9) sassc-rails (2.1.2) railties (>= 4.0.0) @@ -451,7 +460,7 @@ GEM daemons (~> 1.0, >= 1.0.9) eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) - thor (0.20.3) + thor (1.0.1) thread_safe (0.3.6) tilt (2.0.10) tomparse (0.4.2) @@ -481,7 +490,7 @@ GEM wkhtmltopdf-binary (0.12.4) xpath (3.2.0) nokogiri (~> 1.8) - yard (0.9.24) + yard (0.9.25) yard-tomdoc (0.7.1) tomparse (>= 0.4.0) yard @@ -507,6 +516,7 @@ DEPENDENCIES capistrano-rails capybara capybara-screenshot + capybara-webmock contact_us database_cleaner devise (>= 4.7.1) @@ -525,7 +535,9 @@ DEPENDENCIES guard guard-rspec htmltoword (= 1.1.0) + httparty jbuilder (~> 2.6.0) + jwt kaminari ledermann-rails-settings mini_racer diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 041bca18c8..e08c490fdd 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -2,7 +2,9 @@ @import "variables/*"; @debug "1 #{$navbar-branding-bg}"; +@import "bootstrap-sprockets"; @import "bootstrap"; +@import "vendor/bootstrap-select/sass/bootstrap-select.scss"; @debug "2 #{$navbar-branding-bg}"; @import "blocks/*"; @debug "3 #{$navbar-branding-bg}"; @@ -21,4 +23,4 @@ @import "dmptool/blocks/*"; @import "dmptool/utils/*"; /* ---------------------------------------- */ -/* END DMPTool customization */ \ No newline at end of file +/* END DMPTool customization */ diff --git a/app/assets/stylesheets/blocks/_autocomplete.scss b/app/assets/stylesheets/blocks/_autocomplete.scss new file mode 100644 index 0000000000..d788289242 --- /dev/null +++ b/app/assets/stylesheets/blocks/_autocomplete.scss @@ -0,0 +1,26 @@ +/* Controls the suggestion list of a JQuery autocomplete */ +ul.ui-menu { + width: 60%; + list-style: none; + background: $color-seccondary-background; + padding-left: 0; + + .ui-menu-item { + color: $color-seccondary-text; + border-bottom: 1px solid $color-border-default; + border-left: 1px solid $color-border-default; + border-right: 1px solid $color-border-default; + padding: 5px 10px 5px 10px; + + .ui-menu-item-wrapper { + background-color: inherit; + font-family: $font-family; + } + } + + .ui-menu-item:hover, + .ui-menu-item:focus { + color: $color-primary-text; + background-color: $color-primary-background; + } +} diff --git a/app/assets/stylesheets/blocks/_combobox.scss b/app/assets/stylesheets/blocks/_combobox.scss deleted file mode 100644 index 6c06c96585..0000000000 --- a/app/assets/stylesheets/blocks/_combobox.scss +++ /dev/null @@ -1,56 +0,0 @@ -/* JQuery Autocomplete Styling */ -/* ---------------------------------------------------- */ -.invisible { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; -} - -.combobox-container { - position: relative; -} - -.combobox-suggestions { - position: absolute; - left: 0; - width: 100%; - background: $color-seccondary-background; - z-index: 1100; -} -.combobox-suggestion { - color: $color-seccondary-text; - border-bottom: 1px solid $color-border-default; - border-left: 1px solid $color-border-default; - border-right: 1px solid $color-border-default; - padding: 5px 10px 5px 10px; - cursor: pointer; - text-align: left; -} -.combobox-suggestion:first-child { - border-top: 1px solid $color-border-default; -} -.combobox-suggestion:hover, -.combobox-suggestion:focus { - color: $color-primary-text; - background-color: $color-primary-background; -} - -.combobox-clear-button, .combobox-clear-button:hover, .combobox-clear-button:focus { - cursor: pointer; - display: inline; - position: absolute; - top: 6px; - right: 5px; - border: none; - background: transparent; - color: $color-seccondary-text; -} - -/* http://geektnt.com/how-to-remove-x-from-search-input-field-on-chrome-and-ie.html */ -.js-combobox[type=text]::-ms-clear { display: none; width: 0; height: 0; } -.js-combobox[type=text]::-ms-reveal { display: none; width: 0; height: 0; } diff --git a/app/assets/stylesheets/blocks/_display.scss b/app/assets/stylesheets/blocks/_display.scss new file mode 100644 index 0000000000..d946a88366 --- /dev/null +++ b/app/assets/stylesheets/blocks/_display.scss @@ -0,0 +1,11 @@ +.display-block { + display: block; +} + +.display-inline { + display: inline; +} + +.display-off { + display: none; +} \ No newline at end of file diff --git a/app/assets/stylesheets/blocks/_labels.scss b/app/assets/stylesheets/blocks/_labels.scss index 75e101f375..c2c99c85a7 100644 --- a/app/assets/stylesheets/blocks/_labels.scss +++ b/app/assets/stylesheets/blocks/_labels.scss @@ -9,3 +9,8 @@ .radio label { margin-right: $grid-gutter-width / 2; } + +/* Remove mandatory * from check_box_tag in Admin search */ +.form-inline span.red { + visibility:hidden; +} diff --git a/app/assets/stylesheets/blocks/_panels.scss b/app/assets/stylesheets/blocks/_panels.scss index 60b34d0f77..5dd89f789e 100644 --- a/app/assets/stylesheets/blocks/_panels.scss +++ b/app/assets/stylesheets/blocks/_panels.scss @@ -10,6 +10,11 @@ border-top-left-radius: 0px; } +.empty-section { + background-color: $color-muted-background !important; + cursor: 'auto' !important; +} + .panel-title a { display: block; padding: 8px $grid-gutter-width / 2; diff --git a/app/assets/stylesheets/blocks/_selectpicker.scss b/app/assets/stylesheets/blocks/_selectpicker.scss new file mode 100644 index 0000000000..d1a561e7c6 --- /dev/null +++ b/app/assets/stylesheets/blocks/_selectpicker.scss @@ -0,0 +1,8 @@ +.bootstrap-select { + 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/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 30c7d70b65..ad17030403 100644 --- a/app/controllers/answers_controller.rb +++ b/app/controllers/answers_controller.rb @@ -3,6 +3,7 @@ class AnswersController < ApplicationController respond_to :html + include ConditionsHelper # POST /answers/create_or_update def create_or_update @@ -87,8 +88,32 @@ def create_or_update @section = @plan.sections.find_by(id: @question.section_id) template = @section.phase.template + remove_list_after = remove_list(@plan) + + all_question_ids = @plan.questions.pluck(:id) + all_answers = @plan.answers + qn_data = { + to_show: all_question_ids - remove_list_after, + to_hide: remove_list_after + } + + section_data = [] + @plan.sections.each do |section| + next if section.number < @section.number + n_qs, n_ans = check_answered(section, qn_data[:to_show], all_answers) + this_section_info = { + sec_id: section.id, + no_qns: num_section_questions(@plan, section), + no_ans: num_section_answers(@plan, section) + } + section_data << this_section_info + end + + send_webhooks(current_user, @answer) # rubocop:disable Metrics/LineLength render json: { + "qn_data": qn_data, + "section_data": section_data, "question" => { "id" => @question.id, "answer_lock_version" => @answer.lock_version, @@ -111,13 +136,6 @@ def create_or_update answer: @answer }, formats: [:html]) }, - "section" => { - "id" => @section.id, - "progress" => render_to_string(partial: "/org_admin/sections/progress", locals: { - section: @section, - plan: @plan - }, formats: [:html]) - }, "plan" => { "id" => @plan.id, "progress" => render_to_string(partial: "plans/progress", locals: { @@ -130,6 +148,7 @@ def create_or_update end end + private def permitted_params permitted = params.require(:answer).permit(:id, :text, :plan_id, :user_id, @@ -151,4 +170,10 @@ def permitted_params permitted end + def check_answered(section, q_array, all_answers) + n_qs = section.questions.select{ |question| q_array.include?(question.id) }.length + n_ans = all_answers.select{ |ans| q_array.include?(ans.question.id) and ans.answered? }.length + [n_qs, n_ans] + end + end diff --git a/app/controllers/api/v0/departments_controller.rb b/app/controllers/api/v0/departments_controller.rb new file mode 100644 index 0000000000..eae270de4a --- /dev/null +++ b/app/controllers/api/v0/departments_controller.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +class Api::V0::DepartmentsController < Api::V0::BaseController + + before_action :authenticate + + ## + # Create a new department based on the information passed in JSON to the API + def create + unless Api::V0::DepartmentsPolicy.new(@user, nil).index? + raise Pundit::NotAuthorizedError + end + @department = Department.new(org: @user.org, + code: params[:code], + name: params[:name]) + if @department.save + redirect_to api_v0_departments_path + else + # the department did not save + self.headers["WWW-Authenticate"] = "Token realm=\"\"" + render json: _("Departments code and name must be unique"), status: 400 + end + end + + ## + # Lists the departments for the API user's organisation + def index + unless Api::V0::DepartmentsPolicy.new(@user, nil).index? + raise Pundit::NotAuthorizedError + end + @departments = @user.org.departments + end + + ## + # List the users for each department on the organisation + def users + unless Api::V0::DepartmentsPolicy.new(@user, nil).users? + raise Pundit::NotAuthorizedError + end + @users = @user.org.users.includes(:department) + end + + ## + # Assign the list of users to the passed department id + def assign_users + @department = Department.find(params[:id]) + + unless Api::V0::DepartmentsPolicy.new(@user, @department).assign_users? + raise Pundit::NotAuthorizedError + end + + assign_users_to(@department.id) + redirect_to users_api_v0_departments_path + end + + ## + # Remove departments from the list of users + def unassign_users + unless Api::V0::DepartmentsPolicy.new(@user, @department).assign_users? + raise Pudndit::NotAuthorizedError + end + + assign_users_to(nil) + redirect_to users_api_v0_departments_path + end + + private + + def assign_users_to(department_id) + params[:users].each do |email| + reassign = User.find_by(email: email) + # Currently the validation is that the user's org matches the API user's + # Not sure if this is possible to capture in pundit + unless @user.present? && @user.org == reassign&.org + raise Pundit::NotAuthorizedError, _("user #{email} was not found on your organisation") + end + + reassign.department_id = department_id + reasign.save! + end + end + +end diff --git a/app/controllers/api/v0/plans_controller.rb b/app/controllers/api/v0/plans_controller.rb index 8f8dae2d19..868e318572 100644 --- a/app/controllers/api/v0/plans_controller.rb +++ b/app/controllers/api/v0/plans_controller.rb @@ -27,20 +27,22 @@ def create # initialize the plan @plan = Plan.new - if plan_user.surname.blank? - @plan.principal_investigator = nil - else - @plan.principal_investigator = plan_user.anem(false) - end - @plan.data_contact = plan_user.email + # Attach the user as the PI and Data Contact + @plan.contributors << Contributor.new( + name: [plan_user.firstname, plan_user.surname].join(" "), + email: plan_user.email, + investigation: true, + data_curation: true + ) + # set funder name to template's org, or original template's org if @template.customization_of.nil? - @plan.funder_name = @template.org.name + @plan.funder_id = @template.org.id else - @plan.funder_name = Template.where( + @plan.funder_id = Template.where( family_id: @template.customization_of - ).first.org.name + ).first.org.id end @plan.template = @template @plan.title = params[:plan][:title] diff --git a/app/controllers/api/v1/authentication_controller.rb b/app/controllers/api/v1/authentication_controller.rb new file mode 100644 index 0000000000..a24040e147 --- /dev/null +++ b/app/controllers/api/v1/authentication_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Api + + module V1 + + # Accepts 2 types of authentication: + # + # Client Credentials: + # * NOTE: requires an entry in the `api_clients` table + # + # POST Body must include the following JSON: { + # grant_type: "client_credentials", + # client_id: "[api_client.client_id]", + # client_secret: "[api_client.client_secret]" + # } + # + # Authorization Code: + # * NOTE: requires a `users.api_token` and User must have permission! + # + # POST Body must includethe following JSON: { + # grant_type: "authorization_code", + # email: "[users.email]", + # code: "[users.api_token]" + # } + class AuthenticationController < BaseApiController + + respond_to :json + + skip_before_action :authorize_request, only: %i[authenticate] + + # POST /api/v1/authenticate + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def authenticate + body = request.body.read + json = JSON.parse(body) + auth_svc = Api::V1::Auth::Jwt::AuthenticationService.new(json: json) + @token = auth_svc.call + + if @token.present? + @expiration = auth_svc.expiration + @token_type = "Bearer" + render "/api/v1/token", status: :ok + else + render_error errors: auth_svc.errors, status: :unauthorized + end + rescue JSON::ParserError => e + Rails.logger.error "API V1 - authenticate: #{e.message}" + Rails.logger.error request.body.read + render_error errors: _("Missing or invalid JSON"), status: :bad_request + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + end + + end + +end diff --git a/app/controllers/api/v1/base_api_controller.rb b/app/controllers/api/v1/base_api_controller.rb new file mode 100644 index 0000000000..b1dbe9c675 --- /dev/null +++ b/app/controllers/api/v1/base_api_controller.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +module Api + + module V1 + + # Base API Controller + # rubocop:disable Metrics/ClassLength + class BaseApiController < ApplicationController + + # Skipping the standard Rails authenticity tokens passed in UI + skip_before_action :verify_authenticity_token + + respond_to :json + + # Verify the JWT + before_action :authorize_request, except: %i[heartbeat] + + # 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] + + attr_reader :client + + # GET /api/v1/heartbeat + def heartbeat + render "/api/v1/heartbeat", status: :ok + end + + protected + + def render_error(errors:, status:) + @payload = { errors: [errors] } + render "/api/v1/error", status: status and return + end + + private + + attr_accessor :json + + # ========================== + # CALLBACKS + # ========================== + def authorize_request + auth_svc = Api::V1::Auth::Jwt::AuthorizationService.new( + headers: request.headers + ) + @client = auth_svc.call + log_access if @client.present? + return true if @client.present? + + render_error(errors: auth_svc.errors, status: :unauthorized) + end + + # Set the generic application and caller variables used in all responses + def base_response_content + @application = ApplicationService.application_name + @caller = caller_name + 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 + # rubocop:disable Metrics/AbcSize + def parse_request + return false unless request.present? && request.body.present? + + begin + body = request.body.read + @json = JSON.parse(body).with_indifferent_access + rescue JSON::ParserError => e + Rails.logger.error "JSON Parser: #{e.message}" + Rails.logger.error request.body + render_error(errors: _("Invalid JSON format"), status: :bad_request) + false + end + end + # rubocop:enable Metrics/AbcSize + + # ========================== + + def log_access + obj = client + return false unless obj.present? + + obj.update(last_access: Time.now) if obj.is_a?(ApiClient) + obj.update(last_api_access: Time.now) if obj.is_a?(User) + true + end + + # Returns either the User name or the ApiClient name + def caller_name + obj = client + return request.remote_ip unless obj.present? + + obj.is_a?(User) ? obj.name(false) : obj.name + end + + def paginate_response(results:) + results = results.page(@page).per(@per_page) + @total_items = results.total_count + results + 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 + + # rubocop:disable Layout/LineLength + 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 + # rubocop:enable Layout/LineLength + + 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 + # rubocop:enable Metrics/ClassLength + + end + +end diff --git a/app/controllers/api/v1/plans_controller.rb b/app/controllers/api/v1/plans_controller.rb new file mode 100644 index 0000000000..66ddef646f --- /dev/null +++ b/app/controllers/api/v1/plans_controller.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class PlansController < BaseApiController + + respond_to :json + + # GET /api/v1/plans/:id + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity, Metrics/MethodLength + def show + plans = Plan.where(id: params[:id]).limit(1) + + if plans.any? + if client.is_a?(User) + # If the specified plan does not belong to the org or the owner's org + if plans.first.org_id != client.org_id && + plans.first.owner&.org_id != client.org_id + + # Kaminari pagination requires an ActiveRecord resultset :/ + plans = Plan.where(id: nil).limit(1) + end + + elsif client.is_a?(ApiClient) && plans.first.api_client_id != client.id && + !plans.first.publicly_visible? + # Kaminari pagination requires an ActiveRecord resultset :/ + plans = Plan.where(id: nil).limit(1) + end + + if plans.present? && plans.any? + @items = paginate_response(results: plans) + render "/api/v1/plans/index", status: :ok + else + render_error(errors: [_("Plan not found")], status: :not_found) + end + else + render_error(errors: [_("Plan not found")], status: :not_found) + end + end + + # POST /api/v1/plans + def create + dmp = @json.with_indifferent_access.fetch(:items, []).first.fetch(:dmp, {}) + + # If a dmp_id was passed in try to find it + if dmp[:dmp_id].present? && dmp[:dmp_id][:identifier].present? + scheme = IdentifierScheme.by_name(dmp[:dmp_id][:type]).first + dmp_id = Identifier.where(value: dmp[:dmp_id][:identifier], + identifier_scheme: scheme) + end + + # Skip if this is an existing DMP + if dmp_id.present? + render_error(errors: _("Plan already exists. Send an update instead."), + status: :bad_request) + else + # Time prior to JSON parser service call which will create the plan so + # we can stop the creation of duplicate plans below + now = (Time.now - 1.minute) + plan = Api::V1::Deserialization::Plan.deserialize!(json: dmp) + + if plan.present? + if plan.created_at.utc < now.utc + render_error(errors: _("Plan already exists. Send an update instead."), + status: :bad_request) + + else + # If the plan was generated by an ApiClient then associate them + # rubocop:disable Metrics/BlockNesting + plan.update(api_client_id: client.id) if client.is_a?(ApiClient) + # rubocop:enable Metrics/BlockNesting + assign_roles(plan: plan) + + # TODO: Remove this customization after the Hackathon + UserMailer.api_plan_creation(plan, plan.owner).deliver_now + + # Kaminari Pagination requires an ActiveRecord result set :/ + @items = paginate_response(results: Plan.where(id: plan.id)) + render "/api/v1/plans/index", status: :created + end + else + render_error(errors: [_("Invalid JSON")], status: :bad_request) + end + end + rescue JSON::ParserError + render_error(errors: [_("Invalid JSON")], status: :bad_request) + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity + # rubocop:enable Metrics/PerceivedComplexity, Metrics/MethodLength + + # GET /api/v1/plans + def index + # ALL can view: public + # ApiClient can view: anything from the API client + # User (non-admin) can view: any personal or organisationally_visible + # User (admin) can view: all from users of their organisation + plans = Api::V1::PlansPolicy::Scope.new(client, Plan).resolve + if plans.present? && plans.any? + @items = paginate_response(results: plans) + @minimal = true + render "api/v1/plans/index", status: :ok + else + render_error(errors: [_("No Plans found")], status: :not_found) + end + end + + private + + def dmp_params + params.require(:dmp).permit(plan_permitted_params).to_h + end + + # rubocop:disable Metrics/MethodLength + def assign_roles(plan:) + # Attach all of the authors and then invite them if necessary + owner = nil + plan.contributors.data_curation.each do |contributor| + user = contributor_to_user(contributor: contributor) + next unless user.present? + + # Attach the role + role = Role.new(user: user, plan: plan) + role.creator = true if contributor.data_curation? + # We only want one owner/creator so jusst use the 1st contributor + # which should be the contact in the JSON input + owner = contributor if contributor.data_curation? + role.administrator = true if contributor.data_curation? && + !contributor.present? + role.save + end + end + # rubocop:enable Metrics/MethodLength + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def contributor_to_user(contributor:) + identifiers = contributor.identifiers.map do |id| + { name: id.identifier_scheme&.name, value: id.value } + end + user = User.from_identifiers(array: identifiers) if identifiers.any? + user = User.find_by(email: contributor.email) unless user.present? + return user if user.present? + + # If the user was not found, invite them and attach any know identifiers + names = contributor.name.split + firstname = names.length > 1 ? names.first : nil + surname = names.length > 1 ? names.last : names.first + # user = User.invite!({ email: contributor.email, + # firstname: firstname, + # surname: surname, + # org: contributor.org }, client) + + # TODO: Remove this customization for Hackathon testing + 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 + ) + end + user + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + end + + end + +end diff --git a/app/controllers/api/v1/templates_controller.rb b/app/controllers/api/v1/templates_controller.rb new file mode 100644 index 0000000000..351997735e --- /dev/null +++ b/app/controllers/api/v1/templates_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class TemplatesController < BaseApiController + + respond_to :json + + # GET /api/v1/templates + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def index + # If this is a User and not an ApiClient include the Org's + # templates and customizations as well as the public ones + if client.is_a?(User) + # TODO: there are much cleaner - Railsish ways to do this in Rails 5+ + # combining the 2 (Public and Organizational) after the queries + # converts templates to an Array which is incompatible with + # Kaminari's pagination + where_clause = <<-SQL + (visibility = 0 AND org_id = ?) OR + (visibility = 1 AND customization_of IS NULL) + SQL + templates = Template.includes(org: :identifiers).joins(:org) + .published + .where(where_clause, client.org&.id) + .order(:title) + else + templates = Template.includes(org: :identifiers).joins(:org) + .published + .publicly_visible + .where(customization_of: nil) + .order(:title) + end + + templates = templates.order(:title) + @items = paginate_response(results: templates) + render "/api/v1/templates/index", status: :ok + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + end + + end + +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e1ac23741e..c0ae15f0d0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -64,16 +64,24 @@ def after_sign_in_path_for(resource) referer_path = URI(request.referer).path unless request.referer.nil? or nil # --------------------------------------------------------- # Start DMPTool Customization - # Added `users_ldap_username_path` and `get_started_path` to if statement below + # Added get_started_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.eql?(users_ldap_username_path) || referer_path.eql?(get_started_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 =~ /#{shibboleth_ds_path}\/[0-9]+/ + root_path + # --------------------------------------------------------- + # End DMPTool Customization + # --------------------------------------------------------- else request.referer end @@ -81,10 +89,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 =~ /#{shibboleth_ds_path}\/[0-9]+/ + root_path + # --------------------------------------------------------- + # End DMPTool Customization + # --------------------------------------------------------- else request.referer end @@ -131,13 +157,15 @@ def errors_for_display(obj) def obj_name_for_display(obj) display_name = { + ApiClient: _("API client"), ExportedPlan: _("plan"), GuidanceGroup: _("guidance group"), Note: _("comment"), Org: _("organisation"), Perm: _("permission"), Pref: _("preferences"), - User: obj == current_user ? _("profile") : _("user") + User: obj == current_user ? _("profile") : _("user"), + QuestionOption: _("question option") } if obj.respond_to?(:customization_of) && obj.send(:customization_of).present? display_name[:Template] = "customization" diff --git a/app/controllers/concerns/org_selectable.rb b/app/controllers/concerns/org_selectable.rb new file mode 100644 index 0000000000..aacee99884 --- /dev/null +++ b/app/controllers/concerns/org_selectable.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# Provides methods to handle the org_id hash returned to the controller +# for pages that use the Org selection autocomplete widget +module OrgSelectable + + extend ActiveSupport::Concern + + # rubocop:disable Metrics/BlockLength + included do + + private + + # Converts the incoming params_into an Org by either locating it + # via its id, identifier and/or name, or initializing a new one + def org_from_params(params_in:, allow_create: true) + params_in = params_in.with_indifferent_access + return nil unless params_in[:org_id].present? && + params_in[:org_id].is_a?(String) + + hash = org_hash_from_params(params_in: params_in) + return nil unless hash.present? + + org = OrgSelection::HashToOrgService.to_org(hash: hash, + allow_create: allow_create) + allow_create ? create_org(org: org, params_in: params_in) : org + end + + # Converts the incoming params_into an array of Identifiers + def identifiers_from_params(params_in:) + params_in = params_in.with_indifferent_access + return [] unless params_in[:org_id].present? && + params_in[:org_id].is_a?(String) + + hash = org_hash_from_params(params_in: params_in) + return [] unless hash.present? + + OrgSelection::HashToOrgService.to_identifiers(hash: hash) + end + + # Remove the extraneous Org Selector hidden fields so that they don't get + # passed on to any save methods + def remove_org_selection_params(params_in:) + params_in.delete(:org_id) + params_in.delete(:org_name) + params_in.delete(:org_sources) + params_in.delete(:org_crosswalk) + params_in + end + + # Just does a JSON parse of the org_id hash + def org_hash_from_params(params_in:) + JSON.parse(params_in[:org_id]).with_indifferent_access + rescue JSON::ParserError => e + Rails.logger.error "Unable to parse Org Selection JSON: #{e.message}" + Rails.logger.error params_in.inspect + {} + end + + # Saves the org if its a new record + 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 + end + org.reload + end + + end + # rubocop:enable Metrics/BlockLength + +end diff --git a/app/controllers/concerns/versionable.rb b/app/controllers/concerns/versionable.rb index 69cbd9a1aa..96efa6cf4a 100644 --- a/app/controllers/concerns/versionable.rb +++ b/app/controllers/concerns/versionable.rb @@ -111,7 +111,11 @@ def find_in_space(obj, search_space) relation = :questions when Question number = obj.question.number - relation = :annotations + if obj.is_a?(QuestionOption) + relation = :question_options + else + relation = :annotations + end else return nil end diff --git a/app/controllers/contributors_controller.rb b/app/controllers/contributors_controller.rb new file mode 100644 index 0000000000..805e1aad67 --- /dev/null +++ b/app/controllers/contributors_controller.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/ClassLength +class ContributorsController < ApplicationController + + include OrgSelectable + helper PaginableHelper + + before_action :fetch_plan + before_action :fetch_contributor, only: %i[edit update destroy] + after_action :verify_authorized + + # GET /plans/:plan_id/contributors + def index + authorize @plan + @contributors = @plan.contributors + end + + # GET /plans/:plan_id/contributors/new + def new + authorize @plan + default_org = @plan.org.present? ? @plan.org : current_user.org + @contributor = Contributor.new(plan: @plan, org: default_org) + end + + # GET /plans/:plan_id/contributors/:id/edit + def edit + authorize @plan + end + + # POST /plans/:plan_id/contributors + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def create + authorize @plan + args = translate_roles(hash: contributor_params) + args = process_org(hash: args) + args = process_orcid_for_create(hash: args) + args[:plan_id] = @plan.id + + @contributor = Contributor.new(args) + stash_orcid + + if @contributor.save + # Now that the model has been ssaved, go ahead and save the identifiers + save_orcid + + redirect_to plan_contributors_path(@plan), + notice: success_message(@contributor, _("added")) + else + flash[:alert] = failure_message(@contributor, _("add")) + render :new + end + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize + + # PUT /plans/:plan_id/contributors/:id + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def update + authorize @plan + args = translate_roles(hash: contributor_params) + args = process_org(hash: args) + args = process_orcid_for_update(hash: args) + + if @contributor.update(args) + redirect_to edit_plan_contributor_path(@plan, @contributor), + notice: success_message(@contributor, _("saved")) + else + flash.now[:alert] = failure_message(@contributor, _("save")) + render :edit + end + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize + + # DELETE /plans/:plan_id/contributors/:id + def destroy + authorize @plan + if @contributor.destroy + msg = success_message(@contributor, _("removed")) + redirect_to plan_contributors_path(@plan), notice: msg + else + flash.now[:alert] = failure_message(@contributor, _("remove")) + render :edit + end + end + + private + + def contributor_params + base_params = %i[name email phone org_id org_name org_crosswalk] + role_params = Contributor.new.all_roles + + params.require(:contributor).permit( + base_params, + role_params, + identifiers_attributes: %i[id identifier_scheme_id value attrs] + ) + end + + # Translate the check boxes values of "1" and "0" to true/false + def translate_roles(hash:) + roles = Contributor.new.all_roles + roles.each { |role| hash[role.to_sym] = hash[role.to_sym] == "1" } + hash + end + + # Convert the Org Hash into an Org object (creating it if necessary) + # and then remove all of the Org args + def process_org(hash:) + return hash unless hash.present? && hash[:org_id].present? + + org = org_from_params(params_in: hash, allow_create: true) + hash = remove_org_selection_params(params_in: hash) + return hash unless org.present? + + hash[:org_id] = org.id + hash + end + + # When creating, just remove the ORCID if it was left blank + def process_orcid_for_create(hash:) + return hash unless hash[:identifiers_attributes].present? + + id_hash = hash[:identifiers_attributes][:"0"] + return hash unless id_hash[:value].blank? + + hash.delete(:identifiers_attributes) + hash + end + + # When updating, destroy the ORCID if it was blanked out on form + def process_orcid_for_update(hash:) + return hash unless hash[:identifiers_attributes].present? + + id_hash = hash[:identifiers_attributes][:"0"] + return hash unless id_hash[:value].blank? + + existing = @contributor.identifier_for_scheme(scheme: "orcid") + existing.destroy if existing.present? + hash.delete(:identifiers_attributes) + hash + end + + # ============= + # = Callbacks = + # ============= + def fetch_plan + @plan = Plan.includes(:contributors).find_by(id: params[:plan_id]) + return true if @plan.present? + + redirect_to root_path, alert: _("plan not found") + end + + def fetch_contributor + @contributor = Contributor.find_by(id: params[:id]) + return true if @contributor.present? && + @plan.contributors.include?(@contributor) + + redirect_to plan_contributors_path, alert: _("contributor not found") + end + + # The following 2 methods address an issue with using Rails normal + # accepts_nested_attributes_for on polymorphic relationships. + # + # Currently, when creating the underlying model, the `.valid?` method is + # called prior to the `save`. This causes all `identifiers` to report that + # the `identifiable_id` is nil. Because Rails forces the `belong_to` relation + # to be present. + # + # To get around it we stash the identifiers during the creation step + # and then save them after the model has been created + # + # Supposedly this is fixed in Rails 5+ by designating `optional: true` + # on the `belong_to` side of the relationship + def stash_orcid + return false unless @contributor.identifiers.any? + + @cached_orcid = @contributor.identifiers.first + @contributor.identifiers = [] + end + + def save_orcid + return true unless @cached_orcid.present? + + @cached_orcid.identifiable = @contributor + @cached_orcid.save + @contributor.reload + end + +end +# rubocop:enable Metrics/ClassLength diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 98b1ab001d..23eef8f63d 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -2,7 +2,13 @@ class HomeController < ApplicationController - include Dmptool::Controller::Home + # -------------------------------- + # Start DMPTool Customization + # -------------------------------- + include Dmptool::Controllers::HomeController + # -------------------------------- + # End DMPTool Customization + # -------------------------------- respond_to :html diff --git a/app/controllers/user_identifiers_controller.rb b/app/controllers/identifiers_controller.rb similarity index 66% rename from app/controllers/user_identifiers_controller.rb rename to app/controllers/identifiers_controller.rb index 4437b9068d..9b90f2c8a3 100644 --- a/app/controllers/user_identifiers_controller.rb +++ b/app/controllers/identifiers_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class UserIdentifiersController < ApplicationController +class IdentifiersController < ApplicationController respond_to :html after_action :verify_authorized @@ -8,19 +8,19 @@ class UserIdentifiersController < ApplicationController # DELETE /users/identifiers # --------------------------------------------------------------------- def destroy - authorize UserIdentifier + authorize Identifier user = User.find(current_user.id) - identifier = UserIdentifier.find(params[:id]) + identifier = Identifier.find(params[:id]) # If the requested identifier belongs to the current user remove it - if user.user_identifiers.include?(identifier) + if user.identifiers.include?(identifier) identifier.destroy! flash[:notice] = _("Successfully unlinked your account from %{is}.") % { - is: identifier.identifier_scheme.description + is: identifier.identifier_scheme&.description } else flash[:alert] = _("Unable to unlink your account from %{is}.") % { - is: identifier.identifier_scheme.description + is: identifier.identifier_scheme&.description } end diff --git a/app/controllers/org_admin/conditions_controller.rb b/app/controllers/org_admin/conditions_controller.rb new file mode 100644 index 0000000000..57615ffebb --- /dev/null +++ b/app/controllers/org_admin/conditions_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class OrgAdmin::ConditionsController < ApplicationController + + def new + question = Question.find(params[:question_id]) + condition_no = params[:condition_no] + next_condition_no = condition_no.to_i + 1 + render json: { add_link: render_to_string(partial: "add", + formats: :html, + layout: false, + locals: { question: question, + condition_no: next_condition_no }), + attachment_partial: render_to_string(partial: "form", + formats: :html, + layout: false, + locals: { question: question, + cond: Condition.new(question: question), + condition_no: condition_no }) } + end + + private + def condition_params + params.require(:question_option_id, :action_type).permit(:remove_question_id, :condition_no) + end +end diff --git a/app/controllers/org_admin/phases_controller.rb b/app/controllers/org_admin/phases_controller.rb index aa28d05552..a41aff268c 100644 --- a/app/controllers/org_admin/phases_controller.rb +++ b/app/controllers/org_admin/phases_controller.rb @@ -98,7 +98,7 @@ def new }) else render org_admin_templates_path, - alert: _("You canot add a phase to a historical version of a template.") + alert: _("You cannot add a phase to a historical version of a template.") end end @@ -117,7 +117,7 @@ def create flash[:alert] = failure_message(phase, _("create")) end rescue StandardError => e - flash[:alert] = _("Unable to create a new version of this template.") + 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) @@ -140,7 +140,7 @@ def update flash[:alert] = failure_message(phase, _("update")) end rescue StandardError => e - flash[:alert] = _("Unable to create a new version of this template.") + 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) @@ -167,7 +167,7 @@ def destroy flash[:alert] = failure_message(phase, _("delete")) end rescue StandardError => e - flash[:alert] = _("Unable to create a new version of this template.") + 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 88cbfa598d..250cb9edfa 100644 --- a/app/controllers/org_admin/plans_controller.rb +++ b/app/controllers/org_admin/plans_controller.rb @@ -14,7 +14,10 @@ def index .where('users.org_id = ? AND plans.feedback_requested is TRUE AND roles.active is TRUE', current_user.org_id).pluck(:plan_id) @feedback_plans = Plan.where(id: feedback_ids).reject{|p| p.nil?} - @plans = current_user.org.plans.page(1) + + @super_admin = current_user.can_super_admin? + @clicked_through = params[:click_through].present? + @plans = @super_admin ? Plan.all.page(1) : current_user.org.plans.page(1) end # GET org_admin/plans/:id/feedback_complete diff --git a/app/controllers/org_admin/question_options_controller.rb b/app/controllers/org_admin/question_options_controller.rb new file mode 100644 index 0000000000..e6ae125210 --- /dev/null +++ b/app/controllers/org_admin/question_options_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module OrgAdmin + + class QuestionOptionsController < ApplicationController + + include Versionable + + after_action :verify_authorized + + + def destroy + question_option = QuestionOption.find(params[:id]) + option_id_to_remove = question_option.id.to_s + authorize question_option + begin + question_option = get_modifiable(question_option) + question = question_option.question + section = question.section + if question_option.destroy! + # need to remove any conditions which refer to this question option + question.conditions.each do |cond| + if cond.option_list.include?(option_id_to_remove) + cond.destroy + end + end + flash[:notice] = success_message(question_option, _("deleted")) + else + flash[:alert] = flash[:alert] = failure_message(question_option, _("delete")) + end + rescue StandardError => e + flash[:alert] = _("Unable to create a new version of this template.") + end + redirect_to edit_org_admin_template_phase_path( + template_id: section.phase.template.id, + id: section.phase.id, + section: section.id + ) + end + + end + + +end diff --git a/app/controllers/org_admin/questions_controller.rb b/app/controllers/org_admin/questions_controller.rb index 1766025565..2ec0bd7a8c 100644 --- a/app/controllers/org_admin/questions_controller.rb +++ b/app/controllers/org_admin/questions_controller.rb @@ -6,6 +6,7 @@ class QuestionsController < ApplicationController include AllowedQuestionFormats include Versionable + include ConditionsHelper respond_to :html after_action :verify_authorized @@ -19,10 +20,23 @@ def show render partial: "show", locals: { template: question.section.phase.template, section: question.section, - question: question + question: question, + conditions: question.conditions } end + def open_conditions + question = Question.find(params[:question_id]) + authorize question + # render partial: "org_admin/conditions/container", locals: { question: question, conditions: question.conditions } + render json: { container: render_to_string(partial: "org_admin/conditions/container", + formats: :html, + layout: false, + locals: { question: question, + conditions: question.conditions.order(:number) }), + webhooks: webhook_hash(question.conditions) } + end + def edit question = Question.includes(:annotations, :question_options, @@ -33,7 +47,8 @@ def edit template: question.section.phase.template, section: question.section, question: question, - question_formats: allowed_question_formats + question_formats: allowed_question_formats, + conditions: question.conditions } end @@ -88,25 +103,62 @@ def create def update question = Question.find(params[:id]) authorize question - begin - question = get_modifiable(question) - # Need to reattach the incoming annotation's and question_options to the - # modifiable (versioned) question - attrs = question_params - attrs = transfer_associations(question) if question.id != params[:id] - # If the user unchecked all of the themes set the association to an empty array - # add check for number present to ensure this is not just an annotation - if attrs[:theme_ids].blank? && attrs[:number].present? - attrs[:theme_ids] = [] + + new_version = question.template.generate_version? + + old_question_ids = {} + if new_version + # get a map from option number to id + old_number_to_id = {} + question.question_options.each do |opt| + old_number_to_id[opt.number] = opt.id + end + + # get a map from question versionable id to old id + question.template.questions.each do |q| + old_question_ids[q.versionable_id] = q.id end - if question.update(attrs) + end + + question = get_modifiable(question) + + question_id_map = {} + if new_version + # params now out of sync (after versioning) with the question_options + # so when we do the question.update it'll mess up + # need to remap params to keep them consistent + old_to_new_opts = {} + question.question_options.each do |opt| + old_id = old_number_to_id[opt.number] + old_to_new_opts[old_id.to_s] = opt.id.to_s + end + + question.template.questions.each do |q| + question_id_map[old_question_ids[q.versionable_id].to_s] = q.id.to_s + end + end + + # rewrite the question_option ids so they match the new + # version of the question + # and also rewrite the remove_data question ids + attrs = question_params + attrs = update_option_ids(attrs, old_to_new_opts) if new_version + + # Need to reattach the incoming annotation's and question_options to the + # modifiable (versioned) question + attrs = transfer_associations(attrs, question) if new_version + + # If the user unchecked all of the themes set the association to an empty array + # add check for number present to ensure this is not just an annotation + if attrs[:theme_ids].blank? && attrs[:number].present? + attrs[:theme_ids] = [] + end + if question.update(attrs) + if question.update_conditions(sanitize_hash(params["conditions"]), old_to_new_opts, question_id_map) flash[:notice] = success_message(question, _("updated")) - else - flash[:alert] = flash[:alert] = failure_message(question, _("update")) end - rescue StandardError => e - puts e.message - flash[:alert] = _("Unable to create a new version of this template.") + else + flash[:alert] = flash[:alert] = failure_message(question, _("update")) end if question.section.phase.template.customization_of.present? redirect_to org_admin_template_phase_path( @@ -146,6 +198,34 @@ def destroy private + # param_condiions looks like: + # [ + # { + # "conditions_N" => { + # name: ... + # subject ... + # ... + # } + # ... + # } + # ] + def sanitize_hash(param_conditions) + return {} if param_conditions.nil? + return {} unless param_conditions.length > 0 + + res = {} + hash_of_hashes = param_conditions[0] + hash_of_hashes.each do |cond_name, cond_hash| + sanitized_hash = {} + cond_hash.each do |k,v| + v = ActionController::Base.helpers.sanitize(v) if k.start_with?("webhook") + sanitized_hash[k] = v + end + res[cond_name] = sanitized_hash + end + res + end + def question_params params.require(:question) .permit(:number, :text, :question_format_id, :option_comment_display, @@ -155,11 +235,24 @@ def question_params theme_ids: []) end + # when a templkate gets versioned while saving the question + # options are now out of sync with the params. + # This sorts that out. + def update_option_ids(qp, opt_map) + qopts = qp["question_options_attributes"] + qopts.keys.each do |k| + attr_hash = qopts[k] + old_id = attr_hash["id"] + new_id = opt_map[old_id] + attr_hash["id"] = new_id + end + qp + end + # When a template gets versioned by changes to one of its questions we need to loop # through the incoming params and ensure that the annotations and question_options # get attached to the new question - def transfer_associations(question) - attrs = question_params + def transfer_associations(attrs, question) if attrs[:annotations_attributes].present? attrs[:annotations_attributes].each_key do |key| old_annotation = question.annotations.select do |a| @@ -171,19 +264,10 @@ def transfer_associations(question) end end end - # TODO: This question_options id swap feel fragile. We cannot really match on any - # of the data elements because the user may have changed them so we rely on its - # position within the array/query since they should be equivalent. - if attrs[:question_options_attributes].present? - attrs[:question_options_attributes].each_key do |key| - next unless question.question_options[key.to_i].present? - hash = attrs.dig(:question_options_attributes, key) - hash[:id] = question.question_options[key.to_i].id.to_s - end - end attrs end end + end diff --git a/app/controllers/org_admin/sections_controller.rb b/app/controllers/org_admin/sections_controller.rb index 96bea20b87..2a60a22409 100644 --- a/app/controllers/org_admin/sections_controller.rb +++ b/app/controllers/org_admin/sections_controller.rb @@ -100,7 +100,7 @@ def update flash[:alert] = failure_message(section, _("save")) end rescue StandardError => e - flash[:alert] = _("Unable to create a new version of this template.") + flash[:alert] = _("Unable to create a new version of this template.") + "
" + e.message end if flash[:alert].present? @@ -129,7 +129,7 @@ def destroy flash[:alert] = failure_message(section, _("delete")) end rescue StandardError => e - flash[:alert] = _("Unable to create a new version of this template.") + 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/templates_controller.rb b/app/controllers/org_admin/templates_controller.rb index 9a82c5000d..ab1c3b8e79 100644 --- a/app/controllers/org_admin/templates_controller.rb +++ b/app/controllers/org_admin/templates_controller.rb @@ -2,6 +2,7 @@ module OrgAdmin + # rubocop:disable Metrics/ClassLength class TemplatesController < ApplicationController include Paginable @@ -18,7 +19,7 @@ def index templates = Template.latest_version.where(customization_of: nil) published = templates.select { |t| t.published? || t.draft? }.length - @orgs = Org.all + @orgs = Org.managed @title = _("All Templates") @templates = templates.includes(:org).page(1) @query_params = { sort_field: "templates.title", sort_direction: "asc" } @@ -152,7 +153,7 @@ def create authorize Template args = template_params # Swap in the appropriate visibility enum value for the checkbox value - args[:visibility] = args.fetch(:visibility, "0") == "1" ? "organisationally_visible" : "publicly_visible" + args[:visibility] = parse_visibility(args, current_user.org) # creates a new template with version 0 and new family_id @template = Template.new(args) @@ -180,7 +181,7 @@ def update begin args = template_params # Swap in the appropriate visibility enum value for the checkbox value - args[:visibility] = args.fetch(:visibility, '0') == '1' ? 'organisationally_visible' : 'publicly_visible' + args[:visibility] = parse_visibility(args, current_user.org) template.assign_attributes(args) if params["template-links"].present? @@ -350,6 +351,19 @@ def template_params params.require(:template).permit(:title, :description, :visibility, :links) end + def parse_visibility(args, org) + # the visibility param is only present in the case of an org that is + # both a funder and an institution. + # If nil and the org is a funder, we default to public + # If nil and the org is not a funder, we default to organisational + # If present, we parse to retrieve the value + if args[:visibility].nil? + return org.funder? ? "publicly_visible" : "organisationally_visible" + else + return args.fetch(:visibility, "0") == "1" ? "organisationally_visible" : "publicly_visible" + end + end + def get_referrer(template, referrer) return org_admin_templates_path unless referrer.present? if referrer.end_with?(new_org_admin_template_path) || @@ -367,5 +381,7 @@ def get_referrer(template, referrer) end end + # rubocop:enable Metrics/ClassLength + end diff --git a/app/controllers/org_admin/users_controller.rb b/app/controllers/org_admin/users_controller.rb index e2c730ce6f..5da20005b1 100644 --- a/app/controllers/org_admin/users_controller.rb +++ b/app/controllers/org_admin/users_controller.rb @@ -44,8 +44,8 @@ def user_plans render "org_admin/users/plans" end - private + def user_params params.require(:user).permit(:department_id) end diff --git a/app/controllers/orgs_controller.rb b/app/controllers/orgs_controller.rb index e8693bf2f0..78ed8ea4ae 100644 --- a/app/controllers/orgs_controller.rb +++ b/app/controllers/orgs_controller.rb @@ -3,8 +3,19 @@ class OrgsController < ApplicationController after_action :verify_authorized, except: ['shibboleth_ds', 'shibboleth_ds_passthru'] - include Dmptool::Controller::Orgs - + # ===================================== + # Start DMPTool Customization + # ===================================== + include Dmptool::Controllers::OrgsController + # ===================================== + # End DMPTool Customization + # ===================================== + + include OrgSelectable + + after_action :verify_authorized, except: %w[ + shibboleth_ds shibboleth_ds_passthru search + ] respond_to :html ## @@ -32,28 +43,44 @@ def admin_update # Only allow super admins to change the org types and shib info if current_user.can_super_admin? - # Handle Shibboleth identifiers if that is enabled + identifiers = [] + attrs[:managed] = attrs[:managed] == "1" + + # Handle Shibboleth identifier if that is enabled if Rails.application.config.shibboleth_use_filtered_discovery_service - shib = IdentifierScheme.find_by(name: "shibboleth") - shib_settings = @org.org_identifiers.select do |ids| - ids.identifier_scheme == shib - end.first - - if params[:shib_id].blank? && shib_settings.present? - # The user cleared the shib values so delete the object - shib_settings.destroy - else - unless shib_settings.present? - shib_settings = OrgIdentifier.new(org: @org, identifier_scheme: shib) - end - shib_settings.identifier = params[:shib_id] - shib_settings.attrs = { domain: params[:shib_domain] } - shib_settings.save + shib = IdentifierScheme.by_name("shibboleth").first + + if shib.present? && attrs.fetch(:identifiers_attributes, {}).any? + entity_id = attrs[:identifiers_attributes].first[1][:value] + identifier = Identifier.find_or_initialize_by( + identifiable: @org, identifier_scheme: shib, value: entity_id + ) + @org = process_identifier_change(org: @org, identifier: identifier) end + attrs.delete(:identifiers_attributes) end + + # See if the user selected a new Org via the Org Lookup and + # convert it into an Org + lookup = org_from_params(params_in: attrs) + ids = identifiers_from_params(params_in: attrs) + identifiers += ids.select { |id| id.value.present? } + + # Remove the extraneous Org Selector hidden fields + attrs = remove_org_selection_params(params_in: attrs) end - if @org.update_attributes(attrs) + if @org.update(attrs) + # Save any identifiers that were found + if current_user.can_super_admin? && lookup.present? + # Loop through the identifiers and then replace the existing + # identifier and save the new one + identifiers.each do |id| + @org = process_identifier_change(org: @org, identifier: id) + end + @org.save + end + redirect_to "#{admin_edit_org_path(@org)}\##{tab}", notice: success_message(@org, _("saved")) else @@ -62,63 +89,131 @@ def admin_update end end - # GET /orgs/shibboleth_ds + # -------------------------------------------------------- + # Start DMPTool customization + # Commenting out so that our customization is used + # -------------------------------------------------------- + + # # GET /orgs/shibboleth_ds + # # ---------------------------------------------------------------- + # def shibboleth_ds + # redirect_to root_path unless current_user.nil? + + # @user = User.new + # # Display the custom Shibboleth discovery service page. + # @orgs = Identifier.by_scheme_name("shibboleth", "Org").order(:name) + + # if @orgs.empty? + # flash.now[:alert] = _("No organisations are currently registered.") + # redirect_to user_shibboleth_omniauth_authorize_path + # end + # end + + # # POST /orgs/shibboleth_ds + # # ---------------------------------------------------------------- + # def shibboleth_ds_passthru + # if !params["shib-ds"][:org_name].blank? + # session["org_id"] = params["shib-ds"][:org_name] + + # org = Org.where(id: params["shib-ds"][:org_id]) + # shib_entity = Identifier.by_scheme_name("shibboleth", "Org") + # .where(identifiable: org) + + # if !shib_entity.empty? + # # Force SSL + # shib_login = Rails.application.config.shibboleth_login + # 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=#{shib_entity.first.value}" + # 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 + + # -------------------------------------------------------- + # End DMPTool customization + # -------------------------------------------------------- + + # POST /orgs/search (via AJAX) # ---------------------------------------------------------------- - def shibboleth_ds - redirect_to root_path unless current_user.nil? - - @user = User.new - # Display the custom Shibboleth discovery service page. - @orgs = Org.joins(:identifier_schemes) - .where("identifier_schemes.name = ?", "shibboleth").sort do |x, y| - x.name <=> y.name - end - - if @orgs.empty? - flash.now[:alert] = _("No organisations are currently registered.") - redirect_to user_shibboleth_omniauth_authorize_path - end - end - - # POST /orgs/shibboleth_ds - # ---------------------------------------------------------------- - def shibboleth_ds_passthru - if !params['shib-ds'][:org_name].blank? - session['org_id'] = params['shib-ds'][:org_name] - scheme = IdentifierScheme.find_by(name: 'shibboleth') - shib_entity = OrgIdentifier.where(org_id: params['shib-ds'][:org_id], - identifier_scheme: scheme) - if !shib_entity.empty? - # Force SSL - shib_login = Rails.application.config.shibboleth_login - 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=#{shib_entity.first.identifier}" + def search + args = search_params + # If the search term is greater than 2 characters + if args.present? && args.fetch(:name, "").length > 2 + type = params.fetch(:type, "local") + + # If we are including external API results + case type + when "combined" + orgs = OrgSelection::SearchService.search_combined( + search_term: args[:name] + ) + when "external" + orgs = OrgSelection::SearchService.search_externally( + search_term: args[:name] + ) else - failure = _("Your organisation does not seem to be properly configured.") - redirect_to shibboleth_ds_path, alert: failure + orgs = OrgSelection::SearchService.search_locally( + search_term: args[:name] + ) end + # If we need to restrict the results to funding orgs then + # only return the ones with a valid fundref + if orgs.present? && params.fetch(:funder_only, "false") == true + orgs = orgs.select do |org| + org[:fundref].present? && !org[:fundref].blank? + end + end + + render json: orgs + else - redirect_to shibboleth_ds_path, notice: _("Please choose an organisation") + render json: [] end end - # START DMPTool customization - # JSON endpoint - # GET /orgs/:id/logo (format: :json) - # ---------------------------------------------------------------- - -# END DMPTool customization -# --------------------------------------------------------- - private + def org_params - params.require(:org).permit(:name, :abbreviation, :logo, :contact_email, - :contact_name, :remove_logo, :org_type, - :feedback_enabled, :feedback_email_msg) + params.require(:org) + .permit(:name, :abbreviation, :logo, :contact_email, :contact_name, + :remove_logo, :org_type, :managed, :feedback_enabled, + :feedback_email_msg, :org_id, :org_name, :org_crosswalk, + identifiers_attributes: [:identifier_scheme_id, :value], + tracker_attributes: [:code]) + end + + def search_params + params.require(:org).permit(:name, :type) + end + + # Destroy the identifier if it exists and was blanked out, replace the + # identifier if it was updated, create the identifier if its new, or + # ignore it + def process_identifier_change(org:, identifier:) + return org unless identifier.is_a?(Identifier) + + if !identifier.new_record? && identifier.value.blank? + # Remove the identifier if it has been blanked out + identifier.destroy + elsif identifier.value.present? + # If the identifier already exists then remove it + current = org.identifier_for_scheme(scheme: identifier.identifier_scheme) + current.destroy if current.present? && current.value != identifier.value + + identifier.identifiable = org + org.identifiers << identifier + end + + org end end diff --git a/app/controllers/paginable/contributors_controller.rb b/app/controllers/paginable/contributors_controller.rb new file mode 100644 index 0000000000..2ba66a0c68 --- /dev/null +++ b/app/controllers/paginable/contributors_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Paginable + + class ContributorsController < ApplicationController + + after_action :verify_authorized + respond_to :html + + include Paginable + + # GET /paginable/plans/:plan_id/contributors + # GET /paginable/plans/:plan_id/contributors/index/:page + def index + @plan = Plan.find_by(id: params[:plan_id]) + authorize @plan + paginable_renderise( + partial: "index", + scope: Contributor.where(plan_id: @plan.id), + query_params: { sort_field: "contributors.name", sort_direction: :asc } + ) + end + + end + +end diff --git a/app/controllers/paginable/orgs_controller.rb b/app/controllers/paginable/orgs_controller.rb index bc761cc39e..5502be727c 100644 --- a/app/controllers/paginable/orgs_controller.rb +++ b/app/controllers/paginable/orgs_controller.rb @@ -2,7 +2,13 @@ class Paginable::OrgsController < ApplicationController - include Dmptool::Controller::Paginable::Orgs + # -------------------------------- + # Start DMPTool Customization + # -------------------------------- + include Dmptool::Controllers::Paginable::OrgsController + # -------------------------------- + # End DMPTool Customization + # -------------------------------- include Paginable diff --git a/app/controllers/paginable/plans_controller.rb b/app/controllers/paginable/plans_controller.rb index 331d1c39e0..5fb2ced092 100644 --- a/app/controllers/paginable/plans_controller.rb +++ b/app/controllers/paginable/plans_controller.rb @@ -42,9 +42,16 @@ def org_admin unless current_user.present? && current_user.can_org_admin? raise Pundit::NotAuthorizedError end + #check if current user if super_admin + @super_admin = current_user.can_super_admin? + @clicked_through = params[:click_through].present? + plans = @super_admin ? Plan.all : current_user.org.plans + plans = plans.joins(:template, roles: [user: :org]).where(Role.creator_condition) + paginable_renderise( partial: "org_admin", - scope: current_user.org.plans, + scope: plans, + view_all: !current_user.can_super_admin?, query_params: { sort_field: 'plans.updated_at', sort_direction: :desc } ) end diff --git a/app/controllers/paginable/users_controller.rb b/app/controllers/paginable/users_controller.rb index 5e8b678f60..61a8d267ff 100644 --- a/app/controllers/paginable/users_controller.rb +++ b/app/controllers/paginable/users_controller.rb @@ -7,11 +7,22 @@ class Paginable::UsersController < ApplicationController # /paginable/users/index/:page def index authorize User + @clicked_through = params[:click_through].present? + + # variable containing the check box value + @filter_admin = params[:filter_admin] == "1" + if current_user.can_super_admin? scope = User.includes(:roles) else scope = current_user.org.users.includes(:roles) end + + if @filter_admin + scope = scope.joins(:perms).distinct + end + + paginable_renderise( partial: "index", scope: scope, diff --git a/app/controllers/plan_exports_controller.rb b/app/controllers/plan_exports_controller.rb index 42c583934b..3cfdfc8b96 100644 --- a/app/controllers/plan_exports_controller.rb +++ b/app/controllers/plan_exports_controller.rb @@ -4,6 +4,8 @@ class PlanExportsController < ApplicationController after_action :verify_authorized + include ConditionsHelper + def show @plan = Plan.includes(:answers).find(params[:plan_id]) diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index 2a3fccff17..ed77f727ab 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -3,6 +3,8 @@ class PlansController < ApplicationController include ConditionalUserMailer + include OrgSelectable + helper PaginableHelper helper SettingsTemplateHelper @@ -30,13 +32,15 @@ def new # Get all of the available funders and non-funder orgs @funders = Org.funder + .includes(identifiers: :identifier_scheme) .joins(:templates) .where(templates: { published: true }).uniq.sort_by(&:name) - @orgs = (Org.organisation + Org.institution + Org.managing_orgs).flatten - .uniq.sort_by(&:name) + @orgs = (Org.includes(identifiers: :identifier_scheme).organisation + + Org.includes(identifiers: :identifier_scheme).institution + + Org.includes(identifiers: :identifier_scheme).default_orgs) + @orgs = @orgs.flatten.uniq.sort_by(&:name) - # Get the current user's org - @default_org = current_user.org if @orgs.include?(current_user.org) + @plan.org_id = current_user.org&.id if params.key?(:test) flash[:notice] = "#{_('This is a')} #{_('test plan')}" @@ -64,20 +68,6 @@ def create format.html { redirect_to new_plan_path } end else - # Otherwise create the plan - if current_user.surname.blank? - @plan.principal_investigator = nil - else - @plan.principal_investigator = current_user.name(false) - end - - @plan.principal_investigator_email = current_user.email - - orcid = current_user.identifier_for(IdentifierScheme.find_by(name: "orcid")) - @plan.principal_investigator_identifier = orcid.identifier unless orcid.nil? - - @plan.funder_name = plan_params[:funder_name] - @plan.visibility = if plan_params["visibility"].blank? Rails.application.config.default_plan_visibility else @@ -96,9 +86,25 @@ def create @plan.title = plan_params[:title] end + # bit of hackery here. There are 2 org selectors on the page + # and each is within its own specific context, plan.org or + # plan.funder which forces the hidden id hash to be :id + # so we need to convert it to :org_id so it works with the + # OrgSelectable and OrgSelection services + if params[:org][:id].present? + attrs = params[:org] + attrs[:org_id] = attrs[:id] + @plan.org = org_from_params(params_in: attrs, allow_create: false) + end + if params[:funder][:id].present? + attrs = params[:funder] + attrs[:org_id] = attrs[:id] + @plan.funder = org_from_params(params_in: attrs, allow_create: false) + end + if @plan.save # pre-select org's guidance and the default org's guidance - ids = (Org.managing_orgs << org_id).flatten.uniq + ids = (Org.default_orgs.pluck(:id) << org_id).flatten.uniq ggs = GuidanceGroup.where(org_id: ids, optional_subset: false, published: true) if !ggs.blank? then @plan.guidance_groups << ggs end @@ -115,7 +121,7 @@ def create # rubocop:disable Metrics/LineLength # We used a customized version of the the funder template # rubocop:disable Metrics/LineLength - msg += " #{_('This plan is based on the')} #{plan_params[:funder_name]}: '#{@plan.template.title}' #{_('template with customisations by the')} #{plan_params[:org_name]}" + msg += " #{_('This plan is based on the')} #{@plan.funder&.name}: '#{@plan.template.title}' #{_('template with customisations by the')} #{plan_params[:org_name]}" # rubocop:enable Metrics/LineLength else # rubocop:disable Metrics/LineLength @@ -129,7 +135,7 @@ def create # Set new identifier to plan id by default on create. # (This may be changed by user.) - @plan.add_identifier!(@plan.id.to_s) + @plan.identifier = @plan.id.to_s respond_to do |format| flash[:notice] = msg @@ -225,10 +231,17 @@ def update params[:guidance_group_ids].map(&:to_i).uniq end @plan.guidance_groups = GuidanceGroup.where(id: guidance_group_ids) - @plan.save - if @plan.update_attributes(attrs) + + # TODO: For some reason the `fields_for` isn't adding the + # appropriate namespace, so org_id represents our funder + funder = org_from_params(params_in: attrs, allow_create: true) + @plan.funder_id = funder.id if funder.present? + process_grant(hash: params[:grant]) + attrs = remove_org_selection_params(params_in: attrs) + + if @plan.update(attrs) #_attributes(attrs) format.html do - redirect_to overview_plan_path(@plan), + redirect_to plan_contributors_path(@plan), notice: success_message(@plan, _("saved")) end format.json do @@ -245,10 +258,11 @@ def update end end - rescue Exception + rescue Exception => e flash[:alert] = failure_message(@plan, _("save")) format.html do - render_phases_edit(@plan, @plan.phases.first, @plan.guidance_groups) + Rails.logger.error "Unable to save plan #{@plan&.id} - #{e.message}" + redirect_to "#{plan_path(@plan)}", alert: failure_message(@plan, _("save")) end format.json do render json: { code: 0, msg: flash[:alert] } @@ -406,12 +420,12 @@ def overview def plan_params params.require(:plan) - .permit(:org_id, :org_name, :funder_id, :funder_name, :template_id, - :title, :visibility, :grant_number, :description, :identifier, - :principal_investigator_phone, :principal_investigator, - :principal_investigator_email, :data_contact, - :principal_investigator_identifier, :data_contact_email, - :data_contact_phone, :guidance_group_ids) + .permit(:template_id, :title, :visibility, :grant_number, + :description, :identifier, :guidance_group_ids, + :start_date, :end_date, + :org_id, :org_name, :org_crosswalk, :identifier, + org: [:org_id, :org_name, :org_sources, :org_crosswalk], + funder: [:org_id, :org_name, :org_sources, :org_crosswalk]) end # different versions of the same template have the same family_id @@ -458,8 +472,6 @@ def rollup(plan, src_plan_key, super_id, obj_plan_key) plan.delete(src_plan_key) end - private - # ============================ # = Private instance methods = # ============================ @@ -480,4 +492,26 @@ def render_phases_edit(plan, phase, guidance_groups) }) end + # Update, destroy or add the grant + def process_grant(hash:) + if hash.present? + if hash[:id].present? + grant = @plan.grant + # delete it if it has been blanked out + if hash[:value].blank? + grant.destroy + @plan.grant_id = nil + elsif hash[:value] != grant.value + # update it iif iit has changed + grant.update(value: hash[:value]) + end + else + identifier = Identifier.create(identifier_scheme: nil, + identifiable: @plan, value: hash[:value]) + @plan.grant_id = identifier.id + end + end + end + + end diff --git a/app/controllers/public_pages_controller.rb b/app/controllers/public_pages_controller.rb index 849149c735..c7ee4cdca7 100644 --- a/app/controllers/public_pages_controller.rb +++ b/app/controllers/public_pages_controller.rb @@ -2,7 +2,13 @@ class PublicPagesController < ApplicationController - include Dmptool::Controller::PublicPages + # -------------------------------- + # Start DMPTool Customization + # -------------------------------- + include Dmptool::Controllers::PublicPagesController + # -------------------------------- + # End DMPTool Customization + # -------------------------------- # GET template_index # ----------------------------------------------------- diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 8b53fe48af..699e8ad206 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -2,13 +2,15 @@ class RegistrationsController < Devise::RegistrationsController + include OrgSelectable + def edit @user = current_user @prefs = @user.get_preferences(:email) @languages = Language.sorted_by_abbreviation @orgs = Org.order("name") @other_organisations = Org.where(is_other: true).pluck(:id) - @identifier_schemes = IdentifierScheme.where(active: true).order(:name) + @identifier_schemes = IdentifierScheme.for_users.order(:name) @default_org = current_user.org if !@prefs @@ -19,7 +21,7 @@ def edit # GET /resource def new oauth = { provider: nil, uid: nil } - IdentifierScheme.all.each do |scheme| + IdentifierScheme.for_users.each do |scheme| unless session["devise.#{scheme.name.downcase}_data"].nil? oauth = session["devise.#{scheme.name.downcase}_data"] end @@ -36,10 +38,20 @@ def new application_name: Rails.configuration.branding[:application][:name] } # rubocop:enable Metrics/LineLength - scheme = IdentifierScheme.find_by(name: oauth["provider"].downcase) - UserIdentifier.create(identifier_scheme: scheme, - identifier: oauth["uid"], - user: @user) + + # --------------------------------------- + # Start DMPTool Customization + # Determine which Org Idp we came from and make it available to form + # --------------------------------------- + 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 DMPTool Customization + # --------------------------------------- end end end @@ -47,7 +59,7 @@ def new # POST /resource def create oauth = { provider: nil, uid: nil } - IdentifierScheme.all.each do |scheme| + IdentifierScheme.for_users.each do |scheme| unless session["devise.#{scheme.name.downcase}_data"].nil? oauth = session["devise.#{scheme.name.downcase}_data"] end @@ -56,7 +68,7 @@ def create if sign_up_params[:accept_terms].to_s == "0" redirect_to after_sign_up_error_path_for(resource), alert: _("You must accept the terms and conditions to register.") - elsif params[:user][:org_id].blank? && params[:user][:other_organisation].blank? + elsif params[:user][:org_id].blank? # rubocop:disable Metrics/LineLength redirect_to after_sign_up_error_path_for(resource), alert: _("Please select an organisation from the list, or enter your organisation's name.") @@ -78,19 +90,13 @@ def create end end - if params[:user][:org_id].blank? - other_org = Org.find_by(is_other: true) - if other_org.nil? - # rubocop:disable Metrics/LineLength - redirect_to(after_sign_up_error_path_for(resource), - alert: _("You cannot be assigned to other organisation since that option does not exist in the system. Please contact your system administrators.")) and return - # rubocop:enable Metrics/LineLength - end - params[:user][:org_id] = other_org.id - end + # Handle the Org selection + attrs = sign_up_params + attrs = handle_org(attrs: attrs) + + build_resource(attrs) - build_resource(sign_up_params) - if resource.save + if verify_recaptcha(model: resource) && resource.save if resource.active_for_authentication? set_flash_message :notice, :signed_up if is_navigational_format? sign_up(resource_name, resource) @@ -109,10 +115,11 @@ def create unless oauth["provider"].nil? || oauth["uid"].nil? prov = IdentifierScheme.find_by(name: oauth["provider"].downcase) # Until we enable ORCID signups - if prov.name == "shibboleth" - UserIdentifier.create(identifier_scheme: prov, - identifier: oauth["uid"], - user: @user) + if prov.present? && prov.name == "shibboleth" + Identifier.create(identifier_scheme: prov, + value: oauth["uid"], + attrs: oauth, + identifiable: resource) # rubocop:disable Metrics/LineLength flash[:notice] = _("Welcome! You have signed up successfully with your institutional credentials. You will now be able to access your account with them.") # rubocop:enable Metrics/LineLength @@ -142,7 +149,7 @@ def update @orgs = Org.order("name") @default_org = current_user.org @other_organisations = Org.where(is_other: true).pluck(:id) - @identifier_schemes = IdentifierScheme.where(active: true).order(:name) + @identifier_schemes = IdentifierScheme.for_users.order(:name) @languages = Language.sorted_by_abbreviation if params[:skip_personal_details] == "true" do_update_password(current_user, params) @@ -188,6 +195,11 @@ def do_update(require_password = true, confirm = false) end # has the user entered all the details if mandatory_params + + # Handle the Org selection + attrs = update_params + attrs = handle_org(attrs: attrs) + # user is changing email or password if require_password # if user is changing email @@ -208,11 +220,11 @@ def do_update(require_password = true, confirm = false) # This case is never reached since this method when called with # require_password = true is because the email changed. # The case for password changed goes to do_update_password instead - successfully_updated = current_user.update_without_password(update_params) + successfully_updated = current_user.update_without_password(attrs) end else # password not required - successfully_updated = current_user.update_without_password(update_params) + successfully_updated = current_user.update_without_password(attrs) end else successfully_updated = false @@ -274,19 +286,37 @@ def do_update_password(current_user, params) def sign_up_params params.require(:user).permit(:email, :password, :password_confirmation, :firstname, :surname, :recovery_email, - :accept_terms, :org_id, :other_organisation) + :accept_terms, :org_id, :org_name, + :org_crosswalk) end def update_params - params.require(:user).permit(:firstname, :org_id, :other_organisation, - :language_id, :surname, :department_id) + params.require(:user).permit(:firstname, :org_id, :language_id, + :surname, :department_id, :org_id, + :org_name, :org_crosswalk) end def password_update params.require(:user).permit(:email, :firstname, :current_password, - :org_id, :language_id, :password, - :password_confirmation, :surname, - :other_organisation, :department_id) + :language_id, :password, + :password_confirmation, :surname, + :department_id, :org_id, :org_name, + :org_crosswalk) + end + + # 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? + + 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? + + # reattach the org_id but with the Org id instead of the hash + attrs[:org_id] = org.id + attrs end end diff --git a/app/controllers/research_projects_controller.rb b/app/controllers/research_projects_controller.rb index 7a0bf24c67..338c7f166d 100644 --- a/app/controllers/research_projects_controller.rb +++ b/app/controllers/research_projects_controller.rb @@ -1,7 +1,6 @@ -class ResearchProjectsController < ApplicationController - +# frozen_string_literal: true - DEFAULT_FUNDER_TYPE = "H2020" +class ResearchProjectsController < ApplicationController def index render json: research_projects @@ -15,15 +14,25 @@ def search private def research_projects - @research_projects ||= begin - Rails.cache.fetch(["research_projects", funder_type], expires_in: 1.day) do - Thread.new { OpenAireRequest.new(funder_type).get!.results }.value - end - end + return @research_projects unless @research_projects.nil? || + @research_projects.empty? + + # Check the cache contents as well since the instance variable is only + # relevant per request + cached = Rails.cache.fetch(["research_projects", funder_type]) + return @research_projects = cached unless cached.nil? || cached.empty? + + @research_projects = fetch_projects end def funder_type - params.fetch(:type, DEFAULT_FUNDER_TYPE) + params.fetch(:type, ExternalApis::OpenAireService.default_funder) + end + + def fetch_projects + Rails.cache.fetch(["research_projects", funder_type], expires_in: 1.day) do + Thread.new { ExternalApis::OpenAireService.search(funder: funder_type) }.value + end end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 53994aba4f..aea6db208e 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -16,28 +16,11 @@ def create if !session["devise.shibboleth_data"].nil? args = { identifier_scheme: IdentifierScheme.find_by(name: "shibboleth"), - identifier: session["devise.shibboleth_data"]["uid"], - user: existing_user + value: session["devise.shibboleth_data"]["uid"], + identifiable: existing_user, + attrs: session["devise.shibboleth_data"] } - if UserIdentifier.create(args) - # rubocop:disable Metrics/LineLength - success = _("Your account has been successfully linked to your institutional credentials. You will now be able to sign in with them.") - # rubocop:enable Metrics/LineLength - end - - #---------------------------------------------- - # Start DMPTool customization - #---------------------------------------------- - else - # If the user has an old LDAP account attempt to convert their - # password over to Devise if it is valid - unless existing_user.encrypted_password.present? - existing_user.valid_password?(params[:user][:password]) - end - #---------------------------------------------- - # End DMPTool customization - #---------------------------------------------- - + @ui = Identifier.new(args) end unless existing_user.get_locale.nil? session[:locale] = existing_user.get_locale @@ -45,9 +28,13 @@ def create # Method defined at controllers/application_controller.rb set_gettext_locale end - super - if success - flash[:notice] = success + + super do + if !@ui.nil? && @ui.save + # rubocop:disable Metrics/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 Metrics/LineLength + end end end diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 458571f1fe..8df387bdec 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -2,7 +2,13 @@ class StaticPagesController < ApplicationController - include Dmptool::Controller::StaticPages + # -------------------------------- + # Start DMPTool Customization + # -------------------------------- + include Dmptool::Controllers::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 new file mode 100644 index 0000000000..7c2610b662 --- /dev/null +++ b/app/controllers/super_admin/api_clients_controller.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +module SuperAdmin + + class ApiClientsController < ApplicationController + + respond_to :html + + helper PaginableHelper + + # GET /api_clients + def index + authorize(ApiClient) + @api_clients = ApiClient.all.page(1) + end + + # GET /api_clients/new + def new + authorize(ApiClient) + @api_client = ApiClient.new + end + + # GET /api_clients/1/edit + def edit + @api_client = ApiClient.find(params[:id]) + authorize(@api_client) + end + + # POST /api_clients + def create + authorize(ApiClient) + @api_client = ApiClient.new(api_client_params) + + 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]) + authorize(@api_client) + if @api_client.update(api_client_params) + flash.now[:notice] = success_message(@api_client, _("updated")) + else + flash.now[:alert] = failure_message(@api_client, _("update")) + end + render :edit + end + + # DELETE /api_clients/:id + def destroy + api_client = ApiClient.find(params[:id]) + authorize(api_client) + if api_client.destroy + msg = success_message(api_client, _("deleted")) + redirect_to super_admin_api_clients_path, notice: msg + else + flash.now[:alert] = failure_message(api_client, _("delete")) + render :edit + end + end + + # GET /api_clients/:id/refresh_credentials/ + def refresh_credentials + @api_client = ApiClient.find(params[:id]) + if @api_client.present? + @api_client.generate_credentials + @api_client.save + end + end + + # GET /api_clients/:id/email_credentials/ + def email_credentials + @api_client = ApiClient.find(params[:id]) + UserMailer.api_credentials(@api_client).deliver_now() if @api_client.present? + end + + private + + # 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, + :contact_name, :contact_email, + :client_id, :client_secret) + end + + end + +end diff --git a/app/controllers/super_admin/notifications_controller.rb b/app/controllers/super_admin/notifications_controller.rb index 3568ded408..e39fe46a06 100644 --- a/app/controllers/super_admin/notifications_controller.rb +++ b/app/controllers/super_admin/notifications_controller.rb @@ -55,6 +55,29 @@ def update render :edit end + + # edit active field displayed in the table + def enable + notification = Notification.find(params[:id]) + authorize(Notification) + notification.enabled = (params[:enabled] === "1") + + # rubocop:disable Metrics/LineLength + if notification.save + render json: { + code: 1, + msg: (notification.enabled ? _("Your notification is now active.") : _("Your notification is no longer active.")) + } + else + render status: :bad_request, json: { + code: 0, msg: _("Unable to change the notification's active status") + } + end + # rubocop:enable Metrics/LineLength + end + + + # DELETE /notifications/1 # DELETE /notifications/1.json def destroy @@ -92,7 +115,7 @@ def set_notifications # Never trust parameters from the scary internet, only allow the white list through. def notification_params - params.require(:notification).permit(:title, :level, :body, :dismissable, + params.require(:notification).permit(:title, :level, :body, :dismissable, :enabled, :starts_at, :expires_at) end diff --git a/app/controllers/super_admin/org_swaps_controller.rb b/app/controllers/super_admin/org_swaps_controller.rb index 7ccfce5700..8428a40dd5 100644 --- a/app/controllers/super_admin/org_swaps_controller.rb +++ b/app/controllers/super_admin/org_swaps_controller.rb @@ -2,20 +2,21 @@ class SuperAdmin::OrgSwapsController < ApplicationController + include OrgSelectable + after_action :verify_authorized def create # Allows the user to swap their org affiliation on the fly authorize current_user, :org_swap? - begin - @org = Org.find(org_swap_params[:org_id]) - rescue ActiveRecord::RecordNotFound - redirect_to(:back, alert: _("Please select an organisation from the list")) - return - end + + # See if the user selected a new Org via the Org Lookup and + # convert it into an Org + lookup = org_from_params(params_in: org_swap_params) + # rubocop:disable Metrics/LineLength - if @org.present? - current_user.org = @org + if lookup.present? && !lookup.new_record? + current_user.org = lookup if current_user.save redirect_to :back, notice: _("Your organisation affiliation has been changed. You may now edit templates for %{org_name}.") % { org_name: current_user.org.name } @@ -32,7 +33,7 @@ def create private def org_swap_params - params.require(:user).permit(:org_id, :org_name) + params.require(:user).permit(:org_id, :org_name, :org_crosswalk) end end diff --git a/app/controllers/super_admin/orgs_controller.rb b/app/controllers/super_admin/orgs_controller.rb index 5bff64539d..484902bba6 100644 --- a/app/controllers/super_admin/orgs_controller.rb +++ b/app/controllers/super_admin/orgs_controller.rb @@ -4,6 +4,8 @@ module SuperAdmin class OrgsController < ApplicationController + include OrgSelectable + after_action :verify_authorized def index @@ -14,17 +16,29 @@ def index end def new - org = Org.new - authorize org - org.links = { "org": [] } - render "orgs/admin_edit", locals: { org: org, languages: Language.all.order("name"), - method: "POST", url: super_admin_orgs_path } + @org = Org.new(managed: true) + authorize @org + @org.links = { "org": [] } end def create authorize Org - org = Org.new(org_params) + attrs = org_params + + # See if the user selected a new Org via the Org Lookup and + # convert it into an Org + org = org_from_params(params_in: attrs) + identifiers = identifiers_from_params(params_in: attrs) + + # Remove the extraneous Org Selector hidden fields + attrs = remove_org_selection_params(params_in: attrs) + + # In the event that the params would create an invalid user, the + # org selectable returns nil because Org.new(params) fails + org = Org.new unless org.present? + org.language = Language.default + org.managed = org_params[:managed] == "1" ? true : false org.logo = params[:logo] if params[:logo] if params[:org_links].present? org.links = JSON.parse(params[:org_links]) @@ -37,30 +51,14 @@ def create org.institution = params[:institution].present? org.organisation = params[:organisation].present? - # Handle Shibboleth identifiers if that is enabled - if Rails.application.config.shibboleth_use_filtered_discovery_service - shib = IdentifierScheme.find_by(name: "shibboleth") - - if params[:shib_id].present? || params[:shib_domain].present? - org.org_identifiers << OrgIdentifier.new( - identifier_scheme: shib, - identifier: params[:shib_id], - attrs: { domain: params[:shib_domain] }.to_json.to_s - ) - end - end - - if org.save + if org.update(attrs) msg = success_message(org, _("created")) redirect_to admin_edit_org_path(org.id), notice: msg else flash.now[:alert] = failure_message(org, _("create")) - render "orgs/admin_edit", locals: { - org: org, - languages: Language.all.order("name"), - method: "POST", - url: super_admin_orgs_path - } + @org = org + @org.links = { "org": [] } unless org.links.present? + render "super_admin/orgs/new" end rescue Dragonfly::Job::Fetch::NotFound => dflye failure = _("There seems to be a problem with your logo. Please upload it again.") @@ -95,9 +93,12 @@ def destroy private def org_params - params.require(:org).permit(:name, :abbreviation, :logo, :contact_email, - :contact_name, :remove_logo, :feedback_enabled, - :feedback_email_subject, :feedback_email_msg) + params.require(:org).permit(:name, :abbreviation, :logo, :managed, + :contact_email, :contact_name, + :remove_logo, :feedback_enabled, + :feedback_email_subject, + :feedback_email_msg, + :org_id, :org_name, :org_crosswalk) end end diff --git a/app/controllers/super_admin/users_controller.rb b/app/controllers/super_admin/users_controller.rb index a4d2a14ba0..93638d2eff 100644 --- a/app/controllers/super_admin/users_controller.rb +++ b/app/controllers/super_admin/users_controller.rb @@ -4,6 +4,8 @@ module SuperAdmin class UsersController < ApplicationController + include OrgSelectable + after_action :verify_authorized def edit @@ -29,7 +31,28 @@ def update # Replace the 'your' word from the canned responses so that it does # not read 'Successfully updated your profile for John Doe' topic = _("profile for %{username}") % { username: @user.name(false) } - if @user.update_attributes(user_params) + + # See if the user selected a new Org via the Org Lookup and + # convert it into an Org + attrs = user_params + lookup = org_from_params(params_in: attrs) + identifiers = identifiers_from_params(params_in: attrs) + + # Remove the extraneous Org Selector hidden fields + attrs = remove_org_selection_params(params_in: attrs) + + if @user.update_attributes(attrs) + # If its a new Org create it + if lookup.present? && lookup.new_record? + lookup.save + identifiers.each do |identifier| + identifier.identifiable = lookup + identifier.save + end + lookup.reload + end + @user.update(org_id: lookup.id) if lookup.present? + flash.now[:notice] = success_message(@user, _("updated")) else flash.now[:alert] = failure_message(@user, _("update")) @@ -90,7 +113,7 @@ def user_params params.require(:user).permit(:email, :firstname, :surname, - :org_id, + :org_id, :org_name, :org_crosswalk, :department_id, :language_id, :other_organisation) diff --git a/app/controllers/template_options_controller.rb b/app/controllers/template_options_controller.rb index 3132971219..8b9600dbe9 100644 --- a/app/controllers/template_options_controller.rb +++ b/app/controllers/template_options_controller.rb @@ -2,28 +2,39 @@ class TemplateOptionsController < ApplicationController + include OrgSelectable + after_action :verify_authorized # GET /template_options (AJAX) # Collect all of the templates available for the org+funder combination def index - org_id = (plan_params[:org_id] == "-1" ? "" : plan_params[:org_id]) - funder_id = (plan_params[:funder_id] == "-1" ? "" : plan_params[:funder_id]) + org_hash = plan_params.fetch(:research_org_id, {}) + funder_hash = plan_params.fetch(:funder_id, {}) authorize Template.new, :template_options? + + if org_hash.present? + org = org_from_params(params_in: { org_id: org_hash.to_json }) + end + if funder_hash.present? + funder = org_from_params(params_in: { org_id: funder_hash.to_json }) + end + @templates = [] - if org_id.present? || funder_id.present? - unless funder_id.blank? + if (org.present? && !org.new_record?) || + (funder.present? && !funder.new_record?) + if funder.present? && !funder.new_record? # Load the funder's template(s) minus the default template (that gets swapped # in below if NO other templates are available) @templates = Template.latest_customizable - .where(org_id: funder_id, is_default: false) - unless org_id.blank? + .where(org_id: funder.id, is_default: false) + if org.present? && !org.new_record? # Swap out any organisational cusotmizations of a funder template @templates = @templates.map do |tmplt| customization = Template.published .latest_customized_version(tmplt.family_id, - org_id).first + org.id).first # Only provide the customized version if its still up to date with the # funder template! if customization.present? && !customization.upgrade_customization? @@ -36,11 +47,11 @@ def index end # If the no funder was specified OR the funder matches the org - if funder_id.blank? || funder_id == org_id + if funder.blank? || funder.id == org&.id # Retrieve the Org's templates @templates << Template.published .organisationally_visible - .where(org_id: org_id, customization_of: nil).to_a + .where(org_id: org.id, customization_of: nil).to_a end @templates = @templates.flatten.uniq end @@ -50,18 +61,24 @@ def index if Template.default.present? customization = Template.published .latest_customized_version(Template.default.family_id, - org_id).first + org&.id).first @templates << (customization.present? ? customization : Template.default) end end + @templates = @templates.sort_by(&:title) end private def plan_params - params.require(:plan).permit(:org_id, :funder_id) + params.require(:plan).permit(research_org_id: org_params, + funder_id: org_params) + end + + def org_params + %i[id name sort_name url language abbreviation ror fundref weight score] end end diff --git a/app/controllers/usage_controller.rb b/app/controllers/usage_controller.rb index 9a65f637dc..00c097ebdd 100644 --- a/app/controllers/usage_controller.rb +++ b/app/controllers/usage_controller.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true +# rubocop:disable Metrics/ClassLength class UsageController < ApplicationController after_action :verify_authorized + # rubocop:disable Metrics/AbcSize # GET /usage def index authorize :usage @@ -13,10 +15,12 @@ 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)) - #TODO: pull this in from branding.yml + # TODO: pull this in from branding.yml @separators = [",", "|", "#"] @funder = current_user.org.funder? + @filtered = args[:filtered] end + # rubocop:enable Metrics/AbcSize # POST /usage_plans_by_template def plans_by_template @@ -37,7 +41,7 @@ def global_statistics # for global usage authorize :usage - data = Org::TotalCountStatService.call + data = Org::TotalCountStatService.call(filtered: parse_filtered) # TODO: Update sep = sep_param data_csvified = Csvable.from_array_of_hashes(data, true, sep) @@ -48,37 +52,15 @@ def global_statistics def org_statistics authorize :usage - data = Org::MonthlyUsageService.call(current_user) + data = Org::MonthlyUsageService.call(current_user, filtered: parse_filtered) sep = sep_param data_csvified = Csvable.from_array_of_hashes(data, true, sep) send_data(data_csvified, filename: "totals.csv") end - # POST /usage_filter + # rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/MethodLength - def filter - # This action is triggered when a user specifies a date range - authorize :usage - - args = args_from_params - plan_data(args: args) - user_data(args: args) - total_plans(args: min_max_dates(args: args)) - total_users(args: min_max_dates(args: args)) - - @topic = usage_params[:topic] - case @topic - when "plans" - @total = @total_org_plans - @ranged = @plans_per_month.sum(:count) - else - @total = @total_org_users - @ranged = @users_per_month.sum(:count) - end - end - # rubocop:enable Metrics/MethodLength - # GET /usage_yearly_users def yearly_users # This action is triggered when a user clicks on the 'download csv' button @@ -87,7 +69,7 @@ def yearly_users user_data(args: default_query_args) sep = sep_param - send_data(CSV.generate({:col_sep => sep}) do |csv| + send_data(CSV.generate(col_sep: sep) do |csv| csv << [_("Month"), _("No. Users joined")] total = 0 @users_per_month.each do |data| @@ -97,7 +79,11 @@ def yearly_users csv << [_("Total"), total] end, filename: "users_joined.csv") end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/MethodLength # GET /usage_yearly_plans def yearly_plans # This action is triggered when a user clicks on the 'download csv' button @@ -106,7 +92,7 @@ def yearly_plans plan_data(args: default_query_args) sep = sep_param - send_data(CSV.generate({:col_sep => sep}) do |csv| + send_data(CSV.generate(col_sep: sep) do |csv| csv << [_("Month"), _("No. Completed Plans")] total = 0 @plans_per_month.each do |data| @@ -116,6 +102,8 @@ def yearly_plans csv << [_("Total"), total] end, filename: "completed_plans.csv") end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength # GET /usage_all_plans_by_template def all_plans_by_template @@ -126,10 +114,11 @@ def all_plans_by_template args = default_query_args args[:start_date] = first_plan_date sep = sep_param - {:col_sep => sep} plan_data(args: args, sort: :desc) + # rubocop:disable Metrics/LineLength data_csvified = StatCreatedPlan.to_csv(@plans_per_month, details: { by_template: true, sep: sep }) + # rubocop:enable Metrics/LineLength send_data(data_csvified, filename: "created_plan_by_template.csv") end @@ -137,7 +126,7 @@ def all_plans_by_template def usage_params params.require(:usage).permit(:template_plans_range, :org_id, :start_date, - :end_date, :topic) + :end_date, :topic, :filtered) end # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity @@ -168,13 +157,18 @@ def default_query_args { org: current_user.org, start_date: Date.today.months_ago(12).end_of_month.strftime("%Y-%m-%d"), - end_date: Date.today.last_month.end_of_month.strftime("%Y-%m-%d") + end_date: Date.today.last_month.end_of_month.strftime("%Y-%m-%d"), + filtered: parse_filtered } end + def parse_filtered + params[:filtered].present? && params[:filtered] == "true" + end + # set the csv separator or default to comma def sep_param - params["sep"] || ',' + params["sep"] || "," end def min_max_dates(args:) @@ -184,16 +178,16 @@ def min_max_dates(args:) end def user_data(args:, as_json: false, sort: :asc) - @users_per_month = StatJoinedUser.monthly_range(args) + @users_per_month = StatJoinedUser.monthly_range(args.except(:filtered)) .order(date: sort) - @users_per_month = @users_per_month.map { |rec| rec.to_json } if as_json + @users_per_month = @users_per_month.map(&:to_json) if as_json end def plan_data(args:, as_json: false, sort: :asc) @plans_per_month = StatCreatedPlan.monthly_range(args) .where.not(details: "{\"by_template\":[]}") .order(date: sort) - @plans_per_month = @plans_per_month.map { |rec| rec.to_json } if as_json + @plans_per_month = @plans_per_month.map(&:to_json) if as_json end def total_plans(args:) @@ -201,7 +195,7 @@ def total_plans(args:) end def total_users(args:) - @total_org_users = StatJoinedUser.monthly_range(args).sum(:count) + @total_org_users = StatJoinedUser.monthly_range(args.except(:filtered)).sum(:count) end def first_plan_date @@ -210,3 +204,4 @@ def first_plan_date end end +# rubocop:enable Metrics/ClassLength diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb index 1f760a0d58..2d7ac08885 100644 --- a/app/controllers/users/invitations_controller.rb +++ b/app/controllers/users/invitations_controller.rb @@ -2,6 +2,12 @@ class Users::InvitationsController < Devise::InvitationsController + include OrgSelectable + + # Creates the selected Org if necessary and then attaches the invited user + # to the Org after Devise does its thing + prepend_after_action :handle_org, only: [:update] + protected # Override require_no_authentication method defined at DeviseController # (parent of Devise::InvitationsController) The following filter gets @@ -19,4 +25,31 @@ def require_no_authentication end end + # Handle the user's Org selection + def handle_org + attrs = update_resource_params + + if attrs[:org_id].present? + # See if the user selected a new Org via the Org Lookup and + # convert it into an Org + lookup = org_from_params(params_in: attrs) + return nil unless lookup.present? + + # If this is a new Org we need to save it first before attaching + # it to the user + if lookup.new_record? + lookup.save + identifiers_from_params(params_in: attrs).each do |identifier| + next unless identifier.value.present? + + identifier.identifiable = lookup + identifier.save + end + lookup.reload + end + + self.resource.update(org_id: lookup.id) + end + end + end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 6a33c782a7..8c4bb7c5f1 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -2,12 +2,18 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController - include Dmptool::Controller::OmniauthCallbacks + # -------------------------------- + # Start DMPTool Customization + # -------------------------------- + include Dmptool::Controllers::Users::OmniauthCallbacksController + # -------------------------------- + # End DMPTool Customization + # -------------------------------- ## # Dynamically build a handler for each omniauth provider # ------------------------------------------------------------- - IdentifierScheme.where(active: true).each do |scheme| + IdentifierScheme.for_authentication.each do |scheme| define_method(scheme.name.downcase) do handle_omniauth(scheme) end @@ -26,7 +32,7 @@ def handle_omniauth(scheme) # -------------------------------------------------------- # Start DMPTool customization # -------------------------------------------------------- - process_omniauth_callback(scheme) + process_omniauth_callback(scheme: scheme) # DMPTool -- commented out the entire block below @@ -61,36 +67,29 @@ def handle_omniauth(scheme) # else # # If the user could not be found by that uid then attach it to their record # if user.nil? - # if UserIdentifier.create(identifier_scheme: scheme, - # identifier: request.env["omniauth.auth"].uid, - # user: current_user) - # # rubocop:disable LineLength + # if Identifier.create(identifier_scheme: scheme, + # value: request.env["omniauth.auth"].uid, + # attrs: request.env["omniauth.auth"], + # identifiable: current_user) + # # rubocop:disable Metrics/LineLength # flash[:notice] = _("Your account has been successfully linked to %{scheme}.") % { # scheme: scheme.description # } - # # rubocop:enable LineLength + # # rubocop:enable Metrics/LineLength # else # flash[:alert] = _("Unable to link your account to %{scheme}.") % { # scheme: scheme.description # } # end - # - # else + + # 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) - # identifier = UserIdentifier.where( - # identifier: request.env["omniauth.auth"].uid - # ).first - # if identifier.user.id != current_user.id - # # rubocop:disable LineLength - # flash[:alert] = _("The current #{scheme.description} iD has been already linked to a user with email #{identifier.user.email}") - # # rubocop:enable LineLength - # end - # - # # Otherwise, the identifier was found and it matches the one already associated - # # with the current user so nothing else needs to be done + # # rubocop:disable Metrics/LineLength + # flash[:alert] = _("The current #{scheme.description} iD has been already linked to a user with email #{identifier.user.email}") + # # rubocop:enable Metrics/LineLength # end - # + # # Redirect to the User Profile page # redirect_to edit_user_registration_path # end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ea09a3d0bc..0c779fec41 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -6,8 +6,6 @@ class UsersController < ApplicationController helper PermsHelper include ConditionalUserMailer - include Dmptool::Controller::Users - after_action :verify_authorized respond_to :html @@ -19,6 +17,9 @@ def admin_index respond_to do |format| format.html do + @clicked_through = params[:click_through].present? + @filter_admin = false + if current_user.can_super_admin? @users = User.includes(:roles).page(1) else diff --git a/app/helpers/conditions_helper.rb b/app/helpers/conditions_helper.rb new file mode 100644 index 0000000000..6de8740c97 --- /dev/null +++ b/app/helpers/conditions_helper.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true + +module ConditionsHelper + + + # return a list of question ids to open/hide + def remove_list(object) + id_list = [] + if object.is_a?(Plan) + plan_answers = object.answers + elsif object.is_a?(Hash) + plan_answers = object[:answers] + else + # TODO: change this to an exception as it shouldn't happen + return [] + end + plan_answers.each do |answer| + id_list += answer_remove_list(answer) + end + id_list + end + + # returns an array of ids to remove based on the conditions associated with an answer + # or trigger the email (TODO: combining these is a bit icky!) + def answer_remove_list(answer, user = nil) + id_list = [] + return id_list unless answer.question.option_based? + answer.question.conditions.each do |cond| + opts = cond.option_list.map{ |s| s.to_i }.sort + action = cond.action_type + chosen = answer.question_option_ids.sort + if chosen == opts + if action == "remove" + rems = cond.remove_data.map{ |s| s.to_i } + id_list += rems + elsif !user.nil? + UserMailer.question_answered(JSON.parse(cond.webhook_data), user, answer, chosen.join(" and ")).deliver_now + end + end + end + # uniq because could get same remove id from diff conds + id_list.uniq + end + + def send_webhooks(user, answer) + answer_remove_list(answer, user) + end + + def email_trigger_list(answer) + email_list = [] + return email_list unless answer.question.option_based? + answer.question.conditions.each do |cond| + opts = cond.option_list.map{ |s| s.to_i }.sort + action = cond.action_type + chosen = answer.question_option_ids.sort + if chosen == opts + if action == "add_webhook" + email_list << JSON.parse(cond.webhook_data)["email"] + end + end + end + # uniq because could get same remove id from diff conds + email_list.uniq.join(',') + end + + # number of answers in a section after answers updated with conditions + def num_section_answers(plan, section) + count = 0 + plan_remove_list = remove_list(plan) + plan.answers.each do |answer| + if answer.question.section.id == section.id && + !plan_remove_list.include?(answer.question.id) && + section.answered_questions(plan).include?(answer) && + answer.answered? + count += 1 + end + end + count + end + + # number of questions in a section after update with conditions + def num_section_questions(plan, section, phase = nil) + # when section and phase are a hash in exports + if section.is_a?(Hash) && + !phase.nil? && + plan.is_a?(Plan) + phase_id = plan.phases.where(number: phase[:number]).first.id + section = plan.sections.where(phase_id: phase_id, title: section[:title]).first + end + count = 0 + plan_remove_list = remove_list(plan) + plan.questions.each do |question| + if question.section.id == section.id && + !plan_remove_list.include?(question.id) + count += 1 + end + end + count + end + + # returns an array of hashes of section_id, number of section questions, and number of section answers + def sections_info(plan) + return [] if plan.nil? + + info = [] + plan.sections.each do |section| + info.push(section_info(plan, section)) + end + info + end + + def section_info(plan, section) + section_hash = {} + section_hash[:id] = section.id + section_hash[:no_qns] = num_section_questions(plan, section) + section_hash[:no_ans] = num_section_answers(plan, section) + section_hash + end + + # collection of questions that could be removed by this question + # basically all question forward if this one + # in a form which is suitable for the bootstrap-select menus + # of the form + # { secion_title => [ + # [question_title, question_id], + # [question_title, question_id], + # ... + # ] + # } + def later_question_list(question) + collection = {} + question.section.phase.template.phases.each do |phase| + next if phase.number < question.phase.number + + phase.sections.each do |section| + next if phase.number == question.phase.number && + section.number < question.section.number + + # original funder template sections will not be modifiable + next unless section.modifiable + + section.questions.each do |q| + next if phase.number == question.phase.number && + section.number == question.section.number && + q.number <= question.number + + key = section_title(section) + + if collection.has_key?(key) + collection[key] += [[question_title(q), q.id]] + else + collection[key] = [[question_title(q), q.id]] + end + end + end + end + collection + end + + + def question_title(question) + raw "Qn. " + question.number.to_s + ": " + + truncate(strip_tags(question.text), + length: 50, + separator: " ", + escape: false) + end + + def section_title(section) + raw "Sec. " + section.number.to_s + ": " + + truncate(strip_tags(section.title), + length: 50, + separator: " ", + escape: false) + end + + + # used when displaying a question while editing the template + # converts condition into text + def condition_to_text(conditions) + return_string = "" + conditions.each do |cond| + opts = cond.option_list.map{ |opt| QuestionOption.find(opt).text } + return_string += "" if return_string.length > 0 + 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 + '"' } + + if rems.length == 1 + return_string += _(" will remove question ") + return_string += rems.join(" and ") + else + return_string += _(" will remove questions ") + return_string += rems.join(" and ") + end + end + end + return_string + "
" + end + + def text_formatted(object) + length = 50 + if object.kind_of?(Integer) # when remove question id + text = Question.find(object).text + elsif object.kind_of?(String) # when email subject + text = object + length = 30 + else + pp 'type error' + end + cleaned_text = text + text = ActionController::Base.helpers.truncate(cleaned_text, length: length, separator: " ", escape: false) + text = _('"') + text + _('"') + end + + # convert a set of conditions into multi-select form + def conditions_to_param_form(conditions) + param_conditions = {} + conditions.each do |condition| + title = "condition" + condition[:number].to_s + condition_hash = {title => + {question_option_id: condition.option_list, + action_type: condition.action_type, + number: condition.number, + remove_question_id: condition.remove_data, + webhook_data: condition.webhook_data} + } + if param_conditions.has_key?(title) + param_conditions[title].merge!(condition_hash[title]) do |key, val1, val2| + if val1.kind_of?(Array) && !val1.include?(val2[0]) + val1 + val2 + else + val1 + end + end + else + param_conditions.merge!(condition_hash) + end + end + param_conditions + end + + # returns an hash of hashes of webhook data given a condition array + def webhook_hash(conditions) + web_hash = {} + param_conditions = conditions_to_param_form(conditions) + param_conditions.each do |title, params| + web_hash.merge!(params[:number] => params[:webhook_data]) + end + web_hash + end +end diff --git a/app/javascript/dmptool/views/shared/org_branding.js b/app/javascript/dmptool/views/shared/org_branding.js new file mode 100644 index 0000000000..35929c21f3 --- /dev/null +++ b/app/javascript/dmptool/views/shared/org_branding.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/dmptool/views/shared/signin_create_form.js b/app/javascript/dmptool/views/shared/signin_create_form.js index 3e8e6ad269..6589cb78d6 100644 --- a/app/javascript/dmptool/views/shared/signin_create_form.js +++ b/app/javascript/dmptool/views/shared/signin_create_form.js @@ -1,12 +1,12 @@ /* eslint-env browser */ // This allows us to reference 'window' below import * as Cookies from 'js-cookie'; -import initAutoComplete from '../../../utils/autoComplete'; +import { initAutocomplete } from '../../../utils/autoComplete'; import { isObject, isString } from '../../../utils/isType'; -import { renderAlert, renderNotice } from '../../../utils/notificationHelper'; import getConstant from '../../../constants'; $(() => { - initAutoComplete(); + initAutocomplete('#create-account-org-controls .autocomplete'); + initAutocomplete('#shib-ds-org-controls .autocomplete'); const email = Cookies.get('dmproadmap_email'); // Signin remember me @@ -71,32 +71,6 @@ $(() => { toggleSignInCreateAccount(true); }); - // Old LDAP username lookup - // ----------------------------------------------------- - // Handling ldap username lookup here to take advantage of shared signin-create logic - $('form#forgot_email_form').on('ajax:success', (e, data) => { - if (isObject(data) && isString(data.msg)) { - if (data.code === 0) { - renderAlert(data.msg); - } else { - renderNotice(data.msg); - } - if (data.email === '' || data.email === null) { - // - } else { - toggleSignInCreateAccount(true); - $('#sign-in-create-account').modal('show'); - $('#signin_user_email').val(data.email); - } - } - }); - $('form#forgot_email_form').on('ajax:error', (e, xhr) => { - const error = xhr.responseJSON; - if (isObject(error) && isString(error)) { - renderAlert(error.msg); - } - }); - // Shibboleth DS // ----------------------------------------------------- const logoSuccess = (data) => { @@ -115,17 +89,7 @@ $(() => { $('#access-control-tabs a[data-target="#sign-in-form"]').tab('show'); }; - $('.org-sign-in').click((e) => { - const target = $(e.target); - $('#org-sign-in').html(''); - $.ajax({ - method: 'GET', - url: target.attr('href'), - }).done((data) => { - logoSuccess(data); - }, logoError); - e.preventDefault(); - }); + // Toggles the full Org list on/off $('#show_list').click((e) => { e.preventDefault(); @@ -139,24 +103,43 @@ $(() => { } }); - // When the user clicks 'Go' click the corresponding link from the list - // of all orgs - $('#org-select-go').click((e) => { - e.preventDefault(); - const id = $('#shib-ds_org_id').val(); - if (isString(id)) { - const link = $(`a[data-content="${id}"]`); - if (isObject(link)) { - // If the org doesn't have a shib setup then display the org sign in modal - if (link.is('.org-sign-in')) { - link.click(); - } else { - window.location.replace(link.attr('href')); - } + // 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', (e, 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) => { diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 16b969c38d..05866cbcfe 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -1,10 +1,12 @@ // Rails setup import 'jquery-ujs'; import 'jquery-accessible-autocomplete-list-aria/jquery-accessible-autocomplete-list-aria'; +import 'bootstrap-select/js/bootstrap-select'; // Generic JS that is applicable across multiple pages import '../utils/array'; import '../utils/charts'; +import '../utils/autoComplete'; import '../utils/externalLink'; import '../utils/paginable'; import '../utils/panelHeading'; @@ -14,10 +16,15 @@ import '../utils/tabHelper'; import '../utils/tooltipHelper'; import '../utils/popoverHelper'; import '../utils/requiredField'; +import '../utils/tinymce.js.erb'; +import '../utils/sectionUpdate'; // Page specific JS import '../views/answers/edit'; +import '../views/answers/conditions'; import '../views/answers/rda_metadata'; +import '../views/contributors/form'; +import '../views/devise/invitations/edit'; import '../views/devise/passwords/edit'; import '../views/devise/registrations/edit'; import '../views/guidances/new_edit'; @@ -48,15 +55,6 @@ import '../views/plans/new'; import '../views/plans/share'; import '../views/roles/edit'; import '../views/shared/create_account_form'; - -// ---------------------------------------- -// START DMPTool customization -// ---------------------------------------- -// import '../views/shared/my_org'; -// ---------------------------------------- -// END DMPTool customization -// ---------------------------------------- - import '../views/shared/sign_in_form'; import '../views/super_admin/themes/new_edit'; import '../views/super_admin/users/edit'; @@ -70,6 +68,7 @@ import '../views/public_templates/show'; // START DMPTool customization // ---------------------------------------- import '../dmptool/views/home/index'; +import '../dmptool/views/shared/org_branding'; import '../dmptool/views/shared/signin_create_form'; // ---------------------------------------- // END DMPTool customization diff --git a/app/javascript/utils/autoComplete.js b/app/javascript/utils/autoComplete.js index 248f42cd51..7ddab409ea 100644 --- a/app/javascript/utils/autoComplete.js +++ b/app/javascript/utils/autoComplete.js @@ -1,88 +1,172 @@ -import debounce from './debounce'; -import { isObject } from './isType'; -import { isValidText } from './isValidInputType'; - -/* - * Looks up the id for the text selected by the user in the jquery autocomplete combobox and - * then sets updates the hidden id field with the id value so that its available on form submit. - * The id-text mappings are stored as JSON in the corresponding hidden crosswalk field - * @param the combobox element - */ -const updateIdField = (el) => { - const crosswalk = $(`#${$(el).attr('id')}_crosswalk`); - const idField = $(el).attr('id').replace(/_name/, '_id'); - if (isObject(crosswalk) && isObject($(idField))) { - const json = JSON.parse(`${$(crosswalk).val().replace(/\\"/g, '"').replace(/\\'/g, '\'')}`); - const selection = (json[$(el).val()] === undefined ? '' : json[$(el).val()]); - $(el).parent().siblings(`#${idField}`).val(selection) - .change(); +import 'jquery-ui/ui/widgets/autocomplete'; + +import getConstant from '../constants'; +import { isObject, isString, isArray } from './isType'; + +// Updates the ARIA help text that lets the user know how many suggestions +const updateAriaHelper = (autocomplete, suggestionCount) => { + if (isObject(autocomplete)) { + const helper = autocomplete.siblings('.autocomplete-help'); + + if (isObject(helper)) { + const text = getConstant('AUTOCOMPLETE_ARIA_HELPER'); + helper.html(text.replace('%{n}', suggestionCount)); + } else { + helper.html(getConstant('AUTOCOMPLETE_ARIA_HELPER_EMPTY')); + } } }; -/* - * The accessible autocomplete box escapes characters so we need to decode any valid ones - * so that they appear correctly to the user and are able to be matched to the JSON list - * so we can retrieve the correct org id. - * We only decode certain characters here by design. - */ -const decodeHtml = (el) => { - if (isObject(el)) { - return $(el).val() - .replace(/&/g, '&') - .replace(/'/g, '\'') - .replace(/"/g, '"'); +// Places the results into the crosswalk, updates the Aria helper and then +// extracts the 'name' from each result and returns it for consumption by +// the JQuery UI autocomplete widget +const processAjaxResults = (autocomplete, crosswalk, results) => { + let out = []; + + if (isObject(autocomplete) && isObject(crosswalk) && isArray(results)) { + crosswalk.attr('value', JSON.stringify(results)); + updateAriaHelper(autocomplete, results.length); + out = results.map(item => item.name); + } else { + crosswalk.attr('value', JSON.stringify([])); + updateAriaHelper(autocomplete, 0); } - return ''; + return out; +}; + +// Extract the AJAX query arguments from the autocomplete +const queryArgs = (autocomplete, searchTerm) => { + const namespace = autocomplete.attr('data-namespace'); + const attribute = autocomplete.attr('data-attribute'); + + return `{"${namespace}":{"${attribute}":"${searchTerm}"}}`; }; -/* - * Shows/hides the combobox's clear button based on whether or not text is present - * @param the combobox id - */ -const toggleClearButton = (el) => { - const clearButton = $(el).parent().find('.combobox-clear-button'); - if (isObject(clearButton)) { - if (isValidText($(el).val())) { - $(clearButton).removeClass('hidden'); +// Makes an AJAX request to the specified target +const search = (autocomplete, term, crosswalk, callback) => { + if (isObject(autocomplete) && isObject(crosswalk) && isString(term)) { + const url = autocomplete.attr('data-url'); + const method = autocomplete.attr('data-method'); + const data = JSON.parse(queryArgs(autocomplete, term)); + + if (isString(url) && term.length > 2) { + $.ajax({ + url, method, data, + }).done((results) => { + callback(processAjaxResults(autocomplete, crosswalk, results)); + }).fail(() => { + callback(processAjaxResults(autocomplete, crosswalk, [])); + }); + } + } +}; + +const toggleWarning = (autocomplete, displayIt) => { + const warning = autocomplete.siblings('.autocomplete-warning'); + + if (warning.length > 0) { + if (displayIt) { + warning.removeClass('hide').show(); } else { - $(clearButton).addClass('hidden'); + warning.addClass('hide').hide(); } } }; -/* - * Wires up the jquery autocomplete combobox so that it calls the above 2 functions when the - * user changes the text values in the combobox by typing or selecting a value - */ -export default () => { - $('.js-combobox').each((idx, el) => { - // Swap out the 'X' with a fontawesome icon - $(el).siblings('.combobox-clear-button').text('') - .addClass('fa') - .addClass('fa-times-circle'); - - const debounced = debounce((e) => { - toggleClearButton(e); - updateIdField(e); - }, 100); - - // When the value in the combobox changes update the hidden id field - $(el).on('keyup focus', (e) => { - const txt = $(e.target); - $(txt).val(decodeHtml(txt)); - debounced(txt); - }); - - // Clear the text and hide the button when the user clicks the clear button - $(el).parent().find('.combobox-clear-button').on('click', () => { - $(el).val(''); - debounced(el); - }); - - // add a Bootstrap 'hide' class to the invisible help text - $('.invisible').addClass('hide'); - - // Show/hide the clear button on page load - toggleClearButton(el); - }); +// Looks up the value in the crosswalk +const findInCrosswalk = (selection, crosswalk) => { + // Default to the name only + let out = JSON.stringify({ name: selection }); + // If the user selected an item and the crosswalk exists then try to + // find it in the crosswalk. + if (selection.length > 0 && crosswalk.length > 0) { + const json = JSON.parse(crosswalk.val()); + const found = json.find(item => item != null && item.name === selection); + // If the crosswalk was empty then out becomes undefined + out = (found === undefined ? out : JSON.stringify(found)); + } + return out; +}; + +// Returns false if the selection is the default `{"name":"[value]"}` +const warnableSelection = (selection) => { + if (selection.length > 0) { + const json = Object.keys(JSON.parse(selection)); + return (json.length <= 1 && json[0] === 'name'); + } + return false; }; + +// Updates the hidden id field with the contents from the crosswalk for the +// selected name +const handleSelection = (autocomplete, hidden, crosswalk, selection) => { + const out = findInCrosswalk(selection, crosswalk); + + toggleWarning(autocomplete, warnableSelection(out)); + + // Set the ID and trigger the onChange event for any view specific + // JS to trigger events + hidden.val(out).trigger('change'); + return true; +}; + +export const initAutocomplete = (selector) => { + if (isString(selector)) { + const context = $(selector); + + if (isObject(context) && context.length > 0) { + const id = context.attr('id'); + const front = context.siblings('div[id$="_ui-front"]'); + const crosswalk = context.siblings(`#${id.replace('_name', '_crosswalk')}`); + const hidden = context.siblings('.autocomplete-result'); + + toggleWarning(context, false); + + // If the crosswalk is empty, make sure it is valid JSON + if (!crosswalk.val()) { + crosswalk.val(JSON.stringify([])); + } + + // If a data-url was defined then this is an AJAX autocomplete + if (context.attr('data-url') && isObject(crosswalk)) { + // Setup the autocomplete and set it's source to the appropriate + context.autocomplete({ + source: (req, resp) => search(context, req.term, crosswalk, resp), + select: (e, ui) => handleSelection(context, hidden, crosswalk, ui.item.label), + minLength: 3, + delay: 600, + appendTo: front, + }); + } else { + const source = context.siblings(`#${id.replace('_name', '_sources')}`); + if (source) { + // Setup the autocomplete and set it's source to the appropriate + context.autocomplete({ + source: JSON.parse(source.val()), + select: (e, ui) => handleSelection(context, hidden, crosswalk, ui.item.label), + minLength: 1, + delay: 300, + appendTo: front, + }); + } + } + + // Handle manual entry (instead of autocomplete selection) + context.on('keyup', (e) => { + const code = (e.keyCode || e.which); + // Only pay attention to key presses that would actually + // change the contents of the field + if ((code >= 48 && code <= 111) || (code >= 144 && code <= 222) + || code === 8 || code === 9) { + handleSelection(context, hidden, crosswalk, context.val()); + } + }); + + // Set the hidden id field to the value in the crosswalk + // or the default `{"name":"[value in textbox]"}` + hidden.val(findInCrosswalk(context.val(), crosswalk)); + } + } +}; + +export { initAutocomplete as default }; diff --git a/app/javascript/utils/charts.js b/app/javascript/utils/charts.js index 7dc776c14a..50ba9a44d3 100644 --- a/app/javascript/utils/charts.js +++ b/app/javascript/utils/charts.js @@ -60,8 +60,8 @@ export const initializeCharts = () => { }); }; -export const createChart = (selector, data, appendTolabel = '') => { - new Chart($(selector), { // eslint-disable-line no-new +export const createChart = (selector, data, appendTolabel = '', onClickHandler = null) => { + const chart = new Chart($(selector), { // eslint-disable-line no-new type: 'bar', data: { labels: Object.keys(data), @@ -85,8 +85,10 @@ export const createChart = (selector, data, appendTolabel = '') => { ticks: { min: 0, suggestedMax: 50 }, }], }, + onClick: onClickHandler, }, }); + return chart; }; export const drawHorizontalBar = (canvasSelector, data) => { diff --git a/app/javascript/utils/paginable.js b/app/javascript/utils/paginable.js index 9424029d97..ca72f11e02 100644 --- a/app/javascript/utils/paginable.js +++ b/app/javascript/utils/paginable.js @@ -1,4 +1,3 @@ -import { isValidText } from './isValidInputType'; export const paginableSelector = '.paginable'; @@ -6,7 +5,6 @@ $(() => { const onAjaxSuccessHandler = (e, data) => { $(e.target).closest(paginableSelector).replaceWith($(data)); }; - $('.paginable-search form[data-remote="true"]').on('ajax:before', e => isValidText($(e.target).find('input[name="search"]').val())); // Event listener for Ajax success event captured in response to a paginable link clicked or // search form submitted. Note the presence of a selector for on (e.g. a[data-remote="true"]) // so that descendant elements from .paginable-results that are added in future are also diff --git a/app/javascript/utils/sectionUpdate.js b/app/javascript/utils/sectionUpdate.js new file mode 100644 index 0000000000..f7af5561c2 --- /dev/null +++ b/app/javascript/utils/sectionUpdate.js @@ -0,0 +1,22 @@ +// update details in section progress panel +export const updateSectionProgress = (id, numSecAnswers, numSecQuestions) => { + const progressDiv = $(`#section-panel-${id}`).find('.section-status'); + progressDiv.html(`(${numSecAnswers} / ${numSecQuestions})`); + const heading = progressDiv.closest('.panel-heading'); + if (numSecQuestions === 0) { // disable section if empty + if (heading.parent().attr('aria-expanded') === 'true') { + heading.parent().trigger('click'); + } + heading.addClass('empty-section'); + heading.closest('.panel').find(`#collapse-${id}`).hide(); + heading.closest('.panel').find('i.fa-plus, i.fa-minus').removeClass('fa-plus').removeClass('fa-minus'); + } else if (heading.hasClass('empty-section')) { // enable section if questions re-added + heading.removeClass('empty-section'); + heading.closest('.panel').find('i[aria-hidden="true"]').addClass('fa-plus'); + heading.closest('.panel').find(`#collapse-${id}`).css('display', ''); + } +}; + +// given a question id find the containing div +// used inconditional questions +export const getQuestionDiv = id => $(`#answer-form-${id}`).closest('.row'); diff --git a/app/javascript/views/answers/conditions.js b/app/javascript/views/answers/conditions.js new file mode 100644 index 0000000000..833ad7f8c5 --- /dev/null +++ b/app/javascript/views/answers/conditions.js @@ -0,0 +1,20 @@ +import { updateSectionProgress, getQuestionDiv } from '../../utils/sectionUpdate'; + +$(() => { + if ($('.answering-phase').length > 0) { // check phase has (standard) questions + // hide already removed questions on load + const removeData = $('#progress-data').data('remove'); + removeData.forEach((id) => { + getQuestionDiv(id).hide(); + }); + + // update progress on section panel on load + const sectionsInfo = $('#progress-data').data('sections'); + sectionsInfo.forEach((sectionInfo) => { + const forms = $(`#collapse-${sectionInfo.id}`).find('form'); + if (forms.length > 0) { // ensure current phase + updateSectionProgress(sectionInfo.id, sectionInfo.no_ans, sectionInfo.no_qns); + } + }); + } +}); diff --git a/app/javascript/views/answers/edit.js b/app/javascript/views/answers/edit.js index f3ee42903a..2f71c0cc68 100644 --- a/app/javascript/views/answers/edit.js +++ b/app/javascript/views/answers/edit.js @@ -5,6 +5,7 @@ import { } from '../../utils/isType'; import { Tinymce } from '../../utils/tinymce.js.erb'; import debounce from '../../utils/debounce'; +import { updateSectionProgress, getQuestionDiv } from '../../utils/sectionUpdate'; import datePicker from '../../utils/datePicker'; import TimeagoFactory from '../../utils/timeagoFactory'; @@ -16,6 +17,17 @@ $(() => { const questionId = jQuery => jQuery.closest('.form-answer').attr('data-autosave'); const isStale = jQuery => jQuery.closest('.question-form').find('.answer-locking').text().trim().length !== 0; const isReadOnly = () => $('.form-answer fieldset:disabled').length > 0; + const showOrHideQuestions = (data) => { + data.section_data.forEach((section) => { + updateSectionProgress(section.sec_id, section.no_ans, section.no_qns); + }); + data.qn_data.to_hide.forEach((questionid) => { + getQuestionDiv(questionid).slideUp(); + }); + data.qn_data.to_show.forEach((questionid) => { + getQuestionDiv(questionid).slideDown(); + }); + }; /* * A map of debounced functions, one for each input, textarea or select change at any * form with class form-answer. The key represents a question id and the value holds @@ -64,13 +76,7 @@ $(() => { $('.progress').html(data.plan.progress); } } - if (isObject(data.section)) { // Object related to section within data received - if (isNumber(data.section.id)) { - if (isString(data.section.progress)) { - $(`.section-progress-${data.section.id}`).html(data.section.progress); - } - } - } + showOrHideQuestions(data); } }; const failCallback = (error, jQuery) => { diff --git a/app/javascript/views/contributors/form.js b/app/javascript/views/contributors/form.js new file mode 100644 index 0000000000..c6cfea9af8 --- /dev/null +++ b/app/javascript/views/contributors/form.js @@ -0,0 +1,5 @@ +import { initAutocomplete } from '../../utils/autoComplete'; + +$(() => { + initAutocomplete('#contributor-org-controls .autocomplete'); +}); diff --git a/app/javascript/views/devise/invitations/edit.js b/app/javascript/views/devise/invitations/edit.js new file mode 100644 index 0000000000..8cd9c5f0e3 --- /dev/null +++ b/app/javascript/views/devise/invitations/edit.js @@ -0,0 +1,5 @@ +import { initAutocomplete } from '../../../utils/autoComplete'; + +$(() => { + initAutocomplete('#invite-org-controls .autocomplete'); +}); diff --git a/app/javascript/views/devise/registrations/edit.js b/app/javascript/views/devise/registrations/edit.js index 5731a49d42..aaadd0a027 100644 --- a/app/javascript/views/devise/registrations/edit.js +++ b/app/javascript/views/devise/registrations/edit.js @@ -1,4 +1,4 @@ -import { initOrgSelection, validateOrgSelection } from '../../shared/my_org'; +import { initAutocomplete } from '../../../utils/autoComplete'; import { isString } from '../../../utils/isType'; import { isValidPassword } from '../../../utils/isValidInputType'; import { addMatchingPasswordValidator, togglisePasswords } from '../../../utils/passwordHelper'; @@ -6,14 +6,7 @@ import { addMatchingPasswordValidator, togglisePasswords } from '../../../utils/ $(() => { addMatchingPasswordValidator({ selector: '#password_details_registration_form' }); togglisePasswords({ selector: '#password_details_registration_form' }); - initOrgSelection({ selector: '#org-controls' }); - - $('#personal_details_registration_form').on('submit', (e) => { - // Additional validation to force the user to choose an org or type something for other - if (!validateOrgSelection({ selector: '#personal_details_registration_form' })) { - e.preventDefault(); - } - }); + initAutocomplete('#profile-org-controls .autocomplete'); const sensitiveInfoCheck = (event) => { const originalEmail = $('#original_email').val(); diff --git a/app/javascript/views/devise/registrations/new.js b/app/javascript/views/devise/registrations/new.js new file mode 100644 index 0000000000..237484c297 --- /dev/null +++ b/app/javascript/views/devise/registrations/new.js @@ -0,0 +1,8 @@ +import { initAutocomplete } from '../../../utils/autoComplete'; + +$(() => { + // Org selector on the /users/sign_up page that loads after a user + // signs in via institutional credentials but has no matching user record + initAutocomplete('#create-account-org-controls .autocomplete'); +}); + diff --git a/app/javascript/views/org_admin/conditions/updateConditions.js b/app/javascript/views/org_admin/conditions/updateConditions.js new file mode 100644 index 0000000000..56a072b833 --- /dev/null +++ b/app/javascript/views/org_admin/conditions/updateConditions.js @@ -0,0 +1,123 @@ +import { isObject } from '../../../utils/isType'; + +// Attach handlers for changing the conditions of a question +export default function updateConditions(id) { + const parent = $(`#${id}.question_container`); + const content = parent.find('#content'); + content.html(''); + const addLogicButton = parent.find('a.add-logic[data-remote="true"]'); + + // display conditions already saved + if (addLogicButton.length > 0) { + if (addLogicButton.attr('data-loaded').toString() === 'true') { + addLogicButton.trigger('click'); + } + } + + // set up selectpicker select boxes for condition options + const setSelectPicker = () => { + $('.selectpicker.narrow').selectpicker({ width: 120 }); + $('.selectpicker.regular').selectpicker({ width: 150 }); + }; + + // test if a webhook is selected and set up if so + const allowWebhook = (selectObject, webhook = false) => { // webhook false => new condition + const condition = $(selectObject).closest('.condition-partial'); + if (webhook === false) { + if ($(selectObject).val() === 'add_webhook') { // condition type is webhook + condition.find('.pseudo-webhook-btn').trigger('click'); + } else { // condition type is remove + condition.find('.remove-dropdown').show(); + condition.find('.webhook-replacement').hide(); + } + } else { // loading already saved conditions + // populate webhook inputs + const nameString = condition.find('select.action-type').attr('name'); + const nameStart = nameString.substring(0, nameString.length - 13); + const fields = ['name', 'email', 'subject', 'message']; + fields.forEach((field, idx) => { + let inputType = 'input'; + if (idx === 3) { + inputType = 'textarea'; + } + condition.find(`${inputType}[name="${nameStart}[webhook-${field}]"]`).val(JSON.parse(webhook)[`${field}`]); + }); + $(selectObject).on('change', () => { + allowWebhook(selectObject, undefined); + }); + } + // allow discarding of webhook data on click of exit symbol + const exit = condition.find('.discard'); + exit.on('click', () => { + exit.closest('.modal').find('.form-control').each((idx, field) => { + $(field).val(''); + }); + }); + if ($(selectObject).val() === 'add_webhook') { + // display edit email section + condition.find('.remove-dropdown').hide(); + condition.find('.webhook-replacement').show(); + condition.find('.webhook-replacement').on('click', (event) => { + event.preventDefault(); + condition.find('.pseudo-webhook-btn').trigger('click'); + }); + } + }; + + // setup when to test for a webhook selected + const webhookSelected = (selectObject, webhook = false) => { + if (webhook) { // current list of conditions + allowWebhook(selectObject, webhook); + } else { // new condition is added + $(selectObject).on('change', () => { + allowWebhook(selectObject, undefined); + }); + } + }; + + // webhook form + const webhookForm = (webhooks = false, selectObject = false) => { + if (selectObject === false) { + $('.selectpicker.action-type').each((idx, selectObject2) => { + webhookSelected(selectObject2, webhooks[idx]); + }); + } else { + webhookSelected(selectObject, undefined); + } + }; + + // display conditions (editing) upon click of 'Add Logic' + parent.on('ajax:success', 'a.add-logic[data-remote="true"]', (e, data) => { + addLogicButton.attr('data-loaded', 'true'); + addLogicButton.addClass('disabled'); + addLogicButton.blur(); + addLogicButton.text('Conditions'); + if (isObject(content)) { + content.html(data.container); + } + setSelectPicker(); + webhookForm(data.webhooks, undefined); + }); + + // add condition + parent.on('ajax:success', 'a.add-condition[data-remote="true"]', (e, data) => { + const conditionList = $(e.target).closest('#condition-container').find('.condition-list'); + const addDiv = $(e.target).closest('#condition-container').find('.add-condition-div'); + if (isObject(conditionList)) { + conditionList.attr('data-loaded', 'true'); + conditionList.append(data.attachment_partial); + addDiv.html(data.add_link); + conditionList.attr('data-loaded', 'false'); + setSelectPicker(); + const selectObject = conditionList.find('.selectpicker.action-type').last(); + webhookForm(undefined, selectObject); + } + }); + + // remove condition + parent.on('click', '.delete-condition', (e) => { + e.preventDefault(); + const source = $(e.target).closest('.condition-partial'); + source.empty(); + }); +} diff --git a/app/javascript/views/org_admin/phases/new_edit.js b/app/javascript/views/org_admin/phases/new_edit.js index 409889607d..530afb8e8c 100644 --- a/app/javascript/views/org_admin/phases/new_edit.js +++ b/app/javascript/views/org_admin/phases/new_edit.js @@ -7,6 +7,7 @@ import { addAsterisks } from '../../../utils/requiredField'; import onChangeQuestionFormat from '../questions/sharedEventHandlers'; import initQuestionOption from '../question_options/index'; +import updateConditions from '../conditions/updateConditions'; $(() => { // Attach handlers for the expand/collapse all accordions @@ -134,6 +135,7 @@ $(() => { // Display the section's html panelBody.html(data); initQuestion(id); + updateConditions(id); if (panelBody.is('.new-question')) { target.hide(); } diff --git a/app/javascript/views/org_admin/question_options/index.js b/app/javascript/views/org_admin/question_options/index.js index 4a30c53004..fa4a5123b6 100644 --- a/app/javascript/views/org_admin/question_options/index.js +++ b/app/javascript/views/org_admin/question_options/index.js @@ -3,7 +3,8 @@ export default (context) => { e.preventDefault(); const source = $(e.target).closest('[data-attribute="question_option"]'); source.find('.destroy-question-option').val(true); - source.hide(); + source.next().remove(); + source.remove(); // $(source).closest('[data-attribute="question_option"]').remove(); }); $(`#${context} .new_question_option`).on('click', (e) => { diff --git a/app/javascript/views/org_admin/templates/index.js b/app/javascript/views/org_admin/templates/index.js index aeaf2526f7..1a176a1005 100644 --- a/app/javascript/views/org_admin/templates/index.js +++ b/app/javascript/views/org_admin/templates/index.js @@ -1,6 +1,12 @@ +import { initAutocomplete } from '../../../utils/autoComplete'; + $(() => { // Update the contents of the table when user clicks on a scope link $('.template-scope').on('ajax:success', 'a[data-remote="true"]', (e, data) => { $(e.target).closest('.template-scope').find('.paginable').html(data); }); + + if ($('#super-admin-switch-org').length > 0) { + initAutocomplete('#super-admin-switch-org .autocomplete'); + } }); diff --git a/app/javascript/views/orgs/admin_edit.js b/app/javascript/views/orgs/admin_edit.js index c184bd117e..d23699bb49 100644 --- a/app/javascript/views/orgs/admin_edit.js +++ b/app/javascript/views/orgs/admin_edit.js @@ -3,6 +3,7 @@ import 'number-to-text/converters/en-us'; import { isObject } from '../../utils/isType'; import { Tinymce } from '../../utils/tinymce.js.erb'; import { eachLinks } from '../../utils/links'; +import { initAutocomplete } from '../../utils/autoComplete'; $(() => { const toggleFeedback = () => { @@ -24,6 +25,9 @@ $(() => { Tinymce.init({ selector: '#org_feedback_email_msg' }); toggleFeedback(); + if ($('#org-details-org-controls').length > 0) { + initAutocomplete('#org-details-org-controls .autocomplete'); + } // update the hidden org_type field based on the checkboxes selected const calculateOrgType = () => { diff --git a/app/javascript/views/orgs/shibboleth_ds.js b/app/javascript/views/orgs/shibboleth_ds.js index 853eaaac52..40bb168c2c 100644 --- a/app/javascript/views/orgs/shibboleth_ds.js +++ b/app/javascript/views/orgs/shibboleth_ds.js @@ -1,16 +1,6 @@ import getConstant from '../../constants'; -import { initOrgSelection, validateOrgSelection } from '../shared/my_org'; $(() => { - initOrgSelection({ selector: '#personal_details_registration_form' }); - - $('#personal_details_registration_form').on('submit', (e) => { - // Additional validation to force the user to choose an org or type something for other - if (!validateOrgSelection({ selector: '#personal_details_registration_form' })) { - e.preventDefault(); - } - }); - $('#show_list').click((e) => { e.preventDefault(); if ($('#full_list').is('.hidden')) { diff --git a/app/javascript/views/plans/edit_details.js b/app/javascript/views/plans/edit_details.js index 6d655a4080..ffe6a88e3c 100644 --- a/app/javascript/views/plans/edit_details.js +++ b/app/javascript/views/plans/edit_details.js @@ -1,3 +1,4 @@ +import { initAutocomplete } from '../../utils/autoComplete'; import { Tinymce } from '../../utils/tinymce.js.erb'; import getConstant from '../../constants'; import 'bootstrap-3-typeahead'; @@ -11,19 +12,6 @@ $(() => { $('#plan_visibility').val($(e.target).is(':checked') ? 'is_test' : 'privately_visible'); }); - const showHideDataContact = (el) => { - if ((el).is(':checked')) { - $('div.data-contact').fadeOut(); - } else { - $('div.data-contact').fadeIn(); - } - }; - - $('#show_data_contact').click((e) => { - showHideDataContact($(e.currentTarget)); - }); - showHideDataContact($('#show_data_contact')); - // Toggle the disabled flags const toggleCheckboxes = (selections) => { $('#priority-guidance-orgs, #other-guidance-orgs').find('input[type="checkbox"]').each((i, el) => { @@ -118,6 +106,8 @@ $(() => { syncGuidance($(e.target).closest('ul[id]')); }); + initAutocomplete('#funder-org-controls .autocomplete'); + toggleCheckboxes($('#priority-guidance-orgs input[type="checkbox"]:checked').map((i, el) => $(el).val()).get()); setUpTypeahead(); diff --git a/app/javascript/views/plans/new.js b/app/javascript/views/plans/new.js index 24d718b9fe..ad9ec5477d 100644 --- a/app/javascript/views/plans/new.js +++ b/app/javascript/views/plans/new.js @@ -1,8 +1,7 @@ import debounce from '../../utils/debounce'; -import initAutoComplete from '../../utils/autoComplete'; +import { initAutocomplete } from '../../utils/autoComplete'; import getConstant from '../../constants'; import { isObject, isArray, isString } from '../../utils/isType'; -import { isValidText } from '../../utils/isValidInputType'; import { renderAlert, hideNotifications } from '../../utils/notificationHelper'; $(() => { @@ -38,6 +37,7 @@ $(() => { if (data.templates.length === 1) { $('#plan_template_id option').attr('selected', 'true'); $('#multiple-templates').hide(); + $('#available-templates').fadeOut(); } else { $('#multiple-templates').show(); $('#available-templates').fadeIn(); @@ -49,43 +49,117 @@ $(() => { } }; + // TODO: Refactor this whole thing when we redo the create plan + // workflow and use js.erb instead! + const getValue = (context) => { + if (context.length > 0) { + const hidden = $(context).find('.autocomplete-result'); + if (hidden.length > 0 && hidden.val().length > 0 + && hidden.val() !== '{}' && hidden.val() !== '{"name":""}') { + return hidden.val(); + } + } + return '{}'; + }; + + const validOptions = (context) => { + let ret = false; + if ($(context).length > 0) { + const checkbox = $(context).find('input.toggle-autocomplete'); + const val = getValue(context); + + if (val.length > 0 && val !== '{}') { + const json = JSON.parse(val); + // If the json ONLY contains a name then it is not a valid selection + ret = (checkbox.prop('checked') || json.id !== undefined); + } else { + // Otherwise just focus on the checkbox + ret = checkbox.prop('checked'); + } + } + return ret; + }; + // When one of the autocomplete fields changes, fetch the available templates const handleComboboxChange = debounce(() => { - const validOrg = (isValidText($('#plan_org_id').val()) || $('#plan_no_org').prop('checked')); - const validFunder = (isValidText($('#plan_funder_id').val()) || $('#plan_no_funder').prop('checked')); + const orgContext = $('#research-org-controls'); + const funderContext = $('#funder-org-controls'); + const validOrg = validOptions(orgContext); + const validFunder = validOptions(funderContext); if (!validOrg || !validFunder) { $('#available-templates').fadeOut(); + $('#plan_template_id').find(':selected').removeAttr('selected'); $('#plan_template_id').val(''); + toggleSubmit(); } else { // Clear out the old template dropdown contents $('#plan_template_id option').remove(); + let orgId = orgContext.find('input[id$="org_id"]').val(); + let funderId = funderContext.find('input[id$="funder_id"]').val(); + + // For some reason Rails freaks out it everything is empty so send + // the word "none" instead and handle on the controller side + if (orgId.length <= 0) { + orgId = '"none"'; + } + if (funderId.length <= 0) { + funderId = '"none"'; + } + const data = `{"plan": {"research_org_id":${orgId},"funder_id":${funderId}}}`; + // Fetch the available templates based on the funder and research org selected - const qryStr = `?plan[org_id]=${$('#plan_org_id').val()}&plan[funder_id]=${$('#plan_funder_id').val()}`; $.ajax({ - url: `${$('#template-option-target').val()}${qryStr}`, + url: $('#template-option-target').val(), + data: JSON.parse(data), }).done(success).fail(error); } }, 150); // When one of the checkboxes is clicked, disable the autocomplete input and clear its contents - const handleCheckboxClick = (name, checked) => { - $(`#plan_${name}_name`).prop('disabled', checked); - $('#plan_template_id').val('').change(); - $('#available-templates').fadeOut(); - - if (checked) { - $(`#plan_${name}_name`).val(''); - $(`#plan_${name}_id`).val('-1'); - $(`#plan_${name}_name`).siblings('.combobox-clear-button').hide(); - } else { - $(`#plan_${name}_id`).val(''); - } + const handleCheckboxClick = (autocomplete, checkbox) => { + // Clear and then Disable/Enable the textbox and hide + // any textbox warnings + const checked = checkbox.prop('checked'); + autocomplete.val(''); + autocomplete.prop('disabled', checked); + autocomplete.siblings('.autocomplete-result').val(''); + autocomplete.siblings('.autocomplete-warning').hide(); + handleComboboxChange(); }; - initAutoComplete(); + const initOrgSelection = (context) => { + const section = $(context); + + if (section.length > 0) { + initAutocomplete(`${context} .autocomplete`); + + const autocomplete = $(section).find('.autocomplete'); + const hidden = autocomplete.siblings('.autocomplete-result'); + const checkbox = $(section).find('input.toggle-autocomplete'); + + hidden.on('change', () => { + handleComboboxChange(); + }); + + checkbox.on('click', () => { + handleCheckboxClick(autocomplete, checkbox); + }); + + if (checkbox.prop('checked')) { + handleCheckboxClick(autocomplete, checkbox); + } + } + }; + + ['#research-org-controls', '#funder-org-controls'].forEach((el) => { + if ($(el).length > 0) { + initOrgSelection(el); + } + }); + const defaultVisibility = $('#plan_visibility').val(); // When the user checks the 'mock project' box we need to set the @@ -94,29 +168,8 @@ $(() => { $('#plan_visibility').val(($(e.currentTarget)[0].checked ? 'is_test' : defaultVisibility)); }); - // Make sure the checkbox is unchecked if we're entering text - $('#new_plan #plan_org_id, #new_plan #plan_funder_id').change((e) => { - const [, whichOne] = $(e.currentTarget).prop('id').split('_'); - $(`#plan_no_${whichOne}`).prop('checked', false); - handleComboboxChange(); - }); - - // If the user clicks the no Org/Funder checkbox disable the dropdown - // and hide clear button - $('#new_plan #plan_no_org, #new_plan #plan_no_funder').click((e) => { - const [, , whichOne] = $(e.currentTarget).prop('id').split('_'); - handleCheckboxClick(whichOne, e.currentTarget.checked); - }); - // Initialize the form $('#new_plan #available-templates').hide(); handleComboboxChange(); toggleSubmit(); - - if ($('#plan_no_org').prop('checked')) { - handleCheckboxClick('org', $('#plan_no_org').prop('checked')); - } - if ($('#plan_no_funder').prop('checked')) { - handleCheckboxClick('funder', $('#plan_no_funder').prop('checked')); - } }); diff --git a/app/javascript/views/shared/create_account_form.js b/app/javascript/views/shared/create_account_form.js index ecb43ad38e..f37d42deb8 100644 --- a/app/javascript/views/shared/create_account_form.js +++ b/app/javascript/views/shared/create_account_form.js @@ -1,31 +1,8 @@ +import { initAutocomplete } from '../../utils/autoComplete'; import { togglisePasswords } from '../../utils/passwordHelper'; -// START: DMPTOOL customization -// -------------------------------------------------- -// import { initOrgSelection, validateOrgSelection } from './my_org'; -import { initOrgSelection } from './my_org'; -// -------------------------------------------------- -// END: DMPTool customization $(() => { - // START: DMPTOOL customization - // -------------------------------------------------- - // const options = { selector: '#create-account-form' }; const options = { selector: '#create_account_form' }; - // -------------------------------------------------- - // END: DMPTool customization - + initAutocomplete('#create-account-org-controls .autocomplete'); togglisePasswords(options); - initOrgSelection(options); - - // START: DMPTOOL customization - // Disable JS validation for org selection - // -------------------------------------------------- - // $('#create_account_form').on('submit', (e) => { - // // Additional validation to force the user to choose an org or type something for other - // if (!validateOrgSelection(options)) { - // e.preventDefault(); - // } - // }); - // -------------------------------------------------- - // END: DMPTool customization }); diff --git a/app/javascript/views/shared/my_org.js b/app/javascript/views/shared/my_org.js deleted file mode 100644 index ff6ecc2c33..0000000000 --- a/app/javascript/views/shared/my_org.js +++ /dev/null @@ -1,82 +0,0 @@ -import { isObject } from '../../utils/isType'; -import { isValidText } from '../../utils/isValidInputType'; - -export const initOrgSelection = (options) => { - if (isObject(options) && options.selector) { - const div = $(options.selector); - - if (isObject(div)) { - const combo = div.find('input.js-combobox'); - const id = div.find('input.org-id'); - const name = div.find('input[name="user[org_name]"]'); - const text = div.find('input.other-org'); - const link = div.find('a.other-org-link'); - const otherOrg = div.find('input[name="user[other_org_id]"]'); - const otherOrgName = div.find('input[name="user[other_org_name]"]'); - - const toggleInputs = (showCombo) => { - if (showCombo) { - text.val('').addClass('hide'); - } else { - combo.val(''); - id.val(''); - text.removeClass('hide'); - } - }; - - // Show the other org textbox when the link is clicked - link.click((e) => { - e.preventDefault(); - toggleInputs(false); - }); - - combo.keyup(() => { - toggleInputs(true); - }); - - // ----------------------------------------------------------------------------- - // Start DMPTool customization to default to the is_other org when no selection - // ----------------------------------------------------------------------------- - name.blur(() => { - if (!name.val()) { - id.val(otherOrg.val()); - } - }); - // ----------------------------------------------------------------------------- - // End DMPTool customization - // ----------------------------------------------------------------------------- - - // when the user enters a value in the 'Other org' textbox, set the org_id to OTHER_ORG_ID - text.blur(() => { - if (isObject(id)) { - id.val(text.val().length > 0 ? otherOrg.val() : ''); - name.val(text.val().length > 0 ? otherOrgName.val() : ''); - } - }); - - // Display the other org textbox if the value is filled out and no org id is selected - if (isValidText(id.val())) { - if (id.val().toString() === otherOrg.val().toString()) { - toggleInputs(false); - text.blur(); - } - } - } - } -}; -export const validateOrgSelection = (options) => { - if (isObject(options) && options.selector) { - const orgId = $(`${options.selector} [name="user[org_id]"]`); - const otherOrg = $(`${options.selector} [name="user[other_organisation]"]`); - - if (isObject(orgId) || isObject(otherOrg)) { - if (isValidText(orgId.val()) || isValidText(otherOrg.val())) { - $('#help-org').hide(); - return true; - } - $('#help-org').show(); - return false; - } - } - return false; -}; diff --git a/app/javascript/views/super_admin/notifications/edit.js b/app/javascript/views/super_admin/notifications/edit.js index 077b8d8640..ef4bbac54a 100644 --- a/app/javascript/views/super_admin/notifications/edit.js +++ b/app/javascript/views/super_admin/notifications/edit.js @@ -1,5 +1,26 @@ import { Tinymce } from '../../../utils/tinymce.js.erb'; +// add the info on selecting the check from notification suitable +import { paginableSelector } from '../../../utils/paginable'; +import * as notifier from '../../../utils/notificationHelper'; + + $(() => { Tinymce.init({ selector: '.notification-text', forced_root_block: '' }); + + + $(paginableSelector).on('click, change', '.enable_notification input[type="checkbox"]', (e) => { + const form = $(e.target).closest('form'); + form.submit(); + }); + + + $(paginableSelector).on('ajax:success', '.enable_notification', (e, data) => { + // const form = $(e.target); + if (data.code === 1 && data.msg && data.msg !== '') { + notifier.renderNotice(data.msg); + } else { + notifier.renderAlert(data.msg); + } + }); }); diff --git a/app/javascript/views/super_admin/users/edit.js b/app/javascript/views/super_admin/users/edit.js index b8ab0ebbe8..6d29a9355d 100644 --- a/app/javascript/views/super_admin/users/edit.js +++ b/app/javascript/views/super_admin/users/edit.js @@ -1,9 +1,6 @@ -import { initOrgSelection, validateOrgSelection } from '../../shared/my_org'; +import { initAutocomplete } from '../../../utils/autoComplete'; $(() => { - const options = { selector: '#super_admin_user_edit' }; - initOrgSelection(options); - const updateMergeConfirmation = (userSelect) => { // update the confirmation dialogue with the selected user's email address const editingUserEmail = $('#superadmin_user_email').val(); @@ -15,13 +12,6 @@ $(() => { The account for ${chosenUserEmail} will then be destroyed.`); }; - $('#super_admin_user_edit').on('submit', (e) => { - // Additional validation to force the user to choose an org or type something for other - if (!validateOrgSelection(options)) { - e.preventDefault(); - } - }); - $('#merge_form').on('ajax:success', (e, data) => { // replace the search form with the merge form $('#merge_form_container').html(data.form); @@ -29,4 +19,8 @@ $(() => { userSelect.on('change', () => updateMergeConfirmation(userSelect)); userSelect.change(); }); + + if ($('#super-admin-user-org-controls').length > 0) { + initAutocomplete('#super-admin-user-org-controls .autocomplete'); + } }); diff --git a/app/javascript/views/usage/index.js b/app/javascript/views/usage/index.js index 1877f905e7..4cad073da8 100644 --- a/app/javascript/views/usage/index.js +++ b/app/javascript/views/usage/index.js @@ -2,6 +2,12 @@ import { isObject, isUndefined } from '../../utils/isType'; import { initializeCharts, createChart, drawHorizontalBar } from '../../utils/charts'; $(() => { + // handles the checkbox for filtered-plans + $('#filter_plans_form').on('click, change', 'input[type="checkbox"]', (e) => { + const form = $(e.target).closest('form'); + form.submit(); + }); + // fns to handle the separator character menu // for CSV download const changeStatFnGen = (str) => { @@ -14,27 +20,54 @@ $(() => { // attach listener to separator select menu // on change look for "stat" elements and chnage their query param - document.getElementById('csv-field-sep').addEventListener('click', (e) => { - const statElems = document.getElementsByClassName('stat'); - const newSep = 'sep='.concat(encodeURIComponent(e.target.value)); - const changeStatFn = changeStatFnGen(newSep); - Array.from(statElems).forEach(changeStatFn); - }); + const fieldSep = document.getElementById('csv-field-sep'); + if (fieldSep !== null) { + fieldSep.addEventListener('click', (e) => { + const statElems = document.getElementsByClassName('stat'); + const newSep = 'sep='.concat(encodeURIComponent(e.target.value)); + const changeStatFn = changeStatFnGen(newSep); + Array.from(statElems).forEach(changeStatFn); + }); + } initializeCharts(); + const labelToUrl = (label) => { + const parts = label.split('-'); + return `search=${parts[0]} 20${parts[1]}&commit=Search&click_through=true`; + }; + // Create the Users joined chart if (!isUndefined($('#users_joined').val())) { const usersData = JSON.parse($('#users_joined').val()); if (isObject(usersData)) { - createChart('#yearly_users', usersData); + const chart = createChart('#yearly_users', usersData, '', (event) => { + const segment = chart.getElementAtEvent(event)[0]; + if (!isUndefined(segment)) { + const target = $('#users_click_target').val(); + /* eslint-disable no-underscore-dangle, no-restricted-globals */ + const label = chart.data.labels[segment._index]; + $(location).attr('href', `${target}?${labelToUrl(label)}`); + /* eslint-enable no-underscore-dangle, no-restricted-globals */ + } + }); } } + // Create the Plans created chart if (!isUndefined($('#plans_created').val())) { const plansData = JSON.parse($('#plans_created').val()); if (isObject(plansData)) { - createChart('#yearly_plans', plansData); + const chart = createChart('#yearly_plans', plansData, '', (event) => { + const segment = chart.getElementAtEvent(event)[0]; + if (!isUndefined(segment)) { + const target = $('#plans_click_target').val(); + /* eslint-disable no-underscore-dangle, no-restricted-globals */ + const label = chart.data.labels[segment._index]; + $(location).attr('href', `${target}?${labelToUrl(label)}`); + /* eslint-enable no-underscore-dangle, no-restricted-globals */ + } + }); } } // TODO: Most of these event listeners would not be necessary if JQuery and diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 89f2afcf3c..b08f368587 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -1,13 +1,8 @@ class UserMailer < ActionMailer::Base - include MailerHelper - # ===================================== - # Start DMPTool Customization - # ===================================== - include Dmptool::Mailers::UserMailer - # ===================================== - # End DMPTool Customization - # ===================================== + prepend_view_path "app/views/branded/" + + include MailerHelper helper MailerHelper helper FeedbacksHelper @@ -22,6 +17,17 @@ def welcome_notification(user) end end + def question_answered(data, user, answer, options_string) + @user = user + @answer = answer + @data = data + @options_string + FastGettext.with_locale FastGettext.default_locale do + mail(to: data['email'], + subject: data['subject']) + end + end + def sharing_notification(role, user, inviter:) @role = role @user = user @@ -73,28 +79,20 @@ def feedback_notification(recipient, plan, requestor) end end - # ===================================== - # Start DMPTool Customization - # See lib/dmptool/mailer/user_mailer for override of this method that changes - # the sender address to be the 'do-not-reply' one defined in Branding.yml. AWS - # SES does not allow the sender to be be from a different domain! - # ===================================== - # def feedback_complete(recipient, plan, requestor) - # @requestor = requestor - # @user = recipient - # @plan = plan - # @phase = plan.phases.first - # if recipient.active? - # FastGettext.with_locale FastGettext.default_locale do - # mail(to: recipient.email, - # from: requestor.org.contact_email, - # subject: _("%{application_name}: Expert feedback has been provided for %{plan_title}") % {application_name: Rails.configuration.branding[:application][:name], plan_title: @plan.title}) - # end - # end - # end - # ============================= - # End DMPTool Customization - # ============================= + def feedback_complete(recipient, plan, requestor) + @requestor = requestor + @user = recipient + @plan = plan + @phase = plan.phases.first + if recipient.active? + FastGettext.with_locale FastGettext.default_locale do + sender = Rails.configuration.branding[:organisation][:do_not_reply_email] || Rails.configuration.branding[:organisation][:email] + mail(to: recipient.email, + from: sender, + subject: _("%{application_name}: Expert feedback has been provided for %{plan_title}") % {application_name: Rails.configuration.branding[:application][:name], plan_title: @plan.title}) + end + end + end def feedback_confirmation(recipient, plan, requestor) user = requestor @@ -154,4 +152,14 @@ def admin_privileges(user) end end end + + def api_credentials(api_client) + @api_client = api_client + if @api_client.contact_email.present? + FastGettext.with_locale FastGettext.default_locale do + mail(to: @api_client.contact_email, + subject: _("%{tool_name} API changes") % { tool_name: Rails.configuration.branding[:application][:name] }) + end + end + end end diff --git a/app/models/annotation.rb b/app/models/annotation.rb index 163a5b82f0..28edcb85b4 100644 --- a/app/models/annotation.rb +++ b/app/models/annotation.rb @@ -13,6 +13,7 @@ # # Indexes # +# fk_rails_aca7521f72 (org_id) # index_annotations_on_question_id (question_id) # index_annotations_on_versionable_id (versionable_id) # diff --git a/app/models/answer.rb b/app/models/answer.rb index c6417be54e..b76e5dae41 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -16,6 +16,9 @@ # # Indexes # +# fk_rails_3d5ed4418f (question_id) +# fk_rails_584be190c2 (user_id) +# fk_rails_84a6005a3e (plan_id) # index_answers_on_plan_id (plan_id) # index_answers_on_question_id (question_id) # diff --git a/app/models/api_client.rb b/app/models/api_client.rb new file mode 100644 index 0000000000..3eb87e32d2 --- /dev/null +++ b/app/models/api_client.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: api_clients +# +# 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 +# +# Indexes +# +# index_api_clients_on_name (name) +# + +class ApiClient < ActiveRecord::Base + + include DeviseInvitable::Inviter + include ValidationMessages + + # ================ + # = Associations = + # ================ + + has_many :plans + + # If the Client_id or client_secret are nil generate them + before_validation :generate_credentials, + if: Proc.new { |c| c.client_id.blank? || c.client_secret.blank? } + + # Force the name to downcase + before_save :name_to_downcase + + # =============== + # = Validations = + # =============== + + validates :name, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { case_sensitive: false, + message: UNIQUENESS_MESSAGE } + + 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 } + + # =========================== + # = Public instance methods = + # =========================== + + # Override the to_s method to keep the id and secret hidden + def to_s + name + end + + # Verify that the incoming secret matches + def authenticate(secret:) + client_secret == secret + end + + # Generate UUIDs for the client_id and client_secret + def generate_credentials + self.client_id = SecureRandom.uuid + self.client_secret = SecureRandom.uuid + end + + private + + def name_to_downcase + self.name = self.name.downcase + end + +end diff --git a/app/models/concerns/date_rangeable.rb b/app/models/concerns/date_rangeable.rb new file mode 100644 index 0000000000..962af656f3 --- /dev/null +++ b/app/models/concerns/date_rangeable.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module DateRangeable + + extend ActiveSupport::Concern + + module ClassMethods + + # Determines whether or not the search term is a date. + # Expecting: '[month abbreviation] [year]' e.g.('Oct 2019') + def date_range?(term:) + term.match(/^[A-Za-z]{3,}\s+[0-9]{2,4}/).present? + end + + # Search the specified field for the specified month + def by_date_range(field, term) + date = Date.parse(term) if term[0..1].match(/[0-9]{2}/).present? + date = Date.parse("1st #{term}") unless date.present? + where("#{table_name}.#{field} BETWEEN ? AND ?", date, date.end_of_month) + end + + end + +end diff --git a/app/models/concerns/exportable_plan.rb b/app/models/concerns/exportable_plan.rb index f9650b5f6d..05edff647b 100644 --- a/app/models/concerns/exportable_plan.rb +++ b/app/models/concerns/exportable_plan.rb @@ -2,6 +2,8 @@ module ExportablePlan + include ConditionsHelper + def as_pdf(coversheet = false) prepare(coversheet) end @@ -12,7 +14,6 @@ def as_csv(headings = true, show_custom_sections = true, show_coversheet = false) hash = prepare(show_coversheet) - CSV.generate do |csv| if show_coversheet prepare_coversheet_for_csv(csv, headings, hash) @@ -35,7 +36,7 @@ def as_csv(headings = true, show_section ||= customization && !section[:modifiable] show_section ||= customization && section[:modifiable] && show_custom_sections - if show_section + if show_section && num_section_questions(self, section, phase) > 0 show_section_for_csv(csv, phase, section, headings, unanswered, hash) end end @@ -97,7 +98,9 @@ def prepare_coversheet hash[:affiliation] = self.owner.present? ? self.owner.org.name : "" # set the funder name - hash[:funder] = self.funder_name.present? ? self.funder_name : "" + hash[:funder] = self.funder.name if self.funder.present? + template_org = self.template.org + hash[:funder] = template_org.name if !hash[:funder].present? && template_org.funder? # set the template name and customizer name if applicable hash[:template] = self.template.title @@ -142,7 +145,10 @@ def prepare_coversheet_for_csv(csv, headings, hash) end def show_section_for_csv(csv, phase, section, headings, unanswered, hash) - section[:questions].each do |question| + section[:questions].each do |question| + if remove_list(hash).include?(question[:id]) + next + end answer = self.answer(question[:id], false) answer_text = "" if answer.present? diff --git a/app/models/concerns/identifiable.rb b/app/models/concerns/identifiable.rb new file mode 100644 index 0000000000..5e1af146d1 --- /dev/null +++ b/app/models/concerns/identifiable.rb @@ -0,0 +1,76 @@ +module Identifiable + + extend ActiveSupport::Concern + + included do + + # ================ + # = Associations = + # ================ + + has_many :identifiers, as: :identifiable, dependent: :destroy + + # ===================== + # = Nested Attributes = + # ===================== + + accepts_nested_attributes_for :identifiers + + # ================= + # = Class Methods = + # ================= + + # Expects an array of `identifier_scheme.name` and `identifier.value` + # [{ name: "fundref", value: "12345" }, { name: "ror", value: "abc"} ] + # Returns an instance of the model + def self.from_identifiers(array:) + return nil unless array.present? && array.any? + + id = nil + array.uniq.each do |hash| + next unless hash[:name].present? && hash[:value].present? + + # Get the IdentifierScheme, skip if it does not exist + scheme = IdentifierScheme.by_name(hash[:name].downcase) + next unless scheme.present? + + # Look for the Identifier and finish up once found + id = Identifier.where(identifier_scheme: scheme, value: hash[:value], + identifiable_type: name).first + break if id.present? + end + + id.present? ? id.identifiable : nil + end + + # ==================== + # = Instance Methods = + # ==================== + + # gets the identifier for the scheme + def identifier_for_scheme(scheme:) + scheme = IdentifierScheme.by_name(scheme.downcase).first if scheme.is_a?(String) + identifiers.select { |id| id.identifier_scheme == scheme }.first + end + + # Combines the existing identifiers with the new ones + def consolidate_identifiers!(array:) + return false unless array.present? && array.is_a?(Array) + + array.each do |id| + next unless id.is_a?(Identifier) && id.value.present? + + # If the identifier already exists then keep it + current = identifier_for_scheme(scheme: id.identifier_scheme) + next if current.present? + + # Otherwise add it + id.identifiable = self + identifiers << id + end + true + end + + end + +end diff --git a/app/models/condition.rb b/app/models/condition.rb new file mode 100644 index 0000000000..0610803e96 --- /dev/null +++ b/app/models/condition.rb @@ -0,0 +1,41 @@ +# == Schema Information +# +# 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 +# +# Indexes +# +# index_conditions_on_question_id (question_id) +# +# Foreign Keys +# +# fk_rails_... (question_id => question.id) +# +# + +class Condition < ActiveRecord::Base + belongs_to :question + enum action_type: [:remove, :add_webhook] + serialize :option_list, Array + serialize :remove_data, Array + serialize :webhook_data, JSON + + # Sort order: Number ASC + default_scope { order(number: :asc) } + + def deep_copy(**options) + copy = self.dup + copy.question_id = options.fetch(:question_id, nil) + copy.save!(validate: false) if options.fetch(:save, false) + copy + end +end diff --git a/app/models/contributor.rb b/app/models/contributor.rb new file mode 100644 index 0000000000..5a7e57330d --- /dev/null +++ b/app/models/contributor.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# == Schema Information +# +# 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 +# +# Indexes +# +# index_contributors_on_id (id) +# index_contributors_on_email (email) +# index_contributors_on_org_id (org_id) +# +# Foreign Keys +# +# fk_rails_... (org_id => orgs.id) +# fk_rails_... (plan_id => plans.id) + +class Contributor < ActiveRecord::Base + + include FlagShihTzu + include ValidationMessages + include Identifiable + + # ================ + # = Associations = + # ================ + + # TODO: uncomment the 'optional' bit after the Rails 5 migration. Rails 5+ will + # NOT allow nil values in a belong_to field! + belongs_to :org # , optional: true + + belongs_to :plan + + # ===================== + # = Nested attributes = + # ===================== + + accepts_nested_attributes_for :org + + # =============== + # = Validations = + # =============== + + validates :roles, presence: { message: PRESENCE_MESSAGE } + validates :roles, numericality: { greater_than: 0, + message: _("You must specify at least one role.") } + + validate :name_or_email_presence + + ONTOLOGY_NAME = "CRediT - Contributor Roles Taxonomy" + ONTOLOGY_LANDING_PAGE = "https://casrai.org/credit/" + ONTOLOGY_BASE_URL = "https://dictionary.casrai.org/Contributor_Roles" + + ## + # Define Bit Field values for roles + # Derived from the CASRAI CRediT Taxonomy: https://casrai.org/credit/ + has_flags 1 => :data_curation, + 2 => :investigation, + 3 => :project_administration, + column: "roles" + + # ========== + # = Scopes = + # ========== + + scope :search, lambda { |term| + search_pattern = "%#{term}%" + joins(:identifiers, :org) + .where("lower(contributors.name) LIKE lower(:search_pattern) + OR lower(contributors.email) LIKE lower(:search_pattern) + OR lower(identifiers.value) LIKE lower(:search_pattern) + OR lower(orgs.name) LIKE lower(:search_pattern)", + search_pattern: search_pattern) + } + + # ======================== + # = Static Class Methods = + # ======================== + + class << self + + # returns the default role + def default_role + "investigation" + end + + end + + # =================== + # = Private Methods = + # =================== + + private + + def name_or_email_presence + return true unless name.blank? && email.blank? + + errors.add(:name, _("can't be blank if no email is provided")) + errors.add(:email, _("can't be blank if no name is provided")) + end + +end diff --git a/app/models/exported_plan.rb b/app/models/exported_plan.rb index 24a854a295..4cf9e920c3 100644 --- a/app/models/exported_plan.rb +++ b/app/models/exported_plan.rb @@ -51,11 +51,15 @@ def grant_title end def principal_investigator - self.plan.principal_investigator + self.plan.contributors.investigation end def project_data_contact - self.plan.data_contact + self.plan.contributors.data_curation + end + + def project_admins + self.plan.contributors.project_administration end def project_description @@ -67,7 +71,8 @@ def owner end def funder - org = self.plan.template.try(:org) + org = self.plan.funder + org = self.plan.template.try(:org) unless org.present? org.name if org.present? && org.funder? end @@ -76,13 +81,10 @@ def institution end def orcid - scheme = IdentifierScheme.find_by(name: 'orcid') - if self.owner.nil? - '' - else - orcid = self.owner.user_identifiers.where(identifier_scheme: scheme).first - (orcid.nil? ? '' : orcid.identifier) - end + return "" unless owner.present? + + ids = owner.identifiers.by_scheme_name("orcid", "User") + ids.first.present? ? ids.first.value : "" end def sections diff --git a/app/models/guidance.rb b/app/models/guidance.rb index 37c294a675..84cdbed545 100644 --- a/app/models/guidance.rb +++ b/app/models/guidance.rb @@ -81,7 +81,7 @@ class Guidance < ActiveRecord::Base # Returns whether or not a given user can view a given guidance # we define guidances viewable to a user by those owned by a guidance group: - # owned by the managing curation center + # owned by the default orgs # owned by a funder organisation # owned by an organisation, of which the user is a member # @@ -99,9 +99,8 @@ def self.can_view?(user, id) if guidance.guidance_group.org == user.org viewable = true end - # guidance groups are viewable if they are owned by the Managing - # Curation Center - if Org.managing_orgs.include?(guidance.guidance_group.org) + # guidance groups are viewable if they are owned by the Default Orgs + if Org.default_orgs.include?(guidance.guidance_group.org) viewable = true end @@ -117,7 +116,7 @@ def self.can_view?(user, id) # Returns a list of all guidances which a specified user can view # we define guidances viewable to a user by those owned by a guidance group: - # owned by the Managing Curation Center + # owned by the Default Orgs # owned by a funder organisation # owned by an organisation, of which the user is a member # @@ -125,8 +124,8 @@ def self.can_view?(user, id) # # Returns Array def self.all_viewable(user) - managing_groups = Org.includes(guidance_groups: :guidances) - .managing_orgs.collect { |o| o.guidance_groups } + default_groups = Org.includes(guidance_groups: :guidances) + .default_orgs.collect { |o| o.guidance_groups } # find all groups owned by a Funder organisation funder_groups = Org.includes(guidance_groups: :guidances) .funder.collect { |org| org.guidance_groups } @@ -134,7 +133,7 @@ def self.all_viewable(user) organisation_groups = user.org.guidance_groups # find all guidances belonging to any of the viewable groups - all_viewable_groups = (managing_groups + + all_viewable_groups = (default_groups + funder_groups + organisation_groups).flatten all_viewable_guidances = all_viewable_groups.collect do |group| diff --git a/app/models/guidance_group.rb b/app/models/guidance_group.rb index bf079e48e7..4e85a1d97a 100644 --- a/app/models/guidance_group.rb +++ b/app/models/guidance_group.rb @@ -76,7 +76,7 @@ class GuidanceGroup < ActiveRecord::Base # Whether or not a given user can view a given guidance group # we define guidances viewable to a user by those owned by: - # the managing curation center + # the default orgs # a funder organisation # an organisation, of which the user is a member # @@ -88,9 +88,9 @@ def self.can_view?(user, guidance_group) viewable = false # groups are viewable if they are owned by any of the user's organisations viewable = true if guidance_group.org == user.org - # groups are viewable if they are owned by the managing curation center - Org.managing_orgs.each do |managing_group| - viewable = true if guidance_group.org.id == managing_group.id + # groups are viewable if they are owned by the default org + Org.default_orgs.each do |default_org| + viewable = true if guidance_group.org.id == default_org.id end # groups are viewable if they are owned by a funder viewable = true if guidance_group.org.funder? @@ -101,7 +101,7 @@ def self.can_view?(user, guidance_group) # A list of all guidance groups which a specified user can view # we define guidance groups viewable to a user by those owned by: - # the Managing Curation Center + # the Default Orgs # a funder organisation # an organisation, of which the user is a member # @@ -109,9 +109,9 @@ def self.can_view?(user, guidance_group) # # Returns Array def self.all_viewable(user) - # first find all groups owned by the Managing Curation Center - managing_org_groups = Org.includes(guidance_groups: [guidances: :themes]) - .managing_orgs.collect(&:guidance_groups) + # first find all groups owned by the Default Orgs + default_org_groups = Org.includes(guidance_groups: [guidances: :themes]) + .default_orgs.collect(&:guidance_groups) # find all groups owned by a Funder organisation funder_groups = Org.includes(:guidance_groups) @@ -122,7 +122,7 @@ def self.all_viewable(user) # pass this organisation guidance groups to the view with respond_with # all_viewable_groups - all_viewable_groups = managing_org_groups + + all_viewable_groups = default_org_groups + funder_groups + organisation_groups all_viewable_groups = all_viewable_groups.flatten.uniq diff --git a/app/models/identifier.rb b/app/models/identifier.rb new file mode 100644 index 0000000000..d897fe325b --- /dev/null +++ b/app/models/identifier.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: identifiers +# +# id :integer not null, primary key +# attrs :text +# identifiable_type :string +# value :string not null +# created_at :datetime +# updated_at :datetime +# identifiable_id :integer +# identifier_scheme_id :integer not null +# +# Indexes +# +# index_identifiers_on_identifiable_type_and_identifiable_id (identifiable_type,identifiable_id) +# +class Identifier < ActiveRecord::Base + + include ValidationMessages + + # ================ + # = Associations = + # ================ + + belongs_to :identifiable, polymorphic: true + + # TODO: uncomment 'optional: true' once we are on Rails 5 + belongs_to :identifier_scheme #, optional: true + + # =============== + # = Validations = + # =============== + + validates :value, presence: { message: PRESENCE_MESSAGE } + + validates :identifiable, presence: { message: PRESENCE_MESSAGE } + + validate :value_uniqueness_with_scheme, if: :has_scheme?, on: :create + + validate :value_uniqueness_without_scheme, unless: :has_scheme? + + # =============== + # = Scopes = + # =============== + + def self.by_scheme_name(value, identifiable_type) + where(identifier_scheme: IdentifierScheme.by_name(value), + identifiable_type: identifiable_type) + end + + # =========================== + # = Public instance methods = + # =========================== + + def attrs=(hash) + write_attribute(:attrs, (hash.is_a?(Hash) ? hash.to_json.to_s : "{}")) + end + + # Determines the format of the identifier based on the scheme or value + def identifier_format + scheme = identifier_scheme&.name + return scheme if %w[orcid ror fundref].include?(scheme) + + return "ark" if value.include?("ark:") + + doi_regex = /(doi:)?[0-9]{2}\.[0-9]+\/./ + return "doi" if value =~ doi_regex + + return "url" if value.starts_with?("http") + + "other" + end + + # 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 + + # Appends the identifier scheme's prefix to the identifier if necessary + # For example: + # value '0000-0000-0000-0001' + # becomes 'https://orcid.org/0000-0000-0000-0001' + def value=(val) + if identifier_scheme.present? && + identifier_scheme.identifier_prefix.present? && + !val.to_s.strip.blank? && + !val.to_s.starts_with?(identifier_scheme.identifier_prefix) + + base = identifier_scheme.identifier_prefix + base += "/" unless base.ends_with?("/") + super("#{base}#{val}") + else + super(val) + end + end + + private + + # ============== + # = VALIDATION = + # ============== + + # Simple check used by :validate methods above + def has_scheme? + self.identifier_scheme.present? + end + + # Verify the uniqueness of :value across :identifiable + def value_uniqueness_without_scheme + # if scheme is nil, then just unique for identifiable + if Identifier.where(identifiable: self.identifiable, value: self.value).any? + errors.add(:value, _("must be unique")) + end + end + + # Ensure that the identifiable only has one identifier for the scheme + def value_uniqueness_with_scheme + if Identifier.where(identifier_scheme: self.identifier_scheme, + identifiable: self.identifiable).any? + errors.add(:identifier_scheme, _("already assigned a value")) + end + end + +end diff --git a/app/models/identifier_scheme.rb b/app/models/identifier_scheme.rb index f310aab929..851edbab6a 100644 --- a/app/models/identifier_scheme.rb +++ b/app/models/identifier_scheme.rb @@ -5,7 +5,8 @@ # id :integer not null, primary key # active :boolean # description :string -# logo_url :string +# context :integer +# logo_url :text # name :string # user_landing_url :string # created_at :datetime @@ -13,6 +14,8 @@ # class IdentifierScheme < ActiveRecord::Base + + include FlagShihTzu include ValidationMessages include ValidationValues @@ -20,8 +23,7 @@ class IdentifierScheme < ActiveRecord::Base # The maximum length for a name NAME_MAXIMUM_LENGTH = 30 - has_many :user_identifiers - has_many :users, through: :user_identifiers + has_many :identifiers # =============== # = Validations = @@ -34,4 +36,32 @@ class IdentifierScheme < ActiveRecord::Base validates :active, inclusion: { message: INCLUSION_MESSAGE, in: BOOLEAN_VALUES } + # =========================== + # = Scopes = + # =========================== + + scope :active, -> { where(active: true) } + scope :by_name, ->(value) { active.where("LOWER(name) = LOWER(?)", value) } + + ## + # 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, + column: "context" + + # =========================== + # = Instance Methods = + # =========================== + + # The name is used by the OrgSelection Services as a Hash key. For example: + # { "ror": "12345" } + # so we cannot allow spaces or non alpha characters! + def name=(value) + super(value&.downcase&.gsub(/[^a-z]/, "")) + end + end diff --git a/app/models/note.rb b/app/models/note.rb index f63d5a6115..439ca291c7 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -13,6 +13,7 @@ # # Indexes # +# fk_rails_7f2323ad43 (user_id) # index_notes_on_answer_id (answer_id) # # Foreign Keys diff --git a/app/models/notification.rb b/app/models/notification.rb index dd6a17dfbc..45a902b25a 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -10,6 +10,7 @@ # notification_type :integer # starts_at :date # title :string +# enabled :boolean # created_at :datetime not null # updated_at :datetime not null # @@ -43,6 +44,8 @@ class Notification < ActiveRecord::Base validates :dismissable, inclusion: { in: BOOLEAN_VALUES } + validates :enabled, inclusion: { in: BOOLEAN_VALUES } + validates :starts_at, presence: { message: PRESENCE_MESSAGE }, after: { date: Date.today, on: :create } @@ -55,12 +58,12 @@ class Notification < ActiveRecord::Base # ========== scope :active, (lambda do - where('starts_at <= :now and :now < expires_at', now: Time.now) + where('starts_at <= :now and :now < expires_at', now: Time.now).where(enabled: true) end) scope :active_per_user, (lambda do |user| if user.present? - acknowledgement_ids = user.notifications.map(&:id) + acknowledgement_ids = user.notifications.pluck(:id) active.where.not(id: acknowledgement_ids) else active.where(dismissable: false) diff --git a/app/models/org.rb b/app/models/org.rb index 99ab251559..8a613e3699 100644 --- a/app/models/org.rb +++ b/app/models/org.rb @@ -15,6 +15,7 @@ # links :text # logo_name :string # logo_uid :string +# managed :boolean default(FALSE), not null # name :string # org_type :integer default(0), not null # sort_name :string @@ -23,11 +24,11 @@ # updated_at :datetime not null # language_id :integer # region_id :integer +# managed :boolean default(false), not null # # Foreign Keys # # fk_rails_... (language_id => languages.id) -# fk_rails_... (region_id => regions.id) # class Org < ActiveRecord::Base @@ -37,8 +38,15 @@ class Org < ActiveRecord::Base include FeedbacksHelper include GlobalHelpers include FlagShihTzu + include Identifiable - include Dmptool::Model::Org + # ---------------------------------------- + # Start DMPTool Customization + # ---------------------------------------- + include Dmptool::Models::Org + # ---------------------------------------- + # End DMPTool Customization + # ---------------------------------------- extend Dragonfly::Model::Validations validates_with OrgLinksValidator @@ -64,8 +72,16 @@ class Org < ActiveRecord::Base belongs_to :region + has_one :tracker, dependent: :destroy + accepts_nested_attributes_for :tracker + validates_associated :tracker + has_many :guidance_groups, dependent: :destroy + has_many :plans + + has_many :funded_plans, class_name: "Plan", foreign_key: "funder_id" + has_many :templates has_many :users @@ -76,10 +92,6 @@ class Org < ActiveRecord::Base join_table: "org_token_permissions", unique: true - has_many :org_identifiers - - has_many :identifier_schemes, through: :org_identifiers - has_many :departments # =============== @@ -114,6 +126,9 @@ class Org < ActiveRecord::Base validates :feedback_email_msg, presence: { message: PRESENCE_MESSAGE, if: :feedback_enabled } + validates :managed, inclusion: { in: BOOLEAN_VALUES, + message: INCLUSION_MESSAGE } + validates_property :format, of: :logo, in: LOGO_FORMATS, message: _("must be one of the following formats: " + "jpeg, jpg, png, gif, bmp") @@ -150,11 +165,18 @@ class Org < ActiveRecord::Base 6 => :school, column: "org_type" - # Predefined queries for retrieving the managain organisation and funders - scope :managing_orgs, -> do + # The default Org is the one whose guidance is auto-attached to + # plans when a plan is created + def self.default_orgs where(abbreviation: Branding.fetch(:organisation, :abbreviation)) end + # The managed flag is set by a Super Admin. A managed org typically has + # at least one Org Admini and can have associated Guidance and Templates + scope :managed, -> { where(managed: true) } + # An un-managed Org is one created on the fly by the system + scope :unmanaged, -> { where(managed: false) } + scope :search, -> (term) { search_pattern = "%#{term}%" where("lower(orgs.name) LIKE lower(?) OR " + @@ -185,6 +207,10 @@ def self.human_attribute_name(attr, options = {}) HUMANIZED_ATTRIBUTES[attr.to_sym] || super end + def links + super() || { "org": [] } + end + # Determines the locale set for the organisation # # Returns String diff --git a/app/models/org_identifier.rb b/app/models/org_identifier.rb index be5faa8dc5..dfe948335c 100644 --- a/app/models/org_identifier.rb +++ b/app/models/org_identifier.rb @@ -10,6 +10,11 @@ # identifier_scheme_id :integer # org_id :integer # +# Indexes +# +# fk_rails_189ad2e573 (identifier_scheme_id) +# fk_rails_36323c0674 (org_id) +# # Foreign Keys # # fk_rails_... (identifier_scheme_id => identifier_schemes.id) diff --git a/app/models/phase.rb b/app/models/phase.rb index a6c9709313..e14c68f4c6 100644 --- a/app/models/phase.rb +++ b/app/models/phase.rb @@ -32,7 +32,7 @@ class Phase < ActiveRecord::Base include ValidationValues include ActsAsSortable include VersionableModel - + include ConditionsHelper ## # Sort order: Number ASC @@ -121,6 +121,22 @@ def num_questions n end + def num_questions_not_removed(plan) + count = 0 + self.sections.each do |section| + count += num_section_questions(plan, section) + end + count + end + + def num_answers_not_removed(plan) + count = 0 + self.sections.each do |section| + count += num_section_answers(plan, section) + end + count + end + def visibility_allowed?(plan) value = Rational(num_answered_questions(plan), plan.num_questions) * 100 value >= Rails.application.config.default_plan_percentage_answered.to_f diff --git a/app/models/plan.rb b/app/models/plan.rb index 1bb2f8dbc2..2d28b3d13b 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -26,28 +26,40 @@ # created_at :datetime # updated_at :datetime # template_id :integer +# org_id :integer +# funder_id :integer +# grant_id :integer +# api_client_id :integer # # Indexes # -# index_plans_on_template_id (template_id) +# 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) # # Foreign Keys # # fk_rails_... (template_id => templates.id) +# fk_rails_... (org_id => orgs.id) # +# TODO: Drop the funder_name and grant_number columns once the funder_id has +# been back filled and we're removing the is_other org stuff + class Plan < ActiveRecord::Base include ConditionalUserMailer include ExportablePlan include ValidationMessages include ValidationValues + include DateRangeable + include Identifiable # ============= # = Constants = # ============= - # Returns visibility message given a Symbol type visibility passed, otherwise # nil VISIBILITY_MESSAGE = { @@ -75,6 +87,10 @@ class Plan < ActiveRecord::Base belongs_to :template + belongs_to :org + + belongs_to :funder, class_name: "Org" + has_many :phases, through: :template has_many :sections, through: :phases @@ -104,6 +120,11 @@ class Plan < ActiveRecord::Base has_many :roles + has_many :contributors, dependent: :destroy + + has_one :grant, as: :identifiable, dependent: :destroy, class_name: "Identifier" + + belongs_to :api_client#, optional: true # UNCOMMENT After Rails 5 # ===================== # = Nested Attributes = @@ -113,6 +134,7 @@ class Plan < ActiveRecord::Base accepts_nested_attributes_for :roles + accepts_nested_attributes_for :contributors # =============== # = Validations = @@ -126,6 +148,7 @@ class Plan < ActiveRecord::Base validates :complete, inclusion: { in: BOOLEAN_VALUES } + validate :end_date_after_start_date # ============= # = Callbacks = @@ -133,7 +156,6 @@ class Plan < ActiveRecord::Base before_validation :set_creation_defaults - # ========== # = Scopes = # ========== @@ -162,16 +184,34 @@ class Plan < ActiveRecord::Base ) } + # TODO: Add in a left join here so we can search contributors as well when + # we move to Rails 5: + # OR lower(contributors.name) LIKE lower(:search_pattern) + # OR lower(identifiers.value) LIKE lower(:search_pattern)", scope :search, lambda { |term| - search_pattern = "%#{term}%" - joins(:template) - .where("lower(plans.title) LIKE lower(:search_pattern) - OR lower(templates.title) LIKE lower(:search_pattern) - OR lower(plans.principal_investigator) LIKE lower(:search_pattern) - OR lower(plans.principal_investigator_identifier) LIKE lower(:search_pattern)", - search_pattern: search_pattern) + if date_range?(term: term) + joins(:template, roles: [user: :org]) + .where(Role.creator_condition) + .by_date_range(:created_at, term) + else + search_pattern = "%#{term}%" + joins(:template, roles: [user: :org]) + .where(Role.creator_condition) + .where("lower(plans.title) LIKE lower(:search_pattern) + OR lower(orgs.name) LIKE lower (:search_pattern) + OR lower(orgs.abbreviation) LIKE lower (:search_pattern) + OR lower(templates.title) LIKE lower(:search_pattern) + OR lower(plans.principal_investigator) LIKE lower(:search_pattern) + OR lower(plans.principal_investigator_identifier) LIKE lower(:search_pattern)", + search_pattern: search_pattern) + end } + ## + # Defines the filter_logic used in the statistics objects. + # For now, we filter out any test plans + scope :stats_filter, -> { where.not(visibility: visibilities[:is_test])} + # Retrieves plan, template, org, phases, sections and questions scope :overview, lambda { |id| includes(:phases, :sections, :questions, template: [:org]).find(id) @@ -387,7 +427,7 @@ def reviewable_by?(user_id) reviewer = User.find(user_id) feedback_requested? && reviewer.present? && - reviewer.org_id == owner.org_id && + reviewer.org_id == owner&.org_id && reviewer.can_review_plans? end @@ -407,7 +447,7 @@ def owner .administrator .order(:created_at) .pluck(:user_id).first - User.find(usr_id) + usr_id.present? ? User.find(usr_id) : nil end # Creates a role for the specified user (will update the user's @@ -442,14 +482,6 @@ def add_user!(user_id, access_type = :commenter) end end - ## Update plan identifier. - # - # Returns Boolean - def add_identifier!(identifier) - self.update(identifier: identifier) - save! - end - ## # Whether or not the plan is associated with users other than the creator # @@ -553,6 +585,10 @@ def deactivate! end end + # Returns the plan's identifier (either a DOI/ARK) + def landing_page + identifiers.select { |i| %w[doi ark].include?(i.identifier_format) }.first + end private @@ -564,7 +600,16 @@ def set_creation_defaults # Only run this before_validation because rails fires this before # save/create return if id? + self.title = "My plan (#{template.title})" if title.nil? && !template.nil? 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? + + errors.add(:end_date, _("must be after the start date")) if end_date < start_date + end + end diff --git a/app/models/question.rb b/app/models/question.rb index 34545bbf58..26663688c3 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -18,6 +18,7 @@ # # Indexes # +# fk_rails_4fbc38c8c7 (question_format_id) # index_questions_on_section_id (section_id) # index_questions_on_versionable_id (versionable_id) # @@ -65,6 +66,8 @@ class Question < ActiveRecord::Base has_one :template, through: :section + has_many :conditions, dependent: :destroy, inverse_of: :question + # =============== # = Validations = # =============== @@ -82,6 +85,8 @@ class Question < ActiveRecord::Base message: UNIQUENESS_MESSAGE } + before_destroy :check_remove_conditions + # ===================== # = Nested Attributes = # ===================== @@ -112,13 +117,13 @@ def deep_copy(**options) copy.section_id = options.fetch(:section_id, nil) copy.save!(validate: false) if options.fetch(:save, false) options[:question_id] = copy.id - self.question_options.each do |question_option| - copy.question_options << question_option.deep_copy(options) - end + self.question_options.each { |question_option| copy.question_options << question_option.deep_copy(options) } self.annotations.each do |annotation| copy.annotations << annotation.deep_copy(options) end self.themes.each { |theme| copy.themes << theme } + self.conditions.each { |condition| copy.conditions << condition.deep_copy(options) } + copy.conditions = copy.conditions.sort_by(&:number) copy end @@ -194,6 +199,55 @@ def annotations_per_org(org_id) [example_answer, guidance] end + # upon saving of question update conditions (via a delete and create) from params + # the old_to_new_opts map allows us to rewrite the question_option ids which may be out of sync + # after versioning + def update_conditions(param_conditions, old_to_new_opts, question_id_map) + res = true + self.conditions.destroy_all + + if param_conditions.present? + param_conditions.each do |_key, value| + saveCondition(value, old_to_new_opts, question_id_map) + end + end + end + + + def saveCondition(value, opt_map, question_id_map) + c = self.conditions.build + c.action_type = value["action_type"] + c.number = value['number'] + # question options may have changed so rewrite them + c.option_list = value["question_option"] + unless opt_map.blank? + new_question_options = [] + c.option_list.each do |qopt| + new_question_options << opt_map[qopt] + end + c.option_list = new_question_options + end + + if value["action_type"] == "remove" + c.remove_data = value["remove_question_id"] + unless question_id_map.blank? + new_question_ids = [] + c.remove_data.each do |qid| + new_question_ids << question_id_map[qid] + end + c.remove_data = new_question_ids + end + else + c.webhook_data = { + name: value['webhook-name'], + email: value['webhook-email'], + subject: value['webhook-subject'], + message: value['webhook-message'] + }.to_json + end + c.save + end + private def ensure_has_question_options @@ -201,5 +255,22 @@ def ensure_has_question_options errors.add :base, OPTION_PRESENCE_MESSAGE end end + + # before destroying a question we need to remove it from + # and condition's remove_data and also if that remove_data is empty + # destroy the condition. + def check_remove_conditions + id = self.id.to_s + self.template.questions.each do |q| + q.conditions.each do |cond| + cond.remove_data.delete(id) + if cond.remove_data.empty? + cond.destroy if cond.remove_data.empty? + else + cond.save + end + end + end + end end diff --git a/app/models/question_option.rb b/app/models/question_option.rb index 31e11873ed..6d13673291 100644 --- a/app/models/question_option.rb +++ b/app/models/question_option.rb @@ -22,6 +22,7 @@ class QuestionOption < ActiveRecord::Base include ValidationMessages include ValidationValues + include VersionableModel # ================ # = Associations = @@ -29,8 +30,13 @@ class QuestionOption < ActiveRecord::Base belongs_to :question - has_and_belongs_to_many :answers, join_table: :answers_question_options + has_one :section, through: :question + + has_one :phase, through: :question + has_one :template, through: :question + + has_and_belongs_to_many :answers, join_table: :answers_question_options # =============== # = Validations = @@ -45,6 +51,8 @@ class QuestionOption < ActiveRecord::Base validates :is_default, inclusion: { in: BOOLEAN_VALUES, message: INCLUSION_MESSAGE } + before_destroy :check_condition_options + # ========== # = Scopes = # ========== @@ -63,6 +71,23 @@ class QuestionOption < ActiveRecord::Base def deep_copy(**options) copy = self.dup copy.question_id = options.fetch(:question_id, nil) - return copy + copy.save!(validate: false) if options.fetch(:save, false) + options[:question_option_id] = copy.id + copy + end + + private + + # if we destroy a question_option + # we need to remove any conditions which depend on it + # even if they depend on something else as well + def check_condition_options + id = self.id.to_s + self.question.conditions.each do |cond| + if cond.option_list.include?(id) + cond.destroy + end + end end + end diff --git a/app/models/region.rb b/app/models/region.rb index 6dd483fe37..6414e47ca2 100644 --- a/app/models/region.rb +++ b/app/models/region.rb @@ -2,11 +2,12 @@ # # Table name: regions # -# id :integer not null, primary key -# abbreviation :string -# description :string -# name :string -# super_region_id :integer +# id :integer not null, primary key +# abbreviation :string +# description :string +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null # class Region < ActiveRecord::Base diff --git a/app/models/section.rb b/app/models/section.rb index c7e52e687e..73f6b4b128 100644 --- a/app/models/section.rb +++ b/app/models/section.rb @@ -31,6 +31,8 @@ class Section < ActiveRecord::Base include ActsAsSortable include VersionableModel + # Sort order: Number ASC + default_scope { order(number: :asc) } # ================ # = Associations = @@ -102,12 +104,15 @@ def to_s # Returns the number of answered questions for a given plan def num_answered_questions(plan) - return 0 if plan.nil? + self.answered_questions(plan).count(&:answered?) + end - answered = plan.answers.select do |answer| - answer.answered? && questions.include?(answer.question) - end - answered.length + # Returns an array of answered questions for a given plan + def answered_questions(plan) + return [] if plan.nil? + plan.answers.includes({ question: :question_format }, :question_options) + .where(question_id: question_ids) + .to_a end def deep_copy(**options) diff --git a/app/models/settings/template.rb b/app/models/settings/template.rb index 3e46aa612d..320497977f 100644 --- a/app/models/settings/template.rb +++ b/app/models/settings/template.rb @@ -3,9 +3,9 @@ # Table name: settings # # id :integer not null, primary key -# target_type :string +# target_type :string not null # value :text -# var :string +# var :string not null # created_at :datetime not null # updated_at :datetime not null # target_id :integer not null diff --git a/app/models/stat.rb b/app/models/stat.rb index a8909a5eb0..fee6ac6585 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -5,9 +5,10 @@ # Table name: stats # # id :integer not null, primary key -# count :integer default(0) +# count :bigint(8) default(0) # date :date not null # details :text +# filtered :boolean default(FALSE) # type :string not null # created_at :datetime not null # updated_at :datetime not null @@ -20,7 +21,7 @@ class Stat < ActiveRecord::Base belongs_to :org - validates_uniqueness_of :type, scope: [:date, :org_id] + validates_uniqueness_of :type, scope: [:date, :org_id, :filtered] class << self diff --git a/app/models/stat_created_plan.rb b/app/models/stat_created_plan.rb index 75b645d762..e04f48c87e 100644 --- a/app/models/stat_created_plan.rb +++ b/app/models/stat_created_plan.rb @@ -5,9 +5,10 @@ # Table name: stats # # id :integer not null, primary key -# count :integer default(0) +# count :bigint(8) default(0) # date :date not null # details :text +# filtered :boolean default(FALSE) # type :string not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/stat_created_plan/create_or_update.rb b/app/models/stat_created_plan/create_or_update.rb index ece95dc9f8..f417fd4db6 100644 --- a/app/models/stat_created_plan/create_or_update.rb +++ b/app/models/stat_created_plan/create_or_update.rb @@ -6,19 +6,21 @@ class CreateOrUpdate class << self - def do(start_date:, end_date:, org:) - count = count_plans(start_date: start_date, end_date: end_date, org: org) - by_template = plan_statistics(start_date: start_date, end_date: end_date, org: org) - using_template = plan_statistics(start_date: start_date, end_date: end_date, org: org, own_templates: true) + def do(start_date:, end_date:, org:, filtered: false) + count = count_plans(start_date: start_date, end_date: end_date, org: org, filtered: filtered) + by_template = plan_statistics(start_date: start_date, end_date: end_date, org: org, filtered: filtered) + using_template = plan_statistics(start_date: start_date, end_date: end_date, org: org, own_templates: true, filtered: filtered) attrs = { date: end_date.to_date, org_id: org.id, count: count, - details: { by_template: by_template, using_template: using_template } + details: { by_template: by_template, using_template: using_template }, + filtered: filtered } stat_created_plan = StatCreatedPlan.find_by( date: attrs[:date], - org_id: attrs[:org_id] + org_id: attrs[:org_id], + filtered: attrs[:filtered] ) if stat_created_plan.present? @@ -34,28 +36,30 @@ def users(org) User.where(users: { org_id: org.id }) end - def plans(start_date:, end_date:) - Plan.where(plans: { created_at: start_date..end_date }) + def plans(start_date:, end_date:, filtered:) + base = Plan + base = base.stats_filter if filtered + base.where(plans: { created_at: start_date..end_date }) end def own_template_plans(org) Plan.joins(:template).where(templates: { org_id: org.id }) end - def count_plans(start_date:, end_date:, org:) + def count_plans(start_date:, end_date:, org:, filtered:) Role.joins(:plan, :user) .administrator .merge(users(org)) - .merge(plans(start_date: start_date, end_date: end_date)) + .merge(plans(start_date: start_date, end_date: end_date, filtered: filtered)) .select(:plan_id) .distinct .count end - def plan_statistics(start_date:, end_date:, org:, own_templates: false) + def plan_statistics(start_date:, end_date:, org:, filtered:, own_templates: false) roleable_plans = Role.joins([:plan, :user]) .administrator - .merge(plans(start_date: start_date, end_date: end_date)) + .merge(plans(start_date: start_date, end_date: end_date, filtered: filtered)) if own_templates roleable_plans = roleable_plans.merge(own_template_plans(org)) else diff --git a/app/models/stat_exported_plan.rb b/app/models/stat_exported_plan.rb index 0f110741ad..2f9e518cf8 100644 --- a/app/models/stat_exported_plan.rb +++ b/app/models/stat_exported_plan.rb @@ -5,9 +5,10 @@ # Table name: stats # # id :integer not null, primary key -# count :integer default(0) +# count :bigint(8) default(0) # date :date not null # details :text +# filtered :boolean default(FALSE) # type :string not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/stat_exported_plan/create_or_update.rb b/app/models/stat_exported_plan/create_or_update.rb index a88b12371b..03828f7898 100644 --- a/app/models/stat_exported_plan/create_or_update.rb +++ b/app/models/stat_exported_plan/create_or_update.rb @@ -6,13 +6,14 @@ class CreateOrUpdate class << self - def do(start_date:, end_date:, org:) - count = exported_plans(start_date: start_date, end_date: end_date, org_id: org.id) - attrs = { date: end_date.to_date, count: count, org_id: org.id } + def do(start_date:, end_date:, org:, filtered: false) + count = exported_plans(start_date: start_date, end_date: end_date, org_id: org.id, filtered: filtered) + attrs = { date: end_date.to_date, count: count, org_id: org.id, filtered: filtered} stat_exported_plan = StatExportedPlan.find_by( date: attrs[:date], - org_id: attrs[:org_id] + org_id: attrs[:org_id], + filtered: attrs[:filtered] ) if stat_exported_plan.present? @@ -28,16 +29,19 @@ def users(org_id) User.where(users: {org_id: org_id }) end - def org_plan_ids(org_id) - Role.joins(:user) + def org_plan_ids(org_id:, filtered:) + plans = Plan.all + plans = plans.stats_filter if filtered + Role.joins(:plan, :user) .creator .merge(users(org_id)) + .merge(plans) .pluck(:plan_id) .uniq end - def exported_plans(start_date:, end_date:, org_id:) - ExportedPlan.where(plan_id: org_plan_ids(org_id)) + def exported_plans(start_date:, end_date:, org_id:, filtered:) + ExportedPlan.where(plan_id: org_plan_ids(org_id: org_id, filtered: filtered)) .where(created_at: start_date..end_date) .count end diff --git a/app/models/stat_joined_user.rb b/app/models/stat_joined_user.rb index e3bdcba8a7..1b10284fc1 100644 --- a/app/models/stat_joined_user.rb +++ b/app/models/stat_joined_user.rb @@ -5,9 +5,10 @@ # Table name: stats # # id :integer not null, primary key -# count :integer default(0) +# count :bigint(8) default(0) # date :date not null # details :text +# filtered :boolean default(FALSE) # type :string not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/stat_joined_user/create_or_update.rb b/app/models/stat_joined_user/create_or_update.rb index 46117d80c7..977a469369 100644 --- a/app/models/stat_joined_user/create_or_update.rb +++ b/app/models/stat_joined_user/create_or_update.rb @@ -6,13 +6,14 @@ class CreateOrUpdate class << self - def do(start_date:, end_date:, org:) + def do(start_date:, end_date:, org:, filtered: false) count = count_users(start_date: start_date, end_date: end_date, org_id: org.id) - attrs = { date: end_date.to_date, count: count, org_id: org.id } + attrs = { date: end_date.to_date, count: count, org_id: org.id, filtered: filtered } stat_joined_user = StatJoinedUser.find_by( date: attrs[:date], - org_id: attrs[:org_id] + org_id: attrs[:org_id], + filtered: attrs[:filtered] ) if stat_joined_user.present? diff --git a/app/models/stat_shared_plan.rb b/app/models/stat_shared_plan.rb index b0ffe10fa8..96230bab20 100644 --- a/app/models/stat_shared_plan.rb +++ b/app/models/stat_shared_plan.rb @@ -5,9 +5,10 @@ # Table name: stats # # id :integer not null, primary key -# count :integer default(0) +# count :bigint(8) default(0) # date :date not null # details :text +# filtered :boolean default(FALSE) # type :string not null # created_at :datetime not null # updated_at :datetime not null diff --git a/app/models/stat_shared_plan/create_or_update.rb b/app/models/stat_shared_plan/create_or_update.rb index 3022d1e3ab..235c902d29 100644 --- a/app/models/stat_shared_plan/create_or_update.rb +++ b/app/models/stat_shared_plan/create_or_update.rb @@ -6,13 +6,14 @@ class CreateOrUpdate class << self - def do(start_date:, end_date:, org:) - count = shared_plans(start_date: start_date, end_date: end_date, org_id: org.id) - attrs = { date: end_date.to_date, count: count, org_id: org.id } + def do(start_date:, end_date:, org:, filtered: false) + count = shared_plans(start_date: start_date, end_date: end_date, org_id: org.id, filtered: filtered) + attrs = { date: end_date.to_date, count: count, org_id: org.id, filtered: filtered} stat_shared_plan = StatSharedPlan.find_by( date: attrs[:date], - org_id: attrs[:org_id] + org_id: attrs[:org_id], + filtered: attrs[:filtered] ) if stat_shared_plan.present? @@ -28,17 +29,20 @@ def users(org_id) User.where(users: {org_id: org_id }) end - def org_plan_ids(org_id) - Role.joins(:user) + def org_plan_ids(org_id:, filtered:) + plans = Plan.all + plans = plans.stats_filter if filtered + Role.joins(:user, :plan) .creator .merge(users(org_id)) + .merge(plans) .pluck(:plan_id) .uniq end - def shared_plans(start_date:, end_date:, org_id:) + def shared_plans(start_date:, end_date:, org_id:, filtered:) Role.not_creator - .where(plan_id: org_plan_ids(org_id)) + .where(plan_id: org_plan_ids(org_id: org_id, filtered: filtered)) .where(created_at: start_date..end_date) .count end diff --git a/app/models/template.rb b/app/models/template.rb index 3db253bf66..ff183e8059 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -70,6 +70,10 @@ class Template < ActiveRecord::Base has_many :annotations, through: :questions + has_many :question_options, through: :questions + + has_many :conditions, through: :questions + # =============== # = Validations = # =============== @@ -278,6 +282,23 @@ def deep_copy(attributes: {}, **options) copy.save! if options.fetch(:save, false) options[:template_id] = copy.id phases.each { |phase| copy.phases << phase.deep_copy(options) } + # transfer the conditions to the new template + # done here as the new questions are not accessible when the conditions deep copy + copy.conditions.each do |cond| + if cond.option_list.any? + versionable_ids = QuestionOption.where(id: cond.option_list).pluck(:versionable_id) + cond.option_list = copy.question_options.where(versionable_id: versionable_ids).pluck(:id).map(&:to_s) + # TODO: these seem to be stored as strings, not sure if that's required by other code + end # TODO: would it be safe to remove conditions without an option list? + + if cond.remove_data.any? + versionable_ids = Question.where(id: cond.remove_data).pluck(:versionable_id) + cond.remove_data = copy.questions.where(versionable_id: versionable_ids).pluck(:id).map(&:to_s) + end + + cond.save if cond.changed? + end + copy end @@ -430,6 +451,10 @@ def publishability error += _("You can not publish a template without questions in a section. ") publishable = false end + if invalid_condition_order + error += _("Conditions in the template refer backwards") + publishable = false + end return publishable, error end @@ -477,4 +502,26 @@ def reconcile_published .update_all(published: false) end + def invalid_condition_order + self.questions.each do |question| + if question.option_based? + question.conditions.each do |condition| + if condition.action_type == "remove" + condition.remove_data.each do |rem_id| + rem_question = Question.find(rem_id.to_s) + if before(rem_question,question) + return true + end + end + end + end + end + end + false + end + + def before(q1,q2) + q1.section.number < q2.section.number || + (q1.section.number == q2.section.number && q1.number < q2.number) + end end diff --git a/app/models/tracker.rb b/app/models/tracker.rb new file mode 100644 index 0000000000..643e07631e --- /dev/null +++ b/app/models/tracker.rb @@ -0,0 +1,5 @@ +class Tracker < ActiveRecord::Base + belongs_to :org + validates :code, format: { with: /\A\z|\AUA-[0-9]+-[0-9]+\z/, + message: "wrong format" } +end diff --git a/app/models/user.rb b/app/models/user.rb index 12febe6e2e..5171283cf2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -41,7 +41,9 @@ # # Indexes # -# index_users_on_email (email) UNIQUE +# fk_rails_45f4f12508 (language_id) +# fk_rails_f29bf9cdf2 (department_id) +# index_users_on_email (email) # index_users_on_org_id (org_id) # # Foreign Keys @@ -56,9 +58,10 @@ class User < ActiveRecord::Base include ConditionalUserMailer include ValidationMessages include ValidationValues - extend UniqueRandom + include DateRangeable + include Identifiable - include Dmptool::Model::User + extend UniqueRandom ## # Devise @@ -99,11 +102,6 @@ class User < ActiveRecord::Base has_many :plans, through: :roles - - has_many :user_identifiers - - has_many :identifier_schemes, through: :user_identifiers - has_and_belongs_to_many :notifications, dependent: :destroy, join_table: "notification_acknowledgements" @@ -139,17 +137,24 @@ class User < ActiveRecord::Base } scope :search, -> (term) { - search_pattern = "%#{term}%" - # 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" - where("lower(concat_ws(' ', firstname, surname)) LIKE lower(?) OR " + - "lower(email) LIKE lower(?)", - search_pattern, search_pattern) + if date_range?(term: term) + by_date_range(:created_at, term) else - where("lower(firstname || ' ' || surname) LIKE lower(?) OR " + - "email LIKE lower(?)", search_pattern, search_pattern) + search_pattern = "%#{term}%" + # 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" + where("lower(concat_ws(' ', firstname, surname)) LIKE lower(?) OR " + + "lower(email) LIKE lower(?)", + search_pattern, search_pattern) + else + joins(:org) + .where("lower(firstname || ' ' || surname) LIKE lower(:search_pattern) + OR lower(email) LIKE lower(:search_pattern) + OR lower(orgs.name) LIKE lower (:search_pattern) + OR lower(orgs.abbreviation) LIKE lower (:search_pattern) ", search_pattern: search_pattern) + end end } @@ -172,9 +177,9 @@ class User < ActiveRecord::Base ## # Load the user based on the scheme and id provided by the Omniauth call def self.from_omniauth(auth) - joins(user_identifiers: :identifier_scheme) - .where(user_identifiers: { identifier: auth.uid }, - identifier_schemes: { name: auth.provider.downcase }).first + Identifier.by_scheme_name(auth.provider.downcase, "User") + .where(value: auth.uid) + .first&.identifiable end def self.to_csv(users) @@ -229,7 +234,7 @@ def name(use_email = true) # # Returns UserIdentifier def identifier_for(scheme) - user_identifiers.where(identifier_scheme: scheme).first + identifiers.by_scheme_name(scheme, "User").first end # Checks if the user is a super admin. If the user has any privelege which requires @@ -245,9 +250,14 @@ def can_super_admin? # # Returns Boolean def can_org_admin? - self.can_grant_permissions? || self.can_modify_guidance? || - self.can_modify_templates? || self.can_modify_org_details? || - self.can_review_plans? + return true if can_super_admin? + + # Automatically false if the user has no Org or the Org is not managed + return false unless org.present? && org.managed? + + can_grant_permissions? || can_modify_guidance? || + can_modify_templates? || can_modify_org_details? || + can_review_plans? end # Can the User add new organisations? @@ -414,18 +424,19 @@ def archive end def merge(to_be_merged) + scheme_ids = identifiers.pluck(:identifier_scheme_id) # merge logic # => answers -> map id - to_be_merged.answers.update_all(user_id: self.id) + to_be_merged.answers.update_all(user_id: id) # => notes -> map id - to_be_merged.notes.update_all(user_id: self.id) + to_be_merged.notes.update_all(user_id: id) # => plans -> map on id roles - to_be_merged.roles.update_all(user_id: self.id) + to_be_merged.roles.update_all(user_id: id) # => prefs -> Keep's from self # => auths -> map onto keep id only if keep does not have the identifier - to_be_merged.user_identifiers. - where.not(identifier_scheme_id: self.identifier_scheme_ids) - .update_all(user_id: self.id) + to_be_merged.identifiers + .where.not(identifier_scheme_id: scheme_ids) + .update_all(user_id: id) # => ignore any perms the deleted user has to_be_merged.destroy end diff --git a/app/models/user_identifier.rb b/app/models/user_identifier.rb index 5a1f5ad3c9..242eb0fe37 100644 --- a/app/models/user_identifier.rb +++ b/app/models/user_identifier.rb @@ -11,6 +11,7 @@ # # Indexes # +# fk_rails_fe95df7db0 (identifier_scheme_id) # index_user_identifiers_on_user_id (user_id) # # Foreign Keys diff --git a/app/policies/api/v0/departments_policy.rb b/app/policies/api/v0/departments_policy.rb new file mode 100644 index 0000000000..e84b988997 --- /dev/null +++ b/app/policies/api/v0/departments_policy.rb @@ -0,0 +1,46 @@ +module Api + module V0 + class DepartmentsPolicy < ApplicationPolicy + attr_reader :user, :department + + def initialize(user, department) + raise Pundit::NotAuthorizedError, _("must be logged in") unless user + @user = user + @department = department + end + + ## + # an org-admin can create a department for their organisation + def create? + @user.can_org_admin? + end + + ## + # any user can view their organisation's list of departments + def index? + true + end + + ## + # an org-admin user can query for a list of users in each department + def users? + @user.can_org_admin? + end + + ## + # an org-admin may assign users (from their org) to a department (from their org) + def assign_users? + @user.can_org_admin? && + @department.present? && + @department.org == @user.org + end + + ## + # an org-admin may unassign users (from their org) from a department + def unassign_users? + @user.can_org_admin? + end + + end + end +end diff --git a/app/policies/api/v1/plans_policy.rb b/app/policies/api/v1/plans_policy.rb new file mode 100644 index 0000000000..1600905eb7 --- /dev/null +++ b/app/policies/api/v1/plans_policy.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class PlansPolicy < ApplicationPolicy + + attr_reader :client, :plan + + class Scope + + attr_reader :client, :scope + + def initialize(client, scope) + @client = client + @scope = scope + end + + ## return the visible plans (via the API) to a given client + # ALL can view: public + # ApiClient can view: anything from the API client + # User (non-admin) can view: any personal or organisationally_visible + # User (admin) can view: all from users of their organisation + # rubocop:disable Metrics/AbcSize + def resolve + ids = Plan.publicly_visible.pluck(:id) + if client.is_a?(ApiClient) + ids += client.plans.pluck(&:id) + elsif client.is_a?(User) + ids += client.org.plans.organisationally_visible.pluck(:id) + ids += client.plans.pluck(:id) + ids += client.org.plans.pluck(:id) if client.can_org_admin? + end + Plan.where(id: ids.uniq) + end + # rubocop:enable Metrics/AbcSize + + end + + def initialize(client, plan) + @client = client + @plan = plan + end + + end + + end + +end diff --git a/app/policies/api_client_policy.rb b/app/policies/api_client_policy.rb new file mode 100644 index 0000000000..3ab9ef57de --- /dev/null +++ b/app/policies/api_client_policy.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class ApiClientPolicy < ApplicationPolicy + + def initialize(user, *_args) + raise Pundit::NotAuthorizedError, _('must be logged in') unless user + @user = user + end + + def index? + @user.can_super_admin? + end + + def new? + @user.can_super_admin? + end + + def create? + @user.can_super_admin? + end + + def edit? + @user.can_super_admin? + end + + def update? + @user.can_super_admin? + end + + def destroy? + @user.can_super_admin? + end + + def refresh_credentials? + @user.can_super_admin? + end + + def email_credentials? + @user.can_super_admin? + end + +end diff --git a/app/policies/user_identifier_policy.rb b/app/policies/identifier_policy.rb similarity index 80% rename from app/policies/user_identifier_policy.rb rename to app/policies/identifier_policy.rb index e57f1c9675..ced12ac738 100644 --- a/app/policies/user_identifier_policy.rb +++ b/app/policies/identifier_policy.rb @@ -1,4 +1,6 @@ -class UserIdentifierPolicy < ApplicationPolicy +# frozen_string_literal: true + +class IdentifierPolicy < ApplicationPolicy attr_reader :user_identifier def initialize(user, users) diff --git a/app/policies/notification_policy.rb b/app/policies/notification_policy.rb index 8d60c3c457..f16fa84f3f 100644 --- a/app/policies/notification_policy.rb +++ b/app/policies/notification_policy.rb @@ -27,4 +27,10 @@ def update? def destroy? @user.can_super_admin? end + + def enable? + @user.can_super_admin? + end + + end diff --git a/app/policies/question_option_policy.rb b/app/policies/question_option_policy.rb new file mode 100644 index 0000000000..e9e0b67c77 --- /dev/null +++ b/app/policies/question_option_policy.rb @@ -0,0 +1,22 @@ +class QuestionOptionPolicy < ApplicationPolicy + attr_reader :user, :question_option + + def initialize(user, question_option) + raise Pundit::NotAuthorizedError, "must be logged in" unless user + @user = user + @question_option = question_option + end + + ## + # The only action specifically on question_options is delete. + # The policy on this is essentially the same as the policy + # for editing questions i.e. + # - They can modify templates + # - The template which they are modifying belongs to their org + ## + + def destroy? + user.can_modify_templates? && (question_option.question.section.phase.template.org_id == user.org_id) + end + +end diff --git a/app/policies/question_policy.rb b/app/policies/question_policy.rb index cad6b84752..69b31e0110 100644 --- a/app/policies/question_policy.rb +++ b/app/policies/question_policy.rb @@ -21,6 +21,10 @@ def show? user.present? end + def open_conditions? + user.can_modify_templates? && (question.section.phase.template.org_id == user.org_id) + end + def edit? user.can_modify_templates? && (question.section.phase.template.org_id == user.org_id) end diff --git a/app/presenters/api/v1/contributor_presenter.rb b/app/presenters/api/v1/contributor_presenter.rb new file mode 100644 index 0000000000..09eda6fc0c --- /dev/null +++ b/app/presenters/api/v1/contributor_presenter.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class ContributorPresenter + + class << self + + # Convert the specified role into a CRediT Taxonomy URL + def role_as_uri(role:) + return nil unless role.present? + + "#{Contributor::ONTOLOGY_BASE_URL}/#{role.to_s.capitalize}" + end + + def contributor_id(identifiers:) + identifiers.select { |id| id.identifier_scheme.name == "orcid" }.first + end + + end + + end + + end + +end diff --git a/app/presenters/api/v1/funding_presenter.rb b/app/presenters/api/v1/funding_presenter.rb new file mode 100644 index 0000000000..ccd1fa0157 --- /dev/null +++ b/app/presenters/api/v1/funding_presenter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class FundingPresenter + + class << self + + # If the plan has a grant number then it has been awarded/granted + # otherwise it is 'planned' + def status(plan:) + return "planned" unless plan.present? && plan.grant_number.present? + + "granted" + end + + end + + end + + end + +end diff --git a/app/presenters/api/v1/language_presenter.rb b/app/presenters/api/v1/language_presenter.rb new file mode 100644 index 0000000000..c326fc2501 --- /dev/null +++ b/app/presenters/api/v1/language_presenter.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class LanguagePresenter + + class << self + + LANGUAGE_MAP = { + aa: "aar", ab: "abk", af: "afr", ak: "aka", am: "amh", ar: "ara", an: "arg", + as: "asm", av: "ava", ae: "ave", ay: "aym", az: "aze", + + ba: "bak", bm: "bam", be: "bel", bn: "ben", bh: "bih", bi: "bis", bo: "tib", + bs: "bos", br: "bre", bg: "bul", + + ca: "cat", cs: "cze", ch: "cha", ce: "che", cu: "chu", cv: "chv", co: "cos", + cr: "cre", cy: "wel", + + da: "dan", de: "deu", dv: "div", dz: "dzo", + + el: "gre", en: "eng", eo: "epo", es: "spa", et: "est", eu: "baq", ee: "ewe", + + fo: "fao", fa: "per", fj: "fij", fi: "fin", fr: "fre", fy: "fry", ff: "ful", + + gd: "gla", ga: "gle", gl: "glg", gv: "glv", gn: "grn", gu: "guj", + + ht: "hat", ha: "hau", he: "heb", hz: "her", hi: "hin", ho: "hmo", hr: "hrv", + hu: "hun", hy: "arm", + + ig: "ibo", io: "ido", ii: "iii", iu: "iku", ie: "ile", ia: "ina", id: "ind", + ik: "ipk", is: "ice", it: "ita", + + jv: "jav", ja: "jpn", + + kl: "kal", kn: "kan", ks: "kas", kr: "kau", kk: "kaz", km: "khm", ki: "kik", + ky: "kir", kv: "kom", kg: "kon", ko: "kor", kj: "kua", ku: "kur", ka: "geo", + kw: "cor", + + lo: "lao", la: "lat", lv: "lav", li: "lim", ln: "lin", lt: "lit", lb: "ltz", + lu: "lub", lg: "lug", + + mk: "mac", mh: "mah", ml: "mal", mi: "mao", mr: "mar", ms: "may", mg: "mlg", + mt: "mlt", mn: "mon", my: "bur", + + na: "nau", nv: "nav", nr: "nbl", nd: "nde", ng: "ndo", ne: "nep", nl: "dut", + nn: "nno", nb: "nob", no: "nor", ny: "nya", + + oc: "oci", oj: "oji", or: "ori", om: "orm", os: "oss", + + pa: "pan", pi: "pli", pl: "pol", pt: "por", ps: "pus", + + qu: "que", + + rm: "roh", ro: "rum", rn: "run", ru: "rus", rw: "kin", + + sg: "sag", sa: "san", si: "sin", sk: "slo", sl: "slv", se: "sme", sm: "smo", + sn: "sna", sd: "snd", so: "som", st: "sot", sq: "alb", sc: "srd", sr: "srp", + ss: "ssw", su: "sun", sw: "swa", sv: "swe", + + ty: "tah", ta: "tam", tt: "tat", te: "tel", tg: "tgk", tl: "tgl", th: "tha", + ti: "tir", to: "ton", tn: "tsn", ts: "tso", tk: "tuk", tr: "tur", tw: "twi", + + ug: "uig", uk: "ukr", ur: "urd", uz: "uzb", + + ve: "ven", vi: "vie", vo: "vol", + + wa: "wln", wo: "wol", + + xh: "xho", + + yi: "yid", yo: "yor", + + za: "zha", zh: "chi", zu: "zul" + }.freeze + + # Convert the incoming 2 (e.g. en - ISO 639-1) or 2+region (e.g. en-UK) + # into the 3 character code (e.g. eng - ISO 639-2) + def three_char_code(lang:) + two_char_code = lang.to_s.split("-").first + LANGUAGE_MAP[two_char_code.to_sym] + end + + end + + end + + end + +end diff --git a/app/presenters/api/v1/org_presenter.rb b/app/presenters/api/v1/org_presenter.rb new file mode 100644 index 0000000000..aa430c96fc --- /dev/null +++ b/app/presenters/api/v1/org_presenter.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class OrgPresenter + + class << self + + def affiliation_id(identifiers:) + ident = identifiers.select { |id| id.identifier_scheme&.name == "ror" }.first + return ident if ident.present? + + identifiers.select { |id| id.identifier_scheme&.name == "fundref" }.first + end + + end + + end + + end + +end diff --git a/app/presenters/api/v1/pagination_presenter.rb b/app/presenters/api/v1/pagination_presenter.rb new file mode 100644 index 0000000000..35066323eb --- /dev/null +++ b/app/presenters/api/v1/pagination_presenter.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class PaginationPresenter + + def initialize(current_url:, per_page:, total_items:, current_page: 1) + @url = current_url + @per_page = per_page + @total_items = total_items + @page = current_page + end + + def url_without_pagination + return nil unless @url.present? && @url.is_a?(String) + + url = @url.gsub(/per_page\=[\d]+/, "") + .gsub(/page\=[\d]+/, "") + .gsub(/(&)+$/, "").gsub(/\?$/, "") + + (url.include?("?") ? "#{url}&" : "#{url}?") + end + + def prev_page? + total_pages > 1 && @page != 1 + end + + def next_page? + total_pages > 1 && @page < total_pages + end + + def prev_page_link + "#{url_without_pagination}page=#{@page - 1}&per_page=#{@per_page}" + end + + def next_page_link + "#{url_without_pagination}page=#{@page + 1}&per_page=#{@per_page}" + end + + private + + def total_pages + return 1 unless @total_items.present? && @per_page.present? && + @total_items.positive? && @per_page.positive? + + (@total_items.to_f / @per_page).ceil + end + + end + + end + +end diff --git a/app/presenters/api/v1/plan_presenter.rb b/app/presenters/api/v1/plan_presenter.rb new file mode 100644 index 0000000000..406a6caf66 --- /dev/null +++ b/app/presenters/api/v1/plan_presenter.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class PlanPresenter + + attr_reader :data_contact + attr_reader :contributors + attr_reader :costs + + def initialize(plan:) + @contributors = [] + return unless plan.present? + + @plan = plan + + # Attach the first data_curation role as the data_contact, otherwise + # add the contributor to the contributors array + @plan.contributors.each do |contributor| + @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 + doi = @plan.identifiers.select do |id| + %w[ark doi].include?(id.identifier_format) + end + return doi.first if doi.first.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 + + 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/api/v1/template_presenter.rb b/app/presenters/api/v1/template_presenter.rb new file mode 100644 index 0000000000..60870c40d9 --- /dev/null +++ b/app/presenters/api/v1/template_presenter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class TemplatePresenter + + def initialize(template:) + @template = template + end + + # If the plan has a grant number then it has been awarded/granted + # otherwise it is 'planned' + def title + return @template.title unless @template.customization_of.present? + + "#{@template.title} - with additional questions for #{@template.org.name}" + end + + end + + end + +end diff --git a/app/presenters/contributor_presenter.rb b/app/presenters/contributor_presenter.rb new file mode 100644 index 0000000000..f988190977 --- /dev/null +++ b/app/presenters/contributor_presenter.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class ContributorPresenter + + class << self + + # Returns the name with each word capitalized + def display_name(name:) + return "" unless name.present? + + name.split.map { |part| part.capitalize }.join(" ") + end + + # Returns the string name for each role + def display_roles(roles:) + return "None" unless roles.present? && roles.any? + + roles.map { |role| role_symbol_to_string(symbol: role) }.join("
").html_safe + end + + # Fetches the contributor's ORCID or initializes one + def orcid(contributor:) + orcid = contributor.identifier_for_scheme(scheme: "orcid") + return orcid if orcid.present? + + scheme = IdentifierScheme.by_name("orcid").first + return nil unless scheme.present? + + Identifier.new(identifiable: contributor, identifier_scheme: scheme) + end + + def roles_for_radio(contributor:) + all_roles = Contributor.new.all_roles + return all_roles unless contributor.present? + + selected = contributor.selected_roles + all_roles.map { |role| { "#{role}": selected.include?(role) } } + end + + def role_symbol_to_string(symbol:) + case symbol + when :data_curation + "Data Manager" + when :project_administration + "Project Administrator" + else + "Principal Investigator" + end + end + + def role_tooltip(symbol:) + case symbol + when :data_curation + _("Management activities to annotate (produce metadata), scrub data and maintain research data (including software code, where it is necessary for interpreting the data itself) for initial use and later re-use.") + when :investigation + _("Conducting a research and investigation process, specifically performing the experiments, or data/evidence collection.") + when :project_administration + _("Management and coordination responsibility for the research activity planning and execution.") + else + "" + end + end + + end + +end diff --git a/app/presenters/identifier_presenter.rb b/app/presenters/identifier_presenter.rb new file mode 100644 index 0000000000..39a3f28c09 --- /dev/null +++ b/app/presenters/identifier_presenter.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class IdentifierPresenter + + attr_reader :schemes + attr_reader :identifiable + + def initialize(identifiable:) + @identifiable = identifiable + + @schemes = load_schemes + end + + def identifiers + @identifiable.identifiers + end + + def id_for_scheme(scheme:) + @identifiable.identifiers.find_or_initialize_by(identifier_scheme: scheme) + end + + def scheme_by_name(name:) + schemes.select { |scheme| scheme.name.downcase == name.downcase } + end + + def id_for_display(id:, with_scheme_name: true) + return _("None defined") if id.new_record? || id.value.blank? + + without = id.value_without_scheme_prefix + return id.value unless without != id.value && !without.starts_with?("http") + + " " + + "#{with_scheme_name ? id.identifier_scheme.description : ""}: #{without}" + end + + private + + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def load_schemes + # Load the schemes for the current context + schemes = IdentifierScheme.for_orgs if @identifiable.is_a?(Org) + schemes = IdentifierScheme.for_plans if @identifiable.is_a?(Plan) + schemes = IdentifierScheme.for_users if @identifiable.is_a?(User) + return [] unless schemes.present? || schemes.empty? + + schemes = schemes.order(:name) + + # Shibboleth Org identifiers are only for use by installations that have + # a curated list of Orgs that can use institutional login + if @identifiable.is_a?(Org) && + !Rails.application.config.shibboleth_use_filtered_discovery_service + schemes = schemes.reject { |scheme| scheme.name.downcase == "shibboleth" } + end + schemes + end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity + # rubocop:enable Metrics/PerceivedComplexity + +end diff --git a/app/presenters/org_selection_presenter.rb b/app/presenters/org_selection_presenter.rb new file mode 100644 index 0000000000..a7799eec65 --- /dev/null +++ b/app/presenters/org_selection_presenter.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class OrgSelectionPresenter + + attr_accessor :suggestions + + def initialize(orgs:, selection:) + @crosswalk = [] + + # TODO: Remove this once the is_other Org has been removed + @name = selection.present? ? selection.name : "" + + orgs = [selection] if !orgs.present? || orgs.empty? + + @crosswalk = orgs.map do |org| + next if org.nil? + + OrgSelection::OrgToHashService.to_hash(org: org) + end + end + + # Return the Org name unless this is the default is_other Org + attr_reader :name + + def crosswalk + @crosswalk.to_json + end + + def select_list + @crosswalk.map { |rec| rec[:name] }.to_json + end + + def crosswalk_entry_from_org_id(value:) + return {}.to_json unless value.present? && value.to_s =~ /[0-9]+/ + + entry = @crosswalk.select { |entry| entry[:id].to_s == value.to_s }.first + entry.present? ? entry.to_json : {}.to_json + end + +end diff --git a/app/presenters/plan_presenter.rb b/app/presenters/plan_presenter.rb new file mode 100644 index 0000000000..ed49b90583 --- /dev/null +++ b/app/presenters/plan_presenter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class PlanPresenter + + attr_accessor :plan + + def initialize(plan) + @plan = plan + end + + # Converts the Project Start and End Dates into human readable text + # rubocop:disable Metrics/AbcSize + def project_dates_to_readonly_display + sd = I18n.l(@plan.start_date.to_date, formats: :short) if @plan.start_date.present? + ed = I18n.l(@plan.end_date.to_date, formats: :short) if @plan.end_date.present? + + return "#{sd} to #{ed}" if sd.present? && ed.present? + return "Starts on #{sd}" if sd.present? + return "Ends on #{ed}" if ed.present? + + "" + end + # rubocop:enable Metrics/AbcSize + +end diff --git a/app/services/api/v1/auth/jwt/authentication_service.rb b/app/services/api/v1/auth/jwt/authentication_service.rb new file mode 100644 index 0000000000..eddfc53d52 --- /dev/null +++ b/app/services/api/v1/auth/jwt/authentication_service.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module Api + + module V1 + + module Auth + + module Jwt + + # This class provides Authentication for: + # + # ApiClients (aka machines) with the following JSON body: { + # "grant_type": "client_credentials", + # "client_id": "[api_clients.client_id]", + # "client_secret": "[api_clients.client_secret]", + # } + # + # Users with the following JSON body: { + # "grant_type": "authorization_code", + # "email": "[users.email]", + # "code": "[users.api_token]", + # } + # + class AuthenticationService + + attr_reader :errors + attr_reader :expiration + + # rubocop:disable Metrics/CyclomaticComplexity + def initialize(json: {}) + json = json.nil? ? {} : json.with_indifferent_access + type = json.fetch(:grant_type, "client_credentials") + + parse_client(json: json) if type == "client_credentials" + parse_code(json: json) if type == "authorization_code" + + @errors = {} + + if @client_id.nil? || @client_secret.nil? || + !%w[client_credentials authorization_code].include?(type) + @errors[:client_authentication] = _("Invalid grant type") + end + end + # rubocop:enable Metrics/CyclomaticComplexity + + # Returns the JWT if the authentication succeeds + # rubocop:disable Metrics/CyclomaticComplexity + def call + return nil unless @client_id.present? && @client_secret.present? + + obj = client + return nil unless obj.present? + + # Fetch either the client_id or the email depending on whether we + # are working with a ApiClient or a User + id = obj.client_id if obj.is_a?(ApiClient) + id = obj.email if obj.is_a?(User) + return nil unless id.present? + + payload = { client_id: id } + token = JsonWebToken.encode(payload: payload) + # JWT appends the expiration directly to the incoming payload + @expiration = payload[:exp] + token + end + # rubocop:enable Metrics/CyclomaticComplexity + + private + + attr_reader :client_id + attr_reader :client_secret + attr_reader :api_client + attr_reader :auth_method + + # Returns the matching ApiClient if authentication succeeds + def client + return @api_client if @api_client.present? + + @api_client = send(:"#{@auth_method}") + return @api_client if @api_client.present? + + # Record an error if no ApiClient or User was authenticated + @errors[:client_authentication] = _("Invalid credentials") + nil + end + + # Tries to find an ApiClient that matches the :client_id. If found + # it will attempt to authenticate the :client_secret + def authenticate_client + clients = ApiClient.where(client_id: @client_id) + return nil unless clients.present? && clients.any? + + clnt = clients.first + clnt.authenticate(secret: @client_secret) ? clnt : nil + end + + # Tries to find a User whose email matches the :client_id. If found + # it will attempt to authenticate the :api_token against the :client_secret + def authenticate_user + users = User.where("lower(email) LIKE lower(?)", @client_id) + return nil unless users.present? && users.any? + + usr = users.first + # Valid if User is active, has permission to use the API and + # the :client_secret matches the token + usr.active && usr.can_use_api? && usr.api_token == @client_secret ? usr : nil + end + + # Handles ApiClient credentials + def parse_client(json: {}) + @client_id = json.fetch(:client_id, nil) + @client_secret = json.fetch(:client_secret, nil) + @auth_method = "authenticate_client" + end + + # Handles User credentials + def parse_code(json: {}) + @client_id = json.fetch(:email, nil) + @client_secret = json.fetch(:code, nil) + @auth_method = "authenticate_user" + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v1/auth/jwt/authorization_service.rb b/app/services/api/v1/auth/jwt/authorization_service.rb new file mode 100644 index 0000000000..9eec194d39 --- /dev/null +++ b/app/services/api/v1/auth/jwt/authorization_service.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Api + + module V1 + + module Auth + + module Jwt + + class AuthorizationService + + def initialize(headers: {}) + @headers = headers.nil? ? {} : headers + @errors = HashWithIndifferentAccess.new + end + + def call + client + end + + attr_reader :errors + + private + + # Lookup the Client bassed on the client_id embedded in the JWT + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize + def client + return @api_client if @api_client.present? + + token = decoded_auth_token + # If the token is missing or invalid then set the client to nil + errors[:token] = _("Invalid token") unless token.present? + @api_client = nil unless token.present? && token[:client_id].present? + return @api_client unless token.present? && token[:client_id].present? + + @api_client = ApiClient.where(client_id: token[:client_id]).first + return @api_client if @api_client.present? + + @api_client = User.where(email: token[:client_id]).first + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/AbcSize + + def decoded_auth_token + return @token if @token.present? + + @token = JsonWebToken.decode(token: http_auth_header) + @token + rescue JWT::ExpiredSignature + errors[:token] = _("Token expired") + nil + end + + # Extract the token from the Authorization header + def http_auth_header + hdr = @headers[:Authorization] + errors[:token] = _("Missing token") unless hdr.present? + return nil unless hdr.present? + + hdr.split.last + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v1/auth/jwt/json_web_token.rb b/app/services/api/v1/auth/jwt/json_web_token.rb new file mode 100644 index 0000000000..74f98c68f7 --- /dev/null +++ b/app/services/api/v1/auth/jwt/json_web_token.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Api + + module V1 + + module Auth + + module Jwt + + class JsonWebToken + + class << self + + def encode(payload:, exp: 24.hours.from_now) + payload[:exp] = exp.to_i + JWT.encode(payload, Rails.application.secrets.secret_key_base) + rescue JWT::EncodeError + nil + end + + def decode(token:) + body = JWT.decode(token, + Rails.application.secrets.secret_key_base)[0] + HashWithIndifferentAccess.new body + rescue JWT::ExpiredSignature => e + raise e + rescue JWT::DecodeError + nil + end + + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v1/conversion_service.rb b/app/services/api/v1/conversion_service.rb new file mode 100644 index 0000000000..9b9af2c270 --- /dev/null +++ b/app/services/api/v1/conversion_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Api + + module V1 + + 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/v1/deserialization/contributor.rb b/app/services/api/v1/deserialization/contributor.rb new file mode 100644 index 0000000000..697dabd183 --- /dev/null +++ b/app/services/api/v1/deserialization/contributor.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +module Api + + module V1 + + module Deserialization + + class Contributor + + class << self + + # Convert the incoming JSON into a Contributor + # { + # "role": [ + # "https://dictionary.casrai.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!(plan_id:, json: {}, is_contact: false) + return nil unless valid?(is_contact: is_contact, json: json) + + json = json.with_indifferent_access + contributor = marshal_contributor(plan_id: plan_id, + is_contact: is_contact, json: json) + contributor.save + return nil unless contributor.valid? + + attach_identifier!(contributor: contributor, json: json) + end + + # =================== + # = PRIVATE METHODS = + # =================== + + private + + # The JSON is valid if the Contributor has a name or email + # and roles (if this is not the Contact) + def valid?(is_contact:, json: {}) + return false unless json.present? + return false unless json[:name].present? || json[:mbox].present? + + is_contact ? true : json[:role].present? + end + + # Find or initialize the Contributor + # rubocop:disable Metrics/CyclomaticComplexity + def marshal_contributor(plan_id:, is_contact:, json: {}) + return nil unless plan_id.present? && json.present? + + # Try to find the Org by the identifier + contributor = find_by_identifier(json: json) + + # Search by email if available and not found above + unless contributor.present? + contributor = find_or_initialize_by(plan_id: plan_id, json: json) + end + + # Attach the Org unless its already defined + contributor.org = deserialize_org(json: json) unless contributor.org.present? + + # Assign the roles + contributor = assign_contact_roles(contributor: contributor) if is_contact + assign_roles(contributor: contributor, json: json) unless is_contact + + contributor + end + # rubocop:enable Metrics/CyclomaticComplexity + + # Locate the Contributor by its identifier + def find_by_identifier(json: {}) + return nil unless json.present? && + (json[:contact_id].present? || + json[:contributor_id].present?) + + id = json.fetch(:contact_id, json.fetch(:contributor_id, {})) + ::Contributor.from_identifiers( + array: [{ name: id[:type], value: id[:identifier] }] + ) + end + + # Find the Contributor by its name or email or initialize one + def find_or_initialize_by(plan_id:, json: {}) + return nil unless json.present? && plan_id.present? + + if json[:mbox].present? + contributor = ::Contributor.find_by(plan_id: plan_id, + email: json[:mbox]) + return contributor if contributor.present? + end + ::Contributor.find_or_initialize_by(plan_id: plan_id, + name: json[:name], + email: json[:mbox]) + end + + # Call the deserializer method for the Org + def deserialize_org(json: {}) + return nil unless json.present? && json[:affiliation].present? + + Api::V1::Deserialization::Org.deserialize!(json: json[:affiliation]) + end + + # Assign the default Contact roles + def assign_contact_roles(contributor:) + return nil unless contributor.present? + + contributor.data_curation = true + contributor + end + + # Assign the specified roles + def assign_roles(contributor:, json: {}) + return nil unless contributor.present? + return contributor unless json.present? && json[:role].present? + + json.fetch(:role, []).each do |url| + role = translate_role(role: url) + contributor.send(:"#{role}=", true) if role.present? + end + contributor + end + + # Marshal the Identifier and saves it (unless it exists) + def attach_identifier!(contributor:, json: {}) + return contributor unless json.present? + + hash = json.fetch(:contact_id, json.fetch(:contributor_id, {})) + return contributor unless hash.present? + + Api::V1::Deserialization::Identifier.deserialize!( + identifiable: contributor, json: hash + ) + contributor.reload + end + + # Translates the role in the json to a PlansContributor 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) + + # Return the role if its a valid one otherwise defualt + return role if ::Contributor.new.respond_to?(role.downcase.to_sym) + + default + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v1/deserialization/dataset.rb b/app/services/api/v1/deserialization/dataset.rb new file mode 100644 index 0000000000..84cde3022e --- /dev/null +++ b/app/services/api/v1/deserialization/dataset.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Api + + module V1 + + module Deserialization + + class Dataset + + class << self + + # Convert incoming JSON into a Dataset + # { + # "title": "Cerebral cortex imaging series", + # "personal_data": "unknown", + # "sensitive_data": "unknown", + # "dataset_id": { + # "type": "doi", + # "identifier": "https://doix.org/10.1234.123abc/y3" + # } + # }, + # "distribution": [ + # { + # "title": "PDF - Testing our maDMP JSON export", + # "data_access": "open", + # "download_url": "http://dmproadmap.org/plans/44247/export.pdf", + # "format": ["application/pdf"] + # } + # ] + # } + def deserialize!(json: {}) + return nil unless json.present? && json[:title].present? + + # TODO: Implement once we have determined the Dataset model + nil + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v1/deserialization/funding.rb b/app/services/api/v1/deserialization/funding.rb new file mode 100644 index 0000000000..8ca965e6bc --- /dev/null +++ b/app/services/api/v1/deserialization/funding.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Api + + module V1 + + 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 valid?(json: json) + + # Attach the Funder + plan.funder = Api::V1::Deserialization::Org.deserialize!(json: json) + return plan unless json[:grant_id].present? + return nil unless plan.save + + # Attach the grant Identifier to the Plan if present + deserialize_grant(plan: plan, json: json) + end + + private + + # The JSON is valid if the Funding has a funder name or funder_id + # or a grant_id + def 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 + + # Convert the JSON grant information into an Identifier + def deserialize_grant(plan:, json: {}) + return plan unless json.present? && json[:grant_id].present? + + grant = Api::V1::Deserialization::Identifier.deserialize!( + identifiable: plan, json: json[:grant_id] + ) + return plan unless grant.present? + + plan.update(grant_id: grant.id) + plan + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v1/deserialization/identifier.rb b/app/services/api/v1/deserialization/identifier.rb new file mode 100644 index 0000000000..f02b77ea3c --- /dev/null +++ b/app/services/api/v1/deserialization/identifier.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Api + + module V1 + + module Deserialization + + class Identifier + + class << self + + # Convert the incoming JSON into an Identifier + # { + # "type": "ror", + # "identifier": "https://ror.org/43y4g4" + # } + def deserialize!(identifiable:, json: {}) + return nil unless identifiable.present? && valid?(json: json) + + json = json.with_indifferent_access + scheme = ::IdentifierScheme.by_name(json[:type].downcase).first + identifier = identifier_for_scheme(scheme: scheme, + identifiable: identifiable, + json: json) + return identifier if identifier.present? + + ::Identifier.find_or_create_by(identifier_scheme: scheme, + identifiable: identifiable, + value: json[:identifier]) + end + + # =================== + # = PRIVATE METHODS = + # =================== + + private + + # The JSON is valid if both the type and identifier are present + def valid?(json:) + json.present? && json[:type].present? && json[:identifier].present? + end + + # Find or intialize an Identifier + def identifier_for_scheme(scheme:, identifiable:, json: {}) + return nil unless identifiable.present? && + json.present? && + scheme.present? + + identifier = ::Identifier.find_by(identifier_scheme: scheme, + identifiable: identifiable) + return nil unless identifier.present? + + identifier.update(value: json[:identifier]) + identifier.reload + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v1/deserialization/org.rb b/app/services/api/v1/deserialization/org.rb new file mode 100644 index 0000000000..1765cf4d09 --- /dev/null +++ b/app/services/api/v1/deserialization/org.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Api + + module V1 + + 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 valid?(json: json) + + json = json.with_indifferent_access + + # Try to find the Org by the identifier + org = find_by_identifier(json: json) + + # Try to find the Org by name + org = find_by_name(json: json) unless org.present? + + # Org model requires a language so just use the default for now + org.language = Language.default + org.abbreviation = json[:abbreviation] if json[:abbreviation].present? + org.save + return nil unless org.valid? + + attach_identifier!(org: org, json: json) + end + + # =================== + # = PRIVATE METHODS = + # =================== + + private + + # The JSON is valid if the Org has a name + def valid?(json: {}) + json.present? && json[:name].present? + end + + # Locate the Org by its identifier + def find_by_identifier(json: {}) + return nil unless json.present? && + (json[:affiliation_id].present? || + json[:funder_id].present?) + + id = json.fetch(:affiliation_id, json.fetch(:funder_id, {})) + ::Org.from_identifiers( + array: [{ name: id[:type], value: id[:identifier] }] + ) + end + + # Search for an Org locally and then externally if not found + # rubocop:disable Metrics/AbcSize + 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 + # rubocop:enable Metrics/AbcSize + + # Marshal the Identifier and saves it (unless it exists) + def attach_identifier!(org:, json: {}) + return org unless json.present? + + hash = json.fetch(:affiliation_id, json.fetch(:funder_id, {})) + return org unless hash.present? + + Api::V1::Deserialization::Identifier.deserialize!( + identifiable: org, json: hash + ) + org.reload + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v1/deserialization/plan.rb b/app/services/api/v1/deserialization/plan.rb new file mode 100644 index 0000000000..562a6dfe3b --- /dev/null +++ b/app/services/api/v1/deserialization/plan.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +module Api + + module V1 + + module Deserialization + + # rubocop:disable Metrics/ClassLength + 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" + # } + # } + # }] + # } + # } + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def deserialize!(json: {}) + return nil unless json.present? && valid?(json: json) + + json = json.with_indifferent_access + plan = marshal_plan(json: json) + return nil unless plan.present? + + plan.title = json[:title] + plan.description = json[:description] if json[:description].present? + plan.save + + # 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) + plan = deserialize_contact(plan: plan, json: json) + plan = deserialize_contributors(plan: plan, json: json) + plan = deserialize_datasets(plan: plan, json: json) + + plan + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + # =================== + # = PRIVATE METHODS = + # =================== + + private + + # Quickly determine whether or not the JSON contains enough information + # to marshal the Plan and its dependencies + def valid?(json: {}) + return false unless json.present? && + json[:title].present? && + json[:contact].present? && + json[:contact][:mbox].present? + + # We either need a Template.id (creating) or a Plan.id (updating) + dmp_id = json[:dmp_id]&.fetch(:identifier, nil) + template_id(json: json).present? || dmp_id.present? + end + + # Find or initialize the Plan + def marshal_plan(json: {}) + plan = find_by_identifier(json: json) + return plan if plan.present? + + # If this is not an existing Plan, then initialize a new one + # for the specified template (or the default template if none specified) + template = find_template(json: json) + return nil unless template.present? + + ::Plan.new(template_id: template.id) + end + + # Deserialize the datasets and attach to plan + # TODO: Implement this once we update the data model + def deserialize_datasets(plan:, json: {}) + return plan unless json.present? && json[:dataset].present? + + plan + end + + # Deserialize the project information and attach to Plan + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + 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 = Time.new(project[:start]).utc if project[:start].present? + plan.end_date = Time.new(project[:end]).utc if project[:end].present? + return plan unless project[:funding].present? + + funding = project.fetch(:funding, []).first + return plan unless funding.present? + + Api::V1::Deserialization::Funding.deserialize!(plan: plan, json: funding) + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:enable Metrics/AbcSize + + # Deserialize the contact as a Contributor + def deserialize_contact(plan:, json: {}) + return plan unless json.present? && json[:contact].present? + + contact = Api::V1::Deserialization::Contributor.deserialize!( + plan_id: plan.id, json: json[:contact], is_contact: true + ) + return plan unless contact.present? + + contact.save + 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| + contrib = Api::V1::Deserialization::Contributor.deserialize!( + plan_id: plan.id, json: hash + ) + contrib + end + +p contributors.compact.uniq + + plan.contributors << contributors.compact.uniq if contributors.any? + plan + end + + # Locate the Org by its identifier + # rubocop:disable Metrics/AbcSize + def find_by_identifier(json: {}) + return nil unless json.present? && json[:dmp_id].present? && + json[:dmp_id][:identifier].present? + + id = json[:dmp_id][:identifier] + if doi?(value: id) + # Find by the DOI or ARK + ::Plan.from_identifiers( + array: [{ name: json[:dmp_id][:type], value: json[:dmp_id][:identifier] }] + ) + else + # For URL based identifiers + ::Plan.find_by(id: id.split("/").last) + end + end + # rubocop:enable Metrics/AbcSize + + # Determine whether or not the value is a DOI or ARK + def doi?(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") + %w[ark doi].include?(identifier.identifier_format) && scheme.present? + end + + # Lookup the Template + def find_template(json: {}) + return nil unless json.present? + + template = ::Template.find_by(id: template_id(json: json)) + template.present? ? template : Template.find_by(is_default: true) + end + + # Extract the Template id from the JSON + def template_id(json: {}) + return nil unless json.present? + + extensions = app_extensions(json: json) + return nil unless extensions.present? + + extensions.fetch(:template, {})[:id] + end + + # Retrieve the extensions to the JSON for this application + # rubocop:disable Metrics/AbcSize + def app_extensions(json: {}) + return {} unless json.present? && json[:extension].present? + + app = ::ApplicationService.application_name.split("-").first + ext = json[:extension].select { |item| item[app.to_sym].present? } + ext.first.present? ? ext.first[app.to_sym] : {} + end + # rubocop:enable Metrics/AbcSize + + end + + end + # rubocop:enable Metrics/ClassLength + + end + + end + +end diff --git a/app/services/application_service.rb b/app/services/application_service.rb new file mode 100644 index 0000000000..f4a6d77ad2 --- /dev/null +++ b/app/services/application_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ApplicationService + + class << self + + # Gets the default language + def default_language + lang = Language.where(default_language: true).first + lang.present? ? lang.abbreviation : "en" + end + + # Returns either the name specified in config/branding.yml or + # the Rails application name + def application_name + Rails.application.config.branding[:application] + .fetch(:name, Rails.application.class.name.split('::').first).downcase + end + + end + +end diff --git a/app/services/external_apis/base_service.rb b/app/services/external_apis/base_service.rb new file mode 100644 index 0000000000..11228ae807 --- /dev/null +++ b/app/services/external_apis/base_service.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'httparty' + +module ExternalApis + + class ExternalApiError < StandardError; end + + class BaseService + + class << self + + # The following should be defined in each inheriting service's initializer. + # For example: + # ExternalApis::RorService.setup do |config| + # config.x.ror.landing_page_url = "https://example.org/" + # config.x.ror.api_base_url = "https://api.example.org/" + # end + def landing_page_url + nil + end + + def api_base_url + nil + end + + def max_pages + 5 + end + + def max_results_per_page + 50 + end + + def max_redirects + 3 + end + + def active + false + end + + # The standard headers to be used when communicating with an external API. + # These headers can be overriden or added to when calling an external API + # by sending your changes in the `additional_headers` attribute of + # `http_get` + def headers + hash = { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "#{app_name} (#{app_email})" + } + hash.merge({ "Host": URI(api_base_url).hostname.to_s }) + + rescue URI::InvalidURIError => e + handle_uri_failure(method: "BaseService.headers #{e.message}", + uri: api_base_url) + hash + end + + # Logs the results of a failed HTTP response + def handle_http_failure(method:, http_response:) + content = http_response.inspect + msg = "received a #{http_response&.code} response with: #{content}!" + log_error(method: method, error: ExternalApiError.new(msg)) + end + + # Logs the results of a failed HTTP response + def handle_uri_failure(method:, uri:) + msg = "received an invalid uri: '#{uri&.to_s}'!" + log_error(method: method, error: ExternalApiError.new(msg)) + end + + # Logs the specified error along with the full backtrace + def log_error(method:, error:) + return unless method.present? && error.present? + + Rails.logger.error "#{self.class.name}.#{method} #{error.message}" + Rails.logger.error error.backtrace + end + + private + + # Shortcut to the branding.yml + def config + Rails.configuration.branding + end + + # Retrieves the application name from branding.yml or uses the App name + def app_name + ApplicationService.application_name + end + + # Retrieves the helpdesk email from branding.yml or uses the contact page url + def app_email + dflt = Rails.application.routes.url_helpers.contact_us_url + config.fetch(:organisation, {}).fetch(:helpdesk_email, dflt) + end + + # Makes a GET 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_get(uri:, additional_headers: {}, debug: false) + return nil unless uri.present? + + HTTParty.get(uri, options(additional_headers: additional_headers, + debug: debug)) + + rescue URI::InvalidURIError => e + handle_uri_failure(method: "BaseService.http_get #{e.message}", + uri: uri) + nil + rescue HTTParty::Error => e + handle_http_failure(method: "BaseService.http_get #{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 + } + hash[:debug_output] = STDOUT if debug + hash + end + + end + + end + +end diff --git a/app/services/external_apis/doi_service.rb b/app/services/external_apis/doi_service.rb new file mode 100644 index 0000000000..33beb8465f --- /dev/null +++ b/app/services/external_apis/doi_service.rb @@ -0,0 +1,75 @@ +# 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 + return true + + # You should implement any necessary authentication step required by the + # DOI API + end + + # Implement the call to retrieve/mint a new DOI + def mint(plan:) + return 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 + + end + + end + +end diff --git a/app/services/external_apis/open_aire_service.rb b/app/services/external_apis/open_aire_service.rb new file mode 100644 index 0000000000..1a5aaab4a2 --- /dev/null +++ b/app/services/external_apis/open_aire_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "nokogiri" + +module ExternalApis + + # This service provides an interface to the OpenAire API. + class OpenAireService < BaseService + + class << self + + # Retrieve the config settings from the initializer + def api_base_url + Rails.configuration.x.open_aire&.api_base_url || super + end + + def active + Rails.configuration.x.open_aire&.active || super + end + + def search_path + Rails.configuration.x.open_aire&.search_path || super + end + + def default_funder + Rails.configuration.x.open_aire&.default_funder + end + + # Search the OpenAire API for the specified Funder OR the Default Funder + # rubocop:disable Metrics/MethodLength + def search(funder: default_funder) + target = "#{api_base_url}#{search_path % funder}" + hdrs = { + "Accept": "application/xml", + "Content-Type": "*/*" + } + resp = http_get(uri: target, additional_headers: hdrs, debug: false) + + unless resp.code == 200 + handle_http_failure(method: "OpenAire search", http_response: resp) + return [] + end + parse_xml(xml: resp.body) + end + # rubocop:enable Metrics/MethodLength + + private + + # Process the XML response and convert each result into a ResearchProject + def parse_xml(xml:) + return [] unless xml.present? + + Nokogiri::XML(xml).xpath("//pair/displayed-value").map do |node| + parts = node.content.split("-") + grant_id = parts.shift.to_s.strip + description = parts.join(" - ").strip + ResearchProject.new(grant_id, description) + end + # If a JSON parse error occurs then return results of a local table search + rescue Nokogiri::XML::SyntaxError => e + log_error(method: "OpenAire search", error: e) + [] + end + + end + + end + +end diff --git a/app/services/external_apis/ror_service.rb b/app/services/external_apis/ror_service.rb new file mode 100644 index 0000000000..5961e8da79 --- /dev/null +++ b/app/services/external_apis/ror_service.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +module ExternalApis + + # This service provides an interface to the Research Organization Registry (ROR) + # API. + # For more information: https://github.com/ror-community/ror-api + + # rubocop:disable Metrics/ClassLength + class RorService < BaseService + + class << self + + # Retrieve the config settings from the initializer + def landing_page_url + Rails.configuration.x.ror&.landing_page_url || super + end + + def api_base_url + Rails.configuration.x.ror&.api_base_url || super + end + + def max_pages + Rails.configuration.x.ror&.max_pages || super + end + + def max_results_per_page + Rails.configuration.x.ror&.max_results_per_page || super + end + + def max_redirects + Rails.configuration.x.ror&.max_redirects || super + end + + def active + Rails.configuration.x.ror&.active || super + end + + def heartbeat_path + Rails.configuration.x.ror&.heartbeat_path + end + + def search_path + Rails.configuration.x.ror&.search_path + end + + # Ping the ROR 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.present? && resp.code == 200 + end + + # Search the ROR API for the given string. + # + # @return an Array of Hashes: + # { + # id: 'https://ror.org/12345', + # name: 'Sample University (sample.edu)', + # sort_name: 'Sample University', + # score: 0 + # weight: 0 + # } + # The ROR limit appears to be 40 results (even with paging :/) + def search(term:, filters: []) + return [] unless active && term.present? && ping + + process_pages( + term: term, + json: query_ror(term: term, filters: filters), + filters: filters + ) + + # If a JSON parse error occurs then return results of a local table search + rescue JSON::ParserError => e + log_error(method: "ROR search", error: e) + [] + end + + private + + # Queries the ROR API for the sepcified name and page + def query_ror(term:, page: 1, filters: []) + return [] unless term.present? + + # build the URL + target = "#{api_base_url}#{search_path}" + query = query_string(term: term, page: page, filters: filters) + + # Call the ROR API and log any errors + resp = http_get(uri: "#{target}?#{query}", additional_headers: {}, + debug: false) + + unless resp.present? && resp.code == 200 + handle_http_failure(method: "ROR search", http_response: resp) + return [] + end + JSON.parse(resp.body) + end + + # Build the query string using the search term, current page and any + # filters specified + def query_string(term:, page: 1, filters: []) + query_string = ["query=#{term}", "page=#{page}"] + query_string << "filter=#{filters.join(',')}" if filters.any? + query_string.join("&") + end + + # Recursive method that can handle multiple ROR result pages if necessary + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + # rubocop:disable Metrics/CyclomaticComplexity + def process_pages(term:, json:, filters: []) + return [] if json.blank? + + results = parse_results(json: json) + num_of_results = json.fetch("number_of_results", 1).to_i + + # Determine if there are multiple pages of results + pages = (num_of_results / max_results_per_page.to_f).to_f.ceil + return results unless pages > 1 + + # Gather the results from the additional page (only up to the max) + (2..(pages > max_pages ? max_pages : pages)).each do |page| + json = query_ror(term: term, page: page, filters: filters) + results += parse_results(json: json) + end + results || [] + + # If we encounter a JSON parse error on subsequent page requests then just + # return what we have so far + rescue JSON::ParserError => e + log_error(method: "ROR search", error: e) + results || [] + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + # rubocop:enable Metrics/CyclomaticComplexity + + # Convert the JSON items into a hash + # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + def parse_results(json:) + results = [] + return results unless json.present? && json.fetch("items", []).any? + + json["items"].each do |item| + next unless item["id"].present? && item["name"].present? + + results << { + ror: item["id"].gsub(/^#{landing_page_url}/, ""), + name: org_name(item: item), + sort_name: item["name"], + url: item.fetch("links", []).first, + language: org_language(item: item), + fundref: fundref_id(item: item), + abbreviation: item.fetch("acronyms", []).first + } + end + results + end + # rubocop:enable Metrics/MethodLength, Metrics/AbcSize + + # Org names are not unique, so include the Org URL if available or + # the country. For example: + # "Example College (example.edu)" + # "Example College (Brazil)" + def org_name(item:) + return "" unless item.present? && item["name"].present? + + country = item.fetch("country", {}).fetch("country_name", "") + website = org_website(item: item) + # If no website or country then just return the name + return item["name"] unless website.present? || country.present? + + # Otherwise return the contextualized name + "#{item['name']} (#{website || country})" + end + + # Extracts the org's ISO639 if available + def org_language(item:) + dflt = FastGettext.default_locale || "en" + return dflt unless item.present? + + labels = item.fetch("labels", [{ "iso639": dflt }]) + labels.first&.fetch("iso639", FastGettext.default_locale) || dflt + end + + # Extracts the website domain from the item + def org_website(item:) + return nil unless item.present? && item.fetch("links", [])&.any? + return nil if item["links"].first.blank? + + # A website was found, so extract just the domain without the www + domain_regex = %r{^(?:http://|www\.|https://)([^/]+)} + website = item["links"].first.scan(domain_regex).last.first + website.gsub("www.", "") + end + + # Extracts the FundRef Id if available + def fundref_id(item:) + return "" unless item.present? && item["external_ids"].present? + return "" unless item["external_ids"].fetch("FundRef", {}).any? + + # If a preferred Id was specified then use it + ret = item["external_ids"].fetch("FundRef", {}).fetch("preferred", "") + return ret if ret.present? + + # Otherwise take the first one listed + item["external_ids"].fetch("FundRef", {}).fetch("all", []).first + end + + end + + end + # rubocop:enable Metrics/ClassLength + +end diff --git a/app/services/org/create_created_plan_service.rb b/app/services/org/create_created_plan_service.rb index c1cd442982..59134bddd1 100644 --- a/app/services/org/create_created_plan_service.rb +++ b/app/services/org/create_created_plan_service.rb @@ -26,6 +26,13 @@ def call(org = nil, threads: 0) end_date: end_date, org: org ) + # 2nd call to pull stats on just 'real' plans + StatCreatedPlan::CreateOrUpdate.do( + start_date: start_date, + end_date: end_date, + org: org, + filtered: true + ) end end end diff --git a/app/services/org/create_exported_plan_service.rb b/app/services/org/create_exported_plan_service.rb index 0d1de4cef0..a9491576c4 100644 --- a/app/services/org/create_exported_plan_service.rb +++ b/app/services/org/create_exported_plan_service.rb @@ -5,6 +5,7 @@ import StatExportedPlan import StatExportedPlan::CreateOrUpdate import Role +import Plan import User import ExportedPlan @@ -24,6 +25,12 @@ def call(org = nil, threads: 0) end_date: end_date, org: org ) + StatExportedPlan::CreateOrUpdate.do( + start_date: start_date, + end_date: end_date, + org: org, + filtered: true + ) end end end diff --git a/app/services/org/create_last_month_created_plan_service.rb b/app/services/org/create_last_month_created_plan_service.rb index 4acea4af3b..6ee42757b4 100644 --- a/app/services/org/create_last_month_created_plan_service.rb +++ b/app/services/org/create_last_month_created_plan_service.rb @@ -29,6 +29,12 @@ def call(org = nil, threads: 0) end_date: last[:end_date], org: org ) + StatCreatedPlan::CreateOrUpdate.do( + start_date: last[:start_date], + end_date: last[:end_date], + org: org, + filtered: true + ) end end end diff --git a/app/services/org/create_last_month_exported_plan_service.rb b/app/services/org/create_last_month_exported_plan_service.rb index 50ff09fb8d..eb5652f1ad 100644 --- a/app/services/org/create_last_month_exported_plan_service.rb +++ b/app/services/org/create_last_month_exported_plan_service.rb @@ -5,6 +5,7 @@ import StatExportedPlan import StatExportedPlan::CreateOrUpdate import Role +import Plan import User import ExportedPlan @@ -26,6 +27,12 @@ def call(org = nil, threads: 0) end_date: last[:end_date], org: org ) + StatExportedPlan::CreateOrUpdate.do( + start_date: last[:start_date], + end_date: last[:end_date], + org: org, + filtered: true + ) end end end diff --git a/app/services/org/create_last_month_shared_plan_service.rb b/app/services/org/create_last_month_shared_plan_service.rb index 9eba8d6bac..9a377751e8 100644 --- a/app/services/org/create_last_month_shared_plan_service.rb +++ b/app/services/org/create_last_month_shared_plan_service.rb @@ -5,6 +5,7 @@ import StatSharedPlan import StatSharedPlan::CreateOrUpdate import User +import Plan import Role class Org @@ -25,6 +26,12 @@ def call(org = nil, threads: 0) end_date: last[:end_date], org: org ) + StatSharedPlan::CreateOrUpdate.do( + start_date: last[:start_date], + end_date: last[:end_date], + org: org, + filtered: true + ) end end end diff --git a/app/services/org/create_shared_plan_service.rb b/app/services/org/create_shared_plan_service.rb index b784235e10..472c5c92e0 100644 --- a/app/services/org/create_shared_plan_service.rb +++ b/app/services/org/create_shared_plan_service.rb @@ -5,6 +5,7 @@ import StatSharedPlan import StatSharedPlan::CreateOrUpdate import User +import Plan import Role class Org @@ -23,6 +24,12 @@ def call(org = nil, threads: 0) end_date: end_date, org: org ) + StatSharedPlan::CreateOrUpdate.do( + start_date: start_date, + end_date: end_date, + org: org, + filtered: true + ) end end end diff --git a/app/services/org/monthly_usage_service.rb b/app/services/org/monthly_usage_service.rb index 9318c6145a..600f6fa620 100644 --- a/app/services/org/monthly_usage_service.rb +++ b/app/services/org/monthly_usage_service.rb @@ -6,11 +6,11 @@ class MonthlyUsageService class << self - def call(current_user) - total = build_from_joined_user(current_user) - build_from_created_plan(current_user, total) - build_from_shared_plan(current_user, total) - build_from_exported_plan(current_user, total) + def call(current_user, filtered: false) + total = build_from_joined_user(current_user, filtered) + build_from_created_plan(current_user, filtered, total) + build_from_shared_plan(current_user, filtered, total) + build_from_exported_plan(current_user, filtered, total) total.values end @@ -41,29 +41,37 @@ def reducer_body(acc, rec, key_target) acc end - def build_from_joined_user(current_user, total = {}) - joined_users = Stat::StatJoinedUser.monthly_range(org: current_user.org).order(:date) + def build_from_joined_user(current_user, filtered, total = {}) + # rubocop:disable Metrics/LineLength + joined_users = Stat::StatJoinedUser.monthly_range(org: current_user.org, filtered: filtered).order(:date) + # rubocop:enable Metrics/LineLength joined_users.reduce(total) do |acc, rec| reducer_body(acc, rec, :new_users) end end - def build_from_created_plan(current_user, total = {}) - created_plans = Stat::StatCreatedPlan.monthly_range(org: current_user.org).order(:date) + def build_from_created_plan(current_user, filtered, total = {}) + # rubocop:disable Metrics/LineLength + created_plans = Stat::StatCreatedPlan.monthly_range(org: current_user.org, filtered: filtered).order(:date) + # rubocop:enable Metrics/LineLength created_plans.reduce(total) do |acc, rec| reducer_body(acc, rec, :new_plans) end end - def build_from_shared_plan(current_user, total = {}) - shared_plans = Stat::StatSharedPlan.monthly_range(org: current_user.org).order(:date) + def build_from_shared_plan(current_user, filtered, total = {}) + # rubocop:disable Metrics/LineLength + shared_plans = Stat::StatSharedPlan.monthly_range(org: current_user.org, filtered: filtered).order(:date) + # rubocop:enable Metrics/LineLength shared_plans.reduce(total) do |acc, rec| reducer_body(acc, rec, :plans_shared) end end - def build_from_exported_plan(current_user, total = {}) - exported_plans = Stat::StatExportedPlan.monthly_range(org: current_user.org).order(:date) + def build_from_exported_plan(current_user, filtered, total = {}) + # rubocop:disable Metrics/LineLength + exported_plans = Stat::StatExportedPlan.monthly_range(org: current_user.org, filtered: filtered).order(:date) + # rubocop:enable Metrics/LineLength exported_plans.reduce(total) do |acc, rec| reducer_body(acc, rec, :downloads) end diff --git a/app/services/org/total_count_created_plan_service.rb b/app/services/org/total_count_created_plan_service.rb index b5f77200d0..8805f52806 100644 --- a/app/services/org/total_count_created_plan_service.rb +++ b/app/services/org/total_count_created_plan_service.rb @@ -6,15 +6,16 @@ class TotalCountCreatedPlanService class << self - def call(org = nil) - return for_orgs unless org.present? - for_org(org) + def call(org = nil, filtered: false) + return for_orgs(filtered) unless org.present? + for_org(org, filtered) end private - def for_orgs + def for_orgs(filtered) result = ::StatCreatedPlan + .where(filtered: filtered) .includes(:org) .select(:"orgs.name", :count) .group(:"orgs.name") @@ -24,8 +25,8 @@ def for_orgs end end - def for_org(org) - result = ::StatCreatedPlan.where(org: org).sum(:count) + def for_org(org, filtered) + result = ::StatCreatedPlan.where(org: org, filtered: filtered).sum(:count) build_model(org_name: org.name, count: result) end diff --git a/app/services/org/total_count_joined_user_service.rb b/app/services/org/total_count_joined_user_service.rb index 21efde4d44..d08930163f 100644 --- a/app/services/org/total_count_joined_user_service.rb +++ b/app/services/org/total_count_joined_user_service.rb @@ -6,15 +6,16 @@ class TotalCountJoinedUserService class << self - def call(org = nil) - return for_orgs unless org.present? - for_org(org) + def call(org = nil, filtered: false) + return for_orgs(filtered) unless org.present? + for_org(org, filtered) end private - def for_orgs + def for_orgs(filtered) result = ::StatJoinedUser + .where(filtered: filtered) .includes(:org) .select(:"orgs.name", :count) .group(:"orgs.name") @@ -24,8 +25,8 @@ def for_orgs end end - def for_org(org) - result = ::StatJoinedUser.where(org: org).sum(:count) + def for_org(org, filtered) + result = ::StatJoinedUser.where(org: org, filtered: filtered).sum(:count) build_model(org_name: org.name, count: result) end diff --git a/app/services/org/total_count_stat_service.rb b/app/services/org/total_count_stat_service.rb index 4a3466b6f0..78a15507f7 100644 --- a/app/services/org/total_count_stat_service.rb +++ b/app/services/org/total_count_stat_service.rb @@ -6,9 +6,9 @@ class TotalCountStatService class << self - def call + def call(filtered: false) total = build_from_joined_user - build_from_created_plan(total) + build_from_created_plan(filtered, total) total.values end @@ -38,14 +38,15 @@ def reducer_body(acc, count, key_target) end def build_from_joined_user(total = {}) + # Users have no concept of filtering (at the moment) joined_user_count = Org::TotalCountJoinedUserService.call joined_user_count.reduce(total) do |acc, count| reducer_body(acc, count, :total_users) end end - def build_from_created_plan(total = {}) - created_plan_count = Org::TotalCountCreatedPlanService.call + def build_from_created_plan(filtered, total = {}) + created_plan_count = Org::TotalCountCreatedPlanService.call(filtered: filtered) created_plan_count.reduce(total) do |acc, count| reducer_body(acc, count, :total_plans) end diff --git a/app/services/org_selection/hash_to_org_service.rb b/app/services/org_selection/hash_to_org_service.rb new file mode 100644 index 0000000000..db66ab8c60 --- /dev/null +++ b/app/services/org_selection/hash_to_org_service.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "text" + +module OrgSelection + + # This class provides conversion methods for turning OrgSelection::Search + # results into Orgs and Identifiers + # For example: + # { + # ror: "http://ror.org/123", + # name: "Foo (foo.org)", + # sort_name: "Foo" + # } + # becomes: + # An Org with name = "Foo (foo.org)", + # org_identifier (ROR) = "http://example.org/123" + # + class HashToOrgService + + class << self + + # Disabling some Rubocop here as I feel that this would be more + # confusing if broken apart further + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + # rubocop:disable Metrics/CyclomaticComplexity + def to_org(hash:, allow_create: true) + return nil unless hash.present? + + # Allow for the hash to have either symbol or string keys + hash = hash.with_indifferent_access + + # 1st: if id is present - find the Org and then verify names match + org = Org.where(id: hash[:id]).first if hash[:id].present? + return org if exact_match?(rec: org, name2: hash[:name]) + + # 2nd: Search by the external identifiers (e.g. "ror", "fundref", etc.) + # and then verify a name match + identifiers = hash.select { |k, _v| identifier_keys.include?(k) } + ids = identifiers.map { |k, v| { name: k, value: v } } + org = Org.from_identifiers(array: ids) if ids.any? + return org if exact_match?(rec: org, name2: hash[:name]) + + # 3rd: Search by name and then verify exact_match + clean_name = OrgSelection::SearchService.name_without_alias( + name: hash[:name] + ) + org = Org.search(clean_name).first + return org if exact_match?(rec: org, name2: hash[:name]) + + # Otherwise: Create an Org if allowed + allow_create ? initialize_org(hash: hash) : nil + end + # rubocop:enable Metrics/CyclomaticComplexity + + def to_identifiers(hash:) + return [] unless hash.present? + + out = [] + # Process each of the identifiers + hash = hash.with_indifferent_access + idents = hash.select { |k, _v| identifier_keys.include?(k) } + idents.each do |key, value| + attrs = hash.select { |k, _v| attr_keys(hash: hash).include?(k) } + attrs = {} unless attrs.present? + out << Identifier.new( + identifier_scheme_id: IdentifierScheme.by_name(key).first&.id, + value: value, + attrs: attrs + ) + end + out + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + private + + # Initialize a new Org from the hash + # rubocop:disable Metrics/MethodLength + def initialize_org(hash:) + return nil unless hash.present? && hash[:name].present? + + org = Org.new( + name: hash[:name], + links: links_from_hash(name: hash[:name], website: hash[:url]), + language: language_from_hash(hash: hash), + target_url: hash[:url], + institution: true, + is_other: false, + abbreviation: abbreviation_from_hash(hash: hash) + ) + org + end + # rubocop:enable Metrics/MethodLength + + # Convert the name and website into Org.links + def links_from_hash(name:, website:) + return { "org": [] } unless name.present? && website.present? + + { "org": [{ "link": website, "text": name }] } + end + + # Converts the Org name over to a unique abbreviation + def abbreviation_from_hash(hash:) + return nil unless hash.present? + + return hash[:abbreviation] if hash[:abbreviation].present? + + # Get the first letter of each word if no abbreviiation was provided + OrgSelection::SearchService.name_without_alias(name: hash[:name]) + .split(" ").map(&:first).join.upcase + end + + # Get the language from the hash or use the default + def language_from_hash(hash:) + return Language.default unless hash.present? && hash[:language].present? + + Language.where(abbreviation: hash[:language]).first || Language.default + end + + def identifier_keys + IdentifierScheme.for_orgs.pluck(:name) + end + + def attr_keys(hash:) + return {} unless hash.present? + + non_attr_keys = identifier_keys + %w[sort_name weight score] + hash.keys.reject { |k| non_attr_keys.include?(k) } + end + + def exact_match?(rec:, name2:) + return false unless rec.present? && name2.present? + + OrgSelection::SearchService.exact_match?(name1: rec.name, name2: name2) + end + + end + + end + +end diff --git a/app/services/org_selection/org_to_hash_service.rb b/app/services/org_selection/org_to_hash_service.rb new file mode 100644 index 0000000000..79ccd4dcb2 --- /dev/null +++ b/app/services/org_selection/org_to_hash_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "text" + +module OrgSelection + + # This class provides a search mechanism for Orgs that looks at records in the + # the database along with any available external APIs + class OrgToHashService + + class << self + + # Convert an Identifiable Model over to hash results like: + # An Org with id = 123, name = "Foo (foo.org)", + # org_identifier (ROR) = "http://example.org/123" + # becomes: + # { + # id: "123", + # ror: "http://ror.org/123", + # name: "Foo (foo.org)", + # sort_name: "Foo" + # } + def to_hash(org:) + return {} unless org.present? + + out = { + id: org.id, + name: org.name, + sort_name: OrgSelection::SearchService.name_without_alias(name: org.name) + } + # tack on any identifiers + org.identifiers.each do |id| + next unless id.identifier_scheme.present? + + out[:"#{id.identifier_scheme.name.downcase}"] = id.value + end + out + end + + end + + end + +end diff --git a/app/services/org_selection/search_service.rb b/app/services/org_selection/search_service.rb new file mode 100644 index 0000000000..cfd42cd1ef --- /dev/null +++ b/app/services/org_selection/search_service.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "text" + +module OrgSelection + + # This class provides a search mechanism for Orgs that looks at records in the + # the database along with any available external APIs + class SearchService + + class << self + + # Search for organizations both locally and externally + def search_combined(search_term:) + return [] unless search_term.present? && search_term.length > 2 + + orgs = local_search(search_term: search_term) + orgs = [] unless orgs.present? + # If we got an exact match out of the database then skip the + # external searches + matches = orgs.select do |org| + exact_match?(name1: org[:name], name2: search_term) + end + return orgs if matches.any? + + externals = externals_search(search_term: search_term) + externals = [] unless externals.present? + prepare(search_term: search_term, records: orgs + externals) + end + + # Search for organizations via External APIs + def search_externally(search_term:) + return [] unless search_term.present? && search_term.length > 2 + + orgs = externals_search(search_term: search_term) + prepare(search_term: search_term, records: orgs) + end + + # Search for organizations in the local DB only + def search_locally(search_term:) + return [] unless search_term.present? && search_term.length > 2 + + orgs = local_search(search_term: search_term) + prepare(search_term: search_term, records: orgs) + end + + # Determines whether or not the 2 names match (ignoring parenthesis text) + def exact_match?(name1:, name2:) + return false unless name1.present? && name2.present? + + a = name_without_alias(name: name1.downcase) + b = name_without_alias(name: name2.downcase) + a == b + end + + # Removes the parenthesis portion of the name. For example: + # "Foo College (foo.edu)" --> "Foo College" + def name_without_alias(name:) + return "" unless name.present? + + name.split(" (")&.first&.strip + end + + private + + def local_search(search_term:) + return [] unless search_term.present? + + Rails.cache.fetch(["org_selection-local", search_term], expires_in: 1.day) do + Org.includes(identifiers: :identifier_scheme) + .search(name_without_alias(name: search_term)).to_a + end + end + + def externals_search(search_term:) + return [] unless ExternalApis::RorService.active && search_term.present? + + Rails.cache.fetch(["org_selection-ror", search_term], expires_in: 1.day) do + ExternalApis::RorService.search(term: search_term) + end + end + + # Prepares all of the records for the view. Records that are Org models get + # converted over to a hash, all other records (e.g. from the ROR API) are + # expected to already be in the appropriate hash format. + def prepare(search_term:, records:) + return [] unless search_term.present? && records.present? && records.is_a?(Array) + + array = [] + records.map do |rec| + item = rec.is_a?(Org) ? OrgSelection::OrgToHashService.to_hash(org: rec) : rec + array << evaluate(search_term: search_term, record: item) + end + sort(array: deduplicate(records: filter(array: array))) + end + + # Removes any duplicates by comparing the sort names and ids + def deduplicate(records:) + return [] unless records.present? && records.is_a?(Array) + + out = [] + found = [] + records.each do |rec| + next if found.include?(rec[:sort_name]) || found.include?(rec[:id]) + + found << rec[:sort_name] + found << rec[:id] if rec[:id].present? + out << rec + end + out + end + + # Resorts the results returned from ROR so that any exact matches + # appear at the top of the list. For example a search for `Example`: + # - Example College + # - Example University + # - University of Example + # - Universidade de Examplar + # - Another College that ROR has a matching alias for + # + def sort(array:) + return [] unless array.present? && array.is_a?(Array) + + # Sort the results by score + weight + name + array.sort do |a, b| + # left = [a[:weight], a[:score], a[:sort_name]] + # right = [b[:weight], b[:score], b[:sort_name]] + [a[:weight], a[:sort_name]] <=> [b[:weight], b[:sort_name]] + end + end + + # Score and weigh the record + def evaluate(search_term:, record:) + return record unless record.present? && search_term.present? + + # Score and weigh each of the record + scr = score(search_term: search_term, item_name: record[:name]) + wght = weigh(search_term: search_term, item_name: record[:name]) + record.merge(score: scr, weight: wght) + end + + # Call the base service's compare_strings + def score(search_term:, item_name:) + return 99 unless search_term.present? && item_name.present? + + Text::Levenshtein.distance(search_term.downcase, item_name.downcase) + end + + # Weighs the result. The lower the weight the closer the match + def weigh(search_term:, item_name:) + return 3 unless search_term.present? && item_name.present? + + return 0 if item_name.downcase.start_with?(search_term.downcase) + + return 1 if item_name.downcase.include?(search_term.downcase) + + 2 + end + + # Discard any results that are not valid matches + def filter(array:) + return [] unless array.present? && array.is_a?(Array) + + array.select do |hash| + # If the natural language processing score is <= 25 OR the + # weight is less than 1 (starts with or includes the search term) + hash.fetch(:score, 0) <= 25 || hash.fetch(:weight, 1) < 2 + end + end + + end + + end + +end diff --git a/app/validators/org_links_validator.rb b/app/validators/org_links_validator.rb index 82ca15d9a8..0ba9642691 100644 --- a/app/validators/org_links_validator.rb +++ b/app/validators/org_links_validator.rb @@ -3,7 +3,7 @@ class OrgLinksValidator < ActiveModel::Validator def validate(record) links = record.links if links.is_a?(Hash) - if !links.has_key?('org') + if !links.with_indifferent_access.has_key?('org') record.errors[:links] << _('A key "org" is expected for links hash') %{ :key => k } end else diff --git a/app/views/answers/_new_edit.html.erb b/app/views/answers/_new_edit.html.erb index 67db511ba9..f3804a2e68 100644 --- a/app/views/answers/_new_edit.html.erb +++ b/app/views/answers/_new_edit.html.erb @@ -73,7 +73,7 @@ <% if annotation.present? && annotation.org.present? && annotation.text.present? %>
- <%="#{annotation.org.abbreviation} "%> <%=_('example answer')%> + <%="#{annotation.org.abbreviation} "%><%=_('example answer')%>
<%= sanitize annotation.text %>
diff --git a/app/views/answers/_status.html.erb b/app/views/answers/_status.html.erb index 3b317d8d04..91287cb020 100644 --- a/app/views/answers/_status.html.erb +++ b/app/views/answers/_status.html.erb @@ -8,5 +8,17 @@ <%= _('Answered')%> <%= _(' by %{user_name}') %{ :user_name => answer.user.name } if answer.user.present? %> - +
+ <% n_question_to_remove = answer_remove_list(answer).size %> + <% if n_question_to_remove > 0 %> + + <%= _('This answer removes ') + n_question_to_remove.to_s + _(' questions from your plan.') %> + + <% end %> + <% email_list = email_trigger_list(answer) %> + <% unless email_list.blank? %> + + <%= _('This answer triggers email(s) to ') + email_list %> + + <% end %> <% end %> diff --git a/app/views/api/v0/departments/index.json.jbuilder b/app/views/api/v0/departments/index.json.jbuilder new file mode 100644 index 0000000000..98ec7f6dbe --- /dev/null +++ b/app/views/api/v0/departments/index.json.jbuilder @@ -0,0 +1,7 @@ +json.prettify! + +json.array! @departments.each do |department| + json.code department.code + json.name department.name + json.id department.id +end diff --git a/app/views/api/v0/departments/users.json.jbuilder b/app/views/api/v0/departments/users.json.jbuilder new file mode 100644 index 0000000000..b4b84ff242 --- /dev/null +++ b/app/views/api/v0/departments/users.json.jbuilder @@ -0,0 +1,10 @@ +json.prettify! + +json.array! @users.group_by(&:department).each do |department, users| + json.code department&.code + json.name department&.name + json.id department&.id + json.users users.each do |u| + json.email u.email + end +end diff --git a/app/views/api/v0/plans/index.json.jbuilder b/app/views/api/v0/plans/index.json.jbuilder index 2e1f07ed1d..2ca50ee9b6 100644 --- a/app/views/api/v0/plans/index.json.jbuilder +++ b/app/views/api/v0/plans/index.json.jbuilder @@ -14,17 +14,21 @@ json.array! @plans.each do |plan| json.id plan.template.family_id end json.funder do - json.name (plan.template.org.funder? ? plan.template.org.name : plan.funder_name) + json.name (plan.template.org.funder? ? plan.template.org.name : plan.funder&.name) end json.principal_investigator do - json.name plan.principal_investigator - json.email plan.principal_investigator_email - json.phone plan.principal_investigator_phone + investigator = plan.contributors.investigation.first + + json.name investigator.name + json.email investigator.email + json.phone investigator.phone end + json.data_contact do - json.name plan.data_contact - json.email plan.data_contact_email - json.phone plan.data_contact_phone + data_contact = plan.contributors.data_curation.first + json.name data_contact.name + json.email data_contact.email + json.phone data_contact.phones end json.users plan.roles.each do |role| json.email role.user.email diff --git a/app/views/api/v0/statistics/plans.json.jbuilder b/app/views/api/v0/statistics/plans.json.jbuilder index d6812674d6..3b10be4b90 100644 --- a/app/views/api/v0/statistics/plans.json.jbuilder +++ b/app/views/api/v0/statistics/plans.json.jbuilder @@ -15,12 +15,12 @@ json.plans @org_plans.each do |plan| json.name (plan.template.org.funder? ? plan.template.org.name : '') end - json.principal_investigator do - json.name plan.principal_investigator + json.principal_investigator + json.name plan.contributors.investigation.first&.name end json.data_contact do - json.info plan.data_contact + json.info plan.contributors.data_curation.first&.name end json.description plan.description diff --git a/app/views/api/v1/_standard_response.json.jbuilder b/app/views/api/v1/_standard_response.json.jbuilder new file mode 100644 index 0000000000..7541daca0e --- /dev/null +++ b/app/views/api/v1/_standard_response.json.jbuilder @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# locals: response, request, total_items + +total_items ||= 0 +paginator = Api::V1::PaginationPresenter.new(current_url: request.path, + per_page: @per_page, + total_items: total_items, + current_page: @page) + +json.prettify! +json.ignore_nil! + +json.application @application +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/v1/contributors/_show.json.jbuilder b/app/views/api/v1/contributors/_show.json.jbuilder new file mode 100644 index 0000000000..757f5db7c9 --- /dev/null +++ b/app/views/api/v1/contributors/_show.json.jbuilder @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# locals: contributor, is_contact + +is_contact ||= false + +json.name 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? + end +end + +if contributor.org.present? + json.affiliation do + json.partial! "api/v1/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/v1/identifiers/show", identifier: id + end + else + json.contributor_id do + json.partial! "api/v1/identifiers/show", identifier: id + end + end +end diff --git a/app/views/api/v1/datasets/_show.json.jbuilder b/app/views/api/v1/datasets/_show.json.jbuilder new file mode 100644 index 0000000000..e70fe44546 --- /dev/null +++ b/app/views/api/v1/datasets/_show.json.jbuilder @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# locals: plan + +presenter = Api::V1::PlanPresenter.new(plan: plan) + +json.title "Generic Dataset" +json.personal_data "unknown" +json.sensitive_data "unknown" + +json.dataset_id do + json.partial! "api/v1/identifiers/show", identifier: presenter.identifier +end + +json.distribution [plan] do |distribution| + json.title "PDF - #{distribution.title}" + json.data_access "open" + json.download_url plan_export_url(distribution, format: :pdf) + json.format do + json.array! ["application/pdf"] + end +end diff --git a/app/views/api/v1/error.json.jbuilder b/app/views/api/v1/error.json.jbuilder new file mode 100644 index 0000000000..6cf9443c02 --- /dev/null +++ b/app/views/api/v1/error.json.jbuilder @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +json.partial! "api/v1/standard_response" + +json.items [] +json.errors @payload[:errors] diff --git a/app/views/api/v1/heartbeat.json.jbuilder b/app/views/api/v1/heartbeat.json.jbuilder new file mode 100644 index 0000000000..af0fcdbdbb --- /dev/null +++ b/app/views/api/v1/heartbeat.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.partial! "api/v1/standard_response" + +json.items [] diff --git a/app/views/api/v1/identifiers/_show.json.jbuilder b/app/views/api/v1/identifiers/_show.json.jbuilder new file mode 100644 index 0000000000..c219222aee --- /dev/null +++ b/app/views/api/v1/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/v1/orgs/_show.json.jbuilder b/app/views/api/v1/orgs/_show.json.jbuilder new file mode 100644 index 0000000000..69b7ebaa0a --- /dev/null +++ b/app/views/api/v1/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/v1/identifiers/show", identifier: id + end +end diff --git a/app/views/api/v1/plans/_cost.json.jbuilder b/app/views/api/v1/plans/_cost.json.jbuilder new file mode 100644 index 0000000000..ad36e3540e --- /dev/null +++ b/app/views/api/v1/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/v1/plans/_funding.json.jbuilder b/app/views/api/v1/plans/_funding.json.jbuilder new file mode 100644 index 0000000000..bd3a6cc280 --- /dev/null +++ b/app/views/api/v1/plans/_funding.json.jbuilder @@ -0,0 +1,22 @@ +# 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) + + if id.present? + json.funder_id do + json.partial! "api/v1/identifiers/show", identifier: id + end + end +end + +if plan.grant_id.present? && plan.grant.present? + json.grant_id do + json.partial! "api/v1/identifiers/show", identifier: plan.grant + end +end +json.funding_status plan.grant.present? ? "granted" : "planned" diff --git a/app/views/api/v1/plans/_project.json.jbuilder b/app/views/api/v1/plans/_project.json.jbuilder new file mode 100644 index 0000000000..bb4ba3ef0f --- /dev/null +++ b/app/views/api/v1/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/v1/plans/funding", plan: plan + end +end diff --git a/app/views/api/v1/plans/_show.json.jbuilder b/app/views/api/v1/plans/_show.json.jbuilder new file mode 100644 index 0000000000..169ccd9268 --- /dev/null +++ b/app/views/api/v1/plans/_show.json.jbuilder @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# locals: plan + +json.schema "https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard/tree/master/examples/JSON/JSON-schema/1.0" + +presenter = Api::V1::PlanPresenter.new(plan: plan) +# 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: ApplicationService.default_language +) +json.created plan.created_at.to_formatted_s(:iso8601) +json.modified plan.updated_at.to_formatted_s(:iso8601) + +# TODO: Update this to pull from the appropriate question once the work is complete +json.ethical_issues_exist "unknown" +# json.ethical_issues_description "" +# json.ethical_issues_report "" + +id = presenter.identifier +if id.present? + json.dmp_id do + json.partial! "api/v1/identifiers/show", identifier: id + end +end + +if presenter.data_contact.present? + json.contact do + json.partial! "api/v1/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/v1/contributors/show", contributor: contributor, + is_contact: false + end + end + + if presenter.costs.any? + json.cost presenter.costs do |cost| + json.partial! "api/v1/plans/cost", cost: cost + end + end + + json.project [plan] do |pln| + json.partial! "api/v1/plans/project", plan: pln + end + + json.dataset [plan] do |dataset| + json.partial! "api/v1/datasets/show", plan: plan, dataset: dataset + end + + json.extension [plan.template] do |template| + json.set! ApplicationService.application_name.split("-").first.to_sym do + json.template do + json.id template.id + json.title template.title + end + end + end +end diff --git a/app/views/api/v1/plans/index.json.jbuilder b/app/views/api/v1/plans/index.json.jbuilder new file mode 100644 index 0000000000..48d9ef740f --- /dev/null +++ b/app/views/api/v1/plans/index.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +json.partial! "api/v1/standard_response", total_items: @total_items + +json.items @items do |item| + json.dmp do + json.partial! "api/v1/plans/show", plan: item + end +end diff --git a/app/views/api/v1/templates/index.json.jbuilder b/app/views/api/v1/templates/index.json.jbuilder new file mode 100644 index 0000000000..ba5991f3d8 --- /dev/null +++ b/app/views/api/v1/templates/index.json.jbuilder @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +json.partial! "api/v1/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/v1/orgs/show", org: template.org + end + + json.template_id do + identifier = Api::V1::ConversionService.to_identifier(context: @application, + value: template.id) + json.partial! "api/v1/identifiers/show", identifier: identifier + end + end +end diff --git a/app/views/api/v1/token.json.jbuilder b/app/views/api/v1/token.json.jbuilder new file mode 100644 index 0000000000..07be549839 --- /dev/null +++ b/app/views/api/v1/token.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +json.prettify! +json.ignore_nil! + +json.access_token @token +json.token_type @token_type +json.expires_in @expiration +json.created_at Time.now.to_formatted_s(:iso8601) diff --git a/app/views/branded/devise/registrations/_personal_details.html.erb b/app/views/branded/devise/registrations/_personal_details.html.erb deleted file mode 100644 index 021f50cbd2..0000000000 --- a/app/views/branded/devise/registrations/_personal_details.html.erb +++ /dev/null @@ -1,114 +0,0 @@ -<%# - DMPTool customization overview: - ------------------------------------------ - 1. Added default_org var - 2. Added if 'shibbolized' check for Org selector/text - 3. Removed shib account linking link - %> -<% default_org = Org.find_by(is_other: true) %> -<%= form_for(resource, namespace: current_user.id, as: resource_name, url: registration_path(resource_name), html: {method: :put, id: 'personal_details_registration_form' }) do |f| %> -

- <%= sanitize _("Please note that your email address is also your username. If you change this remember to use your new email address on sign in. If your account is created with your institutional credentials you must contact us to change your email or organisation.") %> -

- - <%= hidden_field_tag :unlink_flag, "false", id: 'unlink_flag' %> - -
- <%= f.label(:email, _('Email'), class: 'control-label') %> - <% if shibbolized %> - <%= render partial: 'shared/popover', - locals: { message: _('Your account is linked to your institutional credentials for login. Please contact the helpdesk to change your email.'), placement: 'right' } %> - - - <% else %> - <%= f.email_field(:email, class: "form-control", "aria-required": true, value: @user.email) %> - <% end %> - <%= hidden_field_tag :original_email, @user.email %> -
- -
- <%= f.label(:firstname, _('First name'), class: 'control-label') %> - <%= f.text_field(:firstname, class: "form-control", "aria-required": true, value: @user.firstname) %> -
- -
- <%= f.label(:surname, _('Last name'), class: 'control-label') %> - <%= f.text_field(:surname, class: "form-control", "aria-required": true, value: @user.surname) %> -
- -
- <% if shibbolized %> - - <%= render partial: 'shared/popover', - locals: { message: _('Your account is linked to your institutional credentials for login. Please contact the helpdesk to change your organisation.'), placement: 'right' } %> - - - <% else %> - <%= render partial: "shared/my_org", - locals: { - f: f, - default_org: current_user.org.is_other? ? nil : current_user.org, - orgs: Org.participating, - allow_other_orgs: true - } %> - <% end %> -
- - <% departments = current_user.org.departments.order(:name) %> - <% if departments.count > 0 %> -
- <% dept_id = current_user.department.nil? ? -1 : current_user.department.id %> - <%= f.label(:department_id, _('Department or school'), class: 'control-label') %> - <%= select_tag("user[department_id]", options_from_collection_for_select(departments, "id", "name", dept_id), include_blank: true, disabled: departments.count === 0, class: "form-control") %> -
- <% end %> - - <% if Language.many? %> -
- <% lang_id = current_user.language.nil? ? Language.id_for(FastGettext.default_locale) : current_user.language.id %> - <%= f.label(:language_id, _('Language'), class: 'control-label') %> - <%= select_tag("user[language_id]", - options_from_collection_for_select(@languages, "id", "name", lang_id), - class: "form-control") %> -
- <% end %> - - <% if current_user.can_org_admin? %> -
- -

<%= (current_user.can_super_admin? ? _('Super Admin') : _('Organisational Admin')) %>

-
- <% end %> - - <% @identifier_schemes.each do |scheme| %> -
- <% if scheme.name.downcase == 'orcid' %> - <%= label_tag(:scheme_name, 'ORCID', class: 'control-label') %> - -
- <%= render partial: "external_identifier", - locals: { scheme: scheme, - id: current_user.identifier_for(scheme)} %> -
- <% end %> -
- <% end %> - - <% unless @user.api_token.blank? %> -
- <%= f.label(:api_token, _('API token'), class: 'control-label') %> - <%= @user.api_token %> -
-
- <%= label_tag(:api_information, _('API Information'), class: 'control-label') %> - <%= _('How to use the API') %> -
- <% end %> - -
- <%= f.button(_('Save'), class: 'btn btn-default', type: "submit") %> -
- - <%= render partial: 'password_confirmation', locals: {f: f} %> - -<% end %> diff --git a/app/views/branded/devise/registrations/edit.html.erb b/app/views/branded/devise/registrations/edit.html.erb deleted file mode 100644 index b50c408a15..0000000000 --- a/app/views/branded/devise/registrations/edit.html.erb +++ /dev/null @@ -1,59 +0,0 @@ -<% shibbolized = current_user.identifier_for(IdentifierScheme.find_by(name: 'shibboleth')).present? %> -<% @default_org = @orgs.include?(current_user.org) && !current_user.org.is_other? ? current_user.org : nil %> -<% @orgs = Org.participating %> - -<% title _('Edit profile') %> -
-
-

<%= _('Edit profile') %>

-
-
- -
-
- - -
-
-
-
- <%= render partial: 'devise/registrations/personal_details', locals: { shibbolized: shibbolized } %> -
-
-
- - <% unless shibbolized %> -
-
-
- <%= render partial: 'devise/registrations/password_details' %> -
-
-
- <% end %> - -
-
-
- <%= render partial: 'users/notification_preferences' %> -
-
-
-
- -
-
diff --git a/app/views/branded/devise/registrations/new.html.erb b/app/views/branded/devise/registrations/new.html.erb index 26b872cef8..d15baecac5 100644 --- a/app/views/branded/devise/registrations/new.html.erb +++ b/app/views/branded/devise/registrations/new.html.erb @@ -1,73 +1,77 @@ -<% require "securerandom" %> +
+
+

<%= _('Sign in or Create account') %>

+
+
-<% user = User.new if user.nil? %> -<% orgs = Org.participating %> +
+
+ <% unless session["devise.shibboleth_data"].nil? %> + <% cookies[:show_shib_link] = { value: 'show_shib_link', + expires: 3.hours.from_now } %> -

<%= _('Create your account') %>

-

<%= _('Your account has been verified with your institutional credentials. Please fill out the form below to finish creating your account.') %>

-<%= form_for user, url: registration_path("user"), html: {autocomplete: "off", id: "omniauth_register_form"} do |f| %> -
-
-
-
-
- <%= f.label(:firstname, _('First Name'), class: "control-label") %> - <%= f.text_field(:firstname, class: "form-control", "aria-required": true) %> -
-
-
-
- <%= f.label(:surname, _('Last Name'), class: "control-label") %> - <%= f.text_field(:surname, class: "form-control", "aria-required": true) %> -
+
+

+ <%= _("Do you have a %{application_name} account?") % { + application_name: Rails.configuration.branding[:application][:name]} %> +

+

+ +

+

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

+

+ <%= _("This will link your existing account to your credentials.") %> +

+

<%= render partial: 'shared/sign_in_form', locals: { resource: resource } %>

-
-
-
-
- <%= f.label(:email, _('Email'), class: "control-label") %> - <%= f.email_field(:email, class: "form-control", "aria-required": true, 'data-validatable': true) %> -
-
-
+
- <% if user.org.present? %> - <%= f.hidden_field :org_id %> - <% else %> -
-
-

<%= _('We were unable to determine your organization. Please select your organization below.') %>

-
- <%= render partial: "shared/accessible_combobox", - locals: {name: 'org_name', - id: 'org_name', - default_selection: nil, - models: @orgs, - attribute: 'name', - required: true, - classes: ''} %> -
-
-
- <% end %> +

+ <%= _("No %{application_name} account?") % { + application_name: Rails.configuration.branding[:application][:name]} %> +

-
-
-
- <%= f.label(:accept_terms, - raw("#{ f.check_box(:accept_terms, "aria-required": true, "data-validation-error": _('You must agree to the term and conditions.')) } #{_('I accept the')} #{_('terms and conditions')}")) %> +

+ +

+ +

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

+

+ <%= _("This will create an account and link it to your credentials.") %> +

+
+

+ <%# --------------------------------------------------- %> + <%# Start DMPTool Customization %> + <%# This is the only change to this Devise page! %> + <%# Add the org info so that JS hides the Org selector %> + <%# --------------------------------------------------- %> + <%= hidden_field_tag "default_org_id", @user&.org&.id %> + <%= hidden_field_tag "default_org_name", @user&.org&.name %> + <%# --------------------------------------------------- %> + <%# End DMPTool Customization %> + <%# --------------------------------------------------- %> + <%= render partial: 'shared/create_account_form', locals: { resource: resource } %> +
+

-
- -
-
- <%= f.hidden_field :org_id %> - <%= f.password_field :password, value: "#{SecureRandom.uuid}", class: 'hidden' %> - + <% else %> +
+

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

+
+ <%= render partial: 'shared/create_account_form' %>
-
+ <% end %>
-<% end %> +
diff --git a/app/views/branded/home/index.html.erb b/app/views/branded/home/index.html.erb index 579165a640..10bbc38d11 100644 --- a/app/views/branded/home/index.html.erb +++ b/app/views/branded/home/index.html.erb @@ -47,7 +47,7 @@
    - <% @top_5.each do |title| %> + <% @top_five.each do |title| %>
  1. <%= title %>
  2. <% end %>
diff --git a/app/views/branded/layouts/_analytics.html.erb b/app/views/branded/layouts/_analytics.html.erb index e7fed22703..15d15cf773 100644 --- a/app/views/branded/layouts/_analytics.html.erb +++ b/app/views/branded/layouts/_analytics.html.erb @@ -1,9 +1,31 @@ -<% if Rails.configuration.branding[:keys].present? %> +<% keys = Rails.configuration.branding.fetch(:keys, []) %> +<% if keys.any? %> - - <% if Rails.env.stage? %> + + <% if Rails.env.stage? && keys[:usersnap_key].present? %> <% end %> + + + <% if (Rails.env.stage? || Rails.env.production?) && + keys[:google_analytics_key].present? %> + <% gkey = keys[:google_analytics_key] %> + + + <% end %> + <% end %> diff --git a/app/views/branded/layouts/_app_menu_links.html.erb b/app/views/branded/layouts/_app_menu_links.html.erb index bb69a67a95..1ce0f27d2a 100644 --- a/app/views/branded/layouts/_app_menu_links.html.erb +++ b/app/views/branded/layouts/_app_menu_links.html.erb @@ -18,7 +18,7 @@ <% else %> <% if current_user.can_modify_org_details? %>
  • - <%= link_to (current_user.can_super_admin? ? _('Organizations') : _('Organisation details')), admin_edit_org_path(current_user.org_id) %> + <%= link_to _('Organisation details'), admin_edit_org_path(current_user.org_id) %>
  • <% end %> <% end %> @@ -44,9 +44,10 @@ <% end %> <% if current_user.can_super_admin? %> +
  • <%= link_to(_('Api Clients'), super_admin_api_clients_path) %>
  • <%= link_to _('Notifications'), super_admin_notifications_path %>
  • <% end %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/branded/layouts/_branding.html.erb b/app/views/branded/layouts/_branding.html.erb index f6e42e65ef..32352d8c92 100644 --- a/app/views/branded/layouts/_branding.html.erb +++ b/app/views/branded/layouts/_branding.html.erb @@ -1,6 +1,6 @@ <% if user_signed_in? && !current_user.org.is_other? %>
    - <%= current_user.org.name %> + <%= current_user.org.name.split("(").first %>
    <% end %> @@ -14,4 +14,4 @@ <%= render partial: 'layouts/org_links' %>
    -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/branded/layouts/_constants.html.erb b/app/views/branded/layouts/_constants.html.erb index 862c3a6765..f0176b289c 100644 --- a/app/views/branded/layouts/_constants.html.erb +++ b/app/views/branded/layouts/_constants.html.erb @@ -28,8 +28,11 @@ constants_json = { 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.') + 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.") }.to_json %> - \ No newline at end of file + diff --git a/app/views/branded/layouts/_fixed_menu.html.erb b/app/views/branded/layouts/_fixed_menu.html.erb index 295746cd9e..e508af9f01 100644 --- a/app/views/branded/layouts/_fixed_menu.html.erb +++ b/app/views/branded/layouts/_fixed_menu.html.erb @@ -18,7 +18,7 @@ <% end %> <% end %> diff --git a/app/views/branded/layouts/_footer.html.erb b/app/views/branded/layouts/_footer.html.erb index fae87839da..ec4af9b9da 100644 --- a/app/views/branded/layouts/_footer.html.erb +++ b/app/views/branded/layouts/_footer.html.erb @@ -49,6 +49,11 @@ <%= _('Copyright 2010-%{current_year} The Regents of the University of California') % { current_year: Date.today.year } %> + <% version = Rails.configuration.x.dmptool.version %> + <% if version.present? %> +

    + <%= _("Version: %{number}") % { number: version } %> + <% end %>

    <% end %> - <% if @plans.length > 0 %> - <%= link_to sanitize(_('Download plans (new window)%{open_in_new_window_text}') % + <% if @plans.length > 0 %> + <% unless @super_admin %> + <%= link_to sanitize(_('Download plans (new window)%{open_in_new_window_text}') % { open_in_new_window_text: _('Opens in new window') }, tags: %w{ span em }), org_admin_download_plans_path(format: :csv), target: '_blank', class: 'btn btn-default pull-right has-new-window-popup-info' %> - <%= paginable_renderise( - partial: '/paginable/plans/org_admin', - controller: 'paginable/plans', - action: 'org_admin', - scope: @plans, - query_params: { sort_field: 'plans.updated_at', sort_direction: :desc }) %> + <% end %> +
    + <%= paginable_renderise( + partial: '/paginable/plans/org_admin', + controller: 'paginable/plans', + action: 'org_admin', + scope: @plans, + view_all: !current_user.can_super_admin?, + query_params: { sort_field: 'plans.updated_at', sort_direction: :desc }) %> +
    <% end %>
    diff --git a/app/views/org_admin/question_options/_option_fields.html.erb b/app/views/org_admin/question_options/_option_fields.html.erb index 4e78540ecb..1de4ee4710 100644 --- a/app/views/org_admin/question_options/_option_fields.html.erb +++ b/app/views/org_admin/question_options/_option_fields.html.erb @@ -12,16 +12,17 @@
    <% if q.question_options.count == 0 %> - <% 2.times {q.question_options.build } %> + <% 2.times { q.question_options.build } %> + <% q.question_options.each {|qopt| qopt.id=0} %> <% end %> <% i = 0 %> - <% q.question_options.to_a.sort_by{|op| op['number']}.each do |options_q| %> - <%= f.fields_for :question_options, options_q do |op|%> +<% q.question_options.to_a.sort_by{|op| op['number']}.each do |options_q| %> + <%= f.fields_for :question_options, options_q do |op| %> <% i = i + 1 %> <% options_q.number = i %>
    - <%= op.number_field :number, min: 1, class: 'form-control' %> + <%= op.number_field :number, min: 1, class: 'form-control', readonly: true %>
    <% if i == 1 %> @@ -33,13 +34,15 @@
    <%= op.check_box :is_default %>
    -
    - <%= op.hidden_field :_destroy, class: 'destroy-question-option' %> - <%= _('Remove') %> -
    + <% if i > 1 %> +
    + <%= op.hidden_field :_destroy, class: 'destroy-question-option' %> + <%= link_to _('Remove'), org_admin_question_option_path(options_q), method: :delete %> +
    + <% end %>
    - <% end %> <% end %> +<% end %>
    <%= _('Add option') %> diff --git a/app/views/org_admin/questions/_container.html.erb b/app/views/org_admin/questions/_container.html.erb index 798b36bbde..581980f3a0 100644 --- a/app/views/org_admin/questions/_container.html.erb +++ b/app/views/org_admin/questions/_container.html.erb @@ -1,5 +1,8 @@ -
    "> - <%= render partial: "org_admin/questions/show", - locals: local_assigns.merge({ question: question }) %> + <%= render partial: "org_admin/questions/show", + locals: local_assigns.merge({ + question: question, + conditions: question.conditions.order(:number) + }) %>
    diff --git a/app/views/org_admin/questions/_edit.html.erb b/app/views/org_admin/questions/_edit.html.erb index da418d63bc..0f8e386510 100644 --- a/app/views/org_admin/questions/_edit.html.erb +++ b/app/views/org_admin/questions/_edit.html.erb @@ -13,6 +13,7 @@ in the admin interface. locals: local_assigns.merge({ question: question, method: :put, + conditions: conditions, url: org_admin_template_phase_section_question_path(template_id: template.id, phase_id: section.phase.id, section_id: section.id, id: question.id) }) %>
    diff --git a/app/views/org_admin/questions/_form.html.erb b/app/views/org_admin/questions/_form.html.erb index 6560c745ce..18c4f873e7 100644 --- a/app/views/org_admin/questions/_form.html.erb +++ b/app/views/org_admin/questions/_form.html.erb @@ -1,8 +1,8 @@ <% question_default_value_tooltip = _('Anything you enter here will display in the answer box. If you want an answer in a certain format (e.g. tables), you can enter that style here.') %> -

    +

    <%= question.id.present? ? _('Question %{number}:') % { number: question.number } : _('New question:') %> -

    +

    <%= form_for(question, url: url, namespace: question.id.present? ? question.id : 'new_question', @@ -45,6 +45,18 @@ <%= render "/org_admin/question_options/option_fields", f: f, q: question %>
    + <% if question.id != nil && question.question_options[0].text != nil %> + <%= link_to _('Add Conditions'), org_admin_question_open_conditions_path(question_id: question.id, conditions: conditions), class: "add-logic btn btn-default", 'data-loaded': (conditions.size > 0).to_s, remote: true %> +
    +

    + <%= render partial: 'org_admin/conditions/container', locals: { f: f, question: question, conditions: conditions } %> +

    +
    + <% else %> +
    + <%= link_to _('Add Conditions'), '#', class: "add-logic btn btn-default disabled" %> +
    + <% end %>
    <% comment_disp = current_format.option_based? || current_format.rda_metadata? %> @@ -85,7 +97,7 @@
    <%= f.submit _('Save'), class: "btn btn-default", role:'button' %> - <% if question.id.present? && !question.section.phase.template.published? %> + <% if question.id.present? %> <% href = org_admin_template_phase_section_question_path(template_id: template.id, phase_id: question.section.phase.id, section_id: question.section.id, id: question.id) %> <%= link_to _('Delete'), href, method: :delete, class: "btn btn-default", role:'button', 'data-confirm': _("You are about to delete question #%{question_number}. Are you sure?") % { question_number: question.number } %> <%= link_to _('Cancel'), href, class: "btn btn-default ajaxified-question", method: 'get', remote: true %> diff --git a/app/views/org_admin/questions/_new.html.erb b/app/views/org_admin/questions/_new.html.erb index 213d89ae68..b9baeab1f3 100644 --- a/app/views/org_admin/questions/_new.html.erb +++ b/app/views/org_admin/questions/_new.html.erb @@ -3,4 +3,5 @@ locals: local_assigns.merge({ question: new_question, method: :post, + conditions: [], #shouldn't be needed url: org_admin_template_phase_section_questions_path(template_id: template.id, phase_id: section.phase.id, section_id: section.id) }) %> \ No newline at end of file diff --git a/app/views/org_admin/questions/_show.html.erb b/app/views/org_admin/questions/_show.html.erb index d582f2db3d..966ce0cef3 100644 --- a/app/views/org_admin/questions/_show.html.erb +++ b/app/views/org_admin/questions/_show.html.erb @@ -21,7 +21,7 @@ <% if question.option_based? %>
    <%= _('Question options') %>
    -
    <%= question.question_options.collect(&:text).join(', ') %>
    +
    <%= question.question_options.order(:number).collect(&:text).join(', ') %>
    <% end %> <% if q_format.textfield? || q_format.textarea? %> @@ -48,6 +48,13 @@ <%= _('No additional comment area will be displayed.')%> <% end %> + + + <% if conditions.count > 0 %> +
    <%= _('Question conditions') %>
    + <%= raw condition_to_text(conditions) %> + <% end %> + <% if !question.section.phase.template.org.funder? %> <% example_answer = question.example_answers(template.base_org.id).first %> @@ -62,7 +69,7 @@ <%= sanitize example_answer.text %> <% end %> - + <% end %> <% end %> @@ -83,7 +90,7 @@ <% end %>
    - +
    <% has_org_themed_guidance = false %> <% themes_q = question.themes %> @@ -91,7 +98,7 @@
    <% ggs = GuidanceGroup.where(org_id: current_user.org.id) %>

    <%= _("Themed Guidance") %>

    - + <% if ggs.length > 0 %> <%# To determine if any themes associated with question exist. %> <% ggs.each do |guidance_group| %> @@ -107,7 +114,7 @@ <% end %> <% end %> <% end %> - + <% if has_org_themed_guidance %> <% ggs.each do |guidance_group| %> <% themes_q.each do |theme| %> @@ -123,7 +130,7 @@ href="#collapseGuidance-<%= guidance.id%>-<%= question.id %>" aria-expanded="false" aria-controls="#collapseGuidance-<%= guidance.id%>-<%= question.id %>"> - +
    - +
    <% if template.latest? %> diff --git a/app/views/org_admin/templates/index.html.erb b/app/views/org_admin/templates/index.html.erb index a67d432145..91d7c309e4 100644 --- a/app/views/org_admin/templates/index.html.erb +++ b/app/views/org_admin/templates/index.html.erb @@ -15,12 +15,13 @@ namespace: 'superadmin', method: "post", html: { id: 'super-admin-switch-org' } do |f| %> - <%= render partial: "shared/my_org", - locals: { f: f, - default_org: current_user.org, - orgs: @orgs, - allow_other_orgs: false - } %> + <%= render partial: "shared/org_selectors/local_only", + locals: { + form: f, + default_org: current_user.org, + orgs: @orgs, + required: false + } %> <%= f.submit _('Change affiliation'), class: 'btn btn-default' %> <% end %>
    diff --git a/app/views/orgs/_departments.html.erb b/app/views/orgs/_departments.html.erb index aa6e5aa4d9..79acb3044b 100644 --- a/app/views/orgs/_departments.html.erb +++ b/app/views/orgs/_departments.html.erb @@ -4,12 +4,12 @@ <%= paginable_renderise( partial: '/paginable/departments/index', controller: 'paginable/departments', action: 'index', - scope: departments, + scope: org.departments || [], query_params: { sort_field: 'departments.name', sort_direction: 'asc' }) %>
    diff --git a/app/views/orgs/_external_identifiers.html.erb b/app/views/orgs/_external_identifiers.html.erb new file mode 100644 index 0000000000..0ded71b7c2 --- /dev/null +++ b/app/views/orgs/_external_identifiers.html.erb @@ -0,0 +1,70 @@ +<%# locals: form, org, editable %> + +<% presenter = IdentifierPresenter.new(identifiable: org) %> + +

    <%= _('Identifiers') %>

    +
    + +<%# If the user is a super admin then they can edit these identifiers %> +<% if editable %> + <% if !org.new_record? %> + <% + schemes = presenter.schemes.select do |s| + %w[ror fundref].include?(s.name) + end + schemes.each do |scheme| %> +
    +
    + <% id = presenter.id_for_scheme(scheme: scheme) %> + <%= scheme.description %>: + <%= presenter.id_for_display(id: id).html_safe %> +
    +
    + <% end %> + + <% + # Shibboleth Org identifiers are only for use by installations that have + # a curated list of Orgs that can use institutional login + shib = presenter.scheme_by_name(name: "shibboleth").first + if shib.present? + shib_id = presenter.id_for_scheme(scheme: shib) + %> +
    +
    +
    + <%= form.fields_for :identifiers, shib_id do |ident_fields| %> + <%= ident_fields.hidden_field :identifier_scheme_id %> + <%= ident_fields.label :value, "#{shib.description} - entityID", class: "control-label" %> + <%= ident_fields.text_field :value, class: "form-control", placeholder: _("Please enter your Shibboleth Entity ID") %> + <% end %> +
    +
    + <% end %> + + <% else %> +
    +
    + <%= render partial: "shared/org_selectors/external_only", + locals: { + form: form, + label: _("Organisation Lookup"), + default_org: nil, + required: false + } %> +
    +
    + <% end %> + +<%# Otherwise this is an Org Admin so just display the identifiers %> +<% else %> + <% presenter.schemes.each do |scheme| %> +
    +
    + <% id = presenter.id_for_scheme(scheme: scheme) %> + <%= scheme.description %>: + <%= presenter.id_for_display(id: id).html_safe %> +
    +
    + <% end %> +

    <%= _("If any of the above identifiers are incorrect or missing, please contact us to have them updated.").html_safe % { contact_us_url: contact_us_path } %>

    +<% end %> diff --git a/app/views/orgs/_feedback_form.html.erb b/app/views/orgs/_feedback_form.html.erb index a8f2a03e7b..98c36cb9e4 100644 --- a/app/views/orgs/_feedback_form.html.erb +++ b/app/views/orgs/_feedback_form.html.erb @@ -1,5 +1,8 @@ -<% title _('Request Feedback') %> -<%= form_for(org, url: url, html: { multipart: true, method: method, id: "edit_org_feedback_form" } ) do |f| %> +<%# locals: org %> + +<%= form_for(org, url: admin_update_org_path(org), + html: { multipart: true, method: :put, + id: "edit_org_feedback_form" } ) do |f| %>

    <%= _('Request Feedback') %>

    @@ -34,4 +37,4 @@ <%= f.button(_('Save'), id:"save_org_submit", class: "btn btn-primary", type: "submit") %>
    -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/orgs/_org_link.html.erb b/app/views/orgs/_org_link.html.erb deleted file mode 100644 index 44b70eb6a8..0000000000 --- a/app/views/orgs/_org_link.html.erb +++ /dev/null @@ -1,15 +0,0 @@ - \ No newline at end of file diff --git a/app/views/orgs/_profile_form.html.erb b/app/views/orgs/_profile_form.html.erb index 439911bb51..1cf9eddb3a 100644 --- a/app/views/orgs/_profile_form.html.erb +++ b/app/views/orgs/_profile_form.html.erb @@ -1,21 +1,50 @@ +<%# locals: org, url, method %> + <% shared_links_tooltip = _('Links will be displayed next to your organisation\'s logo') org_config_info_tooltip = _('This information can only be changed by a system administrator. Contact the Help Desk if you have questions or to request changes.') %> -<%= form_for(org, url: url, html: { multipart: true, method: method, id: "edit_org_profile_form" } ) do |f| %> -
    -
    - <%= f.label :name, _('Organisation full name'), class: "control-label" %> - <%= f.text_field :name, id: "org_name", class: "form-control", "aria-required": true %> +<%= form_for(org, url: url, html: { multipart: true, method: method, + id: "edit_org_profile_form" } ) do |f| %> + + <% if org.new_record? %> + <%= render partial: "orgs/external_identifiers", + locals: { + form: f, + org: org, + editable: current_user.can_super_admin? + } %> + <% else %> +
    +
    + <%= f.label :name, _('Organisation full name'), class: "control-label" %> + <%= f.text_field :name, id: "org_name", class: "form-control", "aria-required": true %> +
    -
    -
    -
    - <%= f.label :abbreviation, _('Organisation abbreviated name'), class: "control-label" %> - <%= f.text_field :abbreviation, id: "org_abbreviation", class: "form-control", "aria-required": true %> +
    +
    + <%= f.label :abbreviation, _('Organisation abbreviated name'), class: "control-label" %> + <%= f.text_field :abbreviation, id: "org_abbreviation", class: "form-control", "aria-required": true %> +
    -
    + <% end %> + + <% if current_user.can_super_admin? %> +
    +
    + <%= f.label :managed do %> + <%= f.check_box :managed, id: "org_managed", "aria-required": true, + title: _("A managed Org is one that can have its own Guidance and/or Templates. An unmanaged Org is one that was automatically created by the system when a user entered/selected it.") %> + <%= _('Managed? (allows Org Admins to access the Admin menu)') %> + <% end %> +
    +
    + <% end %> + + <% unless org.tracker.present? + org.build_tracker + end %>
    @@ -23,7 +52,7 @@ <% if org.logo.present? %>
    - <%= image_tag logo_url_for_org(org), alt: "#{org.name} #{_('logo')}" %> + <%= image_tag logo_url_for_org(org), alt: "#{org.name} #{_('logo')}", class: "org-logo" %>
    <%= f.label :remove_logo do %> <%= f.check_box :remove_logo, @@ -39,6 +68,8 @@
    +
    +
    <%= shared_links_tooltip %> @@ -46,13 +77,15 @@ locals: { context: 'org', title: _('Organisation URLs'), - links: (org.links.present? ? org.links['org'] : []), + links: org.links.fetch("org", []), max_number_links: MAX_NUMBER_LINKS_FUNDER, tooltip: shared_links_tooltip }) %> <%= hidden_field_tag('org_links', value: org.links) %>
    +
    +

    <%= _("Administrator contact") %>

    @@ -69,66 +102,74 @@
    -
    -

    <%= _('Organisational Configuration Information') %><%= org_config_info_tooltip %>

    - <% if current_user.can_super_admin? %> - <% if Rails.application.config.shibboleth_use_filtered_discovery_service %> - <% shibboleth = org.org_identifiers.select{ |ids| ids.identifier_scheme == IdentifierScheme.find_by(name: 'shibboleth')} %> - <% shib_id = (shibboleth.first.present? ? shibboleth.first.identifier : '') %> +
    +
    +
    +

    <%= _("Google Analytics Tracker") %>

    +
    +
    +
    +
    + <%= f.fields_for :tracker do |t| %> + <%= t.label :code, _('Tracker Code'), class: "control-label" %> + <%= t.text_field :code, class: "form-control", aria: { required: false } %> + <% end %> +
    +
    - <% shib_domain = shibboleth.first.present? ? (shibboleth.first.attrs.present? ? JSON.parse(shibboleth.first.attrs)['domain'] : '') : '' %> +
    -
    -
    - <%= f.label :shib_id, _('Shibboleth Entity Id'), class: "control-label" %> - <%= text_field_tag :shib_id, shib_id, class: "form-control", placeholder: _('Example: https://idp.my-org.org/... or urn:mace:...') %> +
    + <% if current_user.can_super_admin? %> +
    + <%= _('Organisation Types') %> +
    + <%= f.label :funder do %> + <%= check_box_tag :funder, 2, org.funder?, class: 'org_types' %> + <%= _('Funder') %> + <% end %>
    -
    -
    -
    - <%= f.label :shib_domain, _('Shibboleth Domain'), class: "control-label" %> - <%= text_field_tag :shib_domain, shib_domain, class: "form-control", placeholder: _('Example: my-org.org') %> +
    + <%= f.label :institution do %> + <%= check_box_tag :institution, 1, org.institution?, class: 'org_types' %> + <%= _('Institution') %> + <% end %>
    -
    - <% end %> -
    -
    - <%= _('Organisation Types') %> -
    - <%= f.label :funder do %> - <%= check_box_tag :funder, 2, org.funder?, class: 'org_types' %> - <%= _('Funder') %> - <% end %> -
    -
    - <%= f.label :institution do %> - <%= check_box_tag :institution, 1, org.institution?, class: 'org_types' %> - <%= _('Institution') %> - <% end %> -
    -
    - <%= f.label :organisation do %> - <%= check_box_tag :organisation, 4, org.organisation?, class: 'org_types' %> - <%= _('Organisation') %> - <% end %> -
    - <%= f.hidden_field :org_type, data: { - validation: 'text', - validation_error: _('You must select at least one organisation type') } - %> -
    -
    +
    + <%= f.label :organisation do %> + <%= check_box_tag :organisation, 4, org.organisation?, class: 'org_types' %> + <%= _('Organisation') %> + <% end %> +
    + <%= f.hidden_field :org_type, data: { + validation: 'text', + validation_error: _('You must select at least one organisation type') } + %> + <% else %> -
    -
    <%= _('Organisation type(s)') %>
    -
    <%= org.org_type_to_s %>
    -
    +
    +
    +
    <%= _('Organisation type(s)') %>
    +
    <%= org.org_type_to_s %>
    +
    +
    <% end %>
    +
    + + <% if !org.new_record? %> + <%= render partial: "orgs/external_identifiers", + locals: { + form: f, + org: org, + editable: current_user.can_super_admin? + } %> + <% end %> +
    <%= f.button(_('Save'), id:"save_org_details_submit", class: "btn btn-primary", type: "submit") %>
    -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/orgs/admin_edit.html.erb b/app/views/orgs/admin_edit.html.erb index 56d2f48cda..2d37230ce4 100644 --- a/app/views/orgs/admin_edit.html.erb +++ b/app/views/orgs/admin_edit.html.erb @@ -1,7 +1,10 @@ -<% title org.id.present? ? _('Organisation details') : _('New organisation') %> +<% title _('Organisation details') %> +
    -

    <%= org.id.present? ? _('Organisation details') : _('New organisation') %>

    +

    + <%= _('Organisation details') %> +

    <% if current_user.can_super_admin? %> <%= link_to _('View all organisations'), super_admin_orgs_path, class: 'btn btn-default pull-right' %> <% end %> @@ -14,14 +17,14 @@
  • <%= _('Profile information') %>
  • - <% if org.id.present? %> -
  • - <%= _('Request feedback') %> -
  • -
  • - <%= _('Schools/Departments') %> -
  • - <% end %> + +
  • + <%= _('Request feedback') %> +
  • + +
  • + <%= _('Schools/Departments') %> +
  • @@ -30,30 +33,31 @@
    - <%= render partial: 'orgs/profile_form', locals: local_assigns %> + <%= render partial: 'orgs/profile_form', + locals: { org: org, method: method, url: url } %>
    +
    - <% if org.id.present? %> -
    - <%= render partial: 'orgs/feedback_form', locals: local_assigns %> -
    - <% end %> +
    + <%= render partial: 'orgs/feedback_form', + locals: { org: org } %> +
    +
    - <% if org.id.present? %> -
    - <%= render partial: 'orgs/departments', locals: local_assigns.merge({ departments: org.departments, org_id: org.id }) %> -
    - <% end %> +
    + <%= render partial: 'orgs/departments', + locals: { org: org } %> +
    diff --git a/app/views/paginable/api_clients/_index.html.erb b/app/views/paginable/api_clients/_index.html.erb new file mode 100644 index 0000000000..ba0db18236 --- /dev/null +++ b/app/views/paginable/api_clients/_index.html.erb @@ -0,0 +1,60 @@ +
    + + + + + + + + + + + + + + + <% scope.each do |client| %> + + + + + + + + + + + <% end %> + +
    + <%= _('Name') %> <%= paginable_sort_link('api_clients.name') %> + + <%= _('Description') %> + + <%= _('Homepage') %> + + <%= _('Contact') %> <%= paginable_sort_link('api_clients.contact_email') %> + + <%= _('Client_id') %> + + <%= _('Client Secret') %> + + <%= _('Last Accessed') %> <%= paginable_sort_link('api_clients.last_access') %> + <%= _('Actions') %>
    <%= client.name %><%= client.description %><%= client.homepage.present? ? link_to(client.homepage) : "" %><%= client.contact_email.present? ? link_to(client.contact_email, "mailto:#{client.contact_email}") : "" %><%= client.client_id %><%= client.client_secret %><%= client.last_access.present? ? l(client.last_access.to_date, formats: :short) : _("Never") %> + +
    +
    diff --git a/app/views/paginable/contributors/_index.html.erb b/app/views/paginable/contributors/_index.html.erb new file mode 100644 index 0000000000..aaf032e83d --- /dev/null +++ b/app/views/paginable/contributors/_index.html.erb @@ -0,0 +1,73 @@ +<%# locals: @plan, scope %> + + + + + + + + + + <% if @plan.administerable_by?(current_user) %> + + <% end %> + + + + <% ror_scheme = IdentifierScheme.by_name("ror").first %> + <% scope.each do |contributor| %> + + + + + + + <% if @plan.administerable_by?(current_user) %> + + <% end %> + + <% end %> + +
    + <%= _("Name") %> <%= paginable_sort_link("contributors.name") %> + <%= _("ORCID") %> + <%= _("Email") %> <%= paginable_sort_link("contributors.email") %> + + <%= _("Affiliation") %> <%= paginable_sort_link("orgs.name") %> + <%= _("Roles") %> + <%= _("Actions") %> +
    <%= ContributorPresenter.display_name(name: contributor.name) %> + <% orcid = contributor.identifier_for_scheme(scheme: "orcid") %> + <% if orcid.present? %> + <%= link_to orcid.value_without_scheme_prefix, orcid.value %> + <% end %> + <%= contributor.email %> + <% if contributor.org.present? %> + <%= contributor.org&.name %> + <% ror = contributor.org.identifier_for_scheme(scheme: ror_scheme) %> + <% if ror.present? %> + <% id_presenter = IdentifierPresenter.new(identifiable: contributor.org) %> +
    + <%= id_presenter.id_for_display(id: ror).html_safe %> + <% end %> + <% end %> +
    <%= ContributorPresenter.display_roles(roles: contributor.selected_roles) %> + +
    diff --git a/app/views/paginable/notifications/_index.html.erb b/app/views/paginable/notifications/_index.html.erb index 3d879033ba..55169aca45 100644 --- a/app/views/paginable/notifications/_index.html.erb +++ b/app/views/paginable/notifications/_index.html.erb @@ -5,6 +5,7 @@ <%= _('Level') %> <%= paginable_sort_link('notifications.level') %> <%= _('Start') %> <%= paginable_sort_link('notifications.starts_at') %> <%= _('Expiration') %> <%= paginable_sort_link('notifications.expires_at') %> + <%= _('Active') %> <%= paginable_sort_link('notifications.enabled') %> @@ -14,6 +15,14 @@ <%= notification.level %> <%= notification.starts_at %> <%= notification.expires_at %> + + <%= form_for notification, + url: enable_super_admin_notification_path(notification), + html: { method: :post, remote: true, class: 'enable_notification' } do |f| %> + <%= check_box_tag(:enabled, "1", notification.enabled, "aria-label": "active" ) %> + <%= f.submit(_('Update'), style: 'display: none;') %> + <% end %> + <% end %> diff --git a/app/views/paginable/orgs/_index.html.erb b/app/views/paginable/orgs/_index.html.erb index a5b03f1de9..a21e4e1c43 100644 --- a/app/views/paginable/orgs/_index.html.erb +++ b/app/views/paginable/orgs/_index.html.erb @@ -6,6 +6,7 @@ <%= _('Administrator Email') %> <%= paginable_sort_link('orgs.contact_email') %> <%= _('Organisation Type(s)') %> <%= paginable_sort_link('orgs.org_type') %> <%= _('Templates') %> + <%= _("Managed") %> <%= paginable_sort_link('orgs.managed') %> <%= _('Actions') %> @@ -16,6 +17,7 @@ <%= org.contact_email %> <%= org.org_type_to_s %> <%= org.template_count %> + <%= org.managed? ? "Yes" : "No" %> \ No newline at end of file +
    diff --git a/app/views/paginable/plans/_org_admin.html.erb b/app/views/paginable/plans/_org_admin.html.erb index 1ea1cd663b..c92cb3fd20 100644 --- a/app/views/paginable/plans/_org_admin.html.erb +++ b/app/views/paginable/plans/_org_admin.html.erb @@ -1,11 +1,25 @@ + +<% if @clicked_through %> +

    <%= _(<<-TEXT + The data on the usage dashboard is historical in nature. This means that the number of records below may not + match the count shown on the usage dashboard. For example if one of your users created a plan in October and + then removed that plan in November, it would have been included on the usage dashboard's total for October but + would not appear in the list below. + TEXT + ) %>

    +<% end %> + +

    <%= _("Note: You can filter this table by 'Created' dates. Enter the month abbreviation and a 4 digit year into the search box above.
    For example: 'Oct 2019' or 'Jun 2013'.").html_safe %>

    +
    - + + @@ -21,8 +35,9 @@ <% end %> - - + + + - - + + - + @@ -62,6 +73,11 @@ <%# The content of this column get updated through AJAX whenever the permission for an user are updated %> diff --git a/app/views/phases/_edit_plan_answers.html.erb b/app/views/phases/_edit_plan_answers.html.erb index d2c5082679..7286f2ea0e 100644 --- a/app/views/phases/_edit_plan_answers.html.erb +++ b/app/views/phases/_edit_plan_answers.html.erb @@ -1,5 +1,5 @@
    -
    +
    @@ -12,6 +12,8 @@
    +
    +
    <% if plan.present? && phase.present? %>
    @@ -72,7 +74,7 @@
    " - class="answer-locking"> + class="answer-locking">
    " class="answer-form"> <%= render(partial: '/answers/new_edit', @@ -98,6 +100,9 @@ answer: answer, guidance_presenter: guidance_presenter } %>
    + <% if i != section.questions.length - 1 %> +
    + <% end %>
    <% if i != section.questions.length - 1 %>
    diff --git a/app/views/plans/_edit_details.html.erb b/app/views/plans/_edit_details.html.erb index 375e84c0ee..61a6016260 100644 --- a/app/views/plans/_edit_details.html.erb +++ b/app/views/plans/_edit_details.html.erb @@ -1,223 +1,23 @@ -<% project_title_tooltip = _('If applying for funding, state the name exactly as in the grant proposal.') %> -<% project_abstract_tooltip = _("Briefly summarise your research project to help others understand the purposes for which the data are being collected or created.") %> -<% id_tooltip = _('A pertinent ID as determined by the funder and/or organisation.') %> +<%# locals: plan %> -
    +<%= form_for plan, html: { method: :put, + class: 'form-horizontal edit_plan' } do |f| %> +
    - <%= form_for plan, html: {method: :put, class: 'form-horizontal edit_plan' } do |f| %> -
    -
    - <%= f.label(:title, _('Project title'), class: 'control-label') %> -
    -
    - <%= project_title_tooltip %> - <%= f.text_field(:title, class: "form-control", "aria-required": true, - 'data-toggle': 'tooltip', spellcheck: true, - title: project_title_tooltip) %> -
    - <%= f.hidden_field :visibility %> - <%= f.label(:is_test, class: 'control-label') do %> - <%= check_box_tag(:is_test, 1, @plan.is_test?, "aria-label": "is_test") %> - <%= _('mock project for testing, practice, or educational purposes') %> - <% end %> -
    -
    -
    -
    -
    - <%= f.label(:funder_name, _('Funder'), class: 'control-label') %> -
    -
    - <%= f.text_field( - :funder_name, - class: "form-control", - spellcheck: true, - "aria-required": false) %> -
    -
    -
    -
    - <%= label_tag(:plan_grant_number_name, _('Grant number'), class: 'control-label') %> -
    + <%= render partial: "plans/project_details", + locals: { form: f, plan: plan } %> -
    - <%= text_field_tag(:plan_grant_number_name, '', - class: "grant-id-typeahead form-control", - autocomplete: "off", - aria: { required: false }) %> - <%= f.hidden_field(:grant_number) %> - Grant number: <%= @plan.grant_number %> -
    -
    -
    -
    - <%= f.label(:description, _('Project abstract'), class: 'control-label') %> -
    -
    - <%= project_abstract_tooltip %> - <%= f.text_area( - :description, rows: 6, - class: 'form-control tinymce', - "aria-required": false) %> -
    -
    -
    -
    - <%= f.label(:identifier, _('ID'), class: 'control-label') %> -
    -
    - <%= id_tooltip %> - <%= f.text_field(:identifier, class: "form-control", "aria-required": false, - 'data-toggle': "tooltip", spellcheck: true, - title: id_tooltip) %> -
    -
    -
    - <%= _("Principal Investigator") %> -
    -
    - <%= f.label(:principal_investigator, _('Name'), class: 'control-label') %> -
    -
    - <%= f.text_field( - :principal_investigator, - class: "form-control", - "aria-required": false) %> -
    -
    -
    -
    - <%= f.label(:principal_investigator_identifier, _('ORCID iD'), class: 'control-label') %> -
    -
    - <%= f.text_field( - :principal_investigator_identifier, - class: "form-control", - "aria-required": false) %> -
    -
    -
    -
    - <%= f.label(:principal_investigator_email, _('Email'), class: 'control-label') %> -
    -
    - <%= f.email_field( - :principal_investigator_email, - class: "form-control", - "aria-required": false) %> -
    -
    -
    -
    - <%= f.label(:principal_investigator_phone, _('Phone'), class: 'control-label') %> -
    -
    - <%= f.phone_field( - :principal_investigator_phone, - class: "form-control", - "aria-required": false) %> -
    -
    -
    -
    - <%= _('Data contact person') %> -
    - <% checked = ((@plan.data_contact.present? || @plan.data_contact_phone.present? || @plan.data_contact_email.present?) ? 1 : 0) %> - <%= label_tag(:show_data_contact, class: 'control-label') do %> - <%= check_box_tag(:show_data_contact, checked, checked == 0) %> - <%= _('Same as Principal Investigator') %> - <% end %> -
    -
    -
    - <%= f.label(:data_contact, _('Name'), class: 'control-label') %> -
    -
    - <%= f.text_field( - :data_contact, - class: "form-control", - "aria-required": false) %> -
    -
    -
    -
    - <%= f.label(:data_contact_email, _('Email'), class: 'control-label') %> -
    -
    - <%= f.email_field( - :data_contact_email, - class: "form-control", - "aria-required": false) %> -
    -
    -
    -
    - <%= f.label(:data_contact_phone, _('Phone'), class: 'control-label') %> -
    -
    - <%= f.phone_field( - :data_contact_phone, - class: "form-control", - "aria-required": false) %> -
    -
    -
    <%= f.button(_('Save'), class: "btn btn-default", type: "submit") %>

    <%= _('Select Guidance') %>

    - - <% if @all_guidance_groups.length > 0 %> -

    <%= _('To help you write your plan, %{application_name} can show you guidance from a variety of organisations.') % - {application_name: Rails.configuration.branding[:application][:name]} %> -

    -
    -

    <%= _('Select up to 6 organisations to see their guidance.') %>

    -
      - <%= render partial: "guidance_choices", - locals: {choices: @important_ggs, form: f, - current_selections: @selected_guidance_groups} %> -
    -
    - - <% if @all_guidance_groups.length > @important_ggs.length %> -

    <%= _('Find guidance from additional organisations below') %>

    - <%= link_to _('See the full list'), '#', 'data-toggle' => 'modal', 'data-target' => '#modal-full-guidances', class: 'modal-guidances-window' %> - <% end %> -
    - <%= f.button(_('Save'), class: "btn btn-default", type: "submit") %> - - <% else %> -

    <%= _("There is no additional guidance for this template.") %>

    - <% end %> + <%= render partial: "plans/guidance_selection", + locals: { + form: f, + all_guidance_groups: @all_guidance_groups, + important_ggs: @important_ggs, + selected_guidance_groups: @selected_guidance_groups + } %>
    - - <% if @all_guidance_groups.length > @important_ggs.length %> - - <% end %> - - <% end %> -
    +
    +<% end %> diff --git a/app/views/plans/_guidance_choices.html.erb b/app/views/plans/_guidance_choices.html.erb index 253a0f23ad..a54e882d06 100644 --- a/app/views/plans/_guidance_choices.html.erb +++ b/app/views/plans/_guidance_choices.html.erb @@ -1,8 +1,13 @@ +<%# locals: choices, current_selections %> + <% choices.each do |org, groups| %> <% if groups && groups.size == 1 %>
  • - <%= check_box_tag "guidance_group_ids[]", groups[0].id, - current_selections.include?(groups[0].id), class: 'guidance-choice', "aria-label": "#{groups[0].id}" %> + <%= check_box_tag "guidance_group_ids[]", + groups[0].id, + current_selections.include?(groups[0].id), + class: 'guidance-choice', + "aria-label": "#{groups[0].id}" %> <%= org.name %>
  • <% elsif groups %> @@ -12,12 +17,15 @@ <% groups.each do |group| %>
  • └─ - <%= check_box_tag "guidance_group_ids[]", group.id, - current_selections.include?(group.id), class: 'guidance-choice', "aria-label": "#{group.id}" %> + <%= check_box_tag "guidance_group_ids[]", + group.id, + current_selections.include?(group.id), + class: 'guidance-choice', + "aria-label": "#{group.id}" %> <%= group.name %>
  • <% end %> <% end%> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/plans/_guidance_selection.html.erb b/app/views/plans/_guidance_selection.html.erb new file mode 100644 index 0000000000..11090c771e --- /dev/null +++ b/app/views/plans/_guidance_selection.html.erb @@ -0,0 +1,54 @@ +<%# locals: form, all_guidance_groups, important_ggs, selected_guidance_groups %> + +<% app_name = Rails.configuration.branding[:application][:name] %> + +<% if all_guidance_groups.length > 0 %> +

    <%= _('To help you write your plan, %{application_name} can show you guidance from a variety of organisations.') % { application_name: app_name } %> +

    +
    +

    <%= _('Select up to 6 organisations to see their guidance.') %>

    +
      + <%= render partial: "guidance_choices", + locals: { choices: important_ggs, form: form, + current_selections: selected_guidance_groups } %> +
    +
    + + <% if all_guidance_groups.length > important_ggs.length %> +

    <%= _('Find guidance from additional organisations below') %>

    + <%= link_to _('See the full list'), '#', data: { toggle: "modal", target: "#modal-full-guidances" }, class: 'modal-guidances-window' %> + <% end %> + +
    + <%= form.button(_('Save'), class: "btn btn-default", type: "submit") %> + +<% else %> +

    <%= _("There is no additional guidance for this template.") %>

    +<% end %> + +<% if all_guidance_groups.length > important_ggs.length %> + +<% end %> diff --git a/app/views/plans/_navigation.html.erb b/app/views/plans/_navigation.html.erb index 0bdb7bf1b1..83c8cf6513 100644 --- a/app/views/plans/_navigation.html.erb +++ b/app/views/plans/_navigation.html.erb @@ -3,6 +3,10 @@ +
  • "> + <%= link_to _("Contributors"), plan_contributors_path(plan), role: "tab", + aria: { controls: "content" } %> +
  • @@ -33,7 +37,7 @@
    - <%= yield %> + <%= yield :plan_tab_body %>
    diff --git a/app/views/plans/_progress.html.erb b/app/views/plans/_progress.html.erb index 650bd05dc3..7a5074a086 100644 --- a/app/views/plans/_progress.html.erb +++ b/app/views/plans/_progress.html.erb @@ -1,7 +1,7 @@ <%# locals: { plan, current_phase } %> <% - nanswers = plan.num_answered_questions(current_phase) - nquestions = current_phase.num_questions() + nanswers = current_phase.num_answers_not_removed(plan) + nquestions = current_phase.num_questions_not_removed(plan) value=(nanswers.to_f/nquestions*100).round(2) %>
    + +<% project_title_tooltip = _('If applying for funding, state the name exactly as in the grant proposal.') %> +<% project_abstract_tooltip = _("Briefly summarise your research project to help others understand the purposes for which the data are being collected or created.") %> +<% id_tooltip = _('A pertinent ID as determined by the funder and/or organisation.') %> + +
    +
    + <%= form.label(:title, _('Project title'), class: 'control-label') %> +
    +
    + <%= project_title_tooltip %> + <%= form.text_field(:title, class: "form-control", "aria-required": true, + 'data-toggle': 'tooltip', spellcheck: true, + title: project_title_tooltip) %> +
    +
    +
    + <%= form.hidden_field :visibility %> + <%= form.label(:is_test, class: 'control-label') do %> + <%= check_box_tag(:is_test, 1, plan.is_test?, "aria-label": "is_test") %> + <%= _('mock project for testing, practice, or educational purposes') %> + <% end %> +
    +
    +
    + +
    +
    + <%= form.label(:description, _('Project abstract'), class: 'control-label') %> +
    +
    + <%= project_abstract_tooltip %> + <%= form.text_area :description, rows: 6, class: 'form-control tinymce', + "aria-required": false %> +
    +
    + +
    +
    + <%= form.label(:start_date, _("Project Start"), class: "control-label") %> + <%= form.date_field :start_date, class: "form-control", + data: { toggle: "tooltip" }, + title: _("The estimated date on which you will begin this project.") %> +
    +
    + <%= form.label(:end_date, _("Project End"), class: "control-label") %> + <%= form.date_field :end_date, class: "form-control", + data: { toggle: "tooltip" }, + title: _("The estimated date on which you will complete this project.") %> +
    +
    + +<%# if DOI minting is enabled %> +<% landing_page = plan.landing_page %> +<% if Rails.configuration.x.doi&.active && landing_page.present? %> +
    +
    + <%= form.label(:identifier, _('Landing page'), class: 'control-label') %> +
    +
    + <% if landing_page.value.starts_with?("http") %> + <%= link_to landing_page.value, landing_page.value %> + <% else %> + <%= landing_page.value %> + <% end %> +
    +
    +<% else %> +
    +
    + <%= form.label :id, _("ID"), class: "control-label" %> +
    +
    + <%= id_tooltip %> + <%= form.text_field :identifier, class: "form-control", + aria: { required: false }, + data: { toggle: "tooltip" }, + spellcheck: true, title: id_tooltip %> +
    +
    +<% end %> + +
    + <% if plan.template.org == plan.funder %> + <%# If the plan's funder is the same as the template owner then just + display the identifiers %> +
    + <%= form.label(:funder_name, _('Funder'), class: 'control-label') %> +
    +
    + <%= plan.funder.name %>: +
    + + <% id_presenter = IdentifierPresenter.new(identifiable: plan.funder) %> +
      + <% %w[fundref ror].each do |scheme_name| %> + <% + scheme = id_presenter.scheme_by_name(name: scheme_name).first + identifier = id_presenter.id_for_scheme(scheme: scheme) + %> +
      +
    • <%= id_presenter.id_for_display(id: identifier).html_safe %>
    • +
      + <% end %> +
    + <% else %> + <%# Otherwise display the Org typeahead for funders %> +
    + <%= fields_for :funder, plan.funder do |funder_fields| %> + <%= render partial: "shared/org_selectors/combined", + locals: { + form: form, + funder_only: true, + label: _("Funder"), + default_org: plan.funder, + required: false + } %> + <% end %> +
    + <% end %> +
    + +
    + <%# If the OpenAire grant typeahead if enabled use it %> + <% if Rails.configuration.x.open_aire.active %> + +
    + <%= label_tag(:plan_grant_number_name, _('Grant number'), + class: 'control-label') %> +
    + +
    + <%= text_field_tag(:plan_grant_number_name, '', + class: "grant-id-typeahead form-control", + autocomplete: "off", + aria: { required: false }) %> + <%= form.hidden_field(:grant_number) %> + Grant number: <%= @plan.grant_number %> +
    + <% else %> + <%= fields_for :grant, @plan.grant do |grant_fields| %> +
    + <%= grant_fields.label(:value, _("Grant number/url"), class: "control-label") %> +
    +
    + <%= grant_fields.text_field(:value, class: "form-control", + data: { toggle: "tooltip" }, + title: _("Provide a URL to the award's landing page if possible, if not please provide the award/grant number.")) %> + <%= grant_fields.hidden_field :id %> +
    + <% end %> + <% end %> +
    diff --git a/app/views/plans/_show_details.html.erb b/app/views/plans/_show_details.html.erb index 01ba3ba82b..7d6c1040d1 100644 --- a/app/views/plans/_show_details.html.erb +++ b/app/views/plans/_show_details.html.erb @@ -1,32 +1,25 @@ +<% presenter = PlanPresenter.new(plan) %> +
    <%= _('Project Title') %>
    <%= plan.title %>
    -
    <%= _('Funder') %>
    -
    <%= plan.funder_name %>
    -
    <%= _('Grant Number') %>
    -
    <%= plan.grant_number %>
    <%= _('Project Abstract') %>
    <%= sanitize plan.description %>
    -
    <%= _('ID') %>
    -
    <%= plan.identifier %>
    -
    -
    -

    <%= _('Principal Investigator') %>

    -
    -
    <%= _('Name') %>
    -
    <%= plan.principal_investigator %>
    -
    <%= _('ORCID iD') %>
    -
    <%= plan.principal_investigator_identifier %>
    -
    <%= _('Email') %>
    -
    <%= plan.principal_investigator_email %>
    -
    -
    -

    ><%= _('Data Contact Person') %>

    -
    -
    <%= _('Name') %>
    -
    <%= plan.data_contact %>
    -
    <%= _('Phone') %>
    -
    <%= plan.data_contact_phone %>
    -
    <%= _('Email') %>
    -
    <%= plan.data_contact_email %>
    +
    <%= _('Start and End Dates') %>
    +
    + <%= presenter.project_dates_to_readonly_display %> +
    +
    <%= _('Funder') %>
    +
    <%= plan.funder&.name %>
    +
    <%= _('Grant Number') %>
    +
    <%= plan.grant&.value %>
    + <%# if DOI minting is enabled %> + <% landing_page = plan.landing_page %> + <% if Rails.configuration.x.doi&.active && landing_page.present? %> +
    <%= _('Landing Page') %>
    +
    <%= link_to landing_page&.value, landing_page&.value %>
    + <% else %> +
    <%= _('ID') %>
    +
    <%= plan.identifier %>
    + <% end %>
    diff --git a/app/views/plans/new.html.erb b/app/views/plans/new.html.erb index 0a408cd140..6e63802b25 100644 --- a/app/views/plans/new.html.erb +++ b/app/views/plans/new.html.erb @@ -46,26 +46,27 @@ *<%= required_research_org_tooltip %> <%= _('Select the primary research organisation') %> -
    +
    <%= research_org_tooltip %> - <%= render partial: "shared/accessible_combobox", - locals: {name: 'plan[org_name]', - id: 'plan_org_name', - default_selection: @default_org, - models: @orgs, - attribute: 'name', - required: true, - error: _('You must select a research organisation from the list.'), - tooltip: research_org_tooltip, - placement: 'bottom'} %> + <% dflt = @orgs.include?(current_user.org) ? current_user.org : nil %> + <%= fields_for :org, @plan.org do |org_fields| %> + <%= render partial: "shared/org_selectors/local_only", + locals: { + form: org_fields, + id_field: :id, + default_org: dflt, + orgs: @orgs, + required: false + } %> + <% end %>
    - <%= _('or') %> -
    <% primary_research_org_message = _('No research organisation associated with this plan or my research organisation is not listed') %> <%= label_tag(:plan_no_org) do %> - <%= check_box_tag(:plan_no_org) %> + <%= check_box_tag(:plan_no_org, "0", false, class: "toggle-autocomplete") %> <%= primary_research_org_message %> <% end %>
    @@ -74,26 +75,27 @@

    * <%= required_primary_funding_tooltip %> <%= _('Select the primary funding organisation') %>

    -
    +
    <%= primary_funding_tooltip %> - <%= render partial: "shared/accessible_combobox", - locals: {name: 'plan[funder_name]', - id: 'plan_funder_name', - default_selection: nil, - models: @funders, - attribute: 'name', - required: true, - error: _('You must select a funding organisation from the list.'), - tooltip: primary_funding_tooltip, - placement: 'bottom'} %> + <%= fields_for :funder, @plan.funder = Org.new do |funder_fields| %> + <%= render partial: "shared/org_selectors/local_only", + locals: { + form: funder_fields, + id_field: :id, + label: _("Funder"), + default_org: nil, + orgs: @funders, + required: false + } %> + <% end %>
    - <%= _('or') %> -
    <% primary_funding_message = _('No funder associated with this plan or my funder is not listed') %> <%= label_tag(:plan_no_funder) do %> - <%= check_box_tag(:plan_no_funder) %> + <%= check_box_tag(:plan_no_funder, "0", false, class: "toggle-autocomplete") %> <%= primary_funding_message %> <% end %>
    diff --git a/app/views/plans/overview.html.erb b/app/views/plans/overview.html.erb index a0636502cf..05d60ff3d2 100644 --- a/app/views/plans/overview.html.erb +++ b/app/views/plans/overview.html.erb @@ -1,13 +1,14 @@ <%# locals: { plan } %> + <% title "#{plan.title}" %>
    -

    <%= plan.title %>

    +

    <%= plan.title %>

    - <%= render partial: 'overview_details', layout: 'plans/navigation', locals: local_assigns %> + <%= render partial: 'overview_details', layout: 'navigation', locals: { plan: plan } %>
    -
    \ No newline at end of file +
    diff --git a/app/views/plans/show.html.erb b/app/views/plans/show.html.erb index 6515930e28..7254197d1e 100644 --- a/app/views/plans/show.html.erb +++ b/app/views/plans/show.html.erb @@ -6,12 +6,11 @@
    +<% partial = @plan.editable_by?(current_user) ? "edit_details" : "show_details" %> +
    - <% if @plan.editable_by?(current_user) %> - <%= render partial: 'edit_details', layout: 'navigation', locals: {plan: @plan, visibility: @visibility} %> - <% else %> - <%= render partial: 'show_details', layout: 'navigation', locals: { plan: @plan, visibility: @visibility } %> - <% end %> + <%= render partial: partial, layout: 'navigation', + locals: { plan: @plan, visibility: @visibility } %>
    -
    \ No newline at end of file +
    diff --git a/app/views/shared/_accessible_combobox.html.erb b/app/views/shared/_accessible_combobox.html.erb deleted file mode 100644 index 3e50b812c4..0000000000 --- a/app/views/shared/_accessible_combobox.html.erb +++ /dev/null @@ -1,44 +0,0 @@ -<% if !models.nil? %> - <% required = required ||= false %> - <% classes = classes ||= '' %> - <% title = tooltip ||= '' %> - <% error = error ||= _('Please select an item from the list.') %> - - <% json = {} %> - <% models.map{|m| json["#{m[attribute]}"] = m.id} %> - - <% name = name.gsub(name.match(/^.*\[/)[0].split('_')[0] + '_', '') %> - <%= title %> - - data-placement="<%= placement %>" - <% end %> - title="<%= title %>" - aria-label= "<%= title %>", - value="<%= default_selection[attribute] unless default_selection.nil? %>" /> - - <% models.each do |model| %> - - <% end %> - - - - - - - - - " name="<%= name.gsub("_#{attribute}]", "_id]") %>" - value="<%= default_selection.id unless default_selection.nil? %>" aria-required="<%= required %>" - class="org-id" data-validation="js-combobox" data-validation-error="<%= error %>" /> - -<% else %> - <%= _('No items available.') %> -<% end %> \ No newline at end of file diff --git a/app/views/shared/_create_account_form.html.erb b/app/views/shared/_create_account_form.html.erb index cb5dacd618..bbb0381723 100644 --- a/app/views/shared/_create_account_form.html.erb +++ b/app/views/shared/_create_account_form.html.erb @@ -12,13 +12,14 @@ <%= f.label(:email, _('Email'), class: "control-label") %> <%= f.email_field(:email, class: "form-control", "aria-required": true) %>
    -
    - <%= render partial: "shared/my_org", - locals: {f: f, default_org: @default_org, - orgs: Org.where(is_other: false).order("sort_name ASC, name ASC"), - allow_other_orgs: true, required: false} %> +
    + <%= render partial: "shared/org_selectors/combined", + locals: { + form: f, + default_org: resource&.org, + required: true + } %>
    -
    <%= f.label(:password, _('Password'), class: "control-label") %> <%= f.password_field(:password, class: "form-control", "aria-required": true) %> @@ -39,5 +40,11 @@ <% end %>
    + <% if Rails.configuration.branding[:application][:use_recaptcha] %> +
    + <%= label_tag(nil, _('Security check')) %> + <%= recaptcha_tags %> +
    + <% end %> <%= f.button(_('Create account'), class: "btn btn-default", type: "submit") %> <% end %> diff --git a/app/views/shared/_my_org.html.erb b/app/views/shared/_my_org.html.erb deleted file mode 100644 index 7f2cf6be68..0000000000 --- a/app/views/shared/_my_org.html.erb +++ /dev/null @@ -1,39 +0,0 @@ -<% required = required ||= false %> -<% other_org = Org.find_by(is_other: true) %> - -<% if f.object.errors[:org].present? %> -
    -<% end %> - -<%= f.label :org_name, _('Organisation'), class: 'control-label' %> -<% object_name = (f.options[:namespace].present? ? "#{f.options[:namespace]}_#{f.object_name}" : f.object_name) %> -<%= render partial: "shared/accessible_combobox", - locals: {name: "#{object_name}[org_name]", - id: "#{object_name}_org_name", - default_selection: default_org, - models: orgs, - attribute: 'name', - error: _('Please select an organisation from the list, or click the "My organisation isn\'t listed" link and enter your organisation\'s name.'), - required: required} %> - -<% if f.object.errors[:org].present? %> -
    - <%= _('Please select an organisation from the list, or click the "My organisation isn\'t listed" link and enter your organisation\'s name.') %> -
    -<% end %> - -<% if allow_other_orgs %> -
    - <%= f.hidden_field :other_org_id, value: other_org.present? ? other_org.id : '' %> - <%= f.hidden_field :other_org_name, value: other_org.present? ? other_org.name : '' %> - <%= _('My organisation isn\'t listed.') %> - -
    - <%= f.text_field :other_organisation, autocomplete: "off", class: "form-control hide other-org", - placeholder: _('Please enter the name of your organisation'), - "aria-label": "other_organisation" %> -<% end %> - -<% if f.object.errors[:org].present? %> -
    -<% end %> \ No newline at end of file diff --git a/app/views/shared/_search.html.erb b/app/views/shared/_search.html.erb index 19f9a8250b..5e65ee0212 100644 --- a/app/views/shared/_search.html.erb +++ b/app/views/shared/_search.html.erb @@ -1,4 +1,5 @@ -<% # locals: { search_term } %> +<%# locals: { search_term } %> + <%= form_tag(paginable_base_url(1), method: :get, remote: true, class: 'form-inline paginable-action') do %>
    @@ -7,7 +8,14 @@ <%= text_field_tag(:search, search_term, class: 'form-control', 'aria-labelledby': 'search', spellcheck: true, 'aria-describedby': 'search-addon', 'aria-required': true) %> +
    + <%# @filter_admin is only defined on the users_index page %> + <% unless @filter_admin.nil? %> + <%= label_tag(:filter_admin, _('Only Show Admins'), class: 'form-check-inline') %> + <%= check_box_tag( :filter_admin , "1", @filter_admin)%> +   + <% end %>
    <%= submit_tag(_('Search'), class: 'btn btn-default', style: 'margin-top: 8px;') %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/shared/export/_plan.erb b/app/views/shared/export/_plan.erb index 4a9cb1c78a..8f036fb0d6 100644 --- a/app/views/shared/export/_plan.erb +++ b/app/views/shared/export/_plan.erb @@ -24,13 +24,16 @@

    <%= download_plan_page_title(@plan, phase, @hash) %>


    <% phase[:sections].each do |section| %> - <% if display_section?(@hash[:customization], section, @show_custom_sections) %> + <% if display_section?(@hash[:customization], section, @show_custom_sections) && num_section_questions(@plan, section, phase) > 0 %> <% if @show_sections_questions %>

    <%= section[:title] %>


    <% end %> <% section[:questions].each do |question| %> + <% if remove_list(@plan).include?(question[:id]) %> + <% next %> + <% end %>
    <% if !@public_plan && @show_sections_questions%> <%# Hack: for DOCX export - otherwise, bold highlighting of question inconsistent. %> diff --git a/app/views/shared/export/_plan_coversheet.erb b/app/views/shared/export/_plan_coversheet.erb index 2d4796de67..1962224469 100644 --- a/app/views/shared/export/_plan_coversheet.erb +++ b/app/views/shared/export/_plan_coversheet.erb @@ -1,6 +1,6 @@

    <%= @plan.title %>

    -

    <%= _("A Data Management Plan created using ") + Rails.configuration.branding[:application][:name] %>

    +

    <%= _("A Data Management Plan created using %{application_name}") % { application_name: Rails.configuration.branding[:application].fetch(:name, "DMPRoadmap") } %>


    <%# Using tags as the htmltoword gem does not recognise css styles defined %> @@ -14,10 +14,12 @@ <% end %>

    <%= _("Template: ") %><%= @hash[:template] + @hash[:customizer] %>


    - - <% if @plan.principal_investigator_identifier.present? %> -

    <%= _("ORCID iD: ") %><%= @plan.principal_investigator_identifier %>


    + <% @plan.contributors.investigation.each do |contributor| %> + <% orcid = contributor.identifier_for_scheme(scheme: "orcid") %> + <% next unless orcid.present? && orcid.value.present? %> + +

    <%= _("ORCID iD: ") %><%= orcid.value_without_scheme_prefix %>


    <% end %> <% if @plan.grant_number.present? %> diff --git a/app/views/shared/export/_plan_txt.erb b/app/views/shared/export/_plan_txt.erb index 0462c8d69a..474d85355a 100644 --- a/app/views/shared/export/_plan_txt.erb +++ b/app/views/shared/export/_plan_txt.erb @@ -26,11 +26,14 @@ <% if phase[:title] == @selected_phase.title %> <%= (@hash[:phases].many? ? "#{phase[:title]}" : "") %> <% phase[:sections].each do |section| %> - <% if display_section?(@hash[:customization], section, @show_custom_sections) %> + <% if display_section?(@hash[:customization], section, @show_custom_sections) && num_section_questions(@plan, section, phase) > 0 %> <% if @show_sections_questions %> <%= "#{section[:title]}\n" %> <% end %> <% section[:questions].each do |question| %> + <% if remove_list(@plan).include?(question[:id]) %> + <% next %> + <% end %> <%# text in this case is an array to accomodate for option_based %> <% if @show_sections_questions %> <% if question[:text].respond_to?(:each) %> diff --git a/app/views/shared/org_selectors/_combined.html.erb b/app/views/shared/org_selectors/_combined.html.erb new file mode 100644 index 0000000000..8d4c4581f7 --- /dev/null +++ b/app/views/shared/org_selectors/_combined.html.erb @@ -0,0 +1,49 @@ +<%# locals: form, default_org, required, funder_only, label %> + +<%# Note the 'data' args in the org_name definition. These are used by the utils/autocomplete.js to determine how to process the AJAX call to the controller. In this case it will make a POST to OrgsController#search with the { org: { name: 'foo' } } params %> + +<% +# Whether or not the org selection is required +required = required || false +# Whether or not to restrict the Orgs to funders +funder_only = funder_only || false +# The label to use +label = label || _("Organisation") + +presenter = OrgSelectionPresenter.new(orgs: [default_org], + selection: default_org) +placeholder = _("Begin typing to see a list of suggestions.") +%> + +<%= form.label :org_name, label %> +<%= form.text_field :org_name, class: "form-control autocomplete", + placeholder: placeholder, + value: presenter.name, + aria: { + label: placeholder, + autocomplete: "list", + required: required + }, + data: { + url: orgs_search_path( + type: "combined", + funder_only: funder_only.to_s + ), + method: "POST", + namespace: "org", + attribute: "name" + } %> + +
    + +<%# crosswalk contains an array of hashes that contain the Org name, id, + identifiers like ROR and other info used by the OrgSelectionService %> +<%= form.hidden_field :org_crosswalk, value: presenter.crosswalk %> +<%# gets updated with the matching record from crosswalk when the user + selects or enters something %> +<%= form.hidden_field :org_id, value: default_org, + class: "autocomplete-result" %> + +

    + <%= _("A new entry will be created for the organisation you have named above. Please double check that your organisation does not appear in the list in a slightly different form.").html_safe %> +

    diff --git a/app/views/shared/org_selectors/_external_only.html.erb b/app/views/shared/org_selectors/_external_only.html.erb new file mode 100644 index 0000000000..612db36404 --- /dev/null +++ b/app/views/shared/org_selectors/_external_only.html.erb @@ -0,0 +1,44 @@ +<%# locals: form, default_org, required, include_locals, include_externals %> + +<%# Note the 'data' args in the org_name definition. These are used by the utils/autocomplete.js to determine how to process the AJAX call to the controller. In this case it will make a POST to OrgsController#search with the { org: { name: 'foo' } } params %> + +<% +# Whether or not the org selection is required +required = required || false +# The label to use +label = label || _("Organisation") + +presenter = OrgSelectionPresenter.new(orgs: [default_org], + selection: default_org) +placeholder = _("Begin typing to see a list of suggestions.") +%> + +<%= form.label :org_name, label %> +<%= form.text_field :org_name, class: "form-control autocomplete", + placeholder: placeholder, + value: presenter.name, + aria: { + label: placeholder, + autocomplete: "list", + required: required + }, + data: { + url: orgs_search_path(type: "external"), + method: "POST", + namespace: "org", + attribute: "name" + } %> + +
    + +<%# crosswalk contains an array of hashes that contain the Org name, id, + identifiers like ROR and other info used by the OrgSelectionService %> +<%= form.hidden_field :org_crosswalk, value: presenter.crosswalk %> +<%# gets updated with the matching record from crosswalk when the user + selects or enters something %> +<%= form.hidden_field :org_id, value: default_org, + class: "autocomplete-result" %> + +

    + <%= _("A new entry will be created for the organisation you have named above. Please double check that your organisation does not appear in the list in a slightly different form.").html_safe %> +

    diff --git a/app/views/shared/org_selectors/_local_only.html.erb b/app/views/shared/org_selectors/_local_only.html.erb new file mode 100644 index 0000000000..4c10cce1d5 --- /dev/null +++ b/app/views/shared/org_selectors/_local_only.html.erb @@ -0,0 +1,47 @@ +<%# locals: form, orgs, default_org, required %> + +<% +# Whether or not the org selection is required +required = required || false +# The label to use +label = label || _("Organisation") +# Allows the hidden id field to be renamed for instances where there are +# multiple org selectors on the same form +id_field = id_field || :org_id + +presenter = OrgSelectionPresenter.new(orgs: orgs, selection: default_org) +placeholder = _("Begin typing to see a list of suggestions.") +%> + +<%= form.label :org_name, label %> +<%= form.text_field :org_name, class: "form-control autocomplete", + placeholder: placeholder, + value: presenter.name, + aria: { + label: placeholder, + autocomplete: "list", + required: required + }, + data: { source: "" } %> + +
    + +<%# sources contains an array of Org names %> +<%= form.hidden_field :org_sources, value: presenter.select_list %> +<%# crosswalk contains an array of hashes that contain the Org name, id, + identifiers like ROR and other info used by the OrgSelectionService %> +<%= form.hidden_field :org_crosswalk, value: presenter.crosswalk %> +<%# gets updated with the matching record from crosswalk when the user + selects or enters something %> +<% if form.object[id_field]&.to_s =~ /[0-9]+/ || form.object[id_field].nil? %> + <% val = presenter.crosswalk_entry_from_org_id(value: form.object[id_field]) %> + <%= form.hidden_field id_field, value: val, class: "autocomplete-result", + autocomplete: "off" %> +<% else %> + <%= form.hidden_field :org_id, class: "autocomplete-result", + autocomplete: "off" %> +<% end %> + +

    + <%= _("The name you entered was not one of the listed suggestions!") %> +

    diff --git a/app/views/super_admin/api_clients/_form.html.erb b/app/views/super_admin/api_clients/_form.html.erb new file mode 100644 index 0000000000..eb928bd6b0 --- /dev/null +++ b/app/views/super_admin/api_clients/_form.html.erb @@ -0,0 +1,79 @@ +<% +url = @api_client.new_record? ? super_admin_api_clients_path : super_admin_api_client_path(@api_client) +meth = @api_client.new_record? ? :post : :put +%> + +<%= form_for @api_client, url: url, method: meth, + html: { class: 'api_client' } do |f| %> +
    +
    + <%= f.label :name, _('Name'), class: 'control-label' %> + <%= f.text_field :name, class: 'form-control', aria: { required: true } %> +
    +
    + <%= 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 :contact_email, _('Contact Name'), class: 'control-label' %> + <%= f.text_field :contact_name, class: 'form-control' %> +
    +
    + <%= 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.email_field :client_id, class: 'form-control', disabled: true %> +
    +
    + <%= f.label :client_secret, _('Client Secret'), class: 'control-label' %> + <%= f.email_field :client_secret, class: 'form-control', disabled: true %> +
    +
    + +
    +
    + <%= f.label :client_id, _('Last accessed on'), class: 'control-label' %> + <% date = @api_client.last_access.present? ? @api_client.last_access.utc.to_s : _("Never") %> + <%= f.text_field :last_access, class: 'form-control', disabled: true, + value: date %> +
    +
    + <% end %> + +
    + <%= f.button _('Save'), class: 'btn btn-default', type: 'submit' %> + + <% unless @api_client.new_record? %> + <%= link_to _("Refresh client ID and secret"), + refresh_credentials_super_admin_api_client_path(@api_client), + class: "btn btn-default", remote: true %> + + <%= link_to _("Email credentials to contact"), + email_credentials_super_admin_api_client_path(@api_client), + class: "btn btn-default", remote: true %> + + <%= link_to( + _('Delete'), + super_admin_api_client_path(@api_client), + class: 'btn btn-default', + method: :delete, + data: { confirm: _('Are you sure you want to delete the API client: "%{name}"') % { name: @api_client.name }}) %> + <% end %> + + <%= link_to _('Cancel'), super_admin_api_clients_path, class: 'btn btn-default', role: 'button' %> +
    +<% end %> diff --git a/app/views/super_admin/api_clients/edit.html.erb b/app/views/super_admin/api_clients/edit.html.erb new file mode 100644 index 0000000000..7a5a5262b6 --- /dev/null +++ b/app/views/super_admin/api_clients/edit.html.erb @@ -0,0 +1,8 @@ +<% title _('Editing API client') %> +

    + <%= _('Editing API Client') %> + <%= link_to(_('View all API clients'), super_admin_api_clients_path, + class: 'btn btn-default pull-right', role: 'button') %> +

    + +<%= render 'form' %> diff --git a/app/views/super_admin/api_clients/email_credentials.js.erb b/app/views/super_admin/api_clients/email_credentials.js.erb new file mode 100644 index 0000000000..bdfbbb9c97 --- /dev/null +++ b/app/views/super_admin/api_clients/email_credentials.js.erb @@ -0,0 +1,6 @@ +var msg = '<%= _("The credentials have been sent to %{email}.") % { email: @api_client.contact_email } %>'; + +<%# TODO: replace this with the notificationHelper.js once we move to Rails 5 %> +var notification = document.getElementById("notification-area"); +notification.append(msg); +notification.classList.remove('hide'); \ No newline at end of file diff --git a/app/views/super_admin/api_clients/index.html.erb b/app/views/super_admin/api_clients/index.html.erb new file mode 100644 index 0000000000..ed11626bf8 --- /dev/null +++ b/app/views/super_admin/api_clients/index.html.erb @@ -0,0 +1,24 @@ +<% title _('API Clients') %> + +
    +
    +

    + <%= _('API Clients') %> + <%= _('Create Api Client') %> +

    +

    Manage API access for external applications

    +
    +
    +
    +
    + + <%= paginable_renderise( + partial: '/paginable/api_clients/index', + controller: 'paginable/api_clients', + action: 'index', + scope: @api_clients, + query_params: { sort_field: 'api_clients.name', sort_direction: :asc }) %> +
    +
    +
    \ No newline at end of file diff --git a/app/views/super_admin/api_clients/new.html.erb b/app/views/super_admin/api_clients/new.html.erb new file mode 100644 index 0000000000..dbdeb61bca --- /dev/null +++ b/app/views/super_admin/api_clients/new.html.erb @@ -0,0 +1,8 @@ +<% title _('New API client') %> +

    + <%= _('New API Client') %> + <%= link_to(_('View all API clients'), super_admin_api_clients_path, + class: 'btn btn-default pull-right', role: 'button') %> +

    + +<%= render 'form' %> diff --git a/app/views/super_admin/api_clients/refresh_credentials.js.erb b/app/views/super_admin/api_clients/refresh_credentials.js.erb new file mode 100644 index 0000000000..213ed90052 --- /dev/null +++ b/app/views/super_admin/api_clients/refresh_credentials.js.erb @@ -0,0 +1,9 @@ +var msg = '<%= _("Successsfully refreshed the client credentials.") %>'; + +var form = document.getElementById("edit_api_client_<%= @api_client.id %>"); +form.innerHTML = '<%= escape_javascript(render partial: "/super_admin/api_clients/form") %>'; + +<%# TODO: replace this with the notificationHelper.js once we move to Rails 5 %> +var notification = document.getElementById("notification-area"); +notification.append(msg); +notification.classList.remove('hide'); \ No newline at end of file diff --git a/app/views/super_admin/notifications/_form.html.erb b/app/views/super_admin/notifications/_form.html.erb index 74c539670d..2b0fc6eab3 100644 --- a/app/views/super_admin/notifications/_form.html.erb +++ b/app/views/super_admin/notifications/_form.html.erb @@ -28,6 +28,13 @@
    +
    +
    + <%= f.check_box :enabled, style: 'width: auto' %> + <%= f.label :enabled, _('Active'), class: 'control-label' %> +
    +
    +
    <%= f.label :starts_at, _('Start'), class: 'control-label' %> diff --git a/app/views/super_admin/notifications/edit.html.erb b/app/views/super_admin/notifications/edit.html.erb index 63e11483bc..5810fac90f 100644 --- a/app/views/super_admin/notifications/edit.html.erb +++ b/app/views/super_admin/notifications/edit.html.erb @@ -1,5 +1,7 @@ <% title _('Editing Notification') %> +

    + <%= _('Editing Notification') %> <%= link_to(_('View all notifications'), super_admin_notifications_path, class: 'btn btn-default pull-right', role: 'button') %> diff --git a/app/views/super_admin/notifications/new.html.erb b/app/views/super_admin/notifications/new.html.erb index a35eeda507..8f1de38a64 100644 --- a/app/views/super_admin/notifications/new.html.erb +++ b/app/views/super_admin/notifications/new.html.erb @@ -5,4 +5,4 @@ class: 'btn btn-default pull-right', role: 'button') %>

    -<%= render 'form' %> + <%= render 'form' %> diff --git a/app/views/super_admin/orgs/new.html.erb b/app/views/super_admin/orgs/new.html.erb new file mode 100644 index 0000000000..8def3306dd --- /dev/null +++ b/app/views/super_admin/orgs/new.html.erb @@ -0,0 +1,37 @@ +
    +
    +

    + <%= _('New organisation') %> +

    + <% if current_user.can_super_admin? %> + <%= link_to _('View all organisations'), super_admin_orgs_path, class: 'btn btn-default pull-right' %> + <% end %> +
    +
    + +
    +
    + + + +
    +
    +
    +
    +
    + <%= render partial: 'orgs/profile_form', + locals: { + org: @org, method: "POST", + url: super_admin_orgs_path + } %> +
    +
    +
    +
    +
    +
    +
    diff --git a/app/views/super_admin/users/edit.html.erb b/app/views/super_admin/users/edit.html.erb index db0db2c093..e1eb297403 100644 --- a/app/views/super_admin/users/edit.html.erb +++ b/app/views/super_admin/users/edit.html.erb @@ -26,13 +26,13 @@ <%= f.text_field(:surname, class: "form-control", "aria-required": true) %>
    -
    - <%= render partial: "shared/my_org", locals: { - f: f, - default_org: @user.org, - orgs: Org.where(is_other: false).order("name"), - allow_other_orgs: true, - required: true } %> +
    + <%= render partial: "shared/org_selectors/combined", + locals: { + form: f, + default_org: @user.org, + required: true + } %>
    <% if @departments.any? %> diff --git a/app/views/usage/_filter.html.erb b/app/views/usage/_filter.html.erb deleted file mode 100644 index 7a5f6c3463..0000000000 --- a/app/views/usage/_filter.html.erb +++ /dev/null @@ -1,62 +0,0 @@ -<%# locals: none %> - -
    -
    -

    <%= _('Use the filters to run organisational usage statistics for a custom date range.') %>

    -
    -
    - -
    -
    -

    <%= _('Run your own filter') %>

    - <%= form_for :usage, url: usage_filter_path, remote: true do |f| %> -
    -
    -
    - <%= f.label :topic, _('Topic') %> - <%= f.select :topic, [ - [_('Users'), 'users'], [_('Plans'), 'plans'] - ], {}, { class: "form-control" } %> -
    -
    -
    -
    - <%= f.label :start_date, _('Start date') %> - <%= f.date_field :start_date, class: 'form-control' %> -
    -
    -
    -
    - <%= f.label :end_date, _('End date') %> - <%= f.date_field :end_date, class: 'form-control' %> -
    -
    - <% if current_user.can_super_admin? %> -
    -
    - <%= f.label :org_id, _('Organisation') %> - <%= f.select :org_id, options_from_collection_for_select(Org.all, :id, :name, current_user.org_id), {}, class: 'form-control' %> -
    -
    - <% end %> -
    -
    - <%= f.submit _('Go'), class: 'btn btn-default pull-right' %> -
    -
    -
    - - <% end %> -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    diff --git a/app/views/usage/_filtered_value.html.erb b/app/views/usage/_filtered_value.html.erb deleted file mode 100644 index 749c4fc464..0000000000 --- a/app/views/usage/_filtered_value.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<%# locals: topic, value %> - -
    -
    -

    <%= label %>

    - <%= value || 0 %> -
    -
    diff --git a/app/views/usage/_plans_created_chart.html.erb b/app/views/usage/_plans_created_chart.html.erb index db77c529c7..5e5fe7597b 100644 --- a/app/views/usage/_plans_created_chart.html.erb +++ b/app/views/usage/_plans_created_chart.html.erb @@ -2,6 +2,7 @@ <% if data.present? && data.any? %> + diff --git a/app/views/usage/_total_usage.html.erb b/app/views/usage/_total_usage.html.erb index f62431ccf4..5486d5c917 100644 --- a/app/views/usage/_total_usage.html.erb +++ b/app/views/usage/_total_usage.html.erb @@ -9,6 +9,12 @@

    <%= plan_count.to_i %> Total plans

    + <%= form_tag('/usage', method: :get, id: :filter_plans_form) do |f| %> + <%= label_tag :filtered do %> + <%= check_box_tag(:filtered, "true", @filtered) %> + <%= _("Excluding Test Plans") %> + <% end %> + <% end %>
    <% if @funder.present? %> @@ -27,14 +33,14 @@
    <% if current_user.can_super_admin? %>
    - <%= link_to usage_global_statistics_path(sep: ","), class: "stat btn btn-default #{'pull-right' if @funder.present?}", role: 'button', target: '_blank' do %> + <%= link_to usage_global_statistics_path(sep: ",", filtered: @filtered), class: "stat btn btn-default #{'pull-right' if @funder.present?}", role: 'button', target: '_blank' do %> <%= _('Download global usage') %> <% end %>
    <% end %> <% unless @funder.present? %>
    - <%= link_to usage_org_statistics_path(sep: ","), class: 'stat btn btn-default pull-right', role: 'button', target: '_blank' do %> + <%= link_to usage_org_statistics_path(sep: ",", filtered: @filtered), class: 'stat btn btn-default pull-right', role: 'button', target: '_blank' do %> <%= _('Download Monthly Usage') %> <% end %>
    diff --git a/app/views/usage/_user_statistics.html.erb b/app/views/usage/_user_statistics.html.erb index 20d35518f6..ec2cf3b256 100644 --- a/app/views/usage/_user_statistics.html.erb +++ b/app/views/usage/_user_statistics.html.erb @@ -9,7 +9,7 @@
    -

    * <%= _('Move the mouse pointer over the bars of a chart to see numbers.') %>

    +

    * <%= _('Move the mouse pointer over the bars of a chart to see numbers. Click on the bar to see the list of these users/plans to interrogate statistics in more detail') %>

    @@ -34,7 +34,7 @@

    <%= _('No. plans during last year') %>

    - <%= link_to usage_yearly_plans_path(sep: ","), class: 'stat btn btn-default', role: 'button', target: '_blank' do %> + <%= link_to usage_yearly_plans_path(sep: ",", filtered: @filtered), class: 'stat btn btn-default', role: 'button', target: '_blank' do %> <%= _('Download') %> <% end %>
    @@ -69,6 +69,9 @@
  • <%= f.select :template_plans_range, plans_per_template_ranges.reverse, {}, { class: "form-control" } %>
  • +
  • + <%= hidden_field_tag :filtered, @filtered %> +
  • <%= f.submit _('Go'), class: 'btn btn-default mt-25' %>
  • @@ -78,7 +81,7 @@
  • - <%= link_to usage_all_plans_by_template_path(sep: ","), class: 'btn btn-default stat', role: 'button', target: '_blank' do %> + <%= link_to usage_all_plans_by_template_path(sep: ",", filtered: @filtered), class: 'btn btn-default stat', role: 'button', target: '_blank' do %> <%= _('Download all') %> <% end %>
    diff --git a/app/views/usage/_users_joined_chart.html.erb b/app/views/usage/_users_joined_chart.html.erb index 30d157adbb..003cd5fe39 100644 --- a/app/views/usage/_users_joined_chart.html.erb +++ b/app/views/usage/_users_joined_chart.html.erb @@ -2,6 +2,7 @@ <% if data.present? && data.any? %> + diff --git a/app/views/usage/filter.js.erb b/app/views/usage/filter.js.erb deleted file mode 100644 index 1ff9644bcb..0000000000 --- a/app/views/usage/filter.js.erb +++ /dev/null @@ -1,5 +0,0 @@ -// Using straight (non-jquery) JS here because the js.erb does not have access to JQuery! -// TODO: We should fix this and load JS and other assets correctly when we move to Rails 5 -// it would be much cleaner to just rerender the html.erb -document.getElementById('filter_ranged').innerHTML = '<%= escape_javascript(render partial: "filtered_value", locals: { label: "New #{@topic}", value: @ranged.to_i }) %>'; -document.getElementById('filter_total').innerHTML = '<%= escape_javascript(render partial: "filtered_value", locals: { label: "Total #{@topic}", value: @total.to_i }) %>'; diff --git a/app/views/usage/index.html.erb b/app/views/usage/index.html.erb index 6b5eab4829..b9f6ca2e72 100644 --- a/app/views/usage/index.html.erb +++ b/app/views/usage/index.html.erb @@ -18,6 +18,4 @@ <%= render partial: 'usage/user_statistics_accordion', locals: {expanded: true} %> <%= render partial: 'usage/template_statistics_accordion', locals: {expanded: false} %>
  • - - <%= render partial: 'usage/filter' %> <% end %> diff --git a/app/views/user_mailer/api_credentials.html.erb b/app/views/user_mailer/api_credentials.html.erb new file mode 100644 index 0000000000..f661bed480 --- /dev/null +++ b/app/views/user_mailer/api_credentials.html.erb @@ -0,0 +1,30 @@ +<% + tool_name = Rails.configuration.branding[:application][:name] + helpdesk_email = Rails.configuration.branding[:organisation][:helpdesk_email] + # api_docs = Rails.configuration.branding[:application][:api_documentation_url] + api_docs = "https://github.com/DMPRoadmap/roadmap/wiki/API-Documentation-V1" + + name = @api_client.contact_name.present? ? @api_client.contact_name : @api_client.contact_email %> +%> + +

    <%= _("Hello %{name},") %{ name: name } %>

    + +

    <%= _("Please use the following credentials when accessing the %{tool_name} API.") % { tool_name: tool_name } %>

    +
    
    +  {
    +    "grant_type": "client_credentials",
    +    "client_id": "<%= @api_client.client_id %>",
    +    "client_secret": "<%= @api_client.client_secret %>"
    +  }
    +
    + +

    <%= (_("Please refer to the API documentation at: %{api_documentation_url}") % { api_documentation_url: "#{api_docs}" }).html_safe %>

    + +

    Note that no invitations or emails will be sent out to DMP contacts because this is a test system. All email communication from plans created via the API will be sent to this address.

    + +

    + <%= _("Do not share these credentials. They for use with the %{external_application} application. If you do share your credentials with another application we reserve the right to revoke your access to the API.") % { external_application: @api_client.name.capitalize } %> +

    +

    <%= _("If you did not request access to the %{tool_name} API or did not request for your credentials to be renewed, please contact us at %{helpdesk_email}") % { tool_name: tool_name, helpdesk_email: helpdesk_email } %>

    + +<%= render partial: 'email_signature' %> diff --git a/app/views/user_mailer/question_answered.html.erb b/app/views/user_mailer/question_answered.html.erb new file mode 100644 index 0000000000..437fa87006 --- /dev/null +++ b/app/views/user_mailer/question_answered.html.erb @@ -0,0 +1,22 @@ +<% + recipient_name = @data['name'].to_s + question_title = @answer.question.text.to_s + user_name = @user.name.to_s + answer_text = @options_string.to_s + plan_title = @answer.plan.title.to_s + template_title = @answer.plan.template.title.to_s + message = @data['message'].to_s +%> + +<% FastGettext.with_locale FastGettext.default_locale do %> +

    + <%= raw _('Hello %{recipient_name},') %{ recipient_name: recipient_name } %> +

    +

    + <%= raw user_name + _(" is creating a Data Management Plan and has answered ") + answer_text + _(" to ") + question_title + _(" in a plan called ") + plan_title + _(" based on the template ") + template_title %> +

    +

    + <%= raw message %> +

    + <%= render partial: 'email_signature' %> +<% end %> \ No newline at end of file diff --git a/config/application.rb b/config/application.rb index 58cab41a23..9be06f1d3e 100644 --- a/config/application.rb +++ b/config/application.rb @@ -127,6 +127,9 @@ class Application < Rails::Application config.branding = config_for(:branding).deep_symbolize_keys end + # org abbreviation for the root google analytics tracker that gets planted on every page + # config.x.tracker_root = "DMPRoadmap" + # The default visibility setting for new plans # organisationally_visible - Any member of the user's org can view, export and duplicate the plan # publicly_visibile - (NOT advisable because plans will show up in Public DMPs page by default) diff --git a/config/branding.yml.sample b/config/branding.yml.sample index 2eae0c9ba7..5df29e8487 100644 --- a/config/branding.yml.sample +++ b/config/branding.yml.sample @@ -8,6 +8,7 @@ defaults: &defaults url: 'https://github.com/DMPRoadmap/roadmap/wiki' copywrite_name: 'Curation Centre (CC)' email: 'tester@cc_curation_centre.org' + do_not_reply_email: 'do-not-reply@cc_curation_centre.org' helpdesk_email: 'someone@somewhere.com' welcome_links: - link1: @@ -42,6 +43,7 @@ defaults: &defaults api_documentation_url: 'https://github.com/DMPRoadmap/roadmap/wiki/API-Documentation' api_max_page_size: 100 archived_accounts_email_suffix: '@removed_accounts-example.org' + use_recaptcha: false preferences: email: diff --git a/config/deploy.rb b/config/deploy.rb index 7dfca7b3e9..5532637203 100644 --- a/config/deploy.rb +++ b/config/deploy.rb @@ -1,12 +1,12 @@ -# config valid only for current version of Capistrano -lock "3.13.0" - # Default branch is :master ask :branch, `git rev-parse --abbrev-ref HEAD`.chomp unless ENV['BRANCH'] set :branch, ENV['BRANCH'] if ENV['BRANCH'] set :default_env, { path: "/dmp/local/bin:$PATH" } +# Gets the current Git tag and revision +set :version_number, `git describe --tags` + # Include optional Gem groups # TODO: For some reason this does not work #set :bundle_with, %w{ aws mysql }.join(' ') @@ -23,9 +23,11 @@ 'config/secrets.yml', 'config/initializers/contact_us.rb', 'config/initializers/devise.rb', + 'config/initializers/dmptool_version.rb', 'config/initializers/dragonfly.rb', 'config/initializers/recaptcha.rb', - 'config/initializers/wicked_pdf.rb' + 'config/initializers/wicked_pdf.rb', + 'config/initializers/external_apis/open_aire.rb' # Default value for linked_dirs is [] append :linked_dirs, 'log', @@ -43,9 +45,9 @@ after :deploy, 'cleanup:copy_tinymce_skins' after :deploy, 'cleanup:copy_logo' after :deploy, 'cleanup:copy_favicon' + after :deploy, 'git:version' after :deploy, 'cleanup:remove_example_configs' after :deploy, 'cleanup:restart_passenger' - after :deploy, 'git:symlink_git' end namespace :config do @@ -59,10 +61,11 @@ end namespace :git do - desc "Symlink the git executable into the bin/ dir" - task :symlink_git do + desc 'Add the version file so that we can display the git version in the footer' + task :version do on roles(:app), wait: 1 do - execute "ln -s /bin/git #{release_path}/bin/" + execute "touch #{release_path}/.version" + execute "echo '#{fetch :version_number}' >> #{release_path}/.version" end end end diff --git a/config/environments/development.rb b/config/environments/development.rb index 434970b6a0..b093c9d885 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -50,3 +50,6 @@ end end + +Rails.application.routes.default_url_options[:host] = "dmproadmap.org" + diff --git a/config/environments/production.rb b/config/environments/production.rb index 175d17bf36..efd303e930 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -82,3 +82,6 @@ config.active_record.dump_schema_after_migration = false end + +Rails.application.routes.default_url_options[:host] = "dmproadmap.org" + diff --git a/config/environments/stage.rb b/config/environments/stage.rb index 4e6f302219..e34819d2df 100644 --- a/config/environments/stage.rb +++ b/config/environments/stage.rb @@ -32,7 +32,7 @@ config.action_dispatch.best_standards_support = :builtin # Raise exception on mass assignment protection for Active Record models - config.active_record.mass_assignment_sanitizer = :strict + # config.active_record.mass_assignment_sanitizer = :strict config.action_mailer.perform_deliveries = false diff --git a/config/environments/test.rb b/config/environments/test.rb index 59a9b326b5..9dde4a2f8d 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -45,3 +45,6 @@ # config.action_view.raise_on_missing_translations = true end + +Rails.application.routes.default_url_options[:host] = "example.org" + diff --git a/config/initializers/external_apis/doi.rb b/config/initializers/external_apis/doi.rb new file mode 100644 index 0000000000..1a0fb82677 --- /dev/null +++ b/config/initializers/external_apis/doi.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# These configuration settings are meant to work with your DOI minting +# authority. If you opt to mint DOIs for your DMPs then you can add +# your configuration options here and then add extend the +# `app/services/external_apis/doi.rb` to communicate with their API. +# +# To disable thiis feature, simply set 'active' to false +Rails.configuration.x.doi.landing_page_url = "https://my.doi.org/" +Rails.configuration.x.doi.api_base_url = "https://my.doi.org/api/" +Rails.configuration.x.doi.auth_path = "auth_path" +Rails.configuration.x.doi.heartbeat_path = "heartbeat" +Rails.configuration.x.doi.mint_path = "doi" +Rails.configuration.x.doi.active = false diff --git a/config/initializers/external_apis/open_aire.rb b/config/initializers/external_apis/open_aire.rb new file mode 100644 index 0000000000..5a861cc088 --- /dev/null +++ b/config/initializers/external_apis/open_aire.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# These configuration settings are used to communicate with the +# Open Aire Research Project Registry API. For more information about +# the API and to verify that your configuration settings are correct, +Rails.configuration.x.open_aire.api_base_url = "https://api.openaire.eu/" +# The api_url should contain `%s. This is where the funder is appended! +Rails.configuration.x.open_aire.search_path = "projects/dspace/%s/ALL/ALL" +Rails.configuration.x.open_aire.default_funder = "H2020" +Rails.configuration.x.open_aire.active = true diff --git a/config/initializers/external_apis/ror.rb b/config/initializers/external_apis/ror.rb new file mode 100644 index 0000000000..caa4d21bce --- /dev/null +++ b/config/initializers/external_apis/ror.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# These configuration settings are used to communicate with the +# Research Organization Registry (ROR) API. For more information about +# the API and to verify that your configuration settings are correct, +# please refer to: https://github.com/ror-community/ror-api +Rails.configuration.x.ror.landing_page_url = "https://ror.org/" +Rails.configuration.x.ror.api_base_url = "https://api.ror.org/" +Rails.configuration.x.ror.heartbeat_path = "heartbeat" +Rails.configuration.x.ror.search_path = "organizations" +Rails.configuration.x.ror.max_pages = 2 +Rails.configuration.x.ror.max_results_per_page = 20 +Rails.configuration.x.ror.max_redirects = 3 +Rails.configuration.x.ror.active = true diff --git a/config/routes.rb b/config/routes.rb index 52071c6d7b..d73256faca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,7 +10,7 @@ get "/users/sign_out", :to => "devise/sessions#destroy" end - delete '/users/identifiers/:id', to: 'user_identifiers#destroy', as: 'destroy_user_identifier' + delete '/users/identifiers/:id', to: 'identifiers#destroy', as: 'destroy_user_identifier' get '/orgs/shibboleth', to: 'orgs#shibboleth_ds', as: 'shibboleth_ds' get '/orgs/shibboleth/:org_name', to: 'orgs#shibboleth_ds_passthru' @@ -18,6 +18,28 @@ get '/users/ldap_username', to: 'users#ldap_username' post '/users/ldap_account', to: 'users#ldap_account' + # ------------------------------------------ + # Start DMPTool customizations + # ------------------------------------------ + # GET is triggered by user clicking an org in the list + get '/orgs/shibboleth/:id', to: 'orgs#shibboleth_ds_passthru' + # POST is triggered by user selecting an org from autocomplete + post '/orgs/shibboleth/:id', to: 'orgs#shibboleth_ds_passthru' + # ------------------------------------------ + # End DMPTool Customization + # ------------------------------------------ + + # ------------------------------------------ + # Start DMPTool customizations + # ------------------------------------------ + # Handle logouts when on the localhost dev environment + unless %w[stage production].include?(Rails.env) + get "/Shibboleth.sso/Logout", to: redirect("/") + end + # ------------------------------------------ + # End DMPTool Customization + # ------------------------------------------ + resources :users, path: 'users', only: [] do resources :org_swaps, only: [:create], controller: "super_admin/org_swaps" @@ -76,8 +98,8 @@ # End DMPTool customizations # ------------------------------------------ - #post 'contact_form' => 'contacts', as: 'localized_contact_creation' - #get 'contact_form' => 'contacts#new', as: 'localized_contact_form' + # AJAX call used to search for Orgs based on user input into autocompletes + post "orgs" => "orgs#search", as: "orgs_search" resources :orgs, :path => 'org/admin', only: [] do member do @@ -132,6 +154,8 @@ resource :export, only: [:show], controller: "plan_exports" + resources :contributors, except: %i[show] + member do get 'answer' get 'share' @@ -146,7 +170,6 @@ resources :usage, only: [:index] post 'usage_plans_by_template', controller: 'usage', action: 'plans_by_template' - post 'usage_filter', controller: 'usage', action: 'filter' get 'usage_all_plans_by_template', controller: 'usage', action: 'all_plans_by_template' get 'usage_global_statistics', controller: 'usage', action: 'global_statistics' get 'usage_org_statistics', controller: 'usage', action: 'org_statistics' @@ -167,6 +190,15 @@ namespace :api, defaults: {format: :json} do namespace :v0 do + resources :departments, only: [:create, :index] do + collection do + get :users + patch :unassign_users + end + member do + patch :assign_users + end + end resources :guidances, only: [:index], controller: 'guidance_groups', path: 'guidances' resources :plans, only: [:create, :index] resources :templates, only: :index @@ -181,6 +213,14 @@ end end end + + namespace :v1 do + get :heartbeat, controller: "base_api" + post :authenticate, controller: "authentication" + + resources :plans, only: [:create, :show, :index] + resources :templates, only: [:index] + end end namespace :paginable do @@ -195,6 +235,11 @@ get 'publicly_visible/:page', action: :publicly_visible, on: :collection, as: :publicly_visible get 'org_admin/:page', action: :org_admin, on: :collection, as: :org_admin get 'org_admin_other_user/:page', action: :org_admin_other_user, on: :collection, as: :org_admin_other_user + + # Paginable actions for contributors + resources :contributors, only: %i[index] do + get "index/:page", action: :index, on: :collection, as: :index + end end # Paginable actions for users resources :users, only: [] do @@ -228,6 +273,10 @@ resources :departments, only: [] do get 'index/:page', action: :index, on: :collection, as: :index end + # Paginable actions for api_clients + resources :api_clients, only: [] do + get 'index/:page', action: :index, on: :collection, as: :index + end end resources :template_options, only: [:index], constraints: { format: /json/ } @@ -239,6 +288,15 @@ get 'user_plans' end end + + resources :question_options, only: [:destroy], controller: "question_options" + + resources :questions, only: [] do + get 'open_conditions' + resources :conditions, only: [:new, :show] do + end + end + resources :plans, only: [:index] do member do get 'feedback_complete' @@ -300,7 +358,19 @@ get :search end end - resources :notifications, except: [:show] + + resources :notifications, except: [:show] do + member do + post 'enable', constraints: {format: [:json]} + end + end + + resources :api_clients do + member do + get :email_credentials + get :refresh_credentials + end + end end get "research_projects/search", action: "search", diff --git a/config/webpack/loaders/erb.js b/config/webpack/loaders/erb.js index a4049f1323..1c33dfac95 100644 --- a/config/webpack/loaders/erb.js +++ b/config/webpack/loaders/erb.js @@ -5,7 +5,7 @@ module.exports = { use: [{ loader: 'rails-erb-loader', options: { - runner: (/^win/.test(process.platform) ? 'ruby ' : '') + 'bin/rails runner' + runner: (/^win/.test(process.platform) ? '/dmp/local/bin/ruby ' : '') + 'bin/rails runner' } }] } diff --git a/db/migrate/20190724134426_create_conditions.rb b/db/migrate/20190724134426_create_conditions.rb new file mode 100644 index 0000000000..4b5c075120 --- /dev/null +++ b/db/migrate/20190724134426_create_conditions.rb @@ -0,0 +1,14 @@ +class CreateConditions < ActiveRecord::Migration + def change + create_table :conditions do |t| + t.references :question, index: true, foreign_key: true + t.text :option_list + t.integer :action_type + t.integer :number + t.text :remove_data + t.text :webhook_data + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20200121190035_add_managed_to_orgs.rb b/db/migrate/20200121190035_add_managed_to_orgs.rb new file mode 100644 index 0000000000..795001968b --- /dev/null +++ b/db/migrate/20200121190035_add_managed_to_orgs.rb @@ -0,0 +1,5 @@ +class AddManagedToOrgs < ActiveRecord::Migration + def change + add_column :orgs, :managed, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20200123162357_create_identifiers.rb b/db/migrate/20200123162357_create_identifiers.rb new file mode 100644 index 0000000000..ace6e8d7d6 --- /dev/null +++ b/db/migrate/20200123162357_create_identifiers.rb @@ -0,0 +1,14 @@ +class CreateIdentifiers < ActiveRecord::Migration + def change + create_table :identifiers do |t| + t.string :value, null: false + t.text :attrs + t.references :identifier_scheme, null: false + t.references :identifiable, polymorphic: true + t.timestamps + end + + add_index :identifiers, [:identifiable_type, :identifiable_id] + add_index :identifiers, [:identifier_scheme_id, :value] + end +end diff --git a/db/migrate/20200130160919_contextualize_identifier_schemes.rb b/db/migrate/20200130160919_contextualize_identifier_schemes.rb new file mode 100644 index 0000000000..09db8da094 --- /dev/null +++ b/db/migrate/20200130160919_contextualize_identifier_schemes.rb @@ -0,0 +1,8 @@ +class ContextualizeIdentifierSchemes < ActiveRecord::Migration + def change + add_column :identifier_schemes, :for_auth, :boolean, default: false + add_column :identifier_schemes, :for_orgs, :boolean, default: false + add_column :identifier_schemes, :for_plans, :boolean, default: false + add_column :identifier_schemes, :for_users, :boolean, default: false + end +end diff --git a/db/migrate/20200203190734_add_funder_and_org_to_plans.rb b/db/migrate/20200203190734_add_funder_and_org_to_plans.rb new file mode 100644 index 0000000000..1ef20ac91c --- /dev/null +++ b/db/migrate/20200203190734_add_funder_and_org_to_plans.rb @@ -0,0 +1,6 @@ +class AddFunderAndOrgToPlans < ActiveRecord::Migration + def change + add_reference :plans, :org, foreign_key: true + add_column :plans, :funder_id, :integer, index: true + end +end diff --git a/db/migrate/20200207212113_create_api_clients.rb b/db/migrate/20200207212113_create_api_clients.rb new file mode 100644 index 0000000000..440cc91cb5 --- /dev/null +++ b/db/migrate/20200207212113_create_api_clients.rb @@ -0,0 +1,15 @@ +class CreateApiClients < ActiveRecord::Migration + def change + create_table :api_clients do |t| + t.string :name, null: false, index: true + t.string :description + t.string :homepage + t.string :contact_name + t.string :contact_email, null: false + t.string :client_id, null: false + t.string :client_secret, null: false + t.date :last_access + t.timestamps null: false + end + end +end diff --git a/db/migrate/20200212145931_add_enabled_to_notifications.rb b/db/migrate/20200212145931_add_enabled_to_notifications.rb new file mode 100644 index 0000000000..183a011f49 --- /dev/null +++ b/db/migrate/20200212145931_add_enabled_to_notifications.rb @@ -0,0 +1,5 @@ +class AddEnabledToNotifications < ActiveRecord::Migration + def change + add_column :notifications, :enabled, :boolean, default: true + end +end diff --git a/db/migrate/20200213203124_add_last_api_access_to_users.rb b/db/migrate/20200213203124_add_last_api_access_to_users.rb new file mode 100644 index 0000000000..96bdf9b216 --- /dev/null +++ b/db/migrate/20200213203124_add_last_api_access_to_users.rb @@ -0,0 +1,5 @@ +class AddLastApiAccessToUsers < ActiveRecord::Migration + def change + add_column :users, :last_api_access, :datetime + end +end diff --git a/db/migrate/20200215190747_add_context_to_identifier_schemes.rb b/db/migrate/20200215190747_add_context_to_identifier_schemes.rb new file mode 100644 index 0000000000..2d919ecbfc --- /dev/null +++ b/db/migrate/20200215190747_add_context_to_identifier_schemes.rb @@ -0,0 +1,15 @@ +class AddContextToIdentifierSchemes < ActiveRecord::Migration + def change + remove_column :identifier_schemes, :for_auth + remove_column :identifier_schemes, :for_orgs + remove_column :identifier_schemes, :for_plans + remove_column :identifier_schemes, :for_users + rename_column :identifier_schemes, :user_landing_url, :identifier_prefix + + add_column :identifier_schemes, :context, :integer, index: true + + change_column :identifiers, :identifier_scheme_id, :integer, null: true + add_index :identifiers, [:identifier_scheme_id, :identifiable_id, :identifiable_type], + name: 'index_identifiers_on_scheme_and_type_and_id' + end +end diff --git a/db/migrate/20200218213103_create_contributors.rb b/db/migrate/20200218213103_create_contributors.rb new file mode 100644 index 0000000000..598e92c25c --- /dev/null +++ b/db/migrate/20200218213103_create_contributors.rb @@ -0,0 +1,13 @@ +class CreateContributors < ActiveRecord::Migration + def change + create_table :contributors do |t| + t.string :name + t.string :email, index: true + t.string :phone + t.integer :roles, index: true, null: false + t.references :org, index: true + t.references :plan, index: true, null: false + t.timestamps + end + end +end \ No newline at end of file diff --git a/db/migrate/20200218213414_add_start_and_end_dates_to_plans.rb b/db/migrate/20200218213414_add_start_and_end_dates_to_plans.rb new file mode 100644 index 0000000000..81aa24a245 --- /dev/null +++ b/db/migrate/20200218213414_add_start_and_end_dates_to_plans.rb @@ -0,0 +1,7 @@ +class AddStartAndEndDatesToPlans < ActiveRecord::Migration + def change + add_column :plans, :grant_id, :integer, index: true + add_column :plans, :start_date, :datetime + add_column :plans, :end_date, :datetime + end +end diff --git a/db/migrate/20200313153356_add_versionable_to_question_options.rb b/db/migrate/20200313153356_add_versionable_to_question_options.rb new file mode 100644 index 0000000000..b7af8e2aa7 --- /dev/null +++ b/db/migrate/20200313153356_add_versionable_to_question_options.rb @@ -0,0 +1,7 @@ +class AddVersionableToQuestionOptions < ActiveRecord::Migration + def change + add_column :question_options, :versionable_id, :string, limit: 36 + + add_index :question_options, :versionable_id + end +end diff --git a/db/migrate/20200323213847_add_api_client_id_to_plans.rb b/db/migrate/20200323213847_add_api_client_id_to_plans.rb new file mode 100644 index 0000000000..2ed2278766 --- /dev/null +++ b/db/migrate/20200323213847_add_api_client_id_to_plans.rb @@ -0,0 +1,5 @@ +class AddApiClientIdToPlans < ActiveRecord::Migration + def change + add_column :plans, :api_client_id, :integer, index: true + end +end diff --git a/db/migrate/20200514102523_create_trackers.rb b/db/migrate/20200514102523_create_trackers.rb new file mode 100644 index 0000000000..6f403ad4b8 --- /dev/null +++ b/db/migrate/20200514102523_create_trackers.rb @@ -0,0 +1,10 @@ +class CreateTrackers < ActiveRecord::Migration + def change + create_table :trackers do |t| + t.references :org, index: true, foreign_key: true + t.string :code + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20200601121822_add_filtered_to_stats.rb b/db/migrate/20200601121822_add_filtered_to_stats.rb new file mode 100644 index 0000000000..434baa027f --- /dev/null +++ b/db/migrate/20200601121822_add_filtered_to_stats.rb @@ -0,0 +1,5 @@ +class AddFilteredToStats < ActiveRecord::Migration + def change + add_column :stats, :filtered, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 095472314b..4c6a5bae9f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,10 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20190507091025) do - - # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" +ActiveRecord::Schema.define(version: 20200601121822) do create_table "annotations", force: :cascade do |t| t.integer "question_id", limit: 4 @@ -54,12 +51,62 @@ add_index "answers_question_options", ["answer_id"], name: "index_answers_question_options_on_answer_id", using: :btree + create_table "api_clients", force: :cascade do |t| + t.string "name", limit: 255, null: false + t.string "description", limit: 255 + t.string "homepage", limit: 255 + t.string "contact_name", limit: 255 + t.string "contact_email", limit: 255, null: false + t.string "client_id", limit: 255, null: false + t.string "client_secret", limit: 255, null: false + t.date "last_access" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "api_clients", ["name"], name: "index_api_clients_on_name", using: :btree + + create_table "ar_internal_metadata", primary_key: "key", force: :cascade do |t| + t.string "value", limit: 255 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "conditions", force: :cascade do |t| + t.integer "question_id", limit: 4 + t.text "option_list", limit: 65535 + t.integer "action_type", limit: 4 + t.integer "number", limit: 4 + t.text "remove_data", limit: 65535 + t.text "webhook_data", limit: 65535 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "conditions", ["question_id"], name: "index_conditions_on_question_id", using: :btree + + create_table "contributors", force: :cascade do |t| + t.string "name", limit: 255 + t.string "email", limit: 255 + t.string "phone", limit: 255 + t.integer "roles", limit: 4, null: false + t.integer "org_id", limit: 4 + t.integer "plan_id", limit: 4, null: false + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "contributors", ["email"], name: "index_contributors_on_email", using: :btree + add_index "contributors", ["org_id"], name: "index_contributors_on_org_id", using: :btree + add_index "contributors", ["plan_id"], name: "index_contributors_on_plan_id", using: :btree + add_index "contributors", ["roles"], name: "index_contributors_on_roles", using: :btree + create_table "departments", force: :cascade do |t| - t.string "name" - t.string "code" - t.integer "org_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "name", limit: 255 + t.string "code", limit: 255 + t.integer "org_id", limit: 4 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false end add_index "departments", ["org_id"], name: "index_departments_on_org_id", using: :btree @@ -95,15 +142,30 @@ add_index "guidances", ["guidance_group_id"], name: "index_guidances_on_guidance_group_id", using: :btree create_table "identifier_schemes", force: :cascade do |t| - t.string "name", limit: 255 - t.string "description", limit: 255 + t.string "name", limit: 255 + t.string "description", limit: 255 t.boolean "active" t.datetime "created_at" t.datetime "updated_at" - t.string "logo_url", limit: 255 - t.string "user_landing_url", limit: 255 + t.string "logo_url", limit: 255 + t.string "identifier_prefix", limit: 255 + t.integer "context", limit: 4 + end + + create_table "identifiers", force: :cascade do |t| + t.string "value", limit: 255, null: false + t.text "attrs", limit: 65535 + t.integer "identifier_scheme_id", limit: 4 + t.integer "identifiable_id", limit: 4 + t.string "identifiable_type", limit: 255 + t.datetime "created_at" + t.datetime "updated_at" end + add_index "identifiers", ["identifiable_type", "identifiable_id"], name: "index_identifiers_on_identifiable_type_and_identifiable_id", using: :btree + add_index "identifiers", ["identifier_scheme_id", "identifiable_id", "identifiable_type"], name: "index_identifiers_on_scheme_and_type_and_id", using: :btree + add_index "identifiers", ["identifier_scheme_id", "value"], name: "index_identifiers_on_identifier_scheme_id_and_value", using: :btree + create_table "languages", force: :cascade do |t| t.string "abbreviation", limit: 255 t.string "description", limit: 255 @@ -142,8 +204,9 @@ t.boolean "dismissable" t.date "starts_at" t.date "expires_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "enabled", default: true end create_table "org_identifiers", force: :cascade do |t| @@ -187,6 +250,7 @@ t.string "feedback_email_subject", limit: 255 t.text "feedback_email_msg", limit: 65535 t.string "contact_name", limit: 255 + t.boolean "managed", default: false, null: false end add_index "orgs", ["language_id"], name: "fk_rails_5640112cab", using: :btree @@ -231,8 +295,15 @@ t.string "principal_investigator_phone", limit: 255 t.boolean "feedback_requested", default: false t.boolean "complete", default: false + t.integer "org_id", limit: 4 + t.integer "funder_id", limit: 4 + t.integer "grant_id", limit: 4 + t.datetime "start_date" + t.datetime "end_date" + t.integer "api_client_id", limit: 4 end + add_index "plans", ["org_id"], name: "fk_rails_eda8ce4bca", using: :btree add_index "plans", ["template_id"], name: "index_plans_on_template_id", using: :btree create_table "plans_guidance_groups", force: :cascade do |t| @@ -268,15 +339,17 @@ end create_table "question_options", force: :cascade do |t| - t.integer "question_id", limit: 4 - t.string "text", limit: 255 - t.integer "number", limit: 4 + t.integer "question_id", limit: 4 + t.string "text", limit: 255 + t.integer "number", limit: 4 t.boolean "is_default" t.datetime "created_at" t.datetime "updated_at" + t.string "versionable_id", limit: 36 end add_index "question_options", ["question_id"], name: "index_question_options_on_question_id", using: :btree + add_index "question_options", ["versionable_id"], name: "index_question_options_on_versionable_id", using: :btree create_table "questions", force: :cascade do |t| t.text "text", limit: 65535 @@ -336,8 +409,8 @@ add_index "sections", ["versionable_id"], name: "index_sections_on_versionable_id", using: :btree create_table "sessions", force: :cascade do |t| - t.string "session_id", limit: 64, null: false - t.text "data" + t.string "session_id", limit: 64, null: false + t.text "data", limit: 65535 t.datetime "created_at" t.datetime "updated_at" end @@ -355,13 +428,14 @@ end create_table "stats", force: :cascade do |t| - t.integer "count", limit: 8, default: 0 - t.date "date", null: false - t.string "type", null: false - t.integer "org_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.text "details" + t.integer "count", limit: 8, default: 0 + t.date "date", null: false + t.string "type", limit: 255, null: false + t.integer "org_id", limit: 4 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "details", limit: 65535 + t.boolean "filtered", default: false end create_table "templates", force: :cascade do |t| @@ -409,6 +483,15 @@ t.datetime "updated_at" end + create_table "trackers", force: :cascade do |t| + t.integer "org_id", limit: 4 + t.string "code", limit: 255 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "trackers", ["org_id"], name: "index_trackers_on_org_id", using: :btree + create_table "user_identifiers", force: :cascade do |t| t.string "identifier", limit: 255 t.datetime "created_at" @@ -454,9 +537,12 @@ t.string "ldap_username", limit: 255 t.boolean "active", default: true t.integer "department_id", limit: 4 + t.datetime "last_api_access" end + add_index "users", ["department_id"], name: "fk_rails_f29bf9cdf2", using: :btree add_index "users", ["email"], name: "index_users_on_email", using: :btree + add_index "users", ["language_id"], name: "fk_rails_45f4f12508", using: :btree add_index "users", ["org_id"], name: "index_users_on_org_id", using: :btree create_table "users_perms", id: false, force: :cascade do |t| @@ -467,11 +553,10 @@ add_index "users_perms", ["perm_id"], name: "fk_rails_457217c31c", using: :btree add_index "users_perms", ["user_id"], name: "index_users_perms_on_user_id", using: :btree - add_foreign_key "annotations", "orgs" - add_foreign_key "annotations", "questions" add_foreign_key "answers", "plans" add_foreign_key "answers", "questions" add_foreign_key "answers", "users" + add_foreign_key "conditions", "questions" add_foreign_key "guidance_groups", "orgs" add_foreign_key "guidances", "guidance_groups" add_foreign_key "notes", "answers" @@ -485,6 +570,7 @@ add_foreign_key "orgs", "languages" add_foreign_key "orgs", "regions" add_foreign_key "phases", "templates" + add_foreign_key "plans", "orgs" add_foreign_key "plans", "templates" add_foreign_key "plans_guidance_groups", "guidance_groups" add_foreign_key "plans_guidance_groups", "plans" @@ -497,6 +583,7 @@ add_foreign_key "templates", "orgs" add_foreign_key "themes_in_guidance", "guidances" add_foreign_key "themes_in_guidance", "themes" + add_foreign_key "trackers", "orgs" add_foreign_key "user_identifiers", "identifier_schemes" add_foreign_key "user_identifiers", "users" add_foreign_key "users", "departments" diff --git a/db/seeds.rb b/db/seeds.rb index cebe1248a1..c7722231dc 100755 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -30,14 +30,14 @@ description: 'ORCID', active: true, logo_url:'http://orcid.org/sites/default/files/images/orcid_16x16.png', - user_landing_url:'https://orcid.org' + identifier_prefix:'https://orcid.org' }, { name: 'shibboleth', description: 'Your institutional credentials', active: true, logo_url: 'http://newsite.shibboleth.net/wp-content/uploads/2017/01/Shibboleth-logo_2000x1200-1.png', - user_landing_url: "https://example.com" + identifier_prefix: "https://example.com" }, ] identifier_schemes.map { |is| create(:identifier_scheme, is) } diff --git a/lib/dmptool/controller/home.rb b/lib/dmptool/controller/home.rb deleted file mode 100644 index 42080ffba9..0000000000 --- a/lib/dmptool/controller/home.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -require 'rss' - -module Dmptool - - module Controller - - module Home - - protected - - def render_home_page - # Usage stats - @stats = Rails.cache.read("stats") || {} - if @stats.empty? - @stats = statistics - end - - # Top 5 templates - @top_5 = Rails.cache.read("top_5") - if @top_5.nil? - @top_5 = top_templates - end - - # Retrieve/cache the DMPTool blog's latest posts - @rss = Rails.cache.read("rss") - if @rss.nil? - @rss = feed - end - - render "home/index" - end - - private - - # Collect general statistics about the application - def statistics - 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 - 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_5 = Template.where(id: ids[0..4]) - .pluck(:title) - cache_content("top_5", top_5) - top_5 - end - - # Get the last 5 blog posts - def feed - begin - xml = open(Rails.application.config.rss).read - rss = RSS::Parser.parse(xml, false).items.first(5) - cache_content("rss", rss) - - rescue Exception - # If we were unable to connect to the blog rss - rss = [] if rss.nil? - logger.error("Caught exception RSS parse: #{e}.") - end - rss - end - - # Store information in the cache - def cache_content(type, data) - begin - Rails.cache.write(type, data, expires_in: 60.minutes) - rescue Exception => e - logger.error("Unable to add #{type} to the Rails cache: #{e}.") - end - end - - end - - end - -end diff --git a/lib/dmptool/controller/omniauth_callbacks.rb b/lib/dmptool/controller/omniauth_callbacks.rb deleted file mode 100644 index 53bc019af7..0000000000 --- a/lib/dmptool/controller/omniauth_callbacks.rb +++ /dev/null @@ -1,155 +0,0 @@ -# frozen_string_literal: true - -module Dmptool - - module Controller - - module OmniauthCallbacks - - protected - - 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) - - if request.env.present? - omniauth = request.env["omniauth.auth"] || request.env - else - omniauth = {} - end - - if scheme.name == "shibboleth" - provider = _("your institutional credentials") - else - provider = scheme.description - end - - # if the user is already signed in then we are attempting to attach - # omniauth credentials to an existing account - if current_user.present? && omniauth.fetch(:uid, "").present? - if attach_omniauth_credentials(current_user, scheme, omniauth) - # rubocop:disable LineLength - flash[:notice] = _("Your account has been successfully linked to %{scheme}.") % { - scheme: provider - } - # rubocop:enable LineLength - else - flash[:alert] = _("Unable to link your account to %{scheme}") % { - scheme: provider - } - end - redirect_to edit_user_registration_path - - 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(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 - if attach_omniauth_credentials(@user, scheme, omniauth) - flash[:notice] = _("Successfully signed in with %{scheme}.") % { - scheme: provider - } - sign_in_and_redirect @user, event: :authentication - - else - # Unable to attach the omniauth creds to the user - flash[:alert] = _("Unable to sign in with %{scheme}") % { - scheme: provider - } - session["devise.#{scheme.name.downcase}_data"] = omniauth - flash[:notice] = _('It looks like this is your first time logging in. Please verify and complete the information below to finish creating an account.') - render 'devise/registrations/new', locals: { - user: @user, - orgs: Org.participating - } - end - - # If we could not find a match take them to the account setup page - else - session["devise.#{scheme.name.downcase}_data"] = omniauth - flash[:notice] = _('It looks like this is your first time logging in. Please verify and complete the information below to finish creating an account.') - render 'devise/registrations/new', locals: { - user: new_user, - orgs: Org.participating - } - end - end - end - end - - private - - def attach_omniauth_credentials(user, scheme, omniauth) - # Attempt to find or attach the omniauth creds - ui = UserIdentifier.where(identifier_scheme: scheme, user: user).first - if ui.present? - if ui.identifier != omniauth[:uid] - ui.update(identifier: omniauth[:uid]) - end - true - else - UserIdentifier.create(identifier_scheme: scheme, user: user, - identifier: omniauth[:uid]) - end - end - - def omniauth_hash_to_new_user(omniauth) - omniauth_info = omniauth.fetch(:info, {}) - names = extract_omniauth_names(omniauth_info) - User.new( - email: extract_omniauth_email(omniauth_info), - firstname: names.fetch(:firstname, ""), - surname: names.fetch(:surname, ""), - org: extract_omniauth_org(omniauth_info) - ) - end - - def extract_omniauth_email(hash) - hash.fetch(:email, "").split(";")[0] - end - - def extract_omniauth_names(hash) - firstname = hash.fetch(:givenname, hash.fetch(:firstname, "")) - surname = hash.fetch(:sn, hash.fetch(:surname, hash.fetch(:lastname, ""))) - - if hash[:name].present? && (!firstname.present? || !surname.present?) - names = hash[:name].split(" ") - firstname = names[0] - if names.length > 1 - surname = names[names.length - 1] - end - end - { firstname: firstname, surname: surname } - end - - def extract_omniauth_org(hash) - idp_name = hash.fetch(:identity_provider, "").downcase - if idp_name.present? - idp = OrgIdentifier.where("LOWER(identifier) = ?", idp_name).first - if idp.present? - org = Org.find_by(id: idp.org_id) - end - end - (org.present? ? org : Org.find_by(is_other: true)) - end - - end - - end - -end diff --git a/lib/dmptool/controller/orgs.rb b/lib/dmptool/controller/orgs.rb deleted file mode 100644 index 9bc939c3e1..0000000000 --- a/lib/dmptool/controller/orgs.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Dmptool - - module Controller - - module Orgs - - # 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(partial: "shared/org_branding", - formats: [:html]) - } - }.to_json - end - - end - - end - -end diff --git a/lib/dmptool/controller/users.rb b/lib/dmptool/controller/users.rb deleted file mode 100644 index 6acfc5e382..0000000000 --- a/lib/dmptool/controller/users.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -module Dmptool - - module Controller - - module Users - - # GET /users/:id/ldap_username - def ldap_username - skip_authorization - end - - def ldap_account - skip_authorization - @user = User.where(ldap_username: params[:username]).first - if @user.present? - # rubocop:disable LineLength - render( - json: { - code: 1, - email: @user.email, - msg: _("The DMPTool Account email associated with this username is #{@user.email}") - } - ) - # rubocop:enable LineLength - else - # rubocop:disable LineLength - render( - json: { - code: 0, - email: "", - msg: _("We do not recognize the username %{username}. Please try again or contact us if you have forgotten the username and email for your existing DMPTool account.") % { - username: params[:username] - } - } - ) - # rubocop:enable LineLength - end - end - - end - - end - -end diff --git a/lib/dmptool/controllers/home_controller.rb b/lib/dmptool/controllers/home_controller.rb new file mode 100644 index 0000000000..1addfd68bd --- /dev/null +++ b/lib/dmptool/controllers/home_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "httparty" +require "rss" + +module Dmptool + + module Controllers + + 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 + # rubocop:disable Metrics/MethodLength + 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 + # rubocop:enable Metrics/MethodLength + + # Get the last 5 blog posts + # rubocop:disable Metrics/AbcSize + def feed + cached = Rails.cache.read("rss") + return cached unless cached.nil? + + resp = HTTParty.get(Rails.application.config.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 + # rubocop:enable Metrics/AbcSize + + # 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 + +end diff --git a/lib/dmptool/controllers/orgs_controller.rb b/lib/dmptool/controllers/orgs_controller.rb new file mode 100644 index 0000000000..37ec3aec3e --- /dev/null +++ b/lib/dmptool/controllers/orgs_controller.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Dmptool + + module Controllers + + 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, Metrics/MethodLength + def shibboleth_ds_passthru + skip_authorization + org = Org.find_by(id: params[:id]) + + if org.present? + entity_id = org.identifier_for_scheme(scheme: "shibboleth") + if entity_id.present? + shib_login = Rails.application.config.shibboleth_login + 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, Metrics/MethodLength + + 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 + +end diff --git a/lib/dmptool/controller/paginable.rb b/lib/dmptool/controllers/paginable/orgs_controller.rb similarity index 90% rename from lib/dmptool/controller/paginable.rb rename to lib/dmptool/controllers/paginable/orgs_controller.rb index 8cab04b0ec..4f194308ac 100644 --- a/lib/dmptool/controller/paginable.rb +++ b/lib/dmptool/controllers/paginable/orgs_controller.rb @@ -2,11 +2,11 @@ module Dmptool - module Controller + module Controllers module Paginable - module Orgs + module OrgsController # /paginable/orgs/public/:page def public diff --git a/lib/dmptool/controller/public_pages.rb b/lib/dmptool/controllers/public_pages_controller.rb similarity index 66% rename from lib/dmptool/controller/public_pages.rb rename to lib/dmptool/controllers/public_pages_controller.rb index 360740baab..27084af66d 100644 --- a/lib/dmptool/controller/public_pages.rb +++ b/lib/dmptool/controllers/public_pages_controller.rb @@ -2,9 +2,9 @@ module Dmptool - module Controller + module Controllers - module PublicPages + module PublicPagesController # The publicly accessible list of participating institutions def orgs @@ -15,22 +15,22 @@ def orgs # 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) - file_name = title.gsub(/[\r\n]/, " ") - .gsub(/[^a-zA-Z\d\s]/, "") - .gsub(/ /, "_") - if file_name.length > 31 - file_name = file_name[0..30] - end - file_name + name = title.gsub(/[\r\n]/, " ") + .gsub(/[^a-zA-Z\d\s]/, "") + .gsub(/ /, "_") + + name.length > 31 ? name[0..30] : name end end diff --git a/lib/dmptool/controller/static_pages.rb b/lib/dmptool/controllers/static_pages_controller.rb similarity index 78% rename from lib/dmptool/controller/static_pages.rb rename to lib/dmptool/controllers/static_pages_controller.rb index 06ab39daf1..cd6862e645 100644 --- a/lib/dmptool/controller/static_pages.rb +++ b/lib/dmptool/controllers/static_pages_controller.rb @@ -2,9 +2,9 @@ module Dmptool - module Controller + module Controllers - module StaticPages + module StaticPagesController def promote end diff --git a/lib/dmptool/controllers/users/omniauth_callbacks_controller.rb b/lib/dmptool/controllers/users/omniauth_callbacks_controller.rb new file mode 100644 index 0000000000..27ddf3fad0 --- /dev/null +++ b/lib/dmptool/controllers/users/omniauth_callbacks_controller.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +module Dmptool + + module Controllers + + module Users + + module OmniauthCallbacksController + + # rubocop:disable Layout/FormatStringToken + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + 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.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 Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + # rubocop:enable Layout/FormatStringToken + + 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 + 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 + # rubocop:disable Metrics/CyclomaticComplexity + def attach_omniauth_credentials(user:, scheme:, omniauth:) + return false unless user.present? && scheme.present? && omniauth.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 + # rubocop:enable Metrics/CyclomaticComplexity + + # 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.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) + ) + 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 + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + 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 + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + # 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 + + end + + end + +end diff --git a/lib/dmptool/mailers/user_mailer.rb b/lib/dmptool/mailers/user_mailer.rb index c0f1f3e7c0..cc1aa48390 100644 --- a/lib/dmptool/mailers/user_mailer.rb +++ b/lib/dmptool/mailers/user_mailer.rb @@ -6,21 +6,24 @@ module Mailers module UserMailer - # AWS SES does not allow the sender to be be from a different domain so - # we remove the `from:` that was being used to pretendd it is coming from - # the Org's contact_email - def feedback_complete(recipient, plan, requestor) - @requestor = requestor - @user = recipient - @plan = plan - @phase = plan.phases.first - if recipient.active? - FastGettext.with_locale FastGettext.default_locale do - mail(to: recipient.email, - subject: _("%{application_name}: Expert feedback has been provided for %{plan_title}") % {application_name: Rails.configuration.branding[:application][:name], plan_title: @plan.title}) - end + # rubocop:disable Metrics/MethodLength + def api_plan_creation(plan, contributor) + return false unless contributor.present? && plan.present? + + @contributor = contributor + @plan = plan + to_addr = @plan.api_client.contact_email + to_addr = "brian.riley@ucop.edu" unless to_addr.present? + + FastGettext.with_locale FastGettext.default_locale do + mail( + to: to_addr, + cc: "brian.riley@ucop.edu; xsrust@gmail.com", # manuel.minwary@ucr.edu", + subject: _("New DMP created") + ) end end + # rubocop:enable Metrics/MethodLength end diff --git a/lib/dmptool/model/user.rb b/lib/dmptool/model/user.rb deleted file mode 100644 index 60e5a44ca1..0000000000 --- a/lib/dmptool/model/user.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -module Dmptool - - module Model - - module User - - extend ActiveSupport::Concern - - included do - # LDap Users password reset - def valid_password?(password) - if !has_devise_password? && ldap_password? - if verify_legacy_password(ldap_password, password) - convert_password_to_devise(password) - else - return false - end - end - super - end - - def ldap_password? - ldap_password.present? - end - end - - private - - def has_devise_password? - encrypted_password.present? - end - - def verify_legacy_password(ldap_password, password) - # LDAP encoding, a 20-byte binary SHA-1 hash and an 8-byte binary - # salt are concatenated, Base64-encoded, and prepended with "{SSHA}". - # Base64Encode(SHA1(password+salt)+salt) - str = ldap_password.sub("{SSHA}", "") - base64_decoded_hash = Base64.decode64(str) - if base64_decoded_hash.length == 28 - # SHA1(password+salt) - sha1_hash = base64_decoded_hash[0, base64_decoded_hash.length - 8] - salt = base64_decoded_hash.split(//).last(8).join - end - # Generate the Ldap hash using user entered password and above salt for - # password verification - digest = Digest::SHA1.digest(password + salt) - hash_to_verify = "{SSHA}" + Base64.encode64(digest + salt).chomp! - return true if hash_to_verify.strip == ldap_password.strip - false - end - - def convert_password_to_devise(password) - self.password = password - self.ldap_password = nil - self.save! - end - - end - - end - -end diff --git a/lib/dmptool/model/org.rb b/lib/dmptool/models/org.rb similarity index 57% rename from lib/dmptool/model/org.rb rename to lib/dmptool/models/org.rb index 5c2b4b8c64..df814b07ce 100644 --- a/lib/dmptool/model/org.rb +++ b/lib/dmptool/models/org.rb @@ -2,7 +2,7 @@ module Dmptool - module Model + module Models module Org @@ -11,15 +11,13 @@ module Org class_methods do # DMPTool participating institution helpers def participating - self.includes(:identifier_schemes) - .where(is_other: false) + includes(identifiers: :identifier_scheme).where(managed: true).order(:name) end end included do def shibbolized? - shib = IdentifierScheme.find_by(name: "shibboleth") - org_identifiers.where(identifier_scheme: shib).present? + managed? && identifier_for_scheme(scheme: "shibboleth").present? end end diff --git a/lib/dmptool/presenters/org_presenter.rb b/lib/dmptool/presenters/org_presenter.rb new file mode 100644 index 0000000000..dd10f7a5d7 --- /dev/null +++ b/lib/dmptool/presenters/org_presenter.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Dmptool + + module Presenters + + class OrgPresenter + + include Rails.application.routes.url_helpers + + def initialize + @shib = IdentifierScheme.by_name("shibboleth").first + end + + def participating_orgs + 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 + +end diff --git a/lib/open_aire_request.rb b/lib/open_aire_request.rb deleted file mode 100644 index 5cefeab0e7..0000000000 --- a/lib/open_aire_request.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal - -require "open-uri" -require "nokogiri" - -class OpenAireRequest - - API_URL = "https://api.openaire.eu/projects/dspace/%s/ALL/ALL" - - attr_reader :funder_type - - def initialize(funder_type) - @funder_type = funder_type - end - - def get! - Rails.logger.info("Fetching fresh data from #{API_URL % funder_type}") - data = open(API_URL % funder_type) - Rails.logger.info("Fetched fresh data from #{API_URL % funder_type}") - @results = Nokogiri::XML(data).xpath("//pair/displayed-value").map do |node| - parts = node.content.split("-") - grant_id = parts.shift.to_s.strip - description = parts.join(" - ").strip - ResearchProject.new(grant_id, description) - end - return self - end - - def results - Array(@results) - end - -end diff --git a/lib/org_date_rangeable.rb b/lib/org_date_rangeable.rb index 08739e117d..918e0eb83a 100644 --- a/lib/org_date_rangeable.rb +++ b/lib/org_date_rangeable.rb @@ -2,9 +2,11 @@ module OrgDateRangeable - def monthly_range(org:, start_date: nil, end_date: Date.today.end_of_month) - query_string = "org_id = :org_id" - query_hash = { org_id: org.id } + # rubocop:disable Metrics/MethodLength, Metrics/LineLength + def monthly_range(org:, start_date: nil, end_date: Date.today.end_of_month, filtered: false) + # rubocop:enable Metrics/LineLength + query_string = "org_id = :org_id and filtered = :filtered" + query_hash = { org_id: org.id, filtered: filtered } unless start_date.nil? query_string += " and date >= :start_date" @@ -17,9 +19,11 @@ def monthly_range(org:, start_date: nil, end_date: Date.today.end_of_month) end where(query_string, query_hash) end + # rubocop:enable Metrics/MethodLength class << self + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def split_months_from_creation(org, &block) starts_at = org.created_at ends_at = starts_at.end_of_month @@ -37,6 +41,7 @@ def split_months_from_creation(org, &block) enumerable end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength end diff --git a/lib/tasks/upgrade.rake b/lib/tasks/upgrade.rake index c0644d9df9..bfee65f41d 100644 --- a/lib/tasks/upgrade.rake +++ b/lib/tasks/upgrade.rake @@ -1,6 +1,32 @@ require 'set' namespace :upgrade do + desc "Upgrade to v2.2.0 Part 1" + task v2_2_0_part1: :environment do + p "Upgrading to v2.2.0 (part 1) ... A summary report will be generated when complete" + p "------------------------------------------------------------------------" + Rake::Task["upgrade:upgrade_2_2_0_identifier_schemes"].execute + Rake::Task["upgrade:upgrade_2_2_0_identifiers"].execute + Rake::Task["upgrade:upgrade_2_2_0_orgs"].execute + Rake::Task["upgrade:results_2_2_0_part1"].execute + end + + desc "Upgrade to v2.2.0 Part 2" + task v2_2_0_part2: :environment do + p "Upgrading to v2.2.0 (part 2) ... A summary report will be generated when complete" + p "------------------------------------------------------------------------" + Rake::Task["upgrade:migrate_other_organisation_to_org"].execute + Rake::Task["upgrade:migrate_contributors"].execute + Rake::Task["upgrade:migrate_plan_org_and_funder"].execute + Rake::Task["upgrade:migrate_plan_grants"].execute + Rake::Task["upgrade:results_2_2_0_part2"].execute + end + + desc "Upgrade to v2.1.6" + task v2_1_6: :environment do + Rake::Task['upgrade:add_versionable_id_to_question_options'].execute + end + desc "Upgrade to v2.1.3" task v2_1_3: :environment do Rake::Task['upgrade:fill_blank_plan_identifiers'].execute @@ -701,6 +727,541 @@ namespace :upgrade do Role.reviewer.destroy_all end + desc "generate versionable_ids for " + task add_versionable_id_to_question_options: :environment do + + QuestionOption.attr_readonly.delete('versionable_id') + + Template.latest_version.where(customization_of: nil) + .includes(phases: { sections: { questions: :question_options }}) + .each do |uncustomized| + + # update the versionable_id for the canonical and all customized templates + uncustomized.question_options.each do |qo| + vers_id = loop do + rand = SecureRandom.uuid + break rand unless QuestionOption.exists?(versionable_id: rand) + end + qo.update! versionable_id: vers_id + text_a = "#{qo.number} - #{qo.text}" + + Question.joins(:question_options) + .where(questions: {versionable_id: qo.question.versionable_id}) + .where.not(questions: {id: qo.question_id}) # ensure we exclude the current question + .includes(:question_options) + .each do |q_cust| + q_cust.question_options.each do |qo_cust| + text_b = "#{qo_cust.number} - #{qo_cust.text}" + + if fuzzy_match?(text_a, text_b) + qo_cust.update! versionable_id: qo.versionable_id + break + end + end + end + end + + end + + end + + # ------------------------------------------------- + # TASKS FOR 2.2.0 + desc "run all of the identifier_scheme changes" + task upgrade_2_2_0_identifier_schemes: :environment do + Rake::Task["upgrade:add_new_identifier_schemes"].execute + Rake::Task["upgrade:update_shibboleth_description"].execute + Rake::Task["upgrade:contextualize_identifier_schemes"].execute + end + desc "run all of the identifier changes" + task upgrade_2_2_0_identifiers: :environment do + Rake::Task["upgrade:convert_org_identifiers"].execute + p "--------------------------" + Rake::Task["upgrade:convert_user_identifiers"].execute + end + desc "run all of the org changes" + task upgrade_2_2_0_orgs: :environment do + Rake::Task["upgrade:default_orgs_to_managed"].execute + p "--------------------------" + Rake::Task["upgrade:retrieve_ror_fundref_ids"].execute + end + + desc "add the ROR and Fundref identifier schemes" + task add_new_identifier_schemes: :environment do + unless IdentifierScheme.where(name: "fundref").any? + IdentifierScheme.create( + name: "fundref", + description: "Crossref Funder Registry (FundRef)", + active: true + ) + end + unless IdentifierScheme.where(name: "ror").any? + IdentifierScheme.create( + name: "ror", + description: "Research Organization Registry (ROR)", + active: true + ) + end + end + + desc "update the Shibboleth scheme description" + task update_shibboleth_description: :environment do + scheme = IdentifierScheme.where(name: "shibboleth") + if scheme.any? + scheme.first.update(description: "Institutional Sign In (Shibboleth)") + end + end + + desc "Contextualize the Identifier Schemes (e.g. which ones are for orgs, etc." + task contextualize_identifier_schemes: :environment do + # Identifier schemes for multiple uses + shib = IdentifierScheme.find_or_initialize_by(name: "shibboleth") + shib.for_users = true + shib.for_orgs = true + shib.for_authentication = true + shib.save + + orcid = IdentifierScheme.find_or_initialize_by(name: "orcid") + orcid.for_users = true + orcid.for_contributors = true + orcid.for_authentication = true + orcid.identifier_prefix = "https://orcid.org/" + orcid.save + + # Org identifier schemes + ror = IdentifierScheme.find_or_initialize_by(name: "ror") + ror.for_orgs = true + ror.identifier_prefix = "https://ror.org/" + ror.save + + fundref = IdentifierScheme.find_or_initialize_by(name: "fundref") + fundref.for_orgs = true + fundref.identifier_prefix = "https://api.crossref.org/funders/" + fundref.save + end + + desc "migrate the old user_identifiers over to the polymorphic identifiers table" + task convert_user_identifiers: :environment do + p "Transferring existing user_identifiers over to the identifiers table" + p "this may take in excess of 10 minutes depending on the size of your users table ..." + identifiers = UserIdentifier.joins(:user, :identifier_scheme) + .includes(:user, :identifier_scheme) + .where.not(identifier: nil) + .where.not(identifier: '') + + Parallel.map(identifiers, in_threads: 8) do |ui| + # Parallel has trouble with ActiveRecord lazy loading + require "org" unless Object.const_defined?("Org") + require "identifier" unless Object.const_defined?("Identifier") + require "identifier_scheme" unless Object.const_defined?("IdentifierScheme") + @reconnected ||= Identifier.connection.reconnect! || true + + lookup = Identifier.where(identifiable_id: ui.user_id, + identifiable_type: "User", + identifier_scheme: ui.identifier_scheme) + next if lookup.present? + + Identifier.create(identifier_scheme: ui.identifier_scheme, attrs: {}.to_json, + identifiable: ui.user, value: ui.identifier) + end + + count = Identifier.where(identifiable_type: "User").length + p "Transfer complete. Orginal user_identifier count #{identifiers.length}, new identifiers count #{count}" + if identifiers.length > count + p "" + p "#{identifiers.length - count} records could not be transferred." + p "This is typically due to the fact that the new identifiers table will automatically" + p "prepend the identifier_scheme.identifier_prefix to the value For example: " + p " '0000-0000-0000-0001' would become 'https://orcid.org/0000-0000-0000-0001'" + p "and your old user_identifiers table may have an entry for both versions" + end + end + + desc "migrate the old org_identifiers over to the polymorphic identifiers table" + task convert_org_identifiers: :environment do + p "Transferring existing org_identifiers over to the identifiers table" + p "please wait ..." + identifiers = OrgIdentifier.joins(:org, :identifier_scheme) + .includes(:org, :identifier_scheme) + .where.not(identifier: nil) + .where.not(identifier: '') + .order(id: :desc) + + Parallel.map(identifiers, in_threads: 8) do |oi| + # Parallel has trouble with ActiveRecord lazy loading + require "org" unless Object.const_defined?("Org") + require "identifier" unless Object.const_defined?("Identifier") + require "identifier_scheme" unless Object.const_defined?("IdentifierScheme") + @reconnected ||= Identifier.connection.reconnect! || true + + lookup = Identifier.where(identifiable_id: oi.org_id, + identifiable_type: "Org", + identifier_scheme: oi.identifier_scheme) + next if lookup.present? + + Identifier.create(identifier_scheme: oi.identifier_scheme, attrs: oi.attrs, + identifiable: oi.org, value: oi.identifier) + end + count = Identifier.where(identifiable_type: "Org").length + p "Transfer complete. Orginal org_identifier count #{identifiers.length}, new identifiers count #{count}" + if identifiers.length > count + p "" + p "#{identifiers.length - count} records could not be transferred. Run the following query manually to identify them:" + p " SELECT * FROM org_identifiers WHERE org_id NOT IN (" + p " SELECT identifiers.identifiable_id FROM identifiers " + p " WHERE identifiers.identifier_scheme_id = org_identifiers.identifier_scheme_id AND identifiable_type = 'Org'" + p " );" + p "Then transfer them manually." + end + end + + desc "Sets the new managed flag for all existing Orgs to managed = true" + task default_orgs_to_managed: :environment do + Org.all.update_all(managed: true) + end + + desc "retrieves ROR ids for each of the Orgs defined in the database" + task retrieve_ror_fundref_ids: :environment do + ror = IdentifierScheme.find_by(name: "ror") + fundref = IdentifierScheme.find_by(name: "fundref") + + out = CSV.generate do |csv| + csv << %w[org_id org_name ror_name ror_id fundref_id] + + if ExternalApis::RorService.ping + p "Scanning ROR for each of your existing Orgs" + p "The results will be written to tmp/ror_fundref_ids.csv to facilitate review and any corrections that may need to be made." + p "The CSV file contains the Org name stored in your DB next to the ROR org name that was matched. Use these 2 values to determine if the match was valid." + p "You can use the ROR search page to find the correct match for any organizations that need to be corrected: https://ror.org/search" + p "" + orgs = Org.includes(identifiers: :identifier_scheme) + .where(is_other: false).order(:name) + + orgs.each do |org| + # If the Org already has a ROR identifier skip it + next if org.identifiers.select { |id| id.identifier_scheme_id == ror.id }.any? + + # The abbreviation sometimes causes weird results so strip it off + # in this instance + org_name = org.name.gsub(" (#{org.abbreviation})", "") + rslts = OrgSelection::SearchService.search_externally(search_term: org_name) + next unless rslts.any? + + # Just use the first match that contains the search term + rslt = rslts.select { |rslt| rslt[:weight] <= 1 }.first + next unless rslt.present? + + ror_id = rslt[:ror] + fundref_id = rslt[:fundref] + + if ror_id.present? + ror_ident = Identifier.find_or_initialize_by(identifiable: org, + identifier_scheme: ror) + ror_ident.value = "#{ror.identifier_prefix}#{ror_id}" + ror_ident.save + p " #{org.name} -> ROR: #{ror_ident.value}, #{rslt[:name]}" + end + if fundref_id.present? + fr_ident = Identifier.find_or_initialize_by(identifiable: org, + identifier_scheme: fundref) + fr_ident.value = "#{fundref.identifier_prefix}#{fundref_id}" + fr_ident.save + p " #{org.name} -> FUNDRF: #{fr_ident.value}, #{rslt[:name]}" + end + + if ror_id.present? || fundref_id.present? + csv << [org.id, org.name, rslt[:name], ror_ident&.value, fr_ident&.value] + end + end + else + p "ROR appears to be offline or your configuration is invalid. Heartbeat check failed. Refer to the log for more information." + end + end + + if out.present? + file = File.open("tmp/ror_fundref_ids.csv", "w") + file.puts out + file.close + end + end + + desc "Attempts to migrate other_organisation entries to Orgs" + task migrate_other_organisation_to_org: :environment do + is_other = Org.find_by(is_other: true) + p "No is_other Org defined, so no orgs need to be created!" unless is_other.present? + return false unless is_other.present? + + users = User.where(org: is_other) + p "Processing #{users.length} users attached to '#{is_other.name}' #{is_other.id}" + p "this may take more than 15 minutes depending on how many users are in your database" + # Unfortunately can't use the Parallel gem here because we can have collisions + # when creating Orgs + users.each do |user| + # First lookup by email domain + term = user.email.split("@").last + + unless %w[gmail.com yahoo.com msn.com 126.com 163.com].include?(term) + # Search the local Org table by its URL + matches = Org.where("orgs.target_url LIKE ?", "%#{term}%") + org = matches.first if matches.any? + + # by RorService if not already in the DB + unless org.present? + # Just use the host (e.g. 'rutgers' instead of 'rutgers.edu') + host = term.split('.').first + next unless host.length > 2 + + matches = OrgSelection::SearchService.search_externally(search_term: host) + # Only allow results that INCLUDE the search term in parenthesis + matches = matches.select do |result| + result[:weight] <= 1 && result[:name].include?("(#{term})") + end + + org = OrgSelection::HashToOrgService.to_org(hash: matches.first, allow_create: true) if matches.any? + org = create_org(org, matches.first) if org.present? + end + end + + # Otherwise lookup by other_organisation name + if !org.present? && user.other_organisation.present? + term = user.other_organisation + matches = OrgSelection::SearchService.search_externally(search_term: term) + # Only allow results that START WITH the search term + matches = matches.select { |result| result[:weight] == 0 } + org = OrgSelection::HashToOrgService.to_org(hash: matches.first, allow_create: true) if matches.any? + org = create_org(org, matches.first) if org.present? && org.valid? + end + + # Otherwise create the Org + if org.nil? && user.other_organisation.present? + name = user.other_organisation + abbrev = OrgSelection::SearchService.name_without_alias(name: name) + .split(" ").map(&:first).join.upcase + org = Org.new(name: name, managed: false, is_other: false, + abbreviation: abbrev, language: Language.default) + org.save if org.present? && org.valid? + end + + if org.present? && org.valid? + # Attach the user to the Org + p " User id: #{user.id} - #{user.email} attaching to org_id: #{org.id} - #{org.name}" + user.update(org_id: org.id) + end + end + + final = User.where(org: is_other).length + p "Complete: #{users.length - final} users could not be processed. Left them attached to '#{is_other.name}'" + end + + desc "migrates any data_contact/principal_investigator information from plans table to contributors" + task migrate_contributors: :environment do + orcid = IdentifierScheme.find_by(name: "orcid") + + # Loop through the plans and convert the Data Contact, owners and PI + # into Contributors + plans = Plan.includes(:contributors, roles: :user).joins(roles: :user) + + Parallel.map(plans, in_threads: 8) do |plan| + next if plan.contributors.any? + owner = plan.owner + + # Either use the Data Contact specified on the plan + if plan.data_contact_email.present? || plan.data_contact.present? + contact, contact_id = to_contributor(plan, plan.data_contact, + plan.data_contact_email, + plan.data_contact_phone, nil, nil) + + elsif owner.present? + contact, contact_id = to_contributor(plan, owner.name(false), + owner.email, nil, owner.identifier_for(orcid)&.first&.value, owner.org_id) + end + # Add the DMP Data Contact + if contact.present? + contact.save + contact.data_curation = true + contact.investigation = true if owner.present? + contact.save + contact_id.save if contact_id.present? + end + + # Get the PI + pi, pi_id = to_contributor(plan, plan.principal_investigator, + plan.principal_investigator_email, + plan.principal_investigator_phone, + plan.principal_investigator_identifier, nil) + # Add the Principal Investigator + if pi.present? + pi.save + pi.investigation = true + pi.save + pi_id.save if pi_id.present? + end + + # Add the authors + if owner.present? && owner == contact + user, id = to_contributor(plan, owner.name(false), + owner.email, nil, owner.identifier_for(orcid)&.first&.value, owner.org_id) + + if user.present? + user.save + user.data_curation = true + user.save + id.save if id.present? + end + end + + plan.reload + if plan.contributors.length > 0 + p "Processed Plan #{plan.id} - which now has #{plan.contributors.length} contributor(s)" + end + end + end + + desc "Attach Plans to their owner's Org and then back fill the Funder" + task migrate_plan_org_and_funder: :environment do + plans = Plan.includes(template: :org, roles: :user) + .joins(template: :org, roles: :user) + + p "Attaching Plans to Orgs ... this can take in excess of 5 minutes depending on how many plans you have." + Parallel.map(plans, in_threads: 8) do |plan| + next if plan.org_id.present? + + # Parallel has trouble with ActiveRecord lazy loading + require "plan" unless Object.const_defined?("Plan") + require "role" unless Object.const_defined?("Role") + require "perm" unless Object.const_defined?("Perm") + require "user" unless Object.const_defined?("User") + @reconnected ||= Plan.connection.reconnect! || true + + next unless plan.owner.present? && plan.owner.org.present? + + plan.update(org_id: plan.owner.org.id) + end + + p "Attaching Plans to Funders" + Parallel.map(plans, in_threads: 8) do |plan| + next if plan.funder_id.present? + + # Parallel has trouble with ActiveRecord lazy loading + require "plan" unless Object.const_defined?("Plan") + require "template" unless Object.const_defined?("Template") + require "org" unless Object.const_defined?("Org") + @reconnected ||= Plan.connection.reconnect! || true + + next unless plan.funder_name.present? || plan.template.org.funder? + + funder_id = plan.template.org.id if plan.template.org.funder? + + if plan.funder_name.present? && !funder_id.present? + matches = OrgSelection::SearchService.search_externally(search_term: plan.funder_name) + # Only allow results that INCLUDE the search term in parenthesis + matches = matches.select do |result| + result[:weight] <= 1 && result[:name].include?("(#{plan.funder_name})") + end + + org = OrgSelection::HashToOrgService.to_org(hash: matches.first, allow_create: true) if matches.any? + org = create_org(org, matches.first) if org.present? && org.valid? + funder_id = org.id if org.present? + end + + plan.update(funder_id: funder_id) if funder_id.present? + end + p "Complete" + end + + desc "Migrate the Plans grant_number to an Identifier" + task migrate_plan_grants: :environment do + plans = Plan.where.not(grant_number: nil).where.not(grant_number: "") + + p "Converting Plan.grant_number into Identifiers" + #Parallel.map(plans, in_threads: 8) do |plan| + plans.each do |plan| + # Parallel has trouble with ActiveRecord lazy loading + require "plan" unless Object.const_defined?("Plan") + @reconnected ||= Plan.connection.reconnect! || true + + identifier = Identifier.find_or_create_by( + identifier_scheme_id: nil, identifiable: plan, value: plan.grant_number + ) + plan.update(grant_id: identifier.id) + end + p "Complete" + end + + desc "Generate stats for all of the 2.2.0 upgrade scripts" + task results_2_2_0_part1: :environment do + ror = IdentifierScheme.find_by(name: "ror") + fundref = IdentifierScheme.find_by(name: "fundref") + org_identifiers_migrated = Identifier.where(identifiable_type: 'Org') + .where.not(identifier_scheme: [ror, fundref]) + .count + user_identifiers_migrated = Identifier.where(identifiable_type: 'User') + .where.not(identifier_scheme: [ror, fundref]) + .count + rors_added = Identifier.where(identifiable_type: 'Org', identifier_scheme: ror).count + fundrefs_added = Identifier.where(identifiable_type: 'Org', identifier_scheme: fundref).count + + p "---------------------------------------------------------------" + p "Results of v2.2.0 part 1 upgrade:" + p " Added new IdentifierScheme: #{ror.id}, '#{ror.name}', '#{ror.description}'" + p " Added new IdentifierScheme: #{fundref.id}, '#{fundref.name}', '#{fundref.description}'" + p "" + p " Migrated #{number_with_delimiter(org_identifiers_migrated)} from org_identifiers to identifiers table." + p " Migrated #{number_with_delimiter(user_identifiers_migrated)} from user_identifiers to identifiers table." + p " NOTE: org_identifier and user_identifiers tables are being deprecated and will be dropped in a future release." + p "" + p " Assigned #{number_with_delimiter(rors_added)} ROR identifiers to your Orgs" + p " Assigned #{number_with_delimiter(fundrefs_added)} Crossref Funder identifiers to your Orgs" + p " NOTE: Please refer to the tmp/ror_fundref_ids.csv file to see how the assigment worked." + p " You should make any adjustments BEFORE running part 2 of the upgrade scripts!" + p " For example ROR sometimes incorrectly matches Orgs. For example:" + p " 'University of Somewhere' may match to 'Univerity of Somewhere - Medical Center'" + p " To correct any issues, please delete/insert/update the corresponding Identifier:" + p " delete from identifiers where identifiable_type = 'Org' and identifiable_id = [orgs.id];" + p " insert into identifiers (identifiable_type, identifier_scheme_id, attrs, identifiable_id, value) values ('Org', [identifier_scheme_id], '{}', [orgs.id], 'https://api.crossref.org/funders/0000000000');" + p " update identifiers set `value` = 'https://ror.org/123456789' where identifiable_id = [orgs.id] and identifier_scheme_id = [identifier_scheme_id] and identifiable_type= 'Org';" + p "---------------------------------------------------------------" + end + + desc "Generate stats for all of the 2.2.0 upgrade scripts" + task results_2_2_0_part2: :environment do + ror = IdentifierScheme.find_by(name: "ror") + fundref = IdentifierScheme.find_by(name: "fundref") + is_other = Org.find_by(is_other: true) + unaffiliated = User.where(org_id: is_other.id).count + unmanaged_orgs = Org.where(managed: false).count + managed_orgs = Org.where(managed: true).count + contributors_converted = Contributor.all.count + orgs_converted = Plan.where.not(org_id: nil).count + funders_converted = Plan.where.not(funder_id: nil).count + grants_converted = Plan.where.not(grant_id: nil).count + + p "---------------------------------------------------------------" + p "Results of v2.2.0 part 2 upgrade:" + p " Set #{number_with_delimiter(managed_orgs)} Orgs to 'managed: true' (all of your existing Orgs)" + p " The is_other Org is deprecated. Users will not be added to this old default Org in the future." + p " you should try to move any remaining users over to actual Orgs, this may require you to create " + p " a new Org and attach the user to it." + p " `SELECT id, email, other_organisation FROM users WHERE org_id = (SELECT orgs.id FROM orgs WHERE is_other = true);" + p " NOTE: all code that checks for `is_other` will instead check `managed` in future releases." + p "" + p " Added #{number_with_delimiter(unmanaged_orgs)} Orgs" + p " NOTE: These Orgs were created from the Funders listed in plans.funder_name and also by examining" + p " all of the users attached to the is_other Org (first checking the domain of the user's email" + p " address and then the text value stored in other_organisation)." + p " In the case of a User, the user was associated with that new Org" + p " Added #{number_with_delimiter(contributors_converted)} Contributor based on the old DataContact, PrincipalInvestigator and Plan Owner" + p " NOTE: the old data_contact and principal_investigator fields on the plans table are deprecated and will be removed in a future release." + p "" + p " Attached #{number_with_delimiter(orgs_converted)} Plans to an Org based on the Owner's Org" + p " Attached #{number_with_delimiter(funders_converted)} Plans to a Funder based on either the Template's Org (if it was a funder) or the name in funder_name field." + p " Migrate #{number_with_delimiter(grants_converted)} Plan grant_numbers to Identifiers" + p " NOTE: funder_name and grant_number fields on the plans table are deprecated and will be dropped in a future release" + p "" + p " #{number_with_delimiter(unaffiliated)} users are still associated with '#{is_other.name}' (is_other Org)." + p "---------------------------------------------------------------" + end + private def fuzzy_match?(text_a, text_b, min = 3) @@ -717,4 +1278,58 @@ namespace :upgrade do end end + # Converts the names, email and phone into a Contributor and an + # Identifier model + def to_contributor(plan, name, email, phone, identifier, org) + return nil, nil unless name.present? || email.present? + + # If the name is not an array already split it up + orcid = IdentifierScheme.find_by(name: "orcid") + + # If no Org and/or identifier were nil try to look them up in the User table + user = User.includes(:identifiers).where(email: email).first + if user.present? + org = user.org_id unless org.present? + + unless identifier.present? + ident = user.identifiers.select { |i| i.identifier_scheme == orcid }.first + identifier = ident.value if ident.present? + end + end + + contributor = Contributor.where("plan_id = ? AND (LOWER(email) = LOWER(?) OR LOWER(name) = LOWER(?))", plan.id, email, name).first + unless contributor.present? + contributor = Contributor.new(email: email, plan: plan) + contributor.name = name + contributor.phone = phone + contributor.org_id = org + end + return contributor, nil if identifier.nil? + + # Get the ORCID id from the string + matched = identifier.match(/([0-9]{4}-?){4}/) + orcid_id = matched[0] if matched.present? + return contributor, nil unless orcid_id.present? + + id = Identifier.find_or_initialize_by(identifiable: contributor, + identifier_scheme: orcid) + id.value = orcid_id + return contributor, id + end + + def create_org(org, match) + org.save + OrgSelection::HashToOrgService.to_identifiers(hash: match).each do |identifier| + next unless identifier.value.present? + + identifier.identifiable = org + identifier.save + end + org.reload + end + + def number_with_delimiter(number) + number.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse + end + end diff --git a/package.json b/package.json index b0e6f39257..7dcc7409d0 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "bootstrap": "^4.1.3", "bootstrap-3-typeahead": "^4.0.2", "bootstrap-sass": "^3.3.7", + "bootstrap-select": "^1.13.10", "chart.js": "^2.7.2", "eslint": "^5.8.0", "eslint-config-airbnb-base": "^13.1.0", @@ -43,7 +44,7 @@ "number-to-text": "^0.3.5", "rails-erb-loader": "^5.5.2", "timeago.js": "4.0.0-beta.1", - "tinymce": "^4.9.7", + "tinymce": "^4.9.10", "webpack": "^3.12.0", "webpack-manifest-plugin": "^2.0.4", "webpack-merge": "3" diff --git a/spec/controllers/concerns/org_selectable_spec.rb b/spec/controllers/concerns/org_selectable_spec.rb new file mode 100644 index 0000000000..8577e27b5d --- /dev/null +++ b/spec/controllers/concerns/org_selectable_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe OrgSelectable do + + before(:each) do + class StubController < ApplicationController + include OrgSelectable + end + + @controller = StubController.new + + OrgSelection::HashToOrgService.stubs(:to_org).returns(build(:org)) + OrgSelection::HashToOrgService.stubs(:to_identifiers) + .returns([build(:identifier)]) + + @params = ActionController::Parameters.new({ + other_param: Faker::Company.name, + org_id: { id: Faker::Number.number, name: Faker::Company.name }.to_json, + org_name: Faker::Company.name, + org_sources: [Faker::Company.name], + org_crosswalk: [{ id: Faker::Number.number }] + }) + end + + after(:each) do + Object.send :remove_const, :StubController + end + + context "private methods" do + + describe "#org_from_params(params:)" do + it "returns nil if params[:org_id] is not present" do + expect(@controller.send(:org_from_params, params_in: {})).to eql(nil) + end + it "returns nil if the params[:org_id] could not be converted" do + @controller.stubs(:org_hash_from_params).returns({}) + expect(@controller.send(:org_from_params, params_in: {})).to eql(nil) + end + it "returns an Org" do + rslt = @controller.send(:org_from_params, params_in: @params) + expect(rslt.is_a?(Org)).to eql(true) + end + end + + describe "#identifiers_from_params(params:)" do + it "returns an empty array if params[:org_id] is not present" do + rslt = @controller.send(:identifiers_from_params, params_in: {}) + expect(rslt).to eql([]) + end + it "returns an empty array if params[:org_id] could not be converted" do + @controller.stubs(:org_hash_from_params).returns({}) + rslt = @controller.send(:identifiers_from_params, params_in: {}) + expect(rslt).to eql([]) + end + it "returns an Array of identifiers" do + rslt = @controller.send(:identifiers_from_params, params_in: @params) + expect(rslt.is_a?(Array)).to eql(true) + expect(rslt.first.is_a?(Identifier)).to eql(true) + end + end + + describe "#org_hash_from_params(params:)" do + it "returns an empty hash is there is a JSON parse error" do + JSON.expects(:parse).raises(JSON::ParserError) + rslt = @controller.send(:org_hash_from_params, params_in: @params) + expect(rslt).to eql({}) + end + it "logs JSON parse error" do + JSON.expects(:parse).raises(JSON::ParserError) + Rails.logger.expects(:error).at_least(2) + @controller.send(:org_hash_from_params, params_in: @params) + end + it "returns the hash" do + rslt = @controller.send(:org_hash_from_params, params_in: @params) + expect(rslt).to eql(JSON.parse(@params[:org_id])) + end + end + + describe "#remove_org_selection_params(params:)" do + before(:each) do + @rslt = @controller.send(:remove_org_selection_params, + params_in: @params) + end + it "removes the org_selector params" do + expect(@rslt[:org_id].present?).to eql(false) + expect(@rslt[:org_name].present?).to eql(false) + expect(@rslt[:org_sources].present?).to eql(false) + expect(@rslt[:org_crosswalk].present?).to eql(false) + end + it "does not remove other params" do + expect(@rslt[:other_param].present?).to eql(true) + end + end + + end + +end diff --git a/spec/controllers/contributors_controller_spec.rb b/spec/controllers/contributors_controller_spec.rb new file mode 100644 index 0000000000..9e37fe2b4a --- /dev/null +++ b/spec/controllers/contributors_controller_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ContributorsController, type: :controller do + + before(:each) do + @scheme = create(:identifier_scheme, name: "orcid") + @org = create(:org, managed: true) + @plan = create(:plan, :creator, org: @org) + @user = @plan.owner + @contributor = create(:contributor, plan: @plan) + + @params_hash = { + contributor: { + name: Faker::TvShows::Simpsons.character, + email: Faker::Internet.email, + phone: Faker::Number.number, + org_id: { + id: @org.id, + name: @org.name, + ror: Faker::Lorem.word + }.to_json, + identifiers_attributes: { "0": { + identifier_scheme_id: @scheme.id, + value: SecureRandom.uuid + }} + } + } + @roles = Contributor.new.all_roles + @roles.each { |role| @params_hash[:contributor][role.to_sym] = %w[0 1].sample } + + @controller = described_class.new + end + + context "actions" do + before(:each) do + sign_in(@user) + end + + it "GET plans/:plan_id/contributors (:index)" do + get :index, plan_id: @plan.id + expect(response).to render_template(:index) + expect(assigns(:plan)).to eql(@plan) + expect(assigns(:contributors).length).to eql(1) + expect(assigns(:contributors).first).to eql(@contributor) + end + + it "GET plans/:plan_id/contributors/new (:new)" do + get :new, plan_id: @plan.id + expect(response).to render_template(:new) + expect(assigns(:plan)).to eql(@plan) + expect(assigns(:contributor).new_record?).to eql(true) + expect(assigns(:contributor).plan).to eql(@plan) + end + + it "GET plans/:plan_id/contributors/:id/edit (:edit)" do + get :edit, plan_id: @plan.id, id: @contributor.id + expect(response).to render_template(:edit) + expect(assigns(:plan)).to eql(@plan) + expect(assigns(:contributor)).to eql(@contributor) + end + + it "POST plans/:plan_id/contributors (:create)" do + post :create, @params_hash.merge({ plan_id: @plan.id }) + expect(response).to redirect_to(plan_contributors_url(@plan)) + contrib = Contributor.last + params = @params_hash[:contributor] + + # Verify that the plan was attached + expect(contrib.plan).to eql(@plan) + + # Verify that the contributor fields were all saved + expect(contrib.name).to eql(params[:name]) + expect(contrib.email).to eql(params[:email]) + expect(contrib.phone).to eql(params[:phone].to_s) + + # Verify that the corrrect roles were assigned + contrib.all_roles.each do |role| + expect(contrib.send(:"#{role}?")).to eql(params[:"#{role}"] == "1") + end + + # Verify that the Org was attached + expect(contrib.org).to eql(@org) + + # Verify that the ORCID was saved + expected = params[:identifiers_attributes][:"0"][:value] + expect(contrib.identifiers.first.identifier_scheme).to eql(@scheme) + expect(contrib.identifiers.first.value.ends_with?(expected)).to eql(true) + end + + it "PUT plans/:plan_id/contributors/:id (:update)" do + put :update, @params_hash.merge({ plan_id: @plan.id, id: @contributor.id }) + @contributor.reload + params = @params_hash[:contributor] + + expect(response).to redirect_to(edit_plan_contributor_url(@plan, @contributor)) + + # Verify that the contributor fields were all saved + expect(@contributor.name).to eql(params[:name]) + expect(@contributor.email).to eql(params[:email]) + expect(@contributor.phone).to eql(params[:phone].to_s) + + # Verify that the corrrect roles were assigned + @contributor.all_roles.each do |role| + expect(@contributor.send(:"#{role}?")).to eql(params[:"#{role}"] == "1") + end + + # Verify that the Org was attached + expect(@contributor.org).to eql(@org) + + # Verify that the ORCID was saved + expected = params[:identifiers_attributes][:"0"][:value] + expect(@contributor.identifiers.first.identifier_scheme).to eql(@scheme) + expect(@contributor.identifiers.first.value.ends_with?(expected)).to eql(true) + end + + it "DELETE plans/:plan_id/contributors/:id (:destroy)" do + id = @contributor.id + delete :destroy, @params_hash.merge({ plan_id: @plan.id, id: @contributor.id }) + expect(Contributor.where(id: id).any?).to eql(false) + end + + end + + context "private methods(hash:)" do + + describe "#translate_roles" do + it "converts integer to boolean" do + roles = @controller.send(:translate_roles, hash: @params_hash[:contributor]) + expect([true, false].include?(roles[@roles.first])).to eql(true) + end + it "leaves non-role integers alone" do + @params_hash[:contributor][:non_role] = "1" + roles = @controller.send(:translate_roles, hash: @params_hash[:contributor]) + expect(roles[:non_role]).to eql("1") + end + end + + describe "#process_org(hash:)" do + it "returns the hash as is if no :org_id is present" do + @params_hash[:contributor].delete(:org_id) + hash = @controller.send(:process_org, hash: @params_hash[:contributor]) + expect(hash).to eql(@params_hash[:contributor]) + end + it "returns the hash as is if the org could not be converted" do + @controller.stubs(:org_from_params).returns(nil) + hash = @controller.send(:process_org, hash: @params_hash[:contributor]) + expect(hash).to eql(@params_hash[:contributor]) + end + it "sets the org_id to the idea of the org" do + new_org = create(:org) + @controller.stubs(:org_from_params).returns(new_org) + hash = @controller.send(:process_org, hash: @params_hash[:contributor]) + expect(hash[:org_id]).to eql(new_org.id) + end + end + + context "callbacks" do + + describe "#fetch_plan" do + it "assigns the plan instance variable" do + get :index, plan_id: @plan.id + expect(assigns(:plan)).to eql(@plan) + end + it "redirects to :root if no plan found" do + get :index, plan_id: 99999 + expect(response).to have_http_status(:redirect) + expect(response).to redirect_to(root_url) + end + end + + describe "#fetch_contributor" do + it "is not triggered on POST :create" do + described_class.any_instance.expects(:fetch_contributor).at_most(0) + post :create, @params_hash.merge({ plan_id: @plan.id }) + end + it "is not triggered on GET :index" do + described_class.any_instance.expects(:fetch_contributor).at_most(0) + get :index, plan_id: @plan.id + end + it "is not triggered on GET :new" do + described_class.any_instance.expects(:fetch_contributor).at_most(0) + get :new, plan_id: @plan.id + end + it "assigns the contributor instance variable" do + get :edit, plan_id: @plan.id, id: @contributor.id + expect(assigns(:contributor)).to eql(@contributor) + end + it "redirects to :index if no contributor found" do + get :edit, plan_id: @plan.id, id: 99999 + expect(response).to have_http_status(:redirect) + expect(response).to redirect_to(plan_contributors_url(@plan)) + end + it "redirects to :index if contributor does not belong to the plan" do + contrib = create(:contributor, plan: create(:plan)) + get :edit, plan_id: @plan.id, id: contrib.id + expect(response).to have_http_status(:redirect) + expect(response).to redirect_to(plan_contributors_url(@plan)) + end + end + + end + + end + +end diff --git a/spec/controllers/orgs_controller_spec.rb b/spec/controllers/orgs_controller_spec.rb new file mode 100644 index 0000000000..79b4426ca0 --- /dev/null +++ b/spec/controllers/orgs_controller_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe OrgsController, type: :controller do + + before(:each) do + uri = URI.parse(Faker::Internet.url) + @name = Faker::Company.name + + hash = { + id: uri.to_s, + name: "#{@name} (#{uri.host})", + sort_name: @name, + score: 0, + weight: 1 + } + OrgSelection::SearchService.stubs(:search_locally).returns([hash]) + OrgSelection::SearchService.stubs(:search_externally).returns([hash]) + OrgSelection::SearchService.stubs(:search_combined).returns([hash]) + end + + describe "POST /search" do + + it "returns an empty array if the search term is blank" do + post :search, org: { name: "" }, format: :js + expect(JSON.parse(response.body)).to eql([]) + end + + it "returns an empty array if the search term is less than 3 characters" do + post :search, org: { name: "Fo" }, format: :js + expect(JSON.parse(response.body)).to eql([]) + end + + it 'assigns the orgs variable' do + post :search, org: { name: Faker::Lorem.sentence }, format: :js + json = JSON.parse(response.body) + expect(json.length).to eql(1) + expect(json.first["sort_name"]).to eql(@name) + end + + it "calls search_locally by default" do + OrgSelection::SearchService.expects(:search_locally).at_least(1) + post :search, org: { name: Faker::Lorem.sentence }, format: :js + end + + it "calls search_externally when query string contains type=external" do + OrgSelection::SearchService.expects(:search_externally).at_least(1) + post :search, org: { name: Faker::Lorem.sentence }, type: "external", + format: :js + end + + it "calls search_combined when query string contains type=combined" do + OrgSelection::SearchService.expects(:search_combined).at_least(1) + post :search, org: { name: Faker::Lorem.sentence }, type: "combined", + format: :js + end + end + +end diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb new file mode 100644 index 0000000000..bf2a6f9a3f --- /dev/null +++ b/spec/controllers/registrations_controller_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe RegistrationsController, type: :controller do + + before(:each) do + @org = create(:org, is_other: false) + @user = create(:user, org: @org) + end + + context "private methods" do + + before(:each) do + @controller = described_class.new + end + + describe "#handle_org(attrs:)" do + + before(:each) do + @params = ActionController::Parameters.new({ + org_id: { + id: @org.id.to_s, + name: Faker::Lorem.word, + ror: Faker::Lorem.word + } + }) + @user = build(:user) + + @controller.stubs(:org_from_params).returns(build(:org)) + end + + it "returns nil if the params are not present" do + rslt = @controller.send(:handle_org, attrs: nil) + expect(rslt).to eql(nil) + end + it "returns the params if the params[:org_id] is not present" do + rslt = @controller.send(:handle_org, attrs: {}) + expect(rslt).to eql({}) + end + it "saved the org if it was a new record" do + count = Org.all.length + @controller.stubs(:org_from_params).returns(create(:org)) + @controller.send(:handle_org, attrs: @params) + expect(Org.all.length).to eql(count + 1) + end + end + + end + +end diff --git a/spec/controllers/usage_controller_spec.rb b/spec/controllers/usage_controller_spec.rb index a9ac495d60..7b87713b42 100644 --- a/spec/controllers/usage_controller_spec.rb +++ b/spec/controllers/usage_controller_spec.rb @@ -73,46 +73,6 @@ end end - describe "POST /usage_filter" do - before(:each) do - @date2 = Date.today.months_ago(3).end_of_month.strftime("%Y-%m-%d") - @user_stat2 = create(:stat_joined_user, date: @date2, org: @org) - @plan_stat2 = create(:stat_created_plan, date: @date2, org: @org, details: @details) - end - - context "date range specified" do - before(:each) do - @args = { - start_date: Date.today.months_ago(3).strftime("%Y-%m-%d"), - end_date: @date2 - } - end - it "returns the correct values for users" do - post :filter, usage: @args.merge({ topic: "users" }), format: :js - expect(assigns(:ranged)).to eql(@user_stat2.count) - expect(assigns(:total)).to eql(@user_stat2.count + @user_stat.count) - end - it "returns the correct values for plans" do - post :filter, usage: @args.merge({ topic: "plans" }), format: :js - expect(assigns(:ranged)).to eql(@plan_stat2.count) - expect(assigns(:total)).to eql(@plan_stat2.count + @plan_stat.count) - end - end - - context "no date range specified" do - it "returns the correct values for users" do - post :filter, usage: { topic: "users" }, format: :js - expect(assigns(:ranged)).to eql(@user_stat2.count + @user_stat.count) - expect(assigns(:total)).to eql(@user_stat2.count + @user_stat.count) - end - it "returns the correct values for plans" do - post :filter, usage: { topic: "plans" }, format: :js - expect(assigns(:ranged)).to eql(@plan_stat2.count + @plan_stat.count) - expect(assigns(:total)).to eql(@plan_stat2.count + @plan_stat.count) - end - end - end - describe "GET /usage_yearly_users" do before(:each) do get :yearly_users diff --git a/spec/factories/annotations.rb b/spec/factories/annotations.rb index 37fce14fe6..519ac954ad 100644 --- a/spec/factories/annotations.rb +++ b/spec/factories/annotations.rb @@ -13,6 +13,7 @@ # # Indexes # +# fk_rails_aca7521f72 (org_id) # index_annotations_on_question_id (question_id) # index_annotations_on_versionable_id (versionable_id) # diff --git a/spec/factories/answers.rb b/spec/factories/answers.rb index fafe4822dd..4dacb9de97 100644 --- a/spec/factories/answers.rb +++ b/spec/factories/answers.rb @@ -7,13 +7,16 @@ # text :text # created_at :datetime # updated_at :datetime -# label_id :string +# label_id :string(255) # plan_id :integer # question_id :integer # user_id :integer # # Indexes # +# fk_rails_3d5ed4418f (question_id) +# fk_rails_584be190c2 (user_id) +# fk_rails_84a6005a3e (plan_id) # index_answers_on_plan_id (plan_id) # index_answers_on_question_id (question_id) # diff --git a/spec/factories/api_clients.rb b/spec/factories/api_clients.rb new file mode 100644 index 0000000000..1d1d30d696 --- /dev/null +++ b/spec/factories/api_clients.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: api_clients +# +# 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 +# +# Indexes +# +# index_api_clients_on_name (name) +# + +FactoryBot.define do + factory :api_client do + name { Faker::Lorem.unique.word } + homepage { Faker::Internet.url } + contact_name { Faker::Movies::StarWars.character } + contact_email { Faker::Internet.email } + client_id { SecureRandom.uuid } + client_secret { SecureRandom.uuid } + end +end diff --git a/spec/factories/conditions.rb b/spec/factories/conditions.rb new file mode 100644 index 0000000000..06f0ffcc4c --- /dev/null +++ b/spec/factories/conditions.rb @@ -0,0 +1,30 @@ +# == Schema Information +# +# 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 +# +# Indexes +# +# index_conditions_on_question_id (question_id) +# +# Foreign Keys +# +# fk_rails_... (question_id => question.id) +# +# + +FactoryBot.define do + factory :condition do + option_list { nil } + remove_data { nil } + end +end diff --git a/spec/factories/contributors.rb b/spec/factories/contributors.rb new file mode 100644 index 0000000000..7af1a6b0de --- /dev/null +++ b/spec/factories/contributors.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: contributors +# +# id :integer not null, primary key +# name :string +# email :string +# phone :string +# roles :integer +# org_id :integer +# plan_id :integer +# created_at :datetime +# updated_at :datetime +# +# Indexes +# +# index_contributors_on_id (id) +# index_contributors_on_email (email) +# index_contributors_on_org_id (org_id) +# +# Foreign Keys +# +# fk_rails_... (org_id => orgs.id) +# fk_rails_... (plan_id => plans.id) + +FactoryBot.define do + factory :contributor do + org + name { Faker::Movies::StarWars.character } + email { Faker::Internet.email } + phone { Faker::PhoneNumber.phone_number_with_country_code } + + transient do + roles_count { 1 } + end + + before(:create) do |contributor, evaluator| + (0..evaluator.roles_count - 1).each do |idx| + contributor.send(:"#{contributor.all_roles[idx]}=", true) + end + end + end +end diff --git a/spec/factories/exported_plans.rb b/spec/factories/exported_plans.rb index d1a1c22955..4e5163f9f6 100644 --- a/spec/factories/exported_plans.rb +++ b/spec/factories/exported_plans.rb @@ -13,9 +13,6 @@ FactoryBot.define do factory :exported_plan do - user - plan - phase_id { create(:phase).id } - format { ExportedPlan::VALID_FORMATS.sample } + format { %w[csv txt docx pdf xml].sample } end end diff --git a/spec/factories/identifier_schemes.rb b/spec/factories/identifier_schemes.rb index b6e7e3285a..c308c155d7 100644 --- a/spec/factories/identifier_schemes.rb +++ b/spec/factories/identifier_schemes.rb @@ -2,14 +2,15 @@ # # Table name: identifier_schemes # -# id :integer not null, primary key -# active :boolean -# description :string -# logo_url :string -# name :string -# user_landing_url :string -# created_at :datetime -# updated_at :datetime +# id :integer not null, primary key +# active :boolean +# description :string +# context :integer +# logo_url :text +# name :string +# identifier_prefix :string +# created_at :datetime +# updated_at :datetime # FactoryBot.define do @@ -17,7 +18,17 @@ name { Faker::Company.unique.name[0..29] } description { Faker::Movies::StarWars.quote } logo_url { Faker::Internet.url } - user_landing_url { Faker::Internet.url } + identifier_prefix { "#{Faker::Internet.url}/" } active { true } + + transient do + context_count { 1 } + end + + after(:create) do |identifier_scheme, evaluator| + (0..evaluator.context_count - 1).each do |idx| + identifier_scheme.update("#{identifier_scheme.all_context[idx]}": true) + end + end end end diff --git a/spec/factories/identifiers.rb b/spec/factories/identifiers.rb new file mode 100644 index 0000000000..49d4a1b832 --- /dev/null +++ b/spec/factories/identifiers.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: identifiers +# +# id :integer not null, primary key +# attrs :text +# identifiable_type :string +# value :string not null +# created_at :datetime +# updated_at :datetime +# identifiable_id :integer +# identifier_scheme_id :integer not null +# +# Indexes +# +# index_identifiers_on_identifiable_type_and_identifiable_id (identifiable_type,identifiable_id) +# + +FactoryBot.define do + factory :identifier do + identifier_scheme + for_user + + value { Faker::Lorem.word } + attrs { {} } + + trait :for_plan do + association :identifiable, factory: :plan + end + trait :for_org do + association :identifiable, factory: :org + end + trait :for_user do + association :identifiable, factory: :user + end + end +end diff --git a/spec/factories/languages.rb b/spec/factories/languages.rb index 6776c23eb6..99f477d6c5 100644 --- a/spec/factories/languages.rb +++ b/spec/factories/languages.rb @@ -11,9 +11,9 @@ FactoryBot.define do factory :language do - name { Faker::Language.name } + name { Faker::Language.unique.name } description { "Language for #{name}" } - abbreviation { Faker::Language.abbreviation } + abbreviation { Faker::Language.unique.abbreviation } default_language { false } trait :with_dialect do abbreviation { diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index 5336a3dfd8..0318411976 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -13,6 +13,7 @@ # # Indexes # +# fk_rails_7f2323ad43 (user_id) # index_notes_on_answer_id (answer_id) # # Foreign Keys diff --git a/spec/factories/notifications.rb b/spec/factories/notifications.rb index 1cacc6ebbd..c50df7fae8 100644 --- a/spec/factories/notifications.rb +++ b/spec/factories/notifications.rb @@ -10,6 +10,7 @@ # notification_type :integer # starts_at :date # title :string +# enable :boolean # created_at :datetime not null # updated_at :datetime not null # @@ -22,10 +23,12 @@ body { Faker::Lorem.paragraph } dismissable { false } starts_at { Time.current } + enabled { false } expires_at { starts_at + 2.days } trait :active do starts_at { Date.today } + enabled { true } end trait :dismissable do dismissable { true } diff --git a/spec/factories/org_identifiers.rb b/spec/factories/org_identifiers.rb deleted file mode 100644 index dbfafef825..0000000000 --- a/spec/factories/org_identifiers.rb +++ /dev/null @@ -1,26 +0,0 @@ -# == Schema Information -# -# Table name: org_identifiers -# -# id :integer not null, primary key -# attrs :string -# identifier :string -# created_at :datetime -# updated_at :datetime -# identifier_scheme_id :integer -# org_id :integer -# -# Foreign Keys -# -# fk_rails_... (identifier_scheme_id => identifier_schemes.id) -# fk_rails_... (org_id => orgs.id) -# - -FactoryBot.define do - factory :org_identifier do - identifier { Faker::Lorem.word } - attrs { Hash.new } - org - identifier_scheme - end -end diff --git a/spec/factories/orgs.rb b/spec/factories/orgs.rb index 4f164583ab..ad20015270 100644 --- a/spec/factories/orgs.rb +++ b/spec/factories/orgs.rb @@ -13,6 +13,7 @@ # links :text # logo_name :string # logo_uid :string +# managed :boolean default(FALSE), not null # name :string # org_type :integer default(0), not null # sort_name :string @@ -20,12 +21,14 @@ # created_at :datetime not null # updated_at :datetime not null # language_id :integer -# region_id :integer +# +# Indexes +# +# fk_rails_5640112cab (language_id) # # Foreign Keys # # fk_rails_... (language_id => languages.id) -# fk_rails_... (region_id => regions.id) # FactoryBot.define do @@ -42,6 +45,7 @@ is_other { false } contact_email { Faker::Internet.safe_email } contact_name { Faker::Name.name } + managed { true } trait :institution do institution { true } end @@ -63,10 +67,12 @@ transient do templates { 0 } + plans { 0 } end after :create do |org, evaluator| create_list(:template, evaluator.templates, :published, org: org) + create_list(:plan, evaluator.plans) end # ---------------------------------------------------- @@ -75,8 +81,8 @@ trait :shibbolized do after :create do |org, evaluator| scheme = IdentifierScheme.find_or_create_by(name: "shibboleth") - create(:org_identifier, org_id: org.id, identifier_scheme: scheme, - identifier: SecureRandom.hex(4)) + create(:identifier, identifiable: org, identifier_scheme: scheme, + value: SecureRandom.hex(4)) end end # ---------------------------------------------------- @@ -84,5 +90,3 @@ # ---------------------------------------------------- end end - - diff --git a/spec/factories/plans.rb b/spec/factories/plans.rb index ca51a56a69..a41be4838a 100644 --- a/spec/factories/plans.rb +++ b/spec/factories/plans.rb @@ -21,29 +21,45 @@ # created_at :datetime # updated_at :datetime # template_id :integer +# org_id :integer +# funder_id :integer +# grant_id :integer +# api_client_id :integer # # Indexes # -# index_plans_on_template_id (template_id) +# 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) # # Foreign Keys # # fk_rails_... (template_id => templates.id) +# fk_rails_... (org_id => orgs.id) # FactoryBot.define do factory :plan do title { Faker::Company.bs } template + org + # TODO: Drop this column once the funder_id has been back filled + # and we're removing the is_other org stuff grant_number { SecureRandom.rand(1_000) } identifier { SecureRandom.hex } description { Faker::Lorem.paragraph } principal_investigator { Faker::Name.name } + # TODO: Drop this column once the funder_id has been back filled + # and we're removing the is_other org stuff funder_name { Faker::Company.name } data_contact_email { Faker::Internet.safe_email } principal_investigator_email { Faker::Internet.safe_email } feedback_requested { false } complete { false } + start_date { Time.now } + end_date { start_date + 2.years } + transient do phases { 0 } answers { 0 } diff --git a/spec/factories/questions.rb b/spec/factories/questions.rb index 074de3cced..c846f0f44a 100644 --- a/spec/factories/questions.rb +++ b/spec/factories/questions.rb @@ -16,6 +16,7 @@ # # Indexes # +# fk_rails_4fbc38c8c7 (question_format_id) # index_questions_on_section_id (section_id) # index_questions_on_versionable_id (versionable_id) # diff --git a/spec/factories/regions.rb b/spec/factories/regions.rb index 99a62996e4..ae3740b08b 100644 --- a/spec/factories/regions.rb +++ b/spec/factories/regions.rb @@ -2,11 +2,12 @@ # # Table name: regions # -# id :integer not null, primary key -# abbreviation :string -# description :string -# name :string -# super_region_id :integer +# id :integer not null, primary key +# abbreviation :string +# description :string +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null # FactoryBot.define do diff --git a/spec/factories/splash_logs.rb b/spec/factories/splash_logs.rb deleted file mode 100644 index 816a8543aa..0000000000 --- a/spec/factories/splash_logs.rb +++ /dev/null @@ -1,15 +0,0 @@ -# == Schema Information -# -# Table name: splash_logs -# -# id :integer not null, primary key -# destination :string -# created_at :datetime not null -# updated_at :datetime not null -# - -FactoryBot.define do - factory :splash_log do - - end -end diff --git a/spec/factories/stat_created_plan.rb b/spec/factories/stat_created_plan.rb index 831f9a0c59..e57e2103b1 100644 --- a/spec/factories/stat_created_plan.rb +++ b/spec/factories/stat_created_plan.rb @@ -1,3 +1,19 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: stats +# +# id :integer not null, primary key +# count :integer default(0) +# date :date not null +# details :text +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# + FactoryBot.define do factory :stat_created_plan do date { Date.today } diff --git a/spec/factories/stat_joined_user.rb b/spec/factories/stat_joined_user.rb index f53cb948d5..f645ad862d 100644 --- a/spec/factories/stat_joined_user.rb +++ b/spec/factories/stat_joined_user.rb @@ -1,3 +1,19 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: stats +# +# id :integer not null, primary key +# count :integer default(0) +# date :date not null +# details :text +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# + FactoryBot.define do factory :stat_joined_user do date { Date.today } diff --git a/spec/factories/trackers.rb b/spec/factories/trackers.rb new file mode 100644 index 0000000000..9897d59ab1 --- /dev/null +++ b/spec/factories/trackers.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :tracker do + org { nil } + code { "MyString" } + end +end diff --git a/spec/factories/user_identifiers.rb b/spec/factories/user_identifiers.rb deleted file mode 100644 index d338189aac..0000000000 --- a/spec/factories/user_identifiers.rb +++ /dev/null @@ -1,28 +0,0 @@ -# == Schema Information -# -# Table name: user_identifiers -# -# id :integer not null, primary key -# identifier :string -# created_at :datetime -# updated_at :datetime -# identifier_scheme_id :integer -# user_id :integer -# -# Indexes -# -# index_user_identifiers_on_user_id (user_id) -# -# Foreign Keys -# -# fk_rails_... (identifier_scheme_id => identifier_schemes.id) -# fk_rails_... (user_id => users.id) -# - -FactoryBot.define do - factory :user_identifier do - identifier { SecureRandom.hex } - user - identifier_scheme - end -end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 995add1a72..1cdc603bc9 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -41,7 +41,9 @@ # # Indexes # -# index_users_on_email (email) UNIQUE +# fk_rails_45f4f12508 (language_id) +# fk_rails_f29bf9cdf2 (department_id) +# index_users_on_email (email) # index_users_on_org_id (org_id) # # Foreign Keys @@ -81,6 +83,7 @@ after(:create) do |user, evaluator| %w[modify_templates modify_guidance change_org_details + use_api grant_permissions].each do |perm_name| user.perms << Perm.find_or_create_by(name: perm_name) end diff --git a/spec/features/feedback_requests_spec.rb b/spec/features/feedback_requests_spec.rb index 69b0ed7f1c..4c70523d0e 100644 --- a/spec/features/feedback_requests_spec.rb +++ b/spec/features/feedback_requests_spec.rb @@ -2,6 +2,8 @@ RSpec.describe "FeedbackRequests", type: :feature do + include Webmocks + let!(:plan) { create(:plan, :organisationally_visible) } let!(:org) do @@ -15,6 +17,7 @@ plan.roles << create(:role, :commenter, :creator, :editor, :administrator, user: user) sign_in(user) ActionMailer::Base.deliveries = [] + stub_openaire end after do diff --git a/spec/features/plans_spec.rb b/spec/features/plans_spec.rb index eaf307472d..3b5ea84eda 100644 --- a/spec/features/plans_spec.rb +++ b/spec/features/plans_spec.rb @@ -12,6 +12,7 @@ @user = create(:user, org: @org) sign_in(@user) +=begin OpenURI.expects(:open_uri).returns(<<~XML @@ -25,26 +26,22 @@ XML ) - +=end end scenario "User creates a new Plan", :js do +# TODO: Revisit this after we start refactoring/building out or tests for +# the new create plan workflow. For some reason the plans/new.js isn't +# firing here but works fine in the UI with manual testing +=begin # Action click_link "Create plan" fill_in :plan_title, with: "My test plan" - fill_in :plan_org_name, with: @research_org.name + fill_in :org_org_name, with: @research_org.name + choose_suggestion(@research_org.name) - # -------------------------------------------------------- - # Start DMPTool Customization - # -------------------------------------------------------- - #find('#suggestion-2-0').click - #fill_in :plan_funder_name, with: @funding_org.name - #find('#suggestion-3-0').click - find('#suggestion-1-0').click - fill_in :plan_funder_name, with: @funding_org.name - # -------------------------------------------------------- - # End DMPTool Customization - # -------------------------------------------------------- + fill_in :funder_org_name, with: @funding_org.name + choose_suggestion(@funding_org.name) click_button "Create plan" # Expectations @@ -82,7 +79,8 @@ expect(current_path).to eql(overview_plan_path(@plan)) expect(@plan.title).to eql("My test plan") - expect(@plan.funder_name).to eql(@funding_org.name) + expect(@plan.org_id).to eql(@research_org.id) + expect(@plan.funder_id).to eql(@funding_org.id) expect(@plan.grant_number).to eql("115797") expect(@plan.description).to eql("Plan abstract...") expect(@plan.identifier).to eql("ABCDEF") @@ -101,6 +99,7 @@ # -------------------------------------------------------- expect(@plan.principal_investigator_email).to eql(@user.email) expect(@plan.principal_investigator_phone).to eql("07787 000 0000") +=end end end diff --git a/spec/features/registrations_spec.rb b/spec/features/registrations_spec.rb index 0cbdfe944b..0e8ec60ee0 100644 --- a/spec/features/registrations_spec.rb +++ b/spec/features/registrations_spec.rb @@ -25,6 +25,10 @@ password: "testing123", email: "john.doe@testing-dmproadmap.org" } } + + before(:each) do + mock_blog + end # ------------------------------------------------------------- # end DMPTool customization # ------------------------------------------------------------- @@ -51,20 +55,7 @@ fill_in "First Name", with: user_attributes[:firstname] fill_in "Last Name", with: user_attributes[:surname] fill_in "Email", with: user_attributes[:email] - - # ------------------------------------------------------------- - # start DMPTool customization - # We do not allow users to select an org - # For some reason Chrome headless is triggering a: - # 'Please include a '@' character' message sometimes - # ------------------------------------------------------------- - #fill_in "Organisation", with: org.name - ## Click from the dropdown autocomplete - #find("#suggestion-1-0").click - # ------------------------------------------------------------- - # end DMPTool customization - # ------------------------------------------------------------- - + select_an_org("#new_user_org_name", org) fill_in "Password", with: user_attributes[:password] check "Show password" check "I accept the terms and conditions" @@ -103,18 +94,7 @@ fill_in "First Name", with: user_attributes[:firstname] fill_in "Last Name", with: user_attributes[:surname] fill_in "Email", with: "invalid-email" - - # ------------------------------------------------------------- - # start DMPTool customization - # We do not allow users to select an org - # ------------------------------------------------------------- - #fill_in "Organisation", with: org.name - ## Click from the dropdown autocomplete - #find("#suggestion-1-0").click - # ------------------------------------------------------------- - # end DMPTool customization - # ------------------------------------------------------------- - + select_an_org("#new_user_org_name", org) fill_in "Password", with: user_attributes[:password] check "Show password" check "I accept the terms and conditions" @@ -126,4 +106,4 @@ expect(User.count).to be_zero end -end \ No newline at end of file +end diff --git a/spec/features/sessions_spec.rb b/spec/features/sessions_spec.rb index 11d569a6d1..a08f70f43a 100644 --- a/spec/features/sessions_spec.rb +++ b/spec/features/sessions_spec.rb @@ -7,6 +7,10 @@ # Initialize the is_other org # ------------------------------------------------------------- include DmptoolHelper + + before(:each) do + mock_blog + end # ------------------------------------------------------------- # end DMPTool customization # ------------------------------------------------------------- @@ -62,46 +66,4 @@ expect(page).to have_text("Error") end - # ------------------------------------------------------------- - # start DMPTool customization - # Shibboleth sign in - # ------------------------------------------------------------- - scenario "User is redirected to Shibboleth Login for a shibbolized org", :js do - generate_shibbolized_orgs(12) - org = Org.participating.first - - # Setup - visit root_path - access_shib_ds_modal - find("#shib-ds_org_name").set(org.name) - sleep(0.2) - ## Click from the dropdown autocomplete - find("#suggestion-1-0").click - #click_button "Go" - click_link "See the full list of participating institutions" - first("a[href^=\"/orgs/shibboleth/\"]").click - expect(current_path).to eql("/Shibboleth.sso/Login") - end - - scenario "User is shown the Org's custom sign in page for non-shibbolized Orgs", :js do - org = create(:org, is_other: false) - generate_shibbolized_orgs(10) - - # Setup - visit root_path - access_shib_ds_modal - find("#shib-ds_org_name").set(org.name) - sleep(0.2) - ## Click from the dropdown autocomplete - find("#suggestion-1-0").click - #click_button "Go" - click_link "See the full list of participating institutions" - first("a[href^=\"/org_logos/\"]").click - - expect(find(".branding-name").present?).to eql(true) - end - # ------------------------------------------------------------- - # end DMPTool customization - # ------------------------------------------------------------- - end diff --git a/spec/features/super_admins/org_swaps_spec.rb b/spec/features/super_admins/org_swaps_spec.rb index 20c13c4a0b..9c33600fbc 100644 --- a/spec/features/super_admins/org_swaps_spec.rb +++ b/spec/features/super_admins/org_swaps_spec.rb @@ -19,7 +19,7 @@ sign_in(@user) click_link "Admin" click_link "Templates" - find('[aria-describedby="label-id-superadmin_user_org_name"]').click + find("#superadmin_user_org_name").click fill_in(:superadmin_user_org_name, with: @org2.name[0..4]) choose_suggestion(@org2.name) click_button "Change affiliation" diff --git a/spec/mixins/dmptool/controller/home_spec.rb b/spec/mixins/dmptool/controller/home_spec.rb deleted file mode 100644 index bfefe2373f..0000000000 --- a/spec/mixins/dmptool/controller/home_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -require 'rails_helper' - -RSpec.describe 'DMPTool custom home page', type: :request do - - describe '#render_home_page' do - - context 'statistics' do - - let!(:other_org) { create(:org, is_other: true) } - - it 'has the correct number of users' do - (0..4).each { create(:user, org: other_org) } - get root_path - expect(assigns(:stats)[:user_count]).to eql(5) - end - - it 'has the correct number of plans' do - (0..4).each { create(:plan) } - get root_path - expect(assigns(:stats)[:completed_plan_count]).to eql(5) - end - - it 'has the correct number of orgs' do - (0..4).each { create(:org, is_other: false) } - get root_path - expect(assigns(:stats)[:institution_count]).to eql(5) - end - - end - - context 'top_templates' do - - let!(:org) { create(:org, is_other: false) } - let!(:templates) { (0..12).map { create(:template, :published, phases: 1, org: org) } } - - before do - templates.each_with_index do |tmplt, i| - i.times do - create(:plan, :publicly_visible, :creator, template: tmplt, - created_at: Date.today-1, complete: i.odd?) - end - end - end - - it 'has the correct number of templates' do - get root_path - expect(assigns(:top_5).length).to eql(5) - end - - it 'includes the correct templates' do - get root_path - # The Top 5 template count should be based on the number of plans - ids = Plan.group(:template_id).order("count_id DESC").count(:id).keys - ids[0..4].each do |id| - expect(assigns(:top_5).include?(Template.find(id).title)).to eql(true) - end - end - - end - - context 'rss' do - # Skipping this test since it relies on an external WP blog. We could stub - end - - end - -end diff --git a/spec/mixins/dmptool/controller/omniauth_callbacks_spec.rb b/spec/mixins/dmptool/controller/omniauth_callbacks_spec.rb deleted file mode 100644 index d4a8da54dd..0000000000 --- a/spec/mixins/dmptool/controller/omniauth_callbacks_spec.rb +++ /dev/null @@ -1,131 +0,0 @@ -require 'rails_helper' - -RSpec.describe 'DMPTool custom handler for Omniauth callbacks', type: :controller do - - include Devise::Test::ControllerHelpers - - describe '#process_omniauth_callback' do - - let!(:org) { create(:org, is_other: false) } - let!(:shibboleth) { create(:identifier_scheme, name: "shibboleth") } - let!(:orcid) { create(:identifier_scheme, name: "orcid") } - - before do - OrgIdentifier.create( org: org, identifier_scheme: shibboleth, identifier: "test-org") - @controller = Users::OmniauthCallbacksController.new - request.env["devise.mapping"] = Devise.mappings[:user] - end - - context "user is already signed in" do - - let!(:user) { create(:user, org: org) } - - before do - sign_in(user) - end - - context "linking account to shibboleth" do - - before do - request.env["omniauth.auth"] = mock_omniauth_call("shibboleth", user) - end - - it "should create the identifier and display success message" do - get :shibboleth - expect(flash[:notice]).to eql("Your account has been successfully linked to your institutional credentials.") - expect(response).to redirect_to("/users/edit") - end - - it "should update the identifier and display success message" do - UserIdentifier.create(identifier_scheme: shibboleth, user: user, identifier: "foo") - get :shibboleth - expect(flash[:notice]).to eql("Your account has been successfully linked to your institutional credentials.") - expect(response).to redirect_to("/users/edit") - expect(user.reload.user_identifiers.first.identifier).not_to eql("foo") - end - end - - context "linking account to orcid" do - - before do - request.env["omniauth.auth"] = mock_omniauth_call("orcid", user) - end - - it "should create the identifier and display success message" do - get :orcid - expect(flash[:notice]).to eql("Your account has been successfully linked to #{orcid.description}.") - expect(response).to redirect_to("/users/edit") - end - - it "should update the identifier and display success message" do - UserIdentifier.create(identifier_scheme: orcid, user: user, identifier: "foo") - get :orcid - expect(flash[:notice]).to eql("Your account has been successfully linked to #{orcid.description}.") - expect(response).to redirect_to("/users/edit") - expect(user.reload.user_identifiers.first.identifier).not_to eql("foo") - end - - end - - end - - context "user is NOT signed in but omniauth uid is already registered" do - - let!(:existing_user) { create(:user, org: org) } - let!(:existing_uid) { create(:user_identifier, user: existing_user, - identifier_scheme: shibboleth, identifier: "123ABC") } - before do - request.env["omniauth.auth"] = mock_omniauth_call("shibboleth", existing_user) - end - - it "should display a success message and sign in" do - get :shibboleth - expect(flash[:notice]).to eql("Successfully signed in") - expect(response).to redirect_to("/") - end - - end - - context "user is NOT signed in and omniauth uid not recognized" do - - context "user's email was recognized" do - - let!(:existing_user) { create(:user, org: org) } - - context "was able to associate their account with the omniauth uid via their email address" do - - before do - request.env["omniauth.auth"] = mock_omniauth_call("shibboleth", existing_user) - end - - it "should display success message and login" do - get :shibboleth - expect(flash[:notice]).to eql("Successfully signed in with your institutional credentials.") - expect(response).to redirect_to("/") - expect(existing_user.user_identifiers.first.identifier).to eql("123ABC") - end - - end - - context "was NOT able to associate their account with the omniauth uid or email address" do - before do - request.env["omniauth.auth"] = mock_omniauth_call("shibboleth", existing_user) - existing_user.update_attributes(email: Faker::Internet.unique.safe_email) - end - - it "should display a warning message and load the finish account creation page" do - get :shibboleth - expect(flash[:notice]).to eql("It looks like this is your first time logging in. Please verify and complete the information below to finish creating an account.") - expect(response).to render_template(:new) #"/users/sign_up") - expect(existing_user.user_identifiers.length).to eql(0) - end - - end - - end - - end - - end - -end diff --git a/spec/mixins/dmptool/controller/orgs_spec.rb b/spec/mixins/dmptool/controller/orgs_spec.rb deleted file mode 100644 index c4480fbab8..0000000000 --- a/spec/mixins/dmptool/controller/orgs_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -require 'rails_helper' - -RSpec.describe 'DMPTool custom endpoint to retrieve Org logo/name', type: :request do - - describe '#logos' do - - let!(:org) { create(:org) } - - it "should be accessible when not logged in" do - get org_logo_path(org.id) - expect(response).to have_http_status(:success) - end - - it "should throw a RecordNotFound exception" do - expect{ get org_logo_path(99999) }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'returns json that includes the org name if the org exists but has no logo' do - get org_logo_path(org.id) - json = JSON.parse(response.body) - expect(assigns(:user).org).to eql(org) - expect(json["org"]["html"].include?("branding-name")).to eql(true) - end - - it 'returns json that includes the logo if the org has a logo' do - org.update_attributes(logo: File.read(Rails.root.join("app", "assets", "images", "logo.png"))) - get org_logo_path(org.id) - json = JSON.parse(response.body) - expect(assigns(:user).org).to eql(org) - expect(json["org"]["html"].include?("org-logo")).to eql(true) - end - - end - -end diff --git a/spec/mixins/dmptool/controller/paginable_spec.rb b/spec/mixins/dmptool/controller/paginable_spec.rb deleted file mode 100644 index ae6984999c..0000000000 --- a/spec/mixins/dmptool/controller/paginable_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -require 'rails_helper' - -RSpec.describe 'DMPTool custom endpoint to public Orgs paginated page', type: :request do - - describe '#public' do - - let!(:funder) { create(:org, :funder) } - let!(:institution) { create(:org, :institution) } - let!(:organisation) { create(:org, :organisation) } - let!(:research_institute) { create(:org, :research_institute) } - let!(:project) { create(:org, :project) } - let!(:school) { create(:org, :school) } - - it "should be accessible when not logged in" do - get public_paginable_orgs_path(1) - expect(response).to have_http_status(:success) - end - - it "should not include funder Org" do - get public_paginable_orgs_path(1) - expect(response.body.include?(funder.name)).to eql(false) - end - - it 'should include any non-funder Orgs' do - get public_paginable_orgs_path(1) - expect(response.body.include?(institution.name)).to eql(true) - expect(response.body.include?(organisation.name)).to eql(true) - expect(response.body.include?(research_institute.name)).to eql(true) - expect(response.body.include?(project.name)).to eql(true) - expect(response.body.include?(school.name)).to eql(true) - end - - end - -end diff --git a/spec/mixins/dmptool/controller/public_pages_spec.rb b/spec/mixins/dmptool/controller/public_pages_spec.rb deleted file mode 100644 index d4ddf51d75..0000000000 --- a/spec/mixins/dmptool/controller/public_pages_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -require 'rails_helper' - -RSpec.describe 'DMPTool custom endpoints for public pages', type: :request do - - describe '#orgs' do - - let!(:funder) { create(:org, :funder, name: Faker::Name.unique.name) } - let!(:institution) { create(:org, :institution, name: Faker::Name.unique.name) } - let!(:organisation) { create(:org, :organisation, name: Faker::Name.unique.name) } - - it "should be accessible when not logged in" do - get public_orgs_path - expect(response).to have_http_status(:success) - end - - it 'should not include a funder Org' do - get public_orgs_path - expect(response.body.include?("
    <%= _('Project Title') %> <%= paginable_sort_link('plans.title') %> <%= _('Template') %> <%= paginable_sort_link('templates.title') %><%= _('Organisation') %><%= _('Organisation') %> <%= paginable_sort_link('orgs.name') %> <%= _('Owner') %><%= _('Created') %> <%= paginable_sort_link('plans.created_at') %> <%= _('Updated') %> <%= paginable_sort_link('plans.updated_at') %> <%= _('Visibility') %>
    <%= plan.template.title %><%= plan.owner.org.name %><%= plan.owner.name(false) %><%= plan.owner&.org&.name %><%= plan.owner&.name(false) %><%= l(plan.created_at.to_date, formats: :short) %> <%= l(plan.updated_at.to_date, formats: :short) %> <%= plan.visibility === 'is_test' ? _('Test') : sanitize(display_visibility(plan.visibility)) %> diff --git a/app/views/paginable/plans/_org_admin_other_user.html.erb b/app/views/paginable/plans/_org_admin_other_user.html.erb index 1ea1cd663b..e4ddfcbde6 100644 --- a/app/views/paginable/plans/_org_admin_other_user.html.erb +++ b/app/views/paginable/plans/_org_admin_other_user.html.erb @@ -21,8 +21,8 @@ <% end %> <%= plan.template.title %><%= plan.owner.org.name %><%= plan.owner.name(false) %><%= plan.owner&.org&.name %><%= plan.owner&.name(false) %> <%= l(plan.updated_at.to_date, formats: :short) %> <%= plan.visibility === 'is_test' ? _('Test') : sanitize(display_visibility(plan.visibility)) %> diff --git a/app/views/paginable/users/_index.html.erb b/app/views/paginable/users/_index.html.erb index 7b27a7140f..bdd8075f3d 100644 --- a/app/views/paginable/users/_index.html.erb +++ b/app/views/paginable/users/_index.html.erb @@ -1,5 +1,16 @@ <% is_super_admin = current_user.can_super_admin? %> <% is_org_admin = current_user.can_org_admin? %> +<% if @clicked_through %> +

    <%= _(<<-TEXT + The data on the usage dashboard is historical in nature. This means that the number of records below may not + match the count shown on the usage dashboard. For example if one of your users joined in October and then + moved to a different organization or deactivated their account, they would have been included on the usage + dashboard's total for October but would not appear in the list below. + TEXT + ) %>

    +<% end %> + +

    <%= _("Note: You can filter this table by 'Created date'. Enter the month abbreviation and a 4 digit year into the search box above.
    For example: 'Oct 2019' or 'Jun 2013'.").html_safe %>

    @@ -15,7 +26,7 @@
    <%= _('Plans') %> <%= _('Current Privileges') %> <%= _('Active') %><%= _('Privileges') %><%= _('Identifiers') %>
    <%= render partial: 'users/current_privileges', locals: { user: user } %> +
    + <%# Do not allow a user to change their own permissions or a super admin's permissions if they are not a super admin %> + <% unless current_user == user || !is_super_admin && user.can_super_admin? %> + <%= link_to( _('Edit'), admin_grant_permissions_user_path(user)) %> + <% end %>
    <% if is_super_admin %> @@ -74,9 +90,9 @@ <% end %> - <%# Do not allow a user to change their own permissions or a super admin's permissions if they are not a super admin %> - <% unless current_user == user || !is_super_admin && user.can_super_admin? %> - <%= link_to( _('Edit'), admin_grant_permissions_user_path(user)) %> + <% presenter = IdentifierPresenter.new(identifiable: user) %> + <% presenter.identifiers.each do |identifier| %> +

    <%= presenter.id_for_display(id: identifier, with_scheme_name: true).html_safe %>

    <% end %>
    #{funder.name}")).to eql(false) - end - - it 'returns json that includes the org names if the org is an institution or organisation' do - get public_orgs_path - expect(response.body.include?("#{institution.name}")).to eql(true) - expect(response.body.include?("#{organisation.name}")).to eql(true) - end - - end - - describe "#get_started" do - - it "should be accessible when not logged in" do - get get_started_path - expect(response).to have_http_status(:success) - end - - end - - describe "strip newline and punctuation characters from file_name for PDF/DOCX" do - class TestPublicPagesController < PublicPagesController - def test_file_name(name) - file_name(name) - end - end - - let!(:ctrl) { TestPublicPagesController.new } - - it "replaces spaces, periods, commas, and colons with underscores" do - expect(ctrl.test_file_name("A title with spaces")).to eql("A_title_with_spaces") - expect(ctrl.test_file_name("A title with, comma")).to eql("A_title_with_comma") - expect(ctrl.test_file_name("A title with. period")).to eql("A_title_with_period") - expect(ctrl.test_file_name("A title with: colon")).to eql("A_title_with_colon") - expect(ctrl.test_file_name("A title with; semicolon")).to eql("A_title_with_semicolon") - end - - it "removes newlines and carriage returns" do - expect(ctrl.test_file_name("A title with\nnewline")).to eql("A_title_with_newline") - expect(ctrl.test_file_name("A title with\rcarriage return")).to eql("A_title_with_carriage_return") - expect(ctrl.test_file_name("A title with\r\nboth")).to eql("A_title_with__both") - expect(ctrl.test_file_name("A title with -newline")).to eql("A_title_with_newline") - end - - it "only uses the first 30 characters" do - expect(ctrl.test_file_name("0123456789012345678901234567890B")).to eql("0123456789012345678901234567890") - end - end - -end diff --git a/spec/mixins/dmptool/controller/users_spec.rb b/spec/mixins/dmptool/controller/users_spec.rb deleted file mode 100644 index e8c6e7e6a9..0000000000 --- a/spec/mixins/dmptool/controller/users_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -require 'rails_helper' - -RSpec.describe 'DMPTool custom endpoints to static pages', type: :request do - - it "#ldap_username should be accessible when not logged in" do - get users_ldap_username_path - expect(response).to have_http_status(:success) - expect(response.body.include?("

    Forgot email?")).to eql(true) - end - - context "#ldap_account" do - - it "email/username is not found" do - post users_ldap_account_path(username: "invalid") - expect(response.body.include?("We do not recognize the username")).to eql(true) - end - - it "email/username was found" do - create(:user, ldap_username: "tester") - post users_ldap_account_path(username: "tester") - expect(response.body.include?("The DMPTool Account email associated")).to eql(true) - end - - end - -end diff --git a/spec/mixins/dmptool/controllers/home_controller_spec.rb b/spec/mixins/dmptool/controllers/home_controller_spec.rb new file mode 100644 index 0000000000..da72d6ae41 --- /dev/null +++ b/spec/mixins/dmptool/controllers/home_controller_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Dmptool::Controllers::HomeController, type: :request do + + before(:each) do + @controller = ::HomeController.new + mock_blog + end + + it "HomeController includes our customizations" do + expect(@controller.respond_to?(:render_home_page)).to eql(true) + end + + describe "#render_home_page" do + it "#page is accessible when not logged in" do + create(:plan, template: create(:template), created_at: Time.now.yesterday) + get root_path + # Request specs are expensive so just check everything in this one test + expect(response).to have_http_status(:success), "should have received a 200" + expect(assigns(:rss).present?).to eql(true), "should have set @rss" + expect(assigns(:stats).present?).to eql(true), "should have set @stats" + expect(assigns(:top_five).present?).to eql(true), "should have set @top_five" + expect(response.body.include?("

    Welcome to the DMPTool")).to eql(true) + end + end + + context "private methods" do + + describe "#statistics" do + it "returns the contents of the Rails.cache if available" do + val = [Faker::Lorem.paragraph] + Rails.cache.stubs(:read).returns(val) + expect(@controller.send(:statistics)).to eql(val) + end + it "returns 0 for :user_count if no Users" do + expect(@controller.send(:statistics)[:user_count]).to eql(0) + end + it "returns 0 for :completed_plan_count if no Plans" do + expect(@controller.send(:statistics)[:completed_plan_count]).to eql(0) + end + it "returns 0 for :institution_count if no participating Orgs" do + Org.destroy_all + expect(@controller.send(:statistics)[:institution_count]).to eql(0) + end + it "returns the total number of Users" do + create(:user) + expect(@controller.send(:statistics)[:user_count]).to eql(1) + end + it "returns the total number of Plans" do + create(:plan) + expect(@controller.send(:statistics)[:completed_plan_count]).to eql(1) + end + it "returns the total number of participating Orgs" do + # The default org is being generated by the rails_helper! + Org.destroy_all + create(:org, managed: false) + create(:org, managed: true) + expect(@controller.send(:statistics)[:institution_count]).to eql(1) + end + end + + describe "#top_templates" do + before(:each) do + @older = create(:plan, template: create(:template), + created_at: Time.now - 120.days) + 6.times { create(:plan, template: create(:template), created_at: Date.yesterday) } + end + it "returns the contents of the Rails.cache if available" do + val = [Faker::Lorem.paragraph] + Rails.cache.stubs(:read).returns(val) + expect(@controller.send(:top_templates)).to eql(val) + end + it "returns an empty array if no plans were created in last 90 days" do + Plan.destroy_all + expect(@controller.send(:top_templates)).to eql([]) + end + it "returns the top 5 templates" do + expect(@controller.send(:top_templates).length).to eql(5) + end + it "does not include plans that are older than 90 days" do + expect(@controller.send(:top_templates).include?(@older.template)).to eql(false) + end + end + + describe "#feed" do + it "returns the contents of the Rails.cache if available" do + val = [Faker::Lorem.paragraph] + Rails.cache.stubs(:read).returns(val) + expect(@controller.send(:feed)).to eql(val) + end + it "returns an empty array if the Blog feed does not return a 200 code" do + HTTParty.stubs(:get).returns(OpenStruct.new(code: 404, body: nil)) + expect(@controller.send(:feed)).to eql([]) + end + it "returns writes to log if an Error is thrown" do + Rails.logger.expects(:error).at_least(1) + RSS::Parser.stubs(:parse).raises(StandardError.new(Faker::Lorem.word)) + expect(@controller.send(:feed)).to eql([]) + end + it "returns the xml" do + expect(@controller.send(:feed).length).to eql(2) + end + end + + describe "#cache_content" do + before(:each) do + # Rails cache is a NULL_STORE by default unless running in production + # Enable the cache for these tests + memory_store = ActiveSupport::Cache.lookup_store(:memory_store) + Rails.stubs(:cache).returns(memory_store) + + @type = Faker::Lorem.word + @val = Faker::Lorem.sentence + end + after(:each) do + Rails.cache.clear + end + + it "Does not add the item to the Rails cache if :type is not present" do + Rails.cache.expects(:write).at_most(0) + @controller.send(:cache_content, nil, @val) + end + it "Logs errors" do + err = StandardError.new(Faker::Lorem.word) + Rails.logger.expects(:error).at_least(1) + Rails.cache.stubs(:write).raises(err) + @controller.send(:cache_content, @type, @val) + end + it "Adds the item to the Rails cache" do + @controller.send(:cache_content, @type, @val) + expect(Rails.cache.read(@type)).to eql(@val) + end + end + + end + +end diff --git a/spec/mixins/dmptool/controllers/orgs_controller_spec.rb b/spec/mixins/dmptool/controllers/orgs_controller_spec.rb new file mode 100644 index 0000000000..27386d0bf1 --- /dev/null +++ b/spec/mixins/dmptool/controllers/orgs_controller_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Dmptool::Controllers::OrgsController, type: :request do + + before(:each) do + @controller = ::OrgsController.new + @controller.prepend_view_path "app/views/branded" + mock_blog + end + + it "OrgController includes our customizations" do + expect(@controller.respond_to?(:logos)).to eql(true) + end + + describe "GET logos" do + it "page is accessible when not logged in" do + org = create(:org, managed: true) + # stub the logo method + logo = OpenStruct.new({ present?: true }) + logo.stubs(:thumb).returns(OpenStruct.new({ url: Faker::Internet.url })) + Org.any_instance.stubs(:logo).returns(logo) + get org_logo_path(org) + # Request specs are expensive so just check everything in this one test + expect(response).to have_http_status(:success), "should have received a 200" + expect(assigns(:user).present?).to eql(true), "should have set @user" + expect(assigns(:user).org).to eql(org), "should have set @user.org" + json = JSON.parse(response.body) + expect(json["org"].present?).to eql(true) + expect(json["org"]["id"]).to eql(org.id.to_s) + expect(json["org"]["html"].include?("
    Participating Institutions")).to eql(true) + end + end + + describe "#get_started" do + it "should be accessible when not logged in" do + get get_started_path + expect(response).to have_http_status(:success) + expect(response.body.include?("

    Sign in options")).to eql(true) + end + + end + + # rubocop:disable Metrics/LineLength + describe "#file_name" do + it "replaces spaces, periods, commas, and colons with underscores" do + expect(@controller.send(:file_name, "A title with spaces")).to eql("A_title_with_spaces") + expect(@controller.send(:file_name, "A title with, comma")).to eql("A_title_with_comma") + expect(@controller.send(:file_name, "A title with. period")).to eql("A_title_with_period") + expect(@controller.send(:file_name, "A title with: colon")).to eql("A_title_with_colon") + expect(@controller.send(:file_name, "A title with; semicolon")).to eql("A_title_with_semicolon") + end + + it "removes newlines and carriage returns" do + expect(@controller.send(:file_name, "A title with\nnewline")).to eql("A_title_with_newline") + expect(@controller.send(:file_name, "A title with\rcarriage return")).to eql("A_title_with_carriage_return") + expect(@controller.send(:file_name, "A title with\r\nboth")).to eql("A_title_with__both") + expect(@controller.send(:file_name, "A title with +newline")).to eql("A_title_with_newline") + end + + it "only uses the first 30 characters" do + expect(@controller.send(:file_name, "0123456789012345678901234567890B")).to eql("0123456789012345678901234567890") + end + end + # rubocop:enable Metrics/LineLength + +end diff --git a/spec/mixins/dmptool/controller/static_pages_spec.rb b/spec/mixins/dmptool/controllers/static_pages_controller_spec.rb similarity index 61% rename from spec/mixins/dmptool/controller/static_pages_spec.rb rename to spec/mixins/dmptool/controllers/static_pages_controller_spec.rb index c6ab978bb9..bd69fb0761 100644 --- a/spec/mixins/dmptool/controller/static_pages_spec.rb +++ b/spec/mixins/dmptool/controllers/static_pages_controller_spec.rb @@ -1,8 +1,18 @@ -require 'rails_helper' +# frozen_string_literal: true -RSpec.describe 'DMPTool custom endpoints to static pages', type: :request do +require "rails_helper" - it "#promote should be accessible when not logged in" do +RSpec.describe Dmptool::Controllers::StaticPagesController, type: :request do + + before(:each) do + @controller = ::StaticPagesController.new + end + + it "StaticPagesController includes our customizations" do + expect(@controller.respond_to?(:faq)).to eql(true) + end + + it "#pages are accessible when not logged in" do get promote_path expect(response).to have_http_status(:success) expect(response.body.include?("

    Promote the DMPTool")).to eql(true) diff --git a/spec/mixins/dmptool/controllers/users/omniauth_callbacks_controller_spec.rb b/spec/mixins/dmptool/controllers/users/omniauth_callbacks_controller_spec.rb new file mode 100644 index 0000000000..3e35466412 --- /dev/null +++ b/spec/mixins/dmptool/controllers/users/omniauth_callbacks_controller_spec.rb @@ -0,0 +1,306 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Dmptool::Controllers::Users::OmniauthCallbacksController, + type: :controller do + + include Devise::Test::ControllerHelpers + + before(:each) do + @scheme = create(:identifier_scheme, identifier_prefix: nil, name: "shibboleth", + for_authentication: true) + @org = create(:org, managed: true) + @entity_id = create(:identifier, identifiable: @org, identifier_scheme: @scheme, + value: SecureRandom.uuid) + @user = create(:user, org: @org) + + @omniauth_hash = { + "omniauth.auth": mock_omniauth_call(@scheme.name, @user) + }.with_indifferent_access + @controller = Users::OmniauthCallbacksController.new + end + + it "OmniauthCallbacksController includes our customizations" do + expect(@controller.respond_to?(:process_omniauth_callback)).to eql(true) + end + + it "Has a path for every " + + describe "#process_omniauth_callback" do + before(:each) do + request.env["devise.mapping"] = Devise.mappings[:user] + end + + context "user is already signed in" do + before do + sign_in(@user) + end + + describe "linking account to shibboleth" do + before do + request.env["omniauth.auth"] = @omniauth_hash["omniauth.auth"] + # rubocop:disable Metrics/LineLength + @msg = "Your account has been successfully linked to your institutional credentials." + # rubocop:enable Metrics/LineLength + @uid = @omniauth_hash["omniauth.auth"]["uid"] + end + + it "should create the identifier and display success message" do + get :shibboleth + expect(flash[:notice]).to eql(@msg) + expect(response).to redirect_to("/users/edit") + expect(@user.reload.identifiers.last.value).to eql(@uid) + end + + it "should update the identifier and display success message" do + id = create(:identifier, identifier_scheme: @scheme, identifiable: @user, + value: SecureRandom.uuid) + get :shibboleth + expect(flash[:notice]).to eql(@msg) + expect(response).to redirect_to("/users/edit") + expect(id.reload.value).to eql(@uid) + end + end + end + + describe "user is NOT signed in but omniauth uid is already registered" do + before do + @id = create(:identifier, identifier_scheme: @scheme, identifiable: @user, + value: @omniauth_hash["omniauth.auth"]["uid"]) + request.env["omniauth.auth"] = @omniauth_hash["omniauth.auth"] + end + + it "should display a success message and sign in" do + get :shibboleth + expect(flash[:notice].starts_with?("Successfully signed in")).to eql(true) + expect(response).to redirect_to("/") + expect(@user.reload.identifiers.last).to eql(@id) + end + end + + describe "user is NOT signed in and omniauth uid not recognized" do + before(:each) do + request.env["omniauth.auth"] = @omniauth_hash["omniauth.auth"] + @uid = @omniauth_hash["omniauth.auth"]["uid"] + end + + context "user's email was recognized" do + it "should display success message and login" do + @user.identifiers.destroy_all + get :shibboleth + # rubocop:disable Metrics/LineLength + expect(flash[:notice]).to eql("Successfully signed in with your institutional credentials.") + # rubocop:enable Metrics/LineLength + expect(response).to redirect_to("/") + expect(@user.reload.identifiers.last.value).to eql(@uid) + end + end + + context "user's email is not recognized" do + it "should display a warning message and load the finish account creation page" do + @user.update(email: Faker::Internet.unique.email) + get :shibboleth + # rubocop:disable Metrics/LineLength + expect(flash[:notice]).to eql("It looks like this is your first time logging in. Please verify and complete the information below to finish creating an account.") + expect(response).to redirect_to("/users/sign_up") + expect(@user.identifiers.length).to eql(0) + expect(session["devise.shibboleth_data"]).to eql(@omniauth_hash["omniauth.auth"]) + # rubocop:enable Metrics/LineLength + end + end + + end + + end + + context "private methods" do + + describe "#provider(scheme:)" do + it "returns 'institutional credentials' if the scheme name is 'shibboleth'" do + expected = "your institutional credentials" + expect(@controller.send(:provider, scheme: @scheme)).to eql(expected) + end + it "returns the scheme name" do + @scheme.name = Faker::Lorem.word + expect(@controller.send(:provider, scheme: @scheme)).to eql(@scheme.description) + end + end + + describe "#omniauth" do + it "returns an empty hash if the Request has no ENV info" do + @controller.stubs(:request).returns(OpenStruct.new({ env: nil })) + expect(@controller.send(:omniauth)).to eql({}) + end + it "finds the 'omniauth.auth' hash in the Request ENV" do + @controller.stubs(:request).returns(OpenStruct.new({ env: @omniauth_hash })) + expect(@controller.send(:omniauth)).to eql(@omniauth_hash["omniauth.auth"]) + end + it "returns the Request ENV if no 'omniauth.auth' is present" do + hash = { uid: SecureRandom.uuid } + @controller.stubs(:request).returns(OpenStruct.new({ env: hash })) + expect(@controller.send(:omniauth)).to eql(hash) + end + end + + describe "#redirect_to_registration(data:)" do + # Tested above because we need the full HTTP Request object to be available + # to access the session and process a redirect + end + + describe "#attach_omniauth_credentials(user:, scheme:, omniauth:)" do + before(:each) do + @user = create(:user) + end + + it "returns nil if no :user is present" do + rslt = @controller.send(:attach_omniauth_credentials, user: nil, + scheme: @scheme, + omniauth: @hash) + expect(rslt).to eql(false) + end + it "returns nil if no :scheme is present" do + rslt = @controller.send(:attach_omniauth_credentials, user: @user, + scheme: nil, + omniauth: @hash) + expect(rslt).to eql(false) + end + it "returns nil if no :omniauth hash is present" do + rslt = @controller.send(:attach_omniauth_credentials, user: @user, + scheme: @scheme, + omniauth: nil) + expect(rslt).to eql(false) + end + it "updates the User's Identifier :value" do + id = create(:identifier, identifiable: @user, identifier_scheme: @scheme) + hash = { uid: SecureRandom.uuid } + rslt = @controller.send(:attach_omniauth_credentials, user: @user, + scheme: @scheme, + omniauth: hash) + expect(rslt).to eql(id.reload) + expect(rslt.value).to eql(hash[:uid]) + end + it "creates an Identifier for the User" do + hash = { uid: SecureRandom.uuid } + rslt = @controller.send(:attach_omniauth_credentials, user: @user, + scheme: @scheme, + omniauth: hash) + expect(rslt.value).to eql(hash[:uid]) + end + end + + describe "#omniauth_hash_to_new_user(scheme:, omniauth:)" do + before(:each) do + @hash = { + info: { + name: Faker::Movies::StarWars.character, + email: Faker::Internet.email, + identity_provider: @entity_id.value + } + } + end + + it "returns nil if no :scheme is present" do + rslt = @controller.send(:omniauth_hash_to_new_user, scheme: nil, + omniauth: @hash) + expect(rslt).to eql(nil) + end + it "returns nil if no :omniauth hash is present" do + rslt = @controller.send(:omniauth_hash_to_new_user, scheme: @scheme, + omniauth: nil) + expect(rslt).to eql(nil) + end + it "initializes a new User" do + rslt = @controller.send(:omniauth_hash_to_new_user, scheme: @scheme, + omniauth: @hash) + expect(rslt.new_record?).to eql(true) + expect(rslt.org).to eql(@org) + expect(rslt.email).to eql(@hash[:info][:email]) + names = @hash[:info][:name].split + first = names.length > 1 ? names.first : nil + expect(rslt.firstname).to eql(first) + last = names.length > 1 ? names.last : names.first + expect(rslt.surname).to eql(last) + end + end + + describe "#extract_omniauth_email(hash:)" do + it "returns nil if no email is present in the hash" do + expect(@controller.send(:extract_omniauth_email, hash: nil)).to eql(nil) + end + it "return the email" do + hash = { email: Faker::Internet.email } + result = @controller.send(:extract_omniauth_email, hash: hash) + expect(result).to eql(hash[:email]) + end + it "returns the 1st email if there are multiples" do + hash = { email: "#{Faker::Internet.email};#{Faker::Internet.email}" } + result = @controller.send(:extract_omniauth_email, hash: hash) + expect(result).to eql(hash[:email].split(";").first) + end + end + + describe "#extract_omniauth_names(hash:)" do + it "returns an empty hash if :hash is not present" do + expect(@controller.send(:extract_omniauth_names, hash: nil)).to eql({}) + end + it "handles :givenname" do + hash = { givenname: Faker::Movies::StarWars.character.split.first } + result = @controller.send(:extract_omniauth_names, hash: hash) + expect(result[:firstname]).to eql(hash[:givenname]) + end + it "handles :firstname" do + hash = { firstname: Faker::Movies::StarWars.character.split.first } + result = @controller.send(:extract_omniauth_names, hash: hash) + expect(result[:firstname]).to eql(hash[:firstname]) + end + it "handles :lastname" do + hash = { lastname: Faker::Movies::StarWars.character.split.first } + result = @controller.send(:extract_omniauth_names, hash: hash) + expect(result[:surname]).to eql(hash[:lastname]) + end + it "handles :surname" do + hash = { surname: Faker::Movies::StarWars.character.split.first } + result = @controller.send(:extract_omniauth_names, hash: hash) + expect(result[:surname]).to eql(hash[:surname]) + end + it "correctly splits :name into first and last" do + hash = { name: Faker::Movies::StarWars.character } + result = @controller.send(:extract_omniauth_names, hash: hash) + names = hash[:name].split + expect(result[:firstname]).to eql(names.length > 1 ? names.first : nil) + expect(result[:surname]).to eql(names.last) + end + end + + describe "#extract_omniauth_org(scheme:, hash:)" do + before(:each) do + @hash = { identity_provider: @entity_id.value_without_scheme_prefix } + end + + it "returns nil if the :scheme is not present" do + rslt = @controller.send(:extract_omniauth_org, scheme: nil, hash: @hash) + expect(rslt).to eql(nil) + end + it "returns nil if the :hash is not present" do + rslt = @controller.send(:extract_omniauth_org, scheme: @scheme, hash: nil) + expect(rslt).to eql(nil) + end + it "returns nil if the :hash has no :identity_provider" do + rslt = @controller.send(:extract_omniauth_org, scheme: @scheme, hash: {}) + expect(rslt).to eql(nil) + end + it "returns nil if there is no matching Org" do + @hash[:identity_provider] = Faker::Lorem.word + rslt = @controller.send(:extract_omniauth_org, scheme: @scheme, hash: @hash) + expect(rslt).to eql(nil) + end + it "returns the Org" do + rslt = @controller.send(:extract_omniauth_org, scheme: @scheme, hash: @hash) + expect(rslt).to eql(@org) + end + end + + end + +end diff --git a/spec/mixins/dmptool/mailers/user_mailer_spec.rb b/spec/mixins/dmptool/mailers/user_mailer_spec.rb new file mode 100644 index 0000000000..e227bc6f3a --- /dev/null +++ b/spec/mixins/dmptool/mailers/user_mailer_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Dmptool::Mailers::UserMailer, type: :mailer do + + describe "DMPTool mixin for the UserMailer" do + + before do + @plan = build(:plan) + @contributor = build(:contributor, plan: @plan) + end + + # TODO: Enable these tests once the contributor code is in place + + xit "UserMailer includes our cusotmizations" do + expect(UserMailer.respond_to?(:api_plan_creation)).to eql(true) + end + + context "#api_plan_creation(plan, contributor)" do + + xit "does not send an email if :plan is not present" do + UserMailer.api_plan_creation(nil, @contributor) + expect(ActionMailer::Base.deliveries.size).to eql(0) + end + xit "does not send an email if :contributor is not present" do + UserMailer.api_plan_creation(@plan, nil) + expect(ActionMailer::Base.deliveries.size).to eql(0) + end + + context "success" do + before(:each) do + @mail = UserMailer.api_plan_creation(@plan, @contributor) + end + + xit "Has the correct :subject" do + expect(@mail.subject).to eql(_("New DMP created")) + end + xit "Has the correct :to recipients" do + expect(@mail.to.include?("brian.riley@ucop.edu")).to eql(true) + end + xit "renders the correct template" do + expected = "a new DMP was created via the API" + expect(@mail.body.encoded.include?(expected)).to eql(true) + end + end + + end + + end + +end diff --git a/spec/mixins/dmptool/model/org_spec.rb b/spec/mixins/dmptool/model/org_spec.rb deleted file mode 100644 index 342299fd26..0000000000 --- a/spec/mixins/dmptool/model/org_spec.rb +++ /dev/null @@ -1,40 +0,0 @@ -require 'rails_helper' - - -RSpec.describe Org, type: :model do - - describe "DMPTool customizations to Org model" do - - before do - generate_shibbolized_orgs(10) - end - - context ".participating" do - - it "is_other org is not included in list of participating" do - org = create(:org, is_other: true) - expect(Org.participating.include?(org)).to eql(false) - end - - it ".participating includes correct orgs" do - expect(Org.participating.size).to eql(10) - end - - end - - context ".shibbolized?" do - - it "when Org does not have an identifier for Shibboleth" do - org = create(:org, is_other: false) - expect(org.shibbolized?).to eql(false) - end - - it "when the Org has a shibboleth identifier" do - org = Org.participating.first - expect(org.shibbolized?).to eql(true) - end - end - - end - -end \ No newline at end of file diff --git a/spec/mixins/dmptool/model/user_spec.rb b/spec/mixins/dmptool/model/user_spec.rb deleted file mode 100644 index c769e389e6..0000000000 --- a/spec/mixins/dmptool/model/user_spec.rb +++ /dev/null @@ -1,70 +0,0 @@ -require 'rails_helper' - - -RSpec.describe User, type: :model do - - describe "DMPTool customizations to User model" do - - before do - generate_shibbolized_orgs(1) - end - - let!(:org) { Org.participating.first } - - context ".ldap_password?" do - - it "correctly determines if the user has an ldap password" do - user = create(:user, ldap_password: "ABCD123") - expect(user.ldap_password?).to eql(true) - end - - end - - context ".valid_password?" do - - let(:password) { "Testing*12!" } - let(:salt) { "saltyTst" } - - it "converts a user's LDAP password to Devise password" do - # Create a user and then remove their Devise passwords to simulate - # a record migrated from the old DMPTool v2 LDAP security model - encoded = Base64.encode64(Digest::SHA1.digest(password+salt)+salt).chomp! - user = create(:user, ldap_password: "{SSHA}"+encoded) - user.password = "" - user.encrypted_password = "" - user.save(validate: false) - expect(user.valid_password?(password)).to eql(true) - # Make sure the old LDAP password was deleted and that the new Devise - # password was properly converted - user.reload - expect(user.encrypted_password.present?).to eql(true) - expect(user.ldap_password.present?).to eql(false) - expect(user.valid_password?(password)).to eql(true) - end - - it "does not change the user's password if they already have a Devise password" do - encoded = Base64.encode64(Digest::SHA1.digest(password+salt)+salt).chomp! - user = create(:user, ldap_password: "{SSHA}"+encoded) - expect(user.valid_password?(password)).to eql(false) - expect(user.ldap_password.present?).to eql(true) - expect(user.encrypted_password.present?).to eql(true) - end - - it "does not change the user's password if the provided password is invalid" do - # Create a user and then remove their Devise passwords to simulate - # a record migrated from the old DMPTool v2 LDAP security model - encoded = Base64.encode64(Digest::SHA1.digest(password+salt)+salt).chomp! - user = create(:user, ldap_password: "{SSHA}"+encoded) - user.password = "" - user.encrypted_password = "" - user.save(validate: false) - expect(user.valid_password?("INVALID_Passwd12")).to eql(false) - expect(user.ldap_password.present?).to eql(true) - expect(user.encrypted_password.present?).to eql(false) - end - - end - - end - -end \ No newline at end of file diff --git a/spec/mixins/dmptool/models/org_spec.rb b/spec/mixins/dmptool/models/org_spec.rb new file mode 100644 index 0000000000..55178bc509 --- /dev/null +++ b/spec/mixins/dmptool/models/org_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Dmptool::Models::Org, type: :model do + + describe "DMPTool customizations to Org model" do + + before do + generate_shibbolized_orgs(2) + @unmanaged = create(:org, managed: false) + end + + it "Org includes our cusotmizations" do + expect(::Org.respond_to?(:participating)).to eql(true) + end + + context "#participating" do + it "does not return unmanaged orgs" do + expect(Org.participating.include?(@unmanaged)).to eql(false) + end + it "includes managed orgs" do + expect(Org.participating.size).to eql(3) + end + end + + context "#shibbolized?" do + it "returns false when the Org is not :managed" do + org = Org.participating.first + org.update(managed: false) + expect(org.shibbolized?).to eql(false) + end + it "returns false if Org does not have an identifier for Shibboleth" do + expect(@unmanaged.shibbolized?).to eql(false) + end + it "returns true" do + org = Org.participating.first + expect(org.shibbolized?).to eql(true) + end + end + + end + +end diff --git a/spec/mixins/dmptool/presenters/org_presenter_spec.rb b/spec/mixins/dmptool/presenters/org_presenter_spec.rb new file mode 100644 index 0000000000..7b88d2b4f5 --- /dev/null +++ b/spec/mixins/dmptool/presenters/org_presenter_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Dmptool::Presenters::OrgPresenter do + + describe "DMPTool OrgPresenter" do + before do + @managed = create(:org, managed: true) + @unmanaged = create(:org, managed: false) + @scheme = create(:identifier_scheme, name: "shibboleth") + @presenter = described_class.new + end + + describe "#initialize" do + it "initializes if a shibboleth scheme is available" do + expect(@presenter.is_a?(Dmptool::Presenters::OrgPresenter)).to eql(true) + end + it "initializes if a shibboleth scheme is NOT available" do + @scheme.destroy + presenter = described_class.new + expect(presenter.is_a?(Dmptool::Presenters::OrgPresenter)).to eql(true) + end + end + + describe "#participating_orgs" do + it "returns 'managed' Orgs" do + expect(@presenter.participating_orgs.include?(@managed)).to eql(true) + end + it "does not return 'unmanaged' Orgs" do + expect(@presenter.participating_orgs.include?(@unmanaged)).to eql(false) + end + end + + describe "#sign_in_url(org:)" do + it "returns nil if the :org is not present" do + expect(@presenter.sign_in_url(org: nil)).to eql(nil) + end + it "returns nil if there is no shibboleth scheme" do + @scheme.destroy + @presenter = described_class.new + expect(@presenter.sign_in_url(org: @managed)).to eql(nil) + end + it "returns the correct URL/path" do + result = @presenter.sign_in_url(org: @unmanaged) + path = Rails.application.routes.url_helpers.shibboleth_ds_path + expect(result.starts_with?(path)).to eql(true) + expect(result.ends_with?(@unmanaged.id.to_s)).to eql(true) + end + end + end + +end diff --git a/spec/models/api_client_spec.rb b/spec/models/api_client_spec.rb new file mode 100644 index 0000000000..98d55fc4a5 --- /dev/null +++ b/spec/models/api_client_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ApiClient, type: :model do + + context "validations" do + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:contact_email) } + + # Uniqueness validation + it { + subject.name = Faker::Lorem.word + subject.contact_email = Faker::Internet.email + subject.client_id = Faker::Lorem.word + subject.client_secret = Faker::Lorem.word + is_expected.to validate_uniqueness_of(:name) + .case_insensitive + .with_message("must be unique") + } + + # Email format validation + it { + is_expected.to allow_values("one@example.com", "foo-bar@ed.ac.uk") + .for(:contact_email) + } + it { + is_expected.not_to allow_values("example.com", "foo bar@ed.ac.uk") + .for(:contact_email) + } + + end + + context "Instance Methods" do + before(:each) do + @client = build(:api_client) + end + + describe "#to_s" do + it "should return the name" do + expect(@client.to_s).to eql(@client.name) + end + + it "should return the name through interpolation" do + expect("#{@client}").to eql(@client.name) + end + end + + describe "#authenticate" do + it "returns false if no secret is specified" do + expect(@client.authenticate(secret: nil)).to eql(false) + end + + it "returns false if the secrets do not match" do + expect(@client.authenticate(secret: SecureRandom.uuid)).to eql(false) + end + + it "returns true if the secrets match" do + expect(@client.authenticate(secret: @client.client_secret)).to eql(true) + end + end + + end + +end diff --git a/spec/models/concerns/date_rangeable_spec.rb b/spec/models/concerns/date_rangeable_spec.rb new file mode 100644 index 0000000000..9059b06ce9 --- /dev/null +++ b/spec/models/concerns/date_rangeable_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DateRangeable do + + # Using the Plan model for testing this Concern + before(:each) do + @plans_in_range = [ + create(:plan, created_at: Date.today - 31.days, updated_at: Date.today - 31.days), + create(:plan, created_at: Date.today - 31.days, updated_at: Date.today - 31.days) + ] + @plan_prior = create(:plan, created_at: Date.today - 90.days, + updated_at: Date.today - 90.days) + @plan_after = create(:plan, created_at: Date.today, updated_at: Date.today) + end + + context "class methods" do + + describe "#date_range?(term:)" do + it "returns true the 'Oct 2019' format" do + expect(Plan.date_range?(term: "Jan 19")).to eql(true) + expect(Plan.date_range?(term: "Jan 2019")).to eql(true) + expect(Plan.date_range?(term: "January 2019")).to eql(true) + expect(Plan.date_range?(term: Date.today.strftime("%b %Y"))).to eql(true) + end + it "returns false for others" do + expect(Plan.date_range?(term: "01 19")).to eql(false) + expect(Plan.date_range?(term: "01 2019")).to eql(false) + expect(Plan.date_range?(term: "1st Jan 2019")).to eql(false) + expect(Plan.date_range?(term: "01-01-2019")).to eql(false) + expect(Plan.date_range?(term: "01/01/2019")).to eql(false) + expect(Plan.date_range?(term: "2019-01-01")).to eql(false) + expect(Plan.date_range?(term: "2019-01-01 00:00:01")).to eql(false) + end + end + + describe "#by_date_range(field, term)" do + before(:each) do + @term = (Date.today - 31.days).strftime("%b %Y") + end + + it "searches by the specified field" do + expect(Plan.by_date_range(:created_at, @term).length).to eql(2) + expect(Plan.by_date_range(:updated_at, @term).length).to eql(2) + end + it "returns the expected records" do + results = Plan.by_date_range(:created_at, @term) + results.each { |r| expect(@plans_in_range.include?(r)).to eql(true) } + end + it "does not return records from a prior month" do + results = Plan.by_date_range(:created_at, @term) + expect(results.include?(@plan_prior)).to eql(false) + end + it "does not return records from a later month" do + results = Plan.by_date_range(:created_at, @term) + expect(results.include?(@plan_after)).to eql(false) + end + end + + end + +end diff --git a/spec/models/concerns/identifiable_spec.rb b/spec/models/concerns/identifiable_spec.rb new file mode 100644 index 0000000000..60869bcdbf --- /dev/null +++ b/spec/models/concerns/identifiable_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Identifiable do + + # Using the Org model for testing this Concern + before(:each) do + @org = create(:org) + @scheme1 = create(:identifier_scheme) + @scheme2 = create(:identifier_scheme) + @id1 = create(:identifier, identifier_scheme: @scheme1, identifiable: @org) + @id2 = create(:identifier, identifier_scheme: @scheme2, identifiable: @org) + end + + context "class methods" do + + describe "#from_identifiers(array:)" do + it "returns nil if array is not present" do + expect(Org.from_identifiers(array: nil)).to eql(nil) + end + it "returns nil if array is empty" do + expect(Org.from_identifiers(array: [])).to eql(nil) + end + it "returns nil if the identifier scheme does not exist" do + array = [{ name: SecureRandom.uuid, value: Faker::Lorem.word }] + expect(Org.from_identifiers(array: array)).to eql(nil) + end + it "returns nil if no matches were found" do + array = [{ name: @scheme1.name, value: SecureRandom.uuid }] + expect(Org.from_identifiers(array: array)).to eql(nil) + end + it "returns the identifiable object" do + array = [{ name: @scheme1.name, value: @id1.value }] + expect(Org.from_identifiers(array: array)).to eql(@org) + end + it "does not return matching identifiable from another object" do + array = [{ name: @scheme1.name, value: @id1.value }] + expect(User.from_identifiers(array: array)).to eql(nil) + end + it "returns the first identifiable object if multiple matches" do + array = [ + { name: @scheme2.name, value: @id2.value }, + { name: @scheme1.name, value: @id1.value } + ] + expect(Org.from_identifiers(array: array)).to eql(@org) + end + end + + end + + context "instance methods" do + + describe "#identifier_for_scheme(scheme:)" do + it "returns nil if no identifier was found" do + scheme3 = create(:identifier_scheme) + expect(@org.identifier_for_scheme(scheme: scheme3)).to eql(nil) + end + it "returns nil if identifier scheme does not exist" do + expect(@org.identifier_for_scheme(scheme: SecureRandom.uuid)).to eql(nil) + end + it "returns the identifier if passed the scheme name" do + expect(@org.identifier_for_scheme(scheme: @scheme1.name)).to eql(@id1) + end + it "returns the identifier if passed the identifier scheme" do + expect(@org.identifier_for_scheme(scheme: @scheme1)).to eql(@id1) + end + end + + describe "#consolidate_identifiers!(array:)" do + it "returns the existing identifiers if array is not present" do + expect(@org.consolidate_identifiers!(array: nil)).to eql(false) + end + it "returns the existing identifiers if array is empty" do + expect(@org.consolidate_identifiers!(array: [])).to eql(false) + end + it "ignores items in array if they are not identifiers" do + array = [build(:org)] + original = @org.identifiers + @org.consolidate_identifiers!(array: array) + expect(@org.identifiers).to eql(original) + end + it "does not replace an existing identifier" do + array = [build(:identifier, identifier_scheme: @scheme1, value: "Foo")] + @org.consolidate_identifiers!(array: array) + expect(@org.identifier_for_scheme(scheme: @scheme1).value).to eql(@id1.value) + end + it "adds the new identifier" do + scheme3 = create(:identifier_scheme) + array = [build(:identifier, identifier_scheme: scheme3, value: "Foo")] + @org.consolidate_identifiers!(array: array) + expected = @org.identifier_for_scheme(scheme: scheme3).value + expect(expected.ends_with?("Foo")).to eql(true) + end + end + + end + +end diff --git a/spec/models/condition_spec.rb b/spec/models/condition_spec.rb new file mode 100644 index 0000000000..bcf85ec6b9 --- /dev/null +++ b/spec/models/condition_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Condition, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/contributor_spec.rb b/spec/models/contributor_spec.rb new file mode 100644 index 0000000000..88f4089013 --- /dev/null +++ b/spec/models/contributor_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Contributor, type: :model do + + context "validations" do + + it { is_expected.to validate_presence_of(:roles) } + + it "should validate that roles is greater than zero" do + subject.name = Faker::Books::Dune.character + subject.email = Faker::Internet.email + is_expected.to validate_numericality_of(:roles) + .with_message("You must specify at least one role.") + end + + describe "#name_or_email_presence" do + before(:each) do + @contributor = build(:contributor, plan: create(:plan), investigation: true) + end + + it "is invalid if both the name and email are blank" do + @contributor.name = nil + @contributor.email = nil + expect(@contributor.valid?).to eql(false) + expect(@contributor.errors[:name].present?).to eql(true) + expect(@contributor.errors[:email].present?).to eql(true) + end + it "is valid if a name is present" do + @contributor.email = nil + expect(@contributor.valid?).to eql(true) + end + it "is valid if an email is present" do + @contributor.name = nil + expect(@contributor.valid?).to eql(true) + end + end + + end + + context "associations" do + it { is_expected.to belong_to(:org) } + it { is_expected.to belong_to(:plan) } + it { is_expected.to have_many(:identifiers) } + end + +end diff --git a/spec/models/exported_plan_spec.rb b/spec/models/exported_plan_spec.rb new file mode 100644 index 0000000000..6c7dd8e5b0 --- /dev/null +++ b/spec/models/exported_plan_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Org, type: :model do + + context "instance methods" do + before(:each) do + plan = create(:plan) + @owner = create(:user) + plan.roles << create(:role, :creator, user: @owner) + @exported = build(:exported_plan, plan: plan) + end + + describe "#orcid" do + it "returns an empty string if the owner is nil" do + @exported.user = nil + expect(@exported.orcid).to eql("") + end + it "returns an empty string if the owner has no ORCID identifier" do + + expect(@exported.orcid).to eql("") + end + it "returns the ORCID identifier" do + scheme = build(:identifier_scheme, name: "orcid") + identifier = build(:identifier, :for_user, identifier_scheme: scheme) + @exported.owner.identifiers << identifier + expect(@exported.orcid).to eql(identifier.value) + end + end + + end + +end diff --git a/spec/models/guidance_group_spec.rb b/spec/models/guidance_group_spec.rb index f57957b34d..c9861b1f3f 100644 --- a/spec/models/guidance_group_spec.rb +++ b/spec/models/guidance_group_spec.rb @@ -2,6 +2,11 @@ RSpec.describe GuidanceGroup, type: :model do + before(:each) do + # Ensure that the default managing org abbreviation is available + Rails.configuration.branding.fetch(:organisation, {})[:abbreviation] = "CC" + end + context "validations" do it { is_expected.to validate_presence_of(:name) } diff --git a/spec/models/guidance_spec.rb b/spec/models/guidance_spec.rb index 9762897c36..7be4df9b2e 100644 --- a/spec/models/guidance_spec.rb +++ b/spec/models/guidance_spec.rb @@ -2,6 +2,11 @@ RSpec.describe Guidance, type: :model do + before(:each) do + # Ensure that the default managing org abbreviation is available + Rails.configuration.branding.fetch(:organisation, {})[:abbreviation] = "CC" + end + context "validations" do it { is_expected.to validate_presence_of(:text) } diff --git a/spec/models/identifier_scheme_spec.rb b/spec/models/identifier_scheme_spec.rb index 27d02628c6..65272a166e 100644 --- a/spec/models/identifier_scheme_spec.rb +++ b/spec/models/identifier_scheme_spec.rb @@ -3,24 +3,73 @@ RSpec.describe IdentifierScheme, type: :model do context "validations" do - it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_length_of(:name).is_at_most(30) } - it { is_expected.to allow_value(true).for(:name) } + it { is_expected.to allow_value("foo").for(:name) } - it { is_expected.to allow_value(false).for(:name) } + it { is_expected.not_to allow_value("012").for(:name) } it { is_expected.to_not allow_value(nil).for(:name) } - end context "associations" do + it { is_expected.to have_many :identifiers } + end + + context "scopes" do + before(:each) do + @scheme = create(:identifier_scheme, for_users: true, active: true) + end + + describe "#active" do + it "returns active identifier schemes" do + expect(described_class.active.first).to eql(@scheme) + end + it "does not return inactive identifier schemes" do + @scheme.update(active: false) + expect(described_class.active.first).to eql(nil) + end + end + + describe "#by_name scope" do + it "is case insensitive" do + rslt = described_class.by_name(@scheme.name.upcase).first + expect(rslt).to eql(@scheme) + end + + it "returns the IdentifierScheme" do + rslt = described_class.by_name(@scheme.name).first + expect(rslt).to eql(@scheme) + end + + it "returns empty ActiveRecord results if nothing is found" do + rslts = described_class.by_name(Faker::Lorem.sentence) + expect(rslts.empty?).to eql(true) + end + end + end - it { is_expected.to have_many :user_identifiers } + context "instance methods" do + before(:each) do + @scheme = build(:identifier_scheme) + end - it { is_expected.to have_many(:users).through(:user_identifiers) } + describe "#name=(value)" do + it "allows single word names" do + @scheme.name = "foo" + expect(@scheme.name).to eql("foo") + end + it "removes no alpha characters" do + @scheme.name = " foo bar- " + expect(@scheme.name).to eql("foobar") + end + it "sets everything to lower case" do + @scheme.name = "FoO" + expect(@scheme.name).to eql("foo") + end + end end diff --git a/spec/models/identifier_spec.rb b/spec/models/identifier_spec.rb new file mode 100644 index 0000000000..5a8f79cb7e --- /dev/null +++ b/spec/models/identifier_spec.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Identifier, type: :model do + + context "validations" do + it { is_expected.to validate_presence_of(:value) } + + it { is_expected.to validate_presence_of(:identifiable) } + + describe "uniqueness" do + before(:each) do + @org = create(:org) + end + + it "prevents duplicate value when identifier_scheme is nil" do + scheme = create(:identifier_scheme) + create(:identifier, identifiable: @org, identifier_scheme: nil, + value: "foo") + id = build(:identifier, identifiable: @org, identifier_scheme: nil, + value: "foo") + expect(id.valid?).to eql(false) + expect(id.errors[:value].present?).to eql(true) + end + it "allows a duplicate value for the identifier_scheme" do + scheme = create(:identifier_scheme) + create(:identifier, identifiable: @org, identifier_scheme: scheme, + value: "foo") + id = build(:identifier, identifiable: create(:org), + identifier_scheme: scheme, value: "foo") + expect(id.valid?).to eql(true) + end + it "prevents multiple identifiers per identifier_scheme" do + scheme = create(:identifier_scheme) + create(:identifier, identifiable: @org, identifier_scheme: scheme, + value: Faker::Lorem.word) + id = build(:identifier, identifiable: @org, identifier_scheme: scheme, + value: Faker::Number.number.to_s) + expect(id.valid?).to eql(false) + expect(id.errors[:identifier_scheme].present?).to eql(true) + end + it "does not apply if the value is unique and identifier_scheme is nil" do + create(:identifier, identifiable: @org, identifier_scheme: nil, + value: Faker::Lorem.word) + id = build(:identifier, identifiable: @org, identifier_scheme: nil, + value: Faker::Number.number.to_s) + expect(id.valid?).to eql(true) + end + it "does not prevent identifiers for same scheme but different identifiables" do + scheme = create(:identifier_scheme) + create(:identifier, identifiable: @org, identifier_scheme: scheme, + value: Faker::Lorem.word) + id = build(:identifier, identifiable: create(:org), + identifier_scheme: scheme, + value: Faker::Number.number.to_s) + expect(id.valid?).to eql(true) + end + it "does not prevent same value for different schemes and identifiables" do + scheme = create(:identifier_scheme) + create(:identifier, identifiable: @org, identifier_scheme: scheme, + value: "foo") + id = build(:identifier, identifiable: create(:org), + identifier_scheme: create(:identifier_scheme), + value: "foo") + expect(id.valid?).to eql(true) + end + end + end + + context "associations" do + it { is_expected.to belong_to(:identifiable) } + + it { is_expected.to belong_to(:identifier_scheme) } + end + + context "scopes" do + describe "#by_scheme_name" do + before(:each) do + @scheme = create(:identifier_scheme) + @scheme2 = create(:identifier_scheme) + @id = create(:identifier, :for_plan, identifier_scheme: @scheme) + @id2 = create(:identifier, :for_plan, identifier_scheme: @scheme2) + + @rslts = described_class.by_scheme_name(@scheme.name, "Plan") + end + + it "returns the correct identifier" do + expect(@rslts.include?(@id)).to eql(true) + end + it "does not return the identifier for the other scheme" do + expect(@rslts.include?(@id2)).to eql(false) + end + end + end + + describe "#attrs=" do + let!(:identifier) { create(:identifier) } + + it "when hash is a Hash sets attrs to a String of JSON" do + identifier.attrs = { foo: "bar" } + expect(identifier.attrs).to eql({ "foo": "bar" }.to_json) + end + + it "when hash is nil sets attrs to empty JSON object" do + identifier.attrs = nil + expect(identifier.attrs).to eql({}.to_json) + end + + it "when hash is a String sets attrs to empty JSON object" do + identifier.attrs = "" + expect(identifier.attrs).to eql({}.to_json) + end + end + + describe "#identifier_format" do + it "returns 'orcid' for identifiers associated with the orcid identifier_scheme" do + scheme = build(:identifier_scheme, name: "orcid") + id = build(:identifier, identifier_scheme: scheme) + expect(id.identifier_format).to eql("orcid") + end + it "returns 'ror' for identifiers associated with the ror identifier_scheme" do + scheme = build(:identifier_scheme, name: "ror") + id = build(:identifier, identifier_scheme: scheme) + expect(id.identifier_format).to eql("ror") + end + it "returns 'fundref' for identifiers associated with the fundref identifier_scheme" do + scheme = build(:identifier_scheme, name: "fundref") + id = build(:identifier, identifier_scheme: scheme) + expect(id.identifier_format).to eql("fundref") + end + it "returns 'ark' for identifiers whose value contains 'ark:'" do + scheme = build(:identifier_scheme, name: "ror") + val = "#{scheme.identifier_prefix}ark:#{Faker::Lorem.word}" + id = create(:identifier, value: val) + expect(id.identifier_format).to eql("ark") + end + it "returns 'doi' for identifiers whose value matches the doi format" do + scheme = build(:identifier_scheme, name: "ror") + val = "#{scheme.identifier_prefix}doi:10.1234/123abc98" + id = create(:identifier, value: val) + expect(id.identifier_format).to eql("doi"), "expected url containing 'doi:' to be a doi" + + val = "#{scheme.identifier_prefix}10.1234/123abc98" + id = create(:identifier, value: val) + expect(id.identifier_format).to eql("doi"), "expected url not containing 'doi:' to be a doi" + end + it "returns 'url' for identifiers whose value matches a URL format" do + scheme = build(:identifier_scheme, name: "ror") + id = create(:identifier, value: "#{scheme.identifier_prefix}#{Faker::Lorem.word}") + expect(id.identifier_format).to eql("url") + + id = create(:identifier, value: "#{scheme.identifier_prefix}#{Faker::Lorem.word}") + expect(id.identifier_format).to eql("url") + end + it "returns 'other' for all other identifier values" do + scheme = build(:identifier_scheme, identifier_prefix: nil) + id = create(:identifier, value: Faker::Lorem.word, identifier_scheme: scheme) + expect(id.identifier_format).to eql("other"), "expected alpha characters to return 'other'" + + id = create(:identifier, value: Faker::Number.number, identifier_scheme: scheme) + expect(id.identifier_format).to eql("other"), "expected numeric characters to return 'other'" + + id = create(:identifier, value: SecureRandom.uuid, identifier_scheme: scheme) + expect(id.identifier_format).to eql("other"), "expected UUID to return 'other'" + end + end + + describe "#value_without_scheme_prefix" do + before(:each) do + @scheme = create(:identifier_scheme, identifier_prefix: Faker::Internet.url) + @without = Faker::Lorem.word + @val = "#{@scheme.identifier_prefix}/#{@without}" + end + + it "returns the value as is if no identifier scheme is present" do + id = create(:identifier, value: @val, identifier_scheme: nil) + expect(id.value_without_scheme_prefix).to eql(@val) + end + it "returns the value as is if no identifier scheme has no prefix" do + @scheme.identifier_prefix = nil + id = create(:identifier, value: @val, identifier_scheme: @scheme) + expect(id.value_without_scheme_prefix).to eql(@val) + end + it "returns the value without the identifier scheme prefix" do + id = create(:identifier, value: @val, identifier_scheme: @scheme) + expect(id.value_without_scheme_prefix).to eql(@without) + end + end + + describe "#value=(val)" do + before(:each) do + @scheme = create(:identifier_scheme, identifier_prefix: Faker::Internet.url) + end + + it "returns the value if the identifier_scheme is not present" do + val = Faker::Lorem.word + id = build(:identifier, value: val, identifier_scheme: nil) + expect(id.value).to eql(val) + end + it "returns the value if the identifier_scheme has no prefix" do + val = Faker::Lorem.word + @scheme.identifier_prefix = nil + id = build(:identifier, value: val, identifier_scheme: @scheme) + expect(id.value).to eql(val) + end + it "returns the value if the value is already a URL" do + val = "#{@scheme.identifier_prefix}/#{Faker::Lorem.word}" + id = build(:identifier, value: val, identifier_scheme: @scheme) + expect(id.value).to eql(val) + end + it "appends the identifier scheme prefix to the value" do + val = Faker::Lorem.word + id = build(:identifier, value: val, identifier_scheme: @scheme) + expected = @scheme.identifier_prefix + expect(id.value.starts_with?(expected)).to eql(true) + end + it "appends the identifier scheme prefix to the value even if its a URL" do + val = Faker::Internet.url + id = build(:identifier, value: val, identifier_scheme: @scheme) + expected = @scheme.identifier_prefix + expect(id.value.starts_with?(expected)).to eql(true) + end + end + +end diff --git a/spec/models/notification_spec.rb b/spec/models/notification_spec.rb index 99fc17620c..c63338f69f 100644 --- a/spec/models/notification_spec.rb +++ b/spec/models/notification_spec.rb @@ -16,6 +16,10 @@ it { is_expected.not_to allow_value(nil).for(:dismissable) } + it { is_expected.to allow_values(true, false).for(:enabled) } + + it { is_expected.not_to allow_value(nil).for(:enabled) } + it { is_expected.to validate_presence_of(:starts_at) } it { is_expected.to validate_presence_of(:expires_at) } @@ -34,19 +38,21 @@ subject { Notification.active } - context "when now is before starts_at" do + context "when enabled and now is before starts_at" do - let!(:notification) { create(:notification, starts_at: 1.week.from_now) } + let!(:notification) { create(:notification, starts_at: 1.week.from_now, + enabled: true) } it { is_expected.not_to include(notification) } end - context "when now lies between starts_at and expires_at" do + context "when enabled and now lies between starts_at and expires_at" do let!(:notification) do record = build(:notification, starts_at: 1.day.ago, - expires_at: 1.day.from_now) + expires_at: 1.day.from_now, + enabled: true) record.save(validate: false) record end @@ -55,15 +61,29 @@ end - context "when now is after expires_at" do + context "when enabled and now is after expires_at" do let!(:notification) do - create(:notification, starts_at: 1.week.from_now) + create(:notification, starts_at: 1.week.from_now, enabled: true) end it { is_expected.not_to include(notification) } end + + context "when disabled and now lies between starts_at and expires_at" do + + let!(:notification) do + record = build(:notification, starts_at: 1.day.ago, + expires_at: 1.day.from_now) + record.save(validate: false) + record + end + + it { is_expected.not_to include(notification) } + + end + end describe ".active_per_user" do @@ -119,6 +139,30 @@ it { is_expected.to include(notification) } end + + context "when User is present and Notification is disabled" do + + let!(:notification) { create(:notification, :active, enabled: false) } + + let!(:user) { create(:user) } + + subject { Notification.active_per_user(user) } + + it { is_expected.not_to include(notification) } + + end + + context "when User is nil and Notification is not dismissable or enabled" do + + let!(:user) { nil } + + let!(:notification) { create(:notification) } + + subject { Notification.active_per_user(user) } + + it { is_expected.not_to include(notification) } + + end end describe "#acknowledged?" do diff --git a/spec/models/org_identifier_spec.rb b/spec/models/org_identifier_spec.rb deleted file mode 100644 index ede69b184a..0000000000 --- a/spec/models/org_identifier_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -require 'rails_helper' - -RSpec.describe OrgIdentifier, type: :model do - - context "validations" do - - it do - # https://github.com/thoughtbot/shoulda-matchers/issues/682 - subject.identifier_scheme = create(:identifier_scheme) - is_expected.to validate_uniqueness_of(:identifier_scheme_id) - .scoped_to(:org_id) - .with_message("must be unique") - end - - it { is_expected.to validate_presence_of(:identifier) } - - it { is_expected.to validate_presence_of(:org) } - - it { is_expected.to validate_presence_of(:identifier_scheme) } - - end - - context "associations" do - - it { is_expected.to belong_to(:org) } - - it { is_expected.to belong_to(:identifier_scheme) } - - end - - describe "#attrs=" do - - context "when hash is a Hash" do - - let!(:org_identifier) { create(:org_identifier) } - - it "sets attrs to a String of JSON" do - org_identifier.attrs = { foo: "bar" } - expect(org_identifier.attrs).to eql({"foo" => "bar"}.to_json) - end - - end - - context "when hash is nil" do - - let!(:org_identifier) { create(:org_identifier) } - - it "sets attrs to empty JSON object" do - org_identifier.attrs = nil - expect(org_identifier.attrs).to eql({}.to_json) - end - - end - - context "when hash is a String" do - - let!(:org_identifier) { create(:org_identifier) } - - it "sets attrs to empty JSON object" do - org_identifier.attrs = '' - expect(org_identifier.attrs).to eql({}.to_json) - end - - end - - end - -end diff --git a/spec/models/org_spec.rb b/spec/models/org_spec.rb index bbe7841e12..c8a17a0148 100644 --- a/spec/models/org_spec.rb +++ b/spec/models/org_spec.rb @@ -20,6 +20,8 @@ it { is_expected.to validate_presence_of(:language) } + it { is_expected.to allow_values(0, 1).for(:managed) } + it "validates presence of contact_email if feedback_enabled" do subject.feedback_enabled = true is_expected.to validate_presence_of(:contact_email) @@ -62,35 +64,57 @@ it { should have_and_belong_to_many(:token_permission_types).join_table("org_token_permissions") } - it { should have_many(:org_identifiers) } + it { should have_many(:identifiers) } - it { should have_many(:identifier_schemes).through(:org_identifiers) } + it { should have_many(:plans) } + it { should have_many(:funded_plans) } end - describe ".managing_orgs" do + context "scopes" do + before(:each) do + @managed = create(:org, managed: true) + @unmanaged = create(:org, managed: false) + end - subject { Org.managing_orgs } + describe ".default_orgs" do + subject { Org.default_orgs } - context "when Org has same abbr as branding" do + context "when Org has same abbr as branding" do - let!(:org) do - create(:org, - abbreviation: Rails.configuration - .branding.dig(:organisation, :abbreviation)) + let!(:org) do + abbrev = Rails.configuration.branding.dig(:organisation, + :abbreviation) + create(:org, abbreviation: abbrev) - end + end - it { is_expected.to include(org) } + it { is_expected.to include(org) } - end + end - context "when Org doesn't have same abbr as branding" do + context "when Org doesn't have same abbr as branding" do - let!(:org) { create(:org, abbreviation: 'foo-bar') } + let!(:org) { create(:org, abbreviation: "foo-bar") } - it { is_expected.not_to include(org) } + it { is_expected.not_to include(org) } + end + end + + describe "#managed" do + it "returns only the managed orgs" do + rslts = described_class.managed + expect(rslts.include?(@managed)).to eql(true) + expect(rslts.include?(@unmanaged)).to eql(false) + end + end + describe "#unmanaged" do + it "returns only the un-managed orgs" do + rslts = described_class.unmanaged + expect(rslts.include?(@managed)).to eql(false) + expect(rslts.include?(@unmanaged)).to eql(true) + end end end @@ -434,8 +458,21 @@ end - end + describe "#links" do + it "returns the contents of the field" do + links = { "org": [{ + "link": Faker::Internet.url, + "text": Faker::Lorem.word + }] } + org = build(:org, links: links) + expect(org.links).to eql(JSON.parse(links.to_json)) + end + it "defaults to {'org': }" do + org = build(:org) + expect(org.links).to eql(JSON.parse({ "org": [] }.to_json)) + end + end end diff --git a/spec/models/phase_spec.rb b/spec/models/phase_spec.rb index 530e443908..949d9d7b43 100644 --- a/spec/models/phase_spec.rb +++ b/spec/models/phase_spec.rb @@ -79,6 +79,10 @@ create_list(:section, 2, phase: phase) end + it "checks number of sections" do + expect(subject.sections.size).to eql(phase.sections.size) + end + it "doesn't persist the record" do expect(subject).to be_a_new_record end diff --git a/spec/models/plan_spec.rb b/spec/models/plan_spec.rb index 4b35384129..505de16ec2 100644 --- a/spec/models/plan_spec.rb +++ b/spec/models/plan_spec.rb @@ -17,12 +17,38 @@ it { is_expected.to allow_values(true, false).for(:complete) } it { is_expected.not_to allow_value(nil).for(:complete) } + + describe "dates" do + before(:each) do + @plan = build(:plan) + end + + it "allows start_date to be nil" do + @plan.start_date = nil + @plan.end_date = Time.now + 3.days + expect(@plan.valid?).to eql(true) + end + it "allows end_date to be nil" do + @plan.start_date = Time.now + 3.days + @plan.end_date = nil + expect(@plan.valid?).to eql(true) + end + it "does not allow end_date to come before start_date" do + @plan.start_date = Time.now + 3.days + @plan.end_date = Time.now + expect(@plan.valid?).to eql(false) + end + end + end context "associations" do it { is_expected.to belong_to :template } + it { is_expected.to belong_to :org } + + it { is_expected.to belong_to :funder } it { is_expected.to have_many :phases } @@ -44,6 +70,10 @@ it { is_expected.to have_many :setting_objects } + it { is_expected.to have_many(:identifiers) } + + it { is_expected.to have_many(:contributors) } + end describe ".publicly_visible" do @@ -457,7 +487,7 @@ context "when Plan title matches term" do - let!(:plan) { create(:plan, title: "foolike title") } + let!(:plan) { create(:plan, :creator, title: "foolike title") } it { is_expected.to include(plan) } @@ -467,18 +497,69 @@ let!(:template) { create(:template, title: "foolike title") } - let!(:plan) { create(:plan, template: template) } + let!(:plan) { create(:plan, :creator, template: template) } it { is_expected.to include(plan) } end + context "when Organisation name matches term" do + + let!(:plan) { create(:plan, :creator, description: "foolike desc") } + + let!(:org) { create(:org, name: 'foolike name') } + + before do + user = plan.owner + user.org = org + user.save + end + + it "returns organisation name" do + expect(subject).to include(plan) + end + + end + + # TODO: Add this one in once we are able to easily do LEFT JOINs in Rails 5 + context "when Contributor name matches term" do + let!(:plan) { create(:plan, :creator, description: "foolike desc") } + let!(:contributor) { create(:contributor, plan: plan, name: "Dr. Foo Bar") } + + xit "returns contributor name" do + expect(subject).to include(plan) + end + end + context "when neither title matches term" do - let!(:plan) { create(:plan, description: "foolike desc") } + let!(:plan) { create(:plan, :creator, description: "foolike desc") } + + it { is_expected.not_to include(plan) } + + end + + + end + + describe ".stats_filter" do + + subject { Plan.all.stats_filter } + + context "when plan visibility is test" do + let!(:plan) { create(:plan, :creator, :is_test) } it { is_expected.not_to include(plan) } + end + + context "when plan visibility is not test" do + let!(:p1) { create(:plan, :creator, :publicly_visible) } + let!(:p2) { create(:plan, :creator, :privately_visible) } + let!(:p3) { create(:plan, :creator, :organisationally_visible) } + it { is_expected.to include(p1) } + it { is_expected.to include(p2) } + it { is_expected.to include(p3) } end end @@ -755,6 +836,12 @@ context "config does not allow admin viewing" do + before(:each) do + Branding.expects(:fetch) + .with(:service_configuration, :plans, :org_admins_read_all) + .returns(false) + end + it "super admins" do Branding.expects(:fetch) .with(:service_configuration, :plans, :super_admins_read_all) @@ -765,10 +852,6 @@ end it "org admins" do - Branding.expects(:fetch) - .with(:service_configuration, :plans, :org_admins_read_all) - .returns(false) - user.perms << create(:perm, name: "modify_guidance") expect(subject.readable_by?(user.id)).to eql(false) end @@ -1417,4 +1500,62 @@ end end + describe "#landing_page" do + let!(:plan) { create(:plan, :creator) } + + it "returns nil if no DOI or ARK is available" do + expect(plan.landing_page).to eql(nil) + end + it "returns the DOI if available" do + id = create(:identifier, identifiable: plan, value: "10.9999/123erge/45f") + plan.reload + expect(plan.landing_page).to eql(id) + end + it "returns the ARK if available" do + id = create(:identifier, identifiable: plan, value: "ark:10.9999/123") + plan.reload + expect(plan.landing_page).to eql(id) + end + end + + describe "#grant association sanity checks" do + let!(:plan) { create(:plan, :creator) } + + it "allows a grant identifier to be associated" do + plan.grant = build(:identifier, identifier_scheme: nil) + plan.save + expect(plan.grant.new_record?).to eql(false) + end + it "allows a grant identifier to be deleted" do + plan.grant = build(:identifier, identifier_scheme: nil) + plan.save + plan.grant = nil + plan.save + expect(plan.grant).to eql(nil) + expect(Identifier.last).to eql(nil) + end + it "does not allow multiple grants on a single plan" do + plan.grant = build(:identifier, identifier_scheme: nil) + plan.save + val = SecureRandom.uuid + plan.grant = build(:identifier, identifier_scheme: nil, value: val) + plan.save + expect(plan.grant.new_record?).to eql(false) + expect(plan.grant.value).to eql(val) + expect(Identifier.all.length).to eql(1) + end + it "allows the same grant to be associated with different plans" do + val = SecureRandom.uuid + id = build(:identifier, identifier_scheme: nil, value: val) + plan.grant = id + plan.save + plan2 = create(:plan, grant: id) + expect(plan2.grant).to eql(plan.grant) + expect(plan2.grant.value).to eql(plan.grant.value) + # Make sure that deleting the plan does not delete the shared grant! + plan.destroy + expect(plan2.grant).not_to eql(nil) + end + end + end diff --git a/spec/models/question_spec.rb b/spec/models/question_spec.rb index 7147288346..1bd55fc62f 100644 --- a/spec/models/question_spec.rb +++ b/spec/models/question_spec.rb @@ -124,6 +124,14 @@ context "when no options are provided" do + before do + create_list(:question_option, 4, question: question) + end + + it "checks number of question options" do + expect(subject.question_options.size).to eql(question.question_options.size) + end + it "doesn't persist the record" do expect(subject).to be_new_record end diff --git a/spec/models/section_spec.rb b/spec/models/section_spec.rb index 904c9aefeb..c5872d4527 100644 --- a/spec/models/section_spec.rb +++ b/spec/models/section_spec.rb @@ -34,6 +34,28 @@ end + describe "#deep_copy" do + + let!(:options) { Hash.new } + + let!(:section) { create(:section) } + + subject { section.deep_copy(options) } + + context "when no options provided" do + + before do + create_list(:question, 3, section: section) + end + + it "checks number of questions" do + expect(section.questions.size).to eql(section.questions.size) + end + + end + + end + describe "#num_answered_questions" do let!(:phase) { create(:phase, template: template) } diff --git a/spec/models/template_spec.rb b/spec/models/template_spec.rb index 800d405220..e4a353b5eb 100644 --- a/spec/models/template_spec.rb +++ b/spec/models/template_spec.rb @@ -46,6 +46,10 @@ it { is_expected.to have_many :questions } + it { is_expected.to have_many :question_options } + + it { is_expected.to have_many :conditions } + it { is_expected.to have_many :annotations } end diff --git a/spec/models/tracker_spec.rb b/spec/models/tracker_spec.rb new file mode 100644 index 0000000000..41ddb2aa28 --- /dev/null +++ b/spec/models/tracker_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +# +require 'rails_helper' + +RSpec.describe Tracker, type: :model do + describe "creation" do + it "can be created from an org" do + org = build(:org) + tracker = org.build_tracker + expect(tracker).to be_valid + end + + it "can be created with an empty code" do + org = build(:org) + tracker = org.build_tracker(code: "") + expect(tracker).to be_valid + end + + it "fails with a badly formatted code" do + org = build(:org) + tracker = org.build_tracker(code: "XXXXXXXXXX") + expect(tracker).to_not be_valid + end + + it "works with a valid code" do + org = build(:org) + tracker = org.build_tracker(code: "UA-12345678-12") + expect(tracker).to be_valid + end + + it "fails with a null org" do + org = build(:org) + tracker = org.build_tracker(code: "XXXXXXXXXX") + tracker.org = nil + expect(tracker).to_not be_valid + end + end +end diff --git a/spec/models/user_identifier_spec.rb b/spec/models/user_identifier_spec.rb deleted file mode 100644 index d82de6ba2a..0000000000 --- a/spec/models/user_identifier_spec.rb +++ /dev/null @@ -1,23 +0,0 @@ -require 'rails_helper' - -RSpec.describe UserIdentifier, type: :model do - - context "validations" do - - it { is_expected.to validate_presence_of(:identifier) } - - it { is_expected.to validate_presence_of(:user) } - - it { is_expected.to validate_presence_of(:identifier_scheme) } - - end - - context "associations" do - - it { is_expected.to belong_to :user } - - it { is_expected.to belong_to :identifier_scheme } - - end - -end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 4529958ca9..397aa57c50 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -57,11 +57,7 @@ it { is_expected.to have_many(:plans).through(:roles) } - it { is_expected.to have_many(:user_identifiers) } - - it { - is_expected.to have_many(:identifier_schemes).through(:user_identifiers) - } + it { should have_many(:identifiers) } it { is_expected.to have_and_belong_to_many(:notifications).dependent(:destroy) @@ -283,30 +279,22 @@ end describe "#identifier_for" do - let!(:user) { create(:user) } + let!(:scheme) { create(:identifier_scheme) } - let!(:identifier_scheme) { create(:identifier_scheme) } - - subject { user.identifier_for(identifier_scheme) } - - context "when user has an user_identifier present" do + subject { user.identifier_for(scheme.name) } - let!(:user_identifier) do - create(:user_identifier, identifier_scheme: identifier_scheme, - user: user) + context "when user has an identifier present" do + let!(:identifier) do + create(:identifier, :for_user, identifier_scheme: scheme, + identifiable: user) end - it { is_expected.to eql(user_identifier) } - + it { is_expected.to eql(identifier) } end context "when user has no user_identifier present" do - - let!(:user_identifier) { create(:user_identifier, user: user) } - - it { is_expected.not_to eql(user_identifier) } - + it { is_expected.not_to eql("") } end end @@ -347,7 +335,6 @@ end describe "#can_org_admin?" do - subject { user.can_org_admin? } context "when user includes Perm with name 'grant_permissions'" do @@ -550,45 +537,34 @@ end end + # Test creationg a User from an omniauth callback like Shibboleth describe ".from_omniauth" do - let!(:user) { create(:user) } - - let!(:auth) { stub(provider: "auth-provider", uid: "1234abcd") } + let!(:auth) do + OpenStruct.new(provider: Faker::Lorem.unique.word, uid: Faker::Lorem.word) + end + let!(:scheme) { create(:identifier_scheme, name: auth[:provider], identifier_prefix: nil) } subject { User.from_omniauth(auth) } - - context "when User has UserIdentifier, with different ID" do - - let!(:identifier_scheme) do - create(:identifier_scheme, name: "auth-provider") - end - - let!(:user_identifier) do - create(:user_identifier, user: user, - identifier_scheme: identifier_scheme, - identifier: "another-auth-uid") + context "when User has Identifier, with different ID" do + let!(:identifier) do + create(:identifier, :for_user, identifiable: user, + identifier_scheme: scheme, + value: Faker::Movies::StarWars.character) end it { is_expected.to be_nil } - end context "when user Identifier and auth Provider are the same string" do - - let!(:identifier_scheme) do - create(:identifier_scheme, name: "auth-provider") - end - - let!(:user_identifier) do - create(:user_identifier, user: user, - identifier_scheme: identifier_scheme, - identifier: "1234abcd") + let!(:identifier) do + create(:identifier, :for_user, identifiable: user, + identifier_scheme: scheme, + value: auth[:uid]) end it { is_expected.to eql(user) } - end end diff --git a/spec/presenters/api/v1/contributor_presenter_spec.rb b/spec/presenters/api/v1/contributor_presenter_spec.rb new file mode 100644 index 0000000000..30604ca332 --- /dev/null +++ b/spec/presenters/api/v1/contributor_presenter_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::ContributorPresenter do + + describe "#role_as_uri" do + it "returns nil if the plans_contributor role is nil" do + uri = described_class.role_as_uri(role: nil) + expect(uri).to eql(nil) + end + it "returns the correct URI" do + uri = described_class.role_as_uri(role: "data_curation") + expect(uri.start_with?("http")).to eql(true) + expect(uri.end_with?("Data_curation")).to eql(true) + end + end + + describe "#contributor_id" do + before(:each) do + @contributor = create(:contributor, investigation: true, plan: create(:plan)) + create(:identifier, identifiable: @contributor) + @contributor.reload + end + + it "returns nil if no ORCID exists" do + rslt = described_class.contributor_id(identifiers: @contributor.identifiers) + expect(rslt).to eql(nil) + end + it "returns the ORCID" do + scheme = create(:identifier_scheme, name: "orcid") + orcid = create(:identifier, identifier_scheme: scheme, identifiable: @contributor) + @contributor.reload + rslt = described_class.contributor_id(identifiers: @contributor.identifiers) + expect(rslt).to eql(orcid) + end + end + +end diff --git a/spec/presenters/api/v1/funding_presenter_spec.rb b/spec/presenters/api/v1/funding_presenter_spec.rb new file mode 100644 index 0000000000..feb6dde3d6 --- /dev/null +++ b/spec/presenters/api/v1/funding_presenter_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::FundingPresenter do + + describe "#status(plan:)" do + it "returns `planned` if the plan is nil" do + expect(described_class.status(plan: nil)).to eql("planned") + end + it "returns `planned` if the plan's grant_number is nil" do + plan = build(:plan, grant_number: nil) + expect(described_class.status(plan: plan)).to eql("planned") + end + it "returns `granted` if the plan has a grant_number" do + plan = build(:plan, grant_number: Faker::Lorem.word) + expect(described_class.status(plan: plan)).to eql("granted") + end + end + +end diff --git a/spec/presenters/api/v1/language_presenter_spec.rb b/spec/presenters/api/v1/language_presenter_spec.rb new file mode 100644 index 0000000000..1997820e84 --- /dev/null +++ b/spec/presenters/api/v1/language_presenter_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::LanguagePresenter do + + describe "#three_char_code(lang:)" do + it "returns nil if the specified lang (as string) has no match" do + expect(described_class.three_char_code(lang: "foo")).to eql(nil) + end + it "returns nil if the specified lang (as symbol) has no match" do + expect(described_class.three_char_code(lang: :foo)).to eql(nil) + end + it "returns the 3 char code for the specified lang (as string)" do + expect(described_class.three_char_code(lang: "en")).to eql("eng") + end + it "returns the 3 char code for the specified lang (as symbol)" do + expect(described_class.three_char_code(lang: :en)).to eql("eng") + end + it "returns the 3 char code for the specified lang with region designation" do + expect(described_class.three_char_code(lang: "en-UK")).to eql("eng") + end + end + +end diff --git a/spec/presenters/api/v1/org_presenter_spec.rb b/spec/presenters/api/v1/org_presenter_spec.rb new file mode 100644 index 0000000000..dea4a61a61 --- /dev/null +++ b/spec/presenters/api/v1/org_presenter_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::OrgPresenter do + + describe "#affiliation_id" do + before(:each) do + @ror_scheme = create(:identifier_scheme, name: "ror") + @fundref_scheme = create(:identifier_scheme, name: "fundref") + @org = create(:org) + create(:identifier, identifiable: @org) + @org.reload + end + + it "returns nil if no ORCID exists" do + rslt = described_class.affiliation_id(identifiers: @org.identifiers) + expect(rslt).to eql(nil) + end + it "returns the ROR" do + ror = create(:identifier, identifier_scheme: @ror_scheme, identifiable: @org) + create(:identifier, identifier_scheme: @fundref_scheme, identifiable: @org) + @org.reload + rslt = described_class.affiliation_id(identifiers: @org.identifiers) + expect(rslt).to eql(ror) + end + it "returns the FUNDREF if no ROR is present" do + fundref = create(:identifier, identifier_scheme: @fundref_scheme, + identifiable: @org) + @org.reload + rslt = described_class.affiliation_id(identifiers: @org.identifiers) + expect(rslt).to eql(fundref) + end + end + +end diff --git a/spec/presenters/api/v1/pagination_presenter_spec.rb b/spec/presenters/api/v1/pagination_presenter_spec.rb new file mode 100644 index 0000000000..5d9ace05f5 --- /dev/null +++ b/spec/presenters/api/v1/pagination_presenter_spec.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::PaginationPresenter do + + describe "#url_without_pagination" do + before(:each) do + @url = Faker::Internet.url + end + + it "returns nil if no url was specified" do + presenter = described_class.new(current_url: nil, per_page: 2, + total_items: 1, current_page: 1) + expect(presenter.url_without_pagination).to eql(nil) + end + it "removes per_page from the query string" do + target = "#{@url}?per_page=2" + presenter = described_class.new(current_url: target, per_page: 2, + total_items: 1, current_page: 1) + rslt = presenter.url_without_pagination.include?("per_page=2") + expect(rslt).to eql(false) + end + it "removes page from the query string" do + target = "#{@url}?page=2" + presenter = described_class.new(current_url: target, per_page: 2, + total_items: 1, current_page: 1) + rslt = presenter.url_without_pagination.include?("page=2") + expect(rslt).to eql(false) + end + it "retains other query string items if there were no pagination ones" do + target = "#{@url}?other=true" + presenter = described_class.new(current_url: target, per_page: 2, + total_items: 1, current_page: 1) + rslt = presenter.url_without_pagination.include?("other=true") + expect(rslt).to eql(true) + end + it "retains other query string items if it removed pagination ones" do + target = "#{@url}?per_page=2&other=true" + presenter = described_class.new(current_url: target, per_page: 2, + total_items: 1, current_page: 1) + rslt = presenter.url_without_pagination.include?("other=true") + expect(rslt).to eql(true) + end + it "ends with a '&' if there were query string items" do + target = "#{@url}?per_page=2&other=true" + presenter = described_class.new(current_url: target, per_page: 2, + total_items: 1, current_page: 1) + rslt = presenter.url_without_pagination.end_with?("&") + expect(rslt).to eql(true) + end + it "ends with a '?' if there were no query string items" do + presenter = described_class.new(current_url: @url, per_page: 2, + total_items: 1, current_page: 1) + rslt = presenter.url_without_pagination.end_with?("?") + expect(rslt).to eql(true) + end + it "ends with a '?' if there were only pagination items in query string" do + target = "#{@url}?page=2" + presenter = described_class.new(current_url: target, per_page: 2, + total_items: 1, current_page: 1) + rslt = presenter.url_without_pagination.end_with?("?") + expect(rslt).to eql(true) + end + end + + describe "#prev_page?" do + it "returns false if we are on page 1" do + presenter = described_class.new(current_url: nil, per_page: 2, + total_items: 4, current_page: 1) + expect(presenter.prev_page?).to eql(false) + end + it "returns false if there is only 1 page" do + presenter = described_class.new(current_url: nil, per_page: 2, + total_items: 2, current_page: 2) + expect(presenter.prev_page?).to eql(false) + end + it "returns true if more than 1 page and we are not on page 1" do + presenter = described_class.new(current_url: nil, per_page: 2, + total_items: 4, current_page: 2) + expect(presenter.prev_page?).to eql(true) + end + end + + describe "#next_page?" do + it "returns false if we are on the last page" do + presenter = described_class.new(current_url: nil, per_page: 2, + total_items: 4, current_page: 2) + expect(presenter.next_page?).to eql(false) + end + it "returns false if there is only 1 page" do + presenter = described_class.new(current_url: nil, per_page: 2, + total_items: 2, current_page: 1) + expect(presenter.next_page?).to eql(false) + end + it "returns true if more than 1 page and we are not on last page" do + presenter = described_class.new(current_url: nil, per_page: 2, + total_items: 4, current_page: 1) + expect(presenter.next_page?).to eql(true) + end + end + + describe "#prev_page_link" do + before(:each) do + url = "#{Faker::Internet.url}?other=true" + @presenter = described_class.new(current_url: url, per_page: 2, + total_items: 4, current_page: 2) + end + + it "includes per_page in the query string" do + expect(@presenter.prev_page_link.include?("per_page=2")).to eql(true) + end + it "includes shows the correct page number" do + expect(@presenter.prev_page_link.include?("page=1")).to eql(true) + end + it "retains other query params" do + expect(@presenter.prev_page_link.include?("other=true")).to eql(true) + end + end + + describe "#next_page_link" do + before(:each) do + url = "#{Faker::Internet.url}?other=true" + @presenter = described_class.new(current_url: url, per_page: 2, + total_items: 4, current_page: 1) + end + + it "includes per_page in the query string" do + expect(@presenter.next_page_link.include?("per_page=2")).to eql(true) + end + it "includes shows the correct page number" do + expect(@presenter.next_page_link.include?("page=2")).to eql(true) + end + it "retains other query params" do + expect(@presenter.next_page_link.include?("other=true")).to eql(true) + end + end + + context "private methods" do + + describe "#total_pages" do + it "returns 1 if total_items is missing" do + presenter = described_class.new(current_url: nil, per_page: 2, + total_items: nil) + expect(presenter.send(:total_pages)).to eql(1) + end + it "returns 1 if per_page is missing" do + presenter = described_class.new(current_url: nil, per_page: nil, + total_items: 4) + expect(presenter.send(:total_pages)).to eql(1) + end + it "returns 1 if total_items is <= 0" do + presenter = described_class.new(current_url: nil, per_page: 2, + total_items: 0) + expect(presenter.send(:total_pages)).to eql(1) + end + it "returns 1 if per_page is <= 0" do + presenter = described_class.new(current_url: nil, per_page: 0, + total_items: 4) + expect(presenter.send(:total_pages)).to eql(1) + end + it "returns the total_items / per_page" do + presenter = described_class.new(current_url: nil, per_page: 2, + total_items: 4) + expect(presenter.send(:total_pages)).to eql(2) + end + it "rounds up" do + presenter = described_class.new(current_url: nil, per_page: 3, + total_items: 4) + expect(presenter.send(:total_pages)).to eql(2) + end + end + + end + +end diff --git a/spec/presenters/api/v1/plan_presenter_spec.rb b/spec/presenters/api/v1/plan_presenter_spec.rb new file mode 100644 index 0000000000..ea5fb9071e --- /dev/null +++ b/spec/presenters/api/v1/plan_presenter_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::PlanPresenter do + + describe "#initialize(plan:)" do + before(:each) do + plan = build(:plan) + @data_contact = build(:contributor, data_curation: true) + @pi = build(:contributor, investigation: true) + plan.contributors = [@data_contact, @pi] + @presenter = described_class.new(plan: plan) + end + + it "sets contributors to empty array if no plan was specified" do + presenter = described_class.new(plan: nil) + expect(presenter.data_contact).to eql(nil) + expect(presenter.contributors).to eql([]) + end + it "sets contributors to empty array if plan has no contributors" do + plan = build(:plan) + plan.contributors = [] + presenter = described_class.new(plan: plan) + expect(presenter.data_contact).to eql(nil) + expect(presenter.contributors).to eql([]) + end + it "sets data_contact" do + expect(@presenter.data_contact).to eql(@data_contact) + end + it "sets other contributors (including the data_contact)" do + expect(@presenter.contributors.length).to eql(2) + expect(@presenter.contributors.include?(@data_contact)).to eql(true) + expect(@presenter.contributors.include?(@pi)).to eql(true) + end + end + +end diff --git a/spec/presenters/api/v1/template_presenter_spec.rb b/spec/presenters/api/v1/template_presenter_spec.rb new file mode 100644 index 0000000000..82966749aa --- /dev/null +++ b/spec/presenters/api/v1/template_presenter_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::TemplatePresenter do + + describe "#title" do + before(:each) do + @org = create(:org) + @template = build(:template, customization_of: nil, org: @org) + end + + it "returns the template title if its not a customization" do + presenter = described_class.new(template: @template) + expect(presenter.title).to eql(@template.title) + end + it "returns the template title and Org name if it is a customization" do + @template.customization_of = Faker::Number.number + presenter = described_class.new(template: @template) + expect(presenter.title.start_with?(@template.title)).to eql(true) + expect(presenter.title.end_with?(@org.name)).to eql(true) + end + end + +end diff --git a/spec/presenters/identifier_presenter_spec.rb b/spec/presenters/identifier_presenter_spec.rb new file mode 100644 index 0000000000..4a97ba7112 --- /dev/null +++ b/spec/presenters/identifier_presenter_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IdentifierPresenter do + + before(:each) do + @user = create(:user) + @user_scheme = create(:identifier_scheme, for_users: true) + @plan_scheme = create(:identifier_scheme, for_plans: true, for_users: false) + @org_scheme = create(:identifier_scheme, for_orgs: true, for_users: false) + end + + describe "#identifiers" do + it "returns the identiable object's identifiers" do + id = build(:identifier) + @user.identifiers << id + @user.org.identifiers << build(:identifier) + presenter = described_class.new(identifiable: @user) + expect(presenter.identifiers.length).to eql(1) + expect(presenter.identifiers.first).to eql(id) + end + end + + describe "#id_for_scheme(scheme:)" do + before(:each) do + @user_id = build(:identifier, identifier_scheme: @user_scheme) + @user_id2 = build(:identifier, identifier_scheme: @org_scheme) + @user.identifiers = [@user_id, @user_id2] + @presenter = described_class.new(identifiable: @user) + end + + it "initializes a new identifier if no matching identifiers exist" do + rslt = @presenter.id_for_scheme(scheme: @plan_scheme) + expect(rslt.new_record?).to eql(true) + end + + it "returns the correct identifier" do + rslt = @presenter.id_for_scheme(scheme: @user_scheme) + expect(rslt).to eql(@user_id) + end + end + + describe "#scheme_by_name(name:)" do + it "returns the correct scheme" do + presenter = described_class.new(identifiable: @user) + rslt = presenter.scheme_by_name(name: @user_scheme.name) + expect(rslt.first).to eql(@user_scheme) + end + end + + describe "#id_for_display(id:, with_scheme_name)" do + before(:each) do + @none = _("None defined") + @presenter = described_class.new(identifiable: @user) + + url = Faker::Internet.url + @user_scheme.identifier_prefix = url + val = "#{url}/#{Faker::Lorem.word}" + @identifier = create(:identifier, identifier_scheme: @user_scheme, + value: val) + end + + it "defaults to showing the scheme name" do + rslt = @presenter.id_for_display(id: @identifier) + expect(rslt.include?(@user_scheme.identifier_prefix)).to eql(true) + end + it "does not display the scheme name if flag is set" do + rslt = @presenter.id_for_display(id: @identifier, with_scheme_name: false) + expect(rslt.include?(@user_scheme.name)).to eql(false) + end + it "returns the correct text when the identifier is new" do + id = build(:identifier) + rslt = @presenter.id_for_display(id: id) + expect(rslt).to eql(@none) + end + it "returns the correct text when the identifier is blank" do + @identifier.value = "" + rslt = @presenter.id_for_display(id: @identifier) + expect(rslt).to eql(@none) + end + it "returns the value when the scheme has no identifier_prefix" do + val = Faker::Lorem.word + @user_scheme.identifier_prefix = nil + @identifier.value = val + rslt = @presenter.id_for_display(id: @identifier) + expect(rslt).to eql(val) + end + it "returns the value as a link when the scheme has a identifier_prefix" do + rslt = @presenter.id_for_display(id: @identifier) + expect(rslt.include?(@identifier.value)).to eql(true) + end + end + + context "#schemes" do + describe "when the identifiable object is an Org" do + before(:each) do + @presenter = described_class.new(identifiable: build(:org)) + end + + it "returns schemes appropriate to the Org context" do + expect(@presenter.schemes.include?(@org_scheme)).to eql(true) + end + it "does not return schemes for other contexts" do + expect(@presenter.schemes.include?(@user_scheme)).not_to eql(true) + expect(@presenter.schemes.include?(@plan_scheme)).not_to eql(true) + end + end + + describe "when the identifiable object is an Plan" do + before(:each) do + @presenter = described_class.new(identifiable: build(:plan)) + end + + it "returns schemes appropriate to the Plan context" do + expect(@presenter.schemes.include?(@plan_scheme)).to eql(true) + end + it "does not return schemes for other contexts" do + expect(@presenter.schemes.include?(@user_scheme)).not_to eql(true) + expect(@presenter.schemes.include?(@org_scheme)).not_to eql(true) + end + end + + describe "when the identifiable object is an User" do + before(:each) do + @presenter = described_class.new(identifiable: build(:user)) + end + + it "returns schemes appropriate to the User context" do + expect(@presenter.schemes.include?(@user_scheme)).to eql(true) + end + it "does not return schemes for other contexts" do + expect(@presenter.schemes.include?(@org_scheme)).not_to eql(true) + expect(@presenter.schemes.include?(@plan_scheme)).not_to eql(true) + end + end + end + +end diff --git a/spec/presenters/org_selection_presenter_spec.rb b/spec/presenters/org_selection_presenter_spec.rb new file mode 100644 index 0000000000..d5a7191080 --- /dev/null +++ b/spec/presenters/org_selection_presenter_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe OrgSelectionPresenter do + + before(:each) do + @org = create(:org) + @orgs = [@org, build(:org)] + @presenter = described_class.new(orgs: @orgs, selection: @org) + end + + describe "#name" do + it "returns blank if no selection is defined" do + presenter = described_class.new(orgs: @orgs, selection: nil) + expect(presenter.name).to eql("") + end + it "#name returns blank" do + expect(@presenter.name).to eql(@org.name) + end + end + + it "#crosswalk returns an array containing the Orgs as hashes" do + rslt = JSON.parse(@presenter.crosswalk) + @orgs.each do |org| + expected = OrgSelection::OrgToHashService.to_hash(org: org).to_json + expect(rslt.include?(JSON.parse(expected))).to eql(true) + end + end + + it "#select_list returns an array of the Org names" do + expect(@presenter.select_list.include?(@org.name)).to eql(true) + end + + describe "#crosswalk_entry_from_org_id(value:)" do + it "return an empty hash if the value is blank" do + expect(@presenter.crosswalk_entry_from_org_id(value: nil)).to eql("{}") + end + it "return an empty hash if the value is not an integer" do + expect(@presenter.crosswalk_entry_from_org_id(value: "a123")).to eql("{}") + end + it "return an empty hash if the value does not have a match in crosswalk" do + expect(@presenter.crosswalk_entry_from_org_id(value: "999")).to eql("{}") + end + it "return ther correct crosswalk entry" do + rslt = @presenter.crosswalk_entry_from_org_id(value: @org.id.to_s) + expected = OrgSelection::OrgToHashService.to_hash(org: @org).to_json + expect(rslt).to eql(expected) + end + end + +end diff --git a/spec/presenters/plan_presenter.rb b/spec/presenters/plan_presenter.rb new file mode 100644 index 0000000000..f316e93fde --- /dev/null +++ b/spec/presenters/plan_presenter.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PlanPresenter do + + before(:each) do + @plan = build(:plan, start_date: nil, end_date: nil) + @presenter = described_class.new(@plan) + end + + describe "#project_dates_to_readonly_display" do + it "returns blank if no start_date or end_date" do + expect(@presenter.project_dates_to_readonly_display).to eql("") + end + it "returns 'Starts on [:date]' if end_date is nil" do + @plan.start_date = Time.now + expected = @presenter.project_dates_to_readonly_display + expect(expected.start_with?("Starts on")).to eql(true) + end + it "returns 'Ends on [:date]' if start_date is nil" do + @plan.end_date = Time.now + expected = @presenter.project_dates_to_readonly_display + expect(expected.start_with?("Ends on")).to eql(true) + end + it "returns '[:date] to [:date]' start_date end_date are present" do + @plan.start_date = Time.now + @plan.end_date = Time.now + 2.months + expected = @presenter.project_dates_to_readonly_display + expect(expected.include?(" to ")).to eql(true) + end + end + +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index dab15ab9fc..3fa3598b4b 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -1,11 +1,14 @@ +# frozen_string_literal: true + # This file is copied to spec/ when you run 'rails generate rspec:install' -require 'spec_helper' -ENV['RAILS_ENV'] ||= 'test' -require File.expand_path('../../config/environment', __FILE__) +require "spec_helper" +ENV["RAILS_ENV"] ||= "test" +require File.expand_path("../config/environment", __dir__) # Prevent database truncation if the environment is production abort("The Rails environment is running in production mode!") if Rails.env.production? -require 'rspec/rails' -require 'capybara-screenshot/rspec' +require "rspec/rails" +# require "capybara-screenshot/rspec" +require "webmock/rspec" # Clear all of the screenshots from old tests Dir[Rails.root.join('tmp/capybara/*')].each { |f| File.delete(f) } @@ -24,9 +27,9 @@ # directory. Alternatively, in the individual `*_spec.rb` files, manually # require only the support files necessary. # -Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } +Dir[Rails.root.join("spec/support/**/*.rb")].sort.each { |f| require f } -Dir[Rails.root.join('spec/mixins/*.rb')].each { |f| require f } +Dir[Rails.root.join("spec/mixins/*.rb")].sort.each { |f| require f } # Checks for pending migrations and applies them before tests are run. # If you are not using ActiveRecord, you can remove this line. @@ -59,6 +62,7 @@ # config.filter_gems_from_backtrace("gem name") config.include Devise::Test::IntegrationHelpers, type: :request config.include Devise::Test::ControllerHelpers, type: :controller + config.include Devise::Test::ControllerHelpers, type: :view config.include Pundit::Matchers, type: :policy # ------------------------------------------------------ diff --git a/spec/requests/api/v1/authentication_controller_spec.rb b/spec/requests/api/v1/authentication_controller_spec.rb new file mode 100644 index 0000000000..c6176a566d --- /dev/null +++ b/spec/requests/api/v1/authentication_controller_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::AuthenticationController, type: :request do + + before(:each) do + @client = create(:api_client) + end + + context "actions" do + + describe "POST /api/v1/authenticate" do + before(:each) do + @client = create(:api_client) + @payload = { + grant_type: "client_credentials", + client_id: @client.client_id, + client_secret: @client.client_secret + } + end + + it "calls the Api::Jwt::AuthenticationService" do + Api::V1::Auth::Jwt::AuthenticationService.expects(:call).at_most(1) + post api_v1_authenticate_path, @payload.to_json + end + it "renders /api/v1/error template if authentication fails" do + errs = [Faker::Lorem.sentence] + Api::V1::Auth::Jwt::AuthenticationService.any_instance + .stubs(:call).returns(nil) + .stubs(:errors).returns(errs) + post api_v1_authenticate_path, @payload.to_json + expect(response.code).to eql("401") + expect(response).to render_template("api/v1/error") + end + it "returns a JSON Web Token" do + token = Api::V1::Auth::Jwt::JsonWebToken.encode(payload: @payload) + Api::V1::Auth::Jwt::AuthenticationService.any_instance.stubs(:call) + .returns(token) + post api_v1_authenticate_path, @payload.to_json + expect(response.code).to eql("200") + expect(response).to render_template("api/v1/token") + end + end + + end + +end diff --git a/spec/requests/api/v1/base_api_controller_spec.rb b/spec/requests/api/v1/base_api_controller_spec.rb new file mode 100644 index 0000000000..eaeff0569c --- /dev/null +++ b/spec/requests/api/v1/base_api_controller_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::BaseApiController, type: :request do + + before(:each) do + @client = create(:api_client) + end + + context "actions" do + + describe "heartbeat (GET api/v1/heartbeat)" do + it "skips the authorize_request callback" do + described_class.new.expects(:authorize_request).at_most(0) + get api_v1_heartbeat_path + end + it "returns a 200 status" do + get api_v1_heartbeat_path + expect(response.code).to eql("200") + end + it "renders the standard response template" do + get api_v1_heartbeat_path + expect(response).to render_template(partial: "api/v1/_standard_response") + end + end + + end + + context "private methods" do + include Mocks::ApiJsonSamples + + before(:each) do + @controller = described_class.new + end + + # See the plans_controller_spec.rb for tests of most of this method's + # callbacks since this controller's only endpoint, :heartbeat, skips them + + describe "#authorize_request" do + before(:each) do + @client = create(:api_client) + struct = OpenStruct.new(headers: {}) + @controller.expects(:request).returns(struct) + end + + it "calls log_access if the authorization succeeds" do + auth_svc = OpenStruct.new(call: @client) + Api::V1::Auth::Jwt::AuthorizationService.expects(:new).returns(auth_svc) + @controller.expects(:log_access).at_least(1) + @controller.send(:authorize_request) + end + + it "sets the client if the authorization succeeds" do + auth_svc = OpenStruct.new(call: @client) + Api::V1::Auth::Jwt::AuthorizationService.expects(:new).returns(auth_svc) + @controller.send(:authorize_request) + expect(@controller.client).to eql(@client) + end + + it "renders an UNAUTHORIZED error if the client is not authorized" do + auth_svc = OpenStruct.new(call: nil) + Api::V1::Auth::Jwt::AuthorizationService.expects(:new).returns(auth_svc) + @controller.expects(:render_error).at_least(1) + @controller.send(:authorize_request) + end + end + + describe "#log_access" do + it "returns false if the client is not set" do + @controller.expects(:client).returns(nil) + expect(@controller.send(:log_access)).to eql(false) + end + it "returns true if the client is set" do + @client = create(:api_client) + @controller.expects(:client).returns(@client) + expect(@controller.send(:log_access)).to eql(true) + end + it "updates the api_client.last_access if client is an ApiClient" do + @client = create(:api_client) + time = @client.last_access + @controller.expects(:client).returns(@client) + @controller.send(:log_access) + expect(time).not_to eql(@client.reload.last_access) + end + it "updates the users.last_api_access if client is a User" do + @user = create(:user) + time = @user.last_api_access + @controller.expects(:client).returns(@user) + @controller.send(:log_access) + expect(time).not_to eql(@user.reload.last_api_access) + end + end + + describe "#caller_name" do + it "returns the caller's IP if the client is nil" do + ip = Faker::Internet.ip_v4_address + @controller.expects(:client).returns(nil) + @controller.expects(:request).returns(OpenStruct.new(remote_ip: ip)) + expect(@controller.send(:caller_name)).to eql(ip) + end + it "returns the user name if the client is a User" do + @user = create(:user) + @controller.expects(:client).returns(@user) + expect(@controller.send(:caller_name)).to eql(@user.name(false)) + end + it "returns the client name if the client is a ApiClient" do + @client = create(:api_client) + @controller.expects(:client).returns(@client) + expect(@controller.send(:caller_name)).to eql(@client.name) + end + end + + end + +end diff --git a/spec/requests/api/v1/plans_controller.rb b/spec/requests/api/v1/plans_controller.rb new file mode 100644 index 0000000000..81019de433 --- /dev/null +++ b/spec/requests/api/v1/plans_controller.rb @@ -0,0 +1,347 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::PlansController, type: :request do + + include ApiHelper + + context "ApiClient" do + + before(:each) do + mock_authorization_for_api_client + + # Org model requires a language so make sure the default is set + create(:language, default_language: true) + end + + describe "GET /api/v1/plan/:id - show" do + it "returns the plan" do + plan = create(:plan, api_client_id: ApiClient.first&.id) + get api_v1_plan_path(plan) + expect(response.code).to eql("200") + expect(response).to render_template("api/v1/plans/index") + expect(assigns(:items).length).to eql(1) + end + it "returns a 404 if the ApiClient did not create the plan" do + plan = create(:plan, api_client_id: create(:api_client)) + get api_v1_plan_path(plan) + expect(response.code).to eql("404") + expect(response).to render_template("api/v1/error") + end + it "returns a 404 if not found" do + get api_v1_plan_path(9999) + expect(response.code).to eql("404") + expect(response).to render_template("api/v1/error") + end + end + + describe "POST /api/v1/plans - create" do + include Webmocks + include Mocks::ApiJsonSamples + + before(:each) do + stub_ror_service + mock_identifier_schemes + create(:template, :publicly_visible, is_default: true, published: true) + end + + context "minimal JSON" do + before(:each) do + @json = JSON.parse(minimal_create_json).with_indifferent_access + end + + it "returns a 400 if the incoming JSON is invalid" do + post api_v1_plans_path, Faker::Lorem.word + expect(response.code).to eql("400") + expect(response).to render_template("api/v1/error") + end + it "returns a 400 if the incoming DMP is invalid" do + create(:plan, api_client_id: ApiClient.first.id) + @json[:items].first[:dmp][:title] = "" + post api_v1_plans_path, @json.to_json + expect(response.code).to eql("400") + expect(response).to render_template("api/v1/error") + end + it "returns a 400 if the plan already exists" do + plan = create(:plan, created_at: (Time.now - 3.days), + api_client_id: ApiClient.first.id) + @json[:items].first[:dmp][:dmp_id] = { + type: "url", + identifier: Rails.application.routes.url_helpers.api_v1_plan_url(plan) + } + post api_v1_plans_path, @json.to_json + expect(response.code).to eql("400") + expect(response).to render_template("api/v1/error") + expect(response.body.include?("already exists")).to eql(true) + end + it "returns a 201 if the incoming JSON is valid" do + post api_v1_plans_path, @json.to_json + expect(response.code).to eql("201") + expect(response).to render_template("api/v1/plans/index") + end + + context "plan inspection" do + before(:each) do + post api_v1_plans_path, @json.to_json + @original = @json.with_indifferent_access[:items].first[:dmp] + @plan = Plan.last + end + + it "set the Plan title" do + expect(@plan.title).to eql(@original[:title]) + end + it "attached the contact to the Plan" do + expect(@plan.contributors.length).to eql(1) + end + it "set the Contact email" do + expected = @plan.contributors.first.email + expect(expected).to eql(@original[:contact][:mbox]) + end + it "set the Contact roles" do + expected = @plan.contributors.first + expect(expected.data_curation?).to eql(true) + end + it "set the Template id" do + app = ApplicationService.application_name.split("-").first + tmplt = @original[:extension].select { |i| i[app].present? }.first + expected = tmplt[app][:template][:id] + expect(@plan.template_id).to eql(expected) + end + end + end + + context "complete JSON" do + before(:each) do + @json = JSON.parse(complete_create_json).with_indifferent_access + end + + it "returns a 201 if the incoming JSON is valid" do + post api_v1_plans_path, @json.to_json + expect(response.code).to eql("201") + expect(response).to render_template("api/v1/plans/index") + end + + context "plan inspection" do + before(:each) do + post api_v1_plans_path, @json.to_json + @original = @json.with_indifferent_access[:items].first[:dmp] + @plan = Plan.last + end + + it "set the Plan title" do + expect(@plan.title).to eql(@original[:title]) + end + + it "set the Plan description" do + expect(@plan.title).to eql(@original[:title]) + end + it "set the Plan start_date" do + expect(@plan.title).to eql(@original[:title]) + end + it "set the Plan end_date" do + expect(@plan.title).to eql(@original[:title]) + end + it "Plan identifiers includes the grant id" do + expect(@plan.identifiers.length).to eql(1) + expected = @original[:project].first[:funding].first[:grant_id][:type] + expect("other").to eql(expected) + + expected = @original[:project].first[:funding].first[:grant_id][:identifier] + expect(@plan.identifiers.first.value).to eql(expected) + end + + context "contact inspection" do + before(:each) do + @original = @original[:contact] + contacts = @plan.contributors.select do |pc| + pc.email == @original[:mbox] + end + @contact = contacts.first + end + + it "attached the Contact to the Plan" do + expect(@contact.present?).to eql(true) + end + it "set the Contact name" do + expect(@contact.name).to eql(@original[:name]) + end + it "set the Contact email" do + expect(@contact.email).to eql(@original[:mbox]) + end + it "set the Contact roles" do + expect(@contact.data_curation?).to eql(true) + end + it "Contact identifiers includes the orcid" do + expect(@contact.identifiers.length).to eql(1) + expected = @original[:contact_id][:type] + expect(@contact.identifiers.first.identifier_scheme.name).to eql(expected) + + expected = @original[:contact_id][:identifier] + rslt = @contact.identifiers.first.value + expect(rslt.ends_with?(expected)).to eql(true) + end + it "ignored the unknown identifier type" do + results = @contact.identifiers.select do |i| + i.value == @original[:contact_id] + end + expect(results.any?).to eql(false) + end + + context "contact org inspection" do + before(:each) do + @original = @original[:affiliation] + end + + it "attached the Org to the Contact" do + expect(@contact.org.present?).to eql(true) + end + it "sets the name" do + expect(@contact.org.name).to eql(@original[:name]) + end + it "sets the abbreviation" do + expect(@contact.org.abbreviation).to eql(@original[:abbreviation]) + end + it "Org identifiers includes the affiation id" do + expect(@contact.org.identifiers.length).to eql(1) + expected = @original[:affiliation_id][:type] + result = @contact.org.identifiers.first.identifier_scheme.name + expect(result).to eql(expected) + + expected = @original[:affiliation_id][:identifier] + rslt = @contact.org.identifiers.first.value + expect(rslt.ends_with?(expected)).to eql(true) + end + it "is the same as the Plan's org" do + expect(@plan.org).to eql(@contact.org) + end + end + end + + context "contributor inspection" do + before(:each) do + @original = @original[:contributor].first + contributors = @plan.contributors.select do |contrib| + contrib.email == @original[:mbox] + end + @subject = contributors.first + end + + it "attached the Contributor to the Plan" do + expect(@subject.present?).to eql(true) + end + it "set the Contributor name" do + expect(@subject.name).to eql(@original[:name]) + end + it "set the Contributor email" do + expect(@subject.email).to eql(@original[:mbox]) + end + it "set the Contributor roles" do + expected = @original[:role].map do |role| + role.gsub("#{Contributor::ONTOLOGY_BASE_URL}/", "") + end + expect(@subject.send(:"#{expected.first.downcase}?")).to eql(true) + end + it "Contributor identifiers includes the orcid" do + expect(@subject.identifiers.length).to eql(1) + expected = @original[:contributor_id][:type] + expect(@subject.identifiers.first.identifier_scheme.name).to eql(expected) + + expected = @original[:contributor_id][:identifier] + rslt = @subject.identifiers.first.value + expect(rslt.ends_with?(expected)).to eql(true) + end + + context "contributor org inspection" do + before(:each) do + @original = @original[:affiliation] + end + + it "attached the Org to the Contributor" do + expect(@subject.org.present?).to eql(true) + end + it "sets the name" do + expect(@subject.org.name).to eql(@original[:name]) + end + it "sets the abbreviation" do + expect(@subject.org.abbreviation).to eql(@original[:abbreviation]) + end + it "Org identifiers includes the affiation id" do + expect(@subject.org.identifiers.length).to eql(1) + expected = @original[:affiliation_id][:type] + expect("ror").to eql(expected) + + expected = @original[:affiliation_id][:identifier] + rslt = @subject.org.identifiers.first.value + expect(rslt.ends_with?(expected)).to eql(true) + end + end + end + + context "funder inspection" do + before(:each) do + @original = @original[:project].first[:funding].first + @funder = @plan.funder + end + + it "attached the Funder to the Plan" do + expect(@funder.present?).to eql(true) + end + it "sets the name" do + expect(@funder.name).to eql(@original[:name]) + end + it "Funder identifiers includes the funder_id id" do + expect(@funder.identifiers.length).to eql(1) + expected = @original[:funder_id][:type] + expect(@funder.identifiers.first.identifier_scheme.name).to eql(expected) + + expected = @original[:funder_id][:identifier].to_s + rslt = @funder.identifiers.first.value + expect(rslt.ends_with?(expected)).to eql(true) + end + end + + it "set the Template id" do + app = ApplicationService.application_name.split("-").first + tmplt = @original[:extension].select { |i| i[app].present? }.first + expected = tmplt[app][:template][:id] + expect(@plan.template_id).to eql(expected) + end + end + + end + + end + end + + context "User" do + + before(:each) do + mock_authorization_for_user + end + + describe "GET /api/v1/plan/:id - show" do + it "returns the plan" do + plan = create(:plan, :creator, :organisationally_visible, org: Org.last) + get api_v1_plan_path(plan) + expect(response.code).to eql("200") + expect(response).to render_template("api/v1/plans/index") + expect(assigns(:items).length).to eql(1) + end + it "returns a 404 if not found" do + get api_v1_plan_path(9999) + expect(response.code).to eql("404") + expect(response).to render_template("api/v1/error") + end + it "returns a 404 if the user does not have access" do + org2 = create(:org) + plan = create(:plan, :creator, :organisationally_visible, org: org2) + get api_v1_plan_path(plan) + expect(response.code).to eql("404") + expect(response).to render_template("api/v1/error") + end + end + + end + +end diff --git a/spec/requests/api/v1/templates_controller_spec.rb b/spec/requests/api/v1/templates_controller_spec.rb new file mode 100644 index 0000000000..f5fcfecd72 --- /dev/null +++ b/spec/requests/api/v1/templates_controller_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::TemplatesController, type: :request do + + include ApiHelper + + context "ApiClient" do + + before(:each) do + mock_authorization_for_api_client + end + + describe "GET /api/v1/templates - index" do + it "returns a even if there are no public templates" do + get api_v1_templates_path + expect(response.code).to eql("200") + expect(response).to render_template("api/v1/templates/index") + expect(assigns(:items).empty?).to eql(true) + end + + it "returns a public published template" do + create(:template, :publicly_visible, published: true, customization_of: nil) + get api_v1_templates_path + expect(assigns(:items).length).to eql(1) + end + + it "does not return an unpublished template" do + create(:template, :publicly_visible, published: false, customization_of: nil) + get api_v1_templates_path + expect(assigns(:items).length).to eql(0) + end + + it "does not return an organizational template" do + get api_v1_templates_path + create(:template, :organisationally_visible, :published, customization_of: nil) + get api_v1_templates_path + expect(assigns(:items).length).to eql(0) + end + end + + end + + context "User" do + + before(:each) do + mock_authorization_for_user + end + + describe "GET /api/v1/templates - index" do + it "returns a even if there are no public templates" do + get api_v1_templates_path + expect(response.code).to eql("200") + expect(response).to render_template("api/v1/templates/index") + expect(assigns(:items).empty?).to eql(true) + end + + it "returns a public published template" do + create(:template, :publicly_visible, :published, customization_of: nil) + get api_v1_templates_path + expect(assigns(:items).length).to eql(1) + end + + it "returns a organizational published template (for user's org)" do + create(:template, :organisationally_visible, :published, org: Org.last, + customization_of: nil) + get api_v1_templates_path + expect(assigns(:items).length).to eql(1) + end + + it "does not return an unpublished template" do + create(:template, :organisationally_visible, published: false, + org: Org.last, customization_of: nil) + get api_v1_templates_path + expect(assigns(:items).length).to eql(0) + end + + it "does not return another Org's organizational template" do + org2 = create(:org) + get api_v1_templates_path + create(:template, :organisationally_visible, published: true, org: org2, + customization_of: nil) + get api_v1_templates_path + expect(assigns(:items).length).to eql(0) + end + end + + end + +end diff --git a/spec/services/api/v1/auth/jwt/authentication_service_spec.rb b/spec/services/api/v1/auth/jwt/authentication_service_spec.rb new file mode 100644 index 0000000000..53e5251799 --- /dev/null +++ b/spec/services/api/v1/auth/jwt/authentication_service_spec.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Auth::Jwt::AuthenticationService do + + before(:each) do + @jwt = SecureRandom.uuid + Api::V1::Auth::Jwt::JsonWebToken.stubs(:encode).returns(@jwt) + end + + context "instance methods" do + + describe "#initialize(json:)" do + it "sets errors to empty hash" do + svc = described_class.new( + json: { + grant_type: "client_credentials", + client_id: Faker::Lorem.word, client_secret: Faker::Lorem.word + } + ) + expect(svc.errors).to eql({}) + end + it "defaults :grant_type to client_credentials" do + id = Faker::Lorem.word + svc = described_class.new( + json: { + client_id: id, + client_secret: Faker::Lorem.word + } + ) + expect(svc.send(:client_id)).to eql(id) + end + it "does not accept invalid :grant_type" do + svc = described_class.new( + json: { + grant_type: Faker::Lorem.word, + client_id: Faker::Lorem.word, + client_secret: Faker::Lorem.word + } + ) + expect(svc.send(:client_id)).to eql(nil) + end + it "accepts client_credentials :grant_type" do + id = Faker::Lorem.word + svc = described_class.new( + json: { + grant_type: "client_credentials", + client_id: id, + client_secret: Faker::Lorem.word + } + ) + expect(svc.send(:client_id)).to eql(id) + end + it "accepts authorization_code :grant_type" do + email = Faker::Internet.email + svc = described_class.new( + json: { + grant_type: "authorization_code", + email: email, + code: Faker::Lorem.word + } + ) + expect(svc.send(:client_id)).to eql(email) + end + end + + describe "#call" do + it "returns null if the client_id is empty" do + svc = described_class.new( + json: { + grant_type: "client_credentials", + client_id: nil, + client_secret: Faker::Lorem.word + } + ) + expect(svc.call).to eql(nil) + end + + it "returns null if the client_secret is empty" do + svc = described_class.new( + json: { + grant_type: "client_credentials", + client_id: Faker::Lorem.word, + client_secret: nil + } + ) + expect(svc.call).to eql(nil) + end + + it "defers to the private #client method" do + svc = described_class.new( + json: { + grant_type: "client_credentials", + client_id: Faker::Lorem.word, + client_secret: Faker::Lorem.word + } + ) + svc.expects(:client).at_least(1) + svc.call + end + + it "returns nil if the #client method returned nil" do + svc = described_class.new( + json: { + grant_type: "client_credentials", + client_id: Faker::Lorem.word, + client_secret: Faker::Lorem.word + } + ) + svc.stubs(:client).returns(nil) + expect(svc.call).to eql(nil) + end + + it "returns nil if the Client is not an ApiClient or User" do + org = build(:org) + svc = described_class.new( + json: { + grant_type: "client_credentials", + client_id: org.name, + client_secret: org.abbreviation + } + ) + svc.stubs(:client).returns(org) + expect(svc.call).to eql(nil) + end + + it "returns a JSON Web Token and Expiration Time for ApiClient" do + client = create(:api_client) + svc = described_class.new( + json: { + grant_type: "client_credentials", + client_id: client.client_id, + client_secret: client.client_secret + } + ) + svc.stubs(:client).returns(client) + expect(svc.call).to eql(@jwt) + end + + it "returns a JSON Web Token and Expiration Time for User" do + user = create(:user, api_token: SecureRandom.uuid) + svc = described_class.new( + json: { + grant_type: "authorization_code", + email: user.email, + code: user.api_token + } + ) + svc.stubs(:client).returns(user) + expect(svc.call).to eql(@jwt) + end + end + + end + + context "private methods" do + + describe "#client" do + before(:each) do + @service = described_class.new + end + + it "is a singleton method" do + client = create(:api_client) + @service.expects(:authenticate_client).at_most(1).returns(client) + rslt = @service.send(:client) + expect(@service.send(:client)).to eql(rslt) + end + it "returns nil if no User or ApiClient was authenticated" do + @service.stubs(:authenticate_user).returns(nil) + @service.stubs(:authenticate_client).returns(nil) + rslt = @service.send(:client) + expect(@service.send(:client)).to eql(rslt) + end + it "returns the api_client if a ApiClient was authenticated" do + client = create(:api_client) + @service.stubs(:authenticate_client).returns(client) + expect(@service.send(:client)).to eql(client) + end + it "returns the user if a User was authenticated" do + user = create(:user) + svc = described_class.new( + json: { + grant_type: "authorization_code", + email: user.email, code: Faker::Lorem.word + } + ) + svc.stubs(:authenticate_user).returns(user) + expect(svc.send(:client)).to eql(user) + end + it "adds 'invalid credentials' to errors if nothing authenticated" do + @service.stubs(:authenticate_user).returns(nil) + @service.stubs(:authenticate_client).returns(nil) + @service.send(:client) + msg = "Invalid credentials" + expect(@service.errors[:client_authentication]).to eql(msg) + end + end + + describe "#authenticate_client" do + before(:each) do + @client = create(:api_client) + @service = described_class.new( + json: { + grant_type: "client_credentials", + client_id: @client.client_id, + client_secret: @client.client_secret + } + ) + end + + it "returns nil if no ApiClient is matched" do + @client.destroy + expect(@service.send(:authenticate_client)).to eql(nil) + end + it "returns nil if the matching ApiClient did not auth" do + @client.update(client_secret: SecureRandom.uuid) + expect(@service.send(:authenticate_client)).to eql(nil) + end + it "returns the ApiClient" do + expect(@service.send(:authenticate_client)).to eql(@client) + end + end + + describe "#authenticate_user" do + before(:each) do + @user = create(:user, :org_admin, api_token: SecureRandom.uuid) + @service = described_class.new( + json: { + grant_type: "authorization_code", + email: @user.email, + code: @user.api_token + } + ) + end + + it "returns nil if no User is matched" do + @user.destroy + expect(@service.send(:authenticate_user)).to eql(nil) + end + it "returns nil if the matching User is inactive" do + @user.update(active: false) + expect(@service.send(:authenticate_user)).to eql(nil) + end + it "returns nil if the matching User does not have permission" do + @user.perms.each(&:destroy) + expect(@service.send(:authenticate_user)).to eql(nil) + end + it "returns nil if the client_secret does not match the api_token" do + @user.update(api_token: SecureRandom.uuid) + expect(@service.send(:authenticate_user)).to eql(nil) + end + it "returns the User" do + expect(@service.send(:authenticate_user)).to eql(@user) + end + end + + describe "#parse_client" do + before(:each) do + @service = described_class.new + @client_id = SecureRandom.uuid + @client_secret = SecureRandom.uuid + end + + it "sets the client_id to nil if its is not in JSON" do + @service.send( + :parse_client, + json: { + client_secret: @client_secret + } + ) + expect(@service.send(:client_id)).to eql(nil) + end + it "sets the client_secret to nil if its is not in JSON" do + @service.send(:parse_client, json: { client_id: @client_id }) + expect(@service.send(:client_secret)).to eql(nil) + end + it "sets the client_id" do + @service.send( + :parse_client, + json: { + client_id: @client_id, + client_secret: @client_secret + } + ) + expect(@service.send(:client_id)).to eql(@client_id) + end + it "sets the client_secret" do + @service.send( + :parse_client, + json: { + client_id: @client_id, + client_secret: @client_secret + } + ) + expect(@service.send(:client_secret)).to eql(@client_secret) + end + end + + describe "#parse_code" do + before(:each) do + @service = described_class.new + @email = Faker::Internet.email + @code = SecureRandom.uuid + end + + it "sets the client_id to nil if :email is not in JSON" do + @service.send(:parse_code, json: { code: @code }) + expect(@service.send(:client_id)).to eql(nil) + end + it "sets the client_secret to nil if :code is not in JSON" do + @service.send(:parse_code, json: { email: @email }) + expect(@service.send(:client_secret)).to eql(nil) + end + it "sets the client_id" do + @service.send(:parse_code, json: { email: @email, code: @code }) + expect(@service.send(:client_id)).to eql(@email) + end + it "sets the client_secret" do + @service.send(:parse_code, json: { email: @email, code: @code }) + expect(@service.send(:client_secret)).to eql(@code) + end + end + + end + +end diff --git a/spec/services/api/v1/auth/jwt/authorization_service_spec.rb b/spec/services/api/v1/auth/jwt/authorization_service_spec.rb new file mode 100644 index 0000000000..06f26f94ba --- /dev/null +++ b/spec/services/api/v1/auth/jwt/authorization_service_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Auth::Jwt::AuthorizationService do + + before(:each) do + @token = SecureRandom.uuid + Api::V1::Auth::Jwt::JsonWebToken.stubs(:decode).returns({ client_id: @token }) + @headers = { "Authorization": "Bearer #{@token}" } + @service = described_class.new(headers: @headers) + end + + context "instance methods" do + + it "#initialize(:headers) sets the errors to an empty hash" do + expect(@service.errors).to eql({}) + end + + it "#call defers to the private #client method" do + @service.expects(:client).at_least(1) + @service.call + end + + end + + context "private methods" do + + before(:each) do + @client = create(:api_client, client_id: @token) + end + + describe "#client" do + it "returns the client if its already set (singleton)" do + ApiClient.expects(:find_by).at_most(1) + rslt = @service.send(:client) + expect(@service.send(:client)).to eql(rslt) + end + it "sets client to the one found with the JWT" do + expect(@service.send(:client)).to eql(@client) + end + it "adds 'invalid token' to errors if no client matches the JWT" do + @service.expects(:decoded_auth_token).returns(nil) + @service.send(:client) + expect(@service.errors[:token]).to eql("Invalid token") + end + end + + describe "#decoded_auth_token" do + it "returns the decoded token if its already set (singleton)" do + rslt = @service.send(:decoded_auth_token) + expect(@service.send(:decoded_auth_token)).to eql(rslt) + end + it "sets the decoded token" do + expect(@service.send(:decoded_auth_token)[:client_id]).to eql(@token) + end + it "adds 'token expired' to errors when a JWT has expired" do + Api::V1::Auth::Jwt::JsonWebToken.stubs(:decode).raises(JWT::ExpiredSignature) + expect(@service.send(:decoded_auth_token)).to eql(nil) + expect(@service.errors[:token]).to eql("Token expired") + end + end + + describe "#http_auth_header" do + it "returns nil if no 'Authorization' header" do + svc = described_class.new(headers: {}) + expect(svc.send(:http_auth_header)).to eql(nil) + end + it "adds 'missing token' to errors if no 'Authorization' header" do + svc = described_class.new(headers: {}) + svc.send(:http_auth_header) + expect(svc.errors[:token]).to eql("Missing token") + end + it "returns the token portion of the 'Authorization' header" do + expect(@service.send(:http_auth_header)).to eql(@token) + end + end + + end + +end diff --git a/spec/services/api/v1/auth/jwt/json_web_token_spec.rb b/spec/services/api/v1/auth/jwt/json_web_token_spec.rb new file mode 100644 index 0000000000..5a5e0db28d --- /dev/null +++ b/spec/services/api/v1/auth/jwt/json_web_token_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Auth::Jwt::JsonWebToken do + + before(:each) do + @payload = { + "foo": Faker::Lorem.sentence, + "bar": Faker::Number.number + } + end + + context "#encode(payload:, exp:)" do + it "encodes the payload into a JWT" do + token = described_class.encode(payload: @payload, + exp: 2.hours.from_now) + expect(token.is_a?(String)).to eql(true) + expect(token.length > 1).to eql(true) + end + it "allows for a default expiration time" do + token = described_class.encode(payload: @payload) + expect(token.is_a?(String)).to eql(true) + expect(token.length > 1).to eql(true) + end + end + + context "#decode(token:)" do + before(:each) do + @token = described_class.encode(payload: @payload) + end + + it "decodes the token and returns the payload" do + hash = described_class.decode(token: @token) + expect(hash[:foo]).to eql(@payload[:foo]) + expect(hash[:bar]).to eql(@payload[:bar]) + end + it "includes the expiration time" do + hash = described_class.decode(token: @token) + expect(hash[:exp]).to eql(@payload[:exp]) + end + it "throws JWT::ExpiredSignature when a token has expired" do + err = JWT::ExpiredSignature + JWT.stubs(:decode).raises(err) + expect { described_class.decode(token: @token) }.to raise_error(err) + end + it "returns nil when other JWT::DecodeError happens" do + JWT.stubs(:decode).raises(JWT::VerificationError) + expect(described_class.decode(token: @token)).to eql(nil) + end + end + +end diff --git a/spec/services/api/v1/conversion_service_spec.rb b/spec/services/api/v1/conversion_service_spec.rb new file mode 100644 index 0000000000..fe404443f2 --- /dev/null +++ b/spec/services/api/v1/conversion_service_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::ConversionService do + + describe "boolean_to_yes_no_unknown" do + it "returns `yes` when true" do + expect(described_class.boolean_to_yes_no_unknown(true)).to eql("yes") + end + it "returns `yes` when 1" do + expect(described_class.boolean_to_yes_no_unknown(1)).to eql("yes") + end + it "returns `no` when false" do + expect(described_class.boolean_to_yes_no_unknown(false)).to eql("no") + end + it "returns `no` when 0" do + expect(described_class.boolean_to_yes_no_unknown(0)).to eql("no") + end + it "returns `unknown` when nil" do + expect(described_class.boolean_to_yes_no_unknown(nil)).to eql("unknown") + end + end + + describe "yes_no_unknown_to_boolean" do + it "returns true when `yes`" do + expect(described_class.yes_no_unknown_to_boolean("yes")).to eql(true) + end + it "returns false when `no`" do + expect(described_class.yes_no_unknown_to_boolean("no")).to eql(false) + end + it "returns nil when `unknown`" do + expect(described_class.yes_no_unknown_to_boolean("unknown")).to eql(nil) + end + end + + describe "#to_identifier(context:, value:)" do + it "returns nil if the context is not present" do + expected = described_class.to_identifier(context: nil, + value: Faker::Lorem.word) + expect(expected).to eql(nil) + end + it "returns nil if the value is not present" do + expected = described_class.to_identifier(context: Faker::Lorem.word, + value: nil) + expect(expected).to eql(nil) + end + it "returns an Identifier with a IdentifierScheme matching the context" do + context = Faker::Lorem.word + expected = described_class.to_identifier(context: context, + value: Faker::Lorem.word) + expect(expected.identifier_scheme.name).to eql(context) + end + it "returns an Identifier asssociated with the 'grant' scheme" do + value = Faker::Lorem.word + expected = described_class.to_identifier(context: Faker::Lorem.word, + value: value) + expect(expected.value).to eql(value) + end + end + +end diff --git a/spec/services/api/v1/deserialization/contributor_spec.rb b/spec/services/api/v1/deserialization/contributor_spec.rb new file mode 100644 index 0000000000..01db8f4583 --- /dev/null +++ b/spec/services/api/v1/deserialization/contributor_spec.rb @@ -0,0 +1,321 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Deserialization::Contributor do + + before(:each) do + # Org requires a language, so make sure a default is available! + create(:language, default_language: true) unless Language.default + @org = create(:org) + @plan = create(:plan, template: create(:template), org: @org) + + @name = Faker::Movies::StarWars.character + @email = Faker::Internet.email + + @contributor = create(:contributor, org: @org, plan: @plan, + name: @name, email: @email) + @role = "#{Contributor::ONTOLOGY_BASE_URL}/#{@contributor.selected_roles.first}" + + @scheme = create(:identifier_scheme) + @identifier = create(:identifier, identifiable: @contributor, + identifier_scheme: @scheme, + value: SecureRandom.uuid) + @contributor.reload + @json = { name: @name, mbox: @email, role: [@role] } + end + + describe "#deserialize!(json: {})" do + before(:each) do + described_class.stubs(:marshal_contributor).returns(@contributor) + end + + it "returns nil if json is not valid" do + expect(described_class.deserialize!(plan_id: @plan.id, json: nil)).to eql(nil) + end + it "returns nil if the Contributor is not valid" do + Contributor.any_instance.stubs(:valid?).returns(false) + expect(described_class.deserialize!(plan_id: @plan.id, json: @json)).to eql(nil) + end + it "calls attach_identifier!" do + described_class.expects(:attach_identifier!).at_least(1) + id = SecureRandom.uuid + scheme = create(:identifier_scheme, identifier_prefix: nil) + json = @json.merge( + { contributor_id: { type: scheme.name.downcase, identifier: id } } + ) + described_class.deserialize!(plan_id: @plan.id, json: json) + end + it "returns the Contributor" do + result = described_class.deserialize!(plan_id: @plan.id, json: @json) + expect(result).to eql(@contributor) + end + end + + context "private methods" do + + describe "#valid?(is_contact:, json:)" do + it "returns false if json is not present" do + result = described_class.send(:valid?, is_contact: true, json: nil) + expect(result).to eql(false) + end + it "returns false if :name and :mbox are not present" do + json = { role: [@role] } + result = described_class.send(:valid?, is_contact: true, json: json) + expect(result).to eql(false) + end + context "Contact" do + it "returns true without :role" do + json = { name: @name, mbox: @email } + result = described_class.send(:valid?, is_contact: true, json: json) + expect(result).to eql(true) + end + it "returns true with :role" do + result = described_class.send(:valid?, is_contact: true, json: @json) + expect(result).to eql(true) + end + end + context "Contributor" do + it "returns false without :role" do + json = { name: @name, mbox: @email } + result = described_class.send(:valid?, is_contact: false, json: json) + expect(result).to eql(false) + end + it "returns true with :role" do + result = described_class.send(:valid?, is_contact: false, json: @json) + expect(result).to eql(true) + end + end + end + + describe "#marshal_contributor(plan_id:, is_contact:, json:)" do + it "returns nil if the plan_id is not present" do + result = described_class.send(:marshal_contributor, plan_id: nil, + is_contact: true, + json: @json) + expect(result).to eql(nil) + end + it "returns nil if the json is not present" do + result = described_class.send(:marshal_contributor, plan_id: @plan.id, + is_contact: true, + json: nil) + expect(result).to eql(nil) + end + it "attaches the Org to the Contributor" do + result = described_class.send(:marshal_contributor, plan_id: @plan.id, + is_contact: true, + json: @json) + expect(result.org).to eql(@org) + end + it "assigns the contact role" do + json = { name: Faker::TvShows::Simpsons.character } + result = described_class.send(:marshal_contributor, plan_id: @plan.id, + is_contact: true, + json: json) + expect(result.data_curation?).to eql(true) + end + it "assigns the contributor role" do + role = @contributor.all_roles[1].to_s + json = { name: Faker::TvShows::Simpsons.character, role: [role] } + result = described_class.send(:marshal_contributor, plan_id: @plan.id, + is_contact: false, + json: json) + expect(result.send(:"#{role}?")).to eql(true) + end + end + + describe "#find_by_identifier(json:)" do + it "returns nil if json is not present" do + expect(described_class.send(:find_by_identifier, json: nil)).to eql(nil) + end + it "returns nil if :contact_id and :contributor_id are not present" do + expect(described_class.send(:find_by_identifier, json: @json)).to eql(nil) + end + it "finds the Contributor by :contact_id" do + json = @json.merge( + { contact_id: { type: @scheme.name, identifier: @identifier.value } } + ) + result = described_class.send(:find_by_identifier, json: json) + expect(result).to eql(@contributor) + end + it "finds the Contributor by :contributor_id" do + json = @json.merge( + { contributor_id: { type: @scheme.name, identifier: @identifier.value } } + ) + result = described_class.send(:find_by_identifier, json: json) + expect(result).to eql(@contributor) + end + it "returns nil if no Contributor was found" do + json = @json.merge( + { contributor_id: { type: @scheme.name, identifier: SecureRandom.uuid } } + ) + expect(described_class.send(:find_by_identifier, json: json)).to eql(nil) + end + end + + describe "#find_or_initialize_by(plan_id:, json:)" do + it "returns nil if json is not present" do + result = described_class.send(:find_or_initialize_by, plan_id: @plan.id, + json: nil) + expect(result).to eql(nil) + end + it "returns nil if plan_id is not present" do + result = described_class.send(:find_or_initialize_by, plan_id: nil, + json: @json) + expect(result).to eql(nil) + end + it "finds the matching Contributor" do + result = described_class.send(:find_or_initialize_by, plan_id: @plan.id, + json: @json) + expect(result).to eql(@contributor) + end + it "initializes the Contributor if there were no viable matches" do + json = { + name: Faker::TvShows::Simpsons.character, + mbox: Faker::Internet.unique.email + } + result = described_class.send(:find_or_initialize_by, plan_id: @plan.id, + json: json) + expect(result.new_record?).to eql(true) + expect(result.name).to eql(json[:name]) + expect(result.email).to eql(json[:mbox]) + end + end + + describe "#deserialize_org(json:)" do + it "returns nil if json is not present" do + expect(described_class.send(:deserialize_org, json: nil)).to eql(nil) + end + it "returns nil if json :affiliation is not present" do + expect(described_class.send(:deserialize_org, json: @json)).to eql(nil) + end + it "calls the Org.deserialize! method" do + Api::V1::Deserialization::Org.expects(:deserialize!).at_least(1) + json = @json.merge({ affiliation: { name: Faker::Company.name } }) + described_class.send(:deserialize_org, json: json) + end + end + + describe "#assign_contact_roles(contributor:)" do + it "returns nil if the contributor is not present" do + result = described_class.send(:assign_contact_roles, contributor: nil) + expect(result).to eql(nil) + end + it "assigns the :data_curation role" do + result = described_class.send(:assign_contact_roles, contributor: @contributor) + expect(result.data_curation?).to eql(true) + end + end + + describe "#assign_roles(contributor:, json:)" do + it "returns nil if the contributor is not present" do + result = described_class.send(:assign_roles, contributor: nil, json: @json) + expect(result).to eql(nil) + end + it "returns the Contributor as-is if json is not present" do + result = described_class.send(:assign_roles, contributor: @contributor, + json: nil) + expect(result).to eql(@contributor) + end + it "returns the Contributor as-is if json :role is not present" do + json = { name: @name } + result = described_class.send(:assign_roles, contributor: @contributor, + json: json) + expect(result).to eql(@contributor) + end + it "ignores unknown/undefined roles" do + @json[:role] << Faker::Lorem.word + result = described_class.send(:assign_roles, contributor: @contributor, + json: @json) + expect(result.selected_roles).to eql(@contributor.selected_roles) + end + it "calls the translate_role" do + described_class.expects(:translate_role).at_least(1) + described_class.send(:assign_roles, contributor: @contributor, json: @json) + end + it "assigns the roles" do + result = described_class.send(:assign_roles, contributor: @contributor, + json: @json) + expect(result.selected_roles).to eql(@contributor.selected_roles) + end + + end + + describe "#attach_identifier!(contributor:, json:)" do + it "returns the Contributor as-is if json is not present" do + result = described_class.send(:attach_identifier!, contributor: @contributor, + json: nil) + expect(result.identifiers).to eql(@contributor.identifiers) + end + it "returns the Contributor as-is if the json has no identifier" do + result = described_class.send(:attach_identifier!, contributor: @contributor, + json: @json) + expect(result.identifiers).to eql(@contributor.identifiers) + end + it "returns the Contributor as-is if it already has a :contributor_id" do + json = @json.merge( + { contributor_id: { type: @scheme.name, identifier: @identifier.value } } + ) + result = described_class.send(:attach_identifier!, contributor: @contributor, + json: json) + expect(result.identifiers).to eql(@contributor.identifiers) + end + it "returns the Contributor as-is if it already has the :contact_id" do + json = @json.merge( + { contact_id: { type: @scheme.name, identifier: @identifier.value } } + ) + result = described_class.send(:attach_identifier!, contributor: @contributor, + json: json) + expect(result.identifiers).to eql(@contributor.identifiers) + end + it "adds the :contributor_id to the Contributor" do + scheme = create(:identifier_scheme, name: "foo") + json = @json.merge( + { contributor_id: { type: scheme.name, identifier: @identifier.value } } + ) + result = described_class.send(:attach_identifier!, contributor: @contributor, + json: json) + expect(result.identifiers.length > @contributor.identifiers.length).to eql(false) + expect(result.identifiers.last.identifier_scheme).to eql(scheme) + id = result.identifiers.last.value + expect(id.end_with?(@identifier.value)).to eql(true) + end + it "adds the :contact_id to the Contributor" do + scheme = create(:identifier_scheme, name: "foo") + json = @json.merge( + { contact_id: { type: scheme.name, identifier: @identifier.value } } + ) + result = described_class.send(:attach_identifier!, contributor: @contributor, + json: json) + expect(result.identifiers.length > @contributor.identifiers.length).to eql(false) + expect(result.identifiers.last.identifier_scheme).to eql(scheme) + id = result.identifiers.last.value + expect(id.end_with?(@identifier.value)).to eql(true) + end + end + + describe "#translate_role(role:)" do + before(:each) do + @default = Contributor.default_role + end + + it "returns the default role if role is not present?" do + expect(described_class.send(:translate_role, role: nil)).to eql(@default) + end + it "returns the default role if role is not a valid/defined role" do + result = described_class.send(:translate_role, role: Faker::Lorem.word) + expect(result).to eql(@default) + end + it "returns the role (when it includes the ONTOLOGY_BASE_URL)" do + expected = @role.split("/").last + expect(described_class.send(:translate_role, role: @role)).to eql(expected) + end + it "returns the role (when it does not include the ONTOLOGY_BASE_URL)" do + role = Contributor.new.all_roles.last.to_s + expect(described_class.send(:translate_role, role: role)).to eql(role) + end + end + + end + +end diff --git a/spec/services/api/v1/deserialization/funding_spec.rb b/spec/services/api/v1/deserialization/funding_spec.rb new file mode 100644 index 0000000000..e66aed93f0 --- /dev/null +++ b/spec/services/api/v1/deserialization/funding_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Deserialization::Funding do + + before(:each) do + # Org requires a language, so make sure a default is available! + create(:language, default_language: true) unless Language.default + + @funder = create(:org, :funder, name: Faker::Company.name) + @plan = create(:plan) + @grant = create(:identifier, identifier_scheme: nil, value: SecureRandom.uuid, + identifiable: @plan) + + Api::V1::Deserialization::Org.stubs(:deserialize!).returns(@funder) + Api::V1::Deserialization::Identifier.stubs(:deserialize!).returns(@grant) + + @json = { + name: @funder.name, + funding_status: %w[planned granted rejected].sample + } + end + + describe "#deserialize!(plan:, json: {})" do + it "returns nil if plan is not present" do + expect(described_class.deserialize!(plan: nil, json: @json)).to eql(nil) + end + it "returns the Plan as-is if json is present" do + expect(described_class.deserialize!(plan: @plan, json: nil)).to eql(@plan) + end + it "returns the Plan as-is if json is not valid" do + json = { funding_status: "planned" } + expect(described_class.deserialize!(plan: @plan, json: json)).to eql(@plan) + end + it "assigns the funder" do + result = described_class.deserialize!(plan: @plan, json: @json) + expect(result.funder).to eql(@funder) + end + it "assigns the grant" do + json = @json.merge({ grant_id: { type: "url", identifier: Faker::Lorem.word } }) + result = described_class.deserialize!(plan: @plan, json: json) + expect(result.grant_id).to eql(@grant.id) + end + it "returns the Plan" do + expect(described_class.deserialize!(plan: @plan, json: @json)).to eql(@plan) + end + end + + context "private methods" do + + describe "#valid?(json:)" do + it "returns false if json is not present" do + expect(described_class.send(:valid?, json: nil)).to eql(false) + end + it "returns false if :name and :funder_id and :grant_id are not present" do + json = { funding_status: %w[] } + expect(described_class.send(:valid?, json: json)).to eql(false) + end + it "returns true if :name is present" do + expect(described_class.send(:valid?, json: @json)).to eql(true) + end + it "returns true if :funder_id is present" do + json = { + funder_id: { type: Faker::Lorem.word, identifier: SecureRandom.uuid } + } + expect(described_class.send(:valid?, json: json)).to eql(true) + end + it "returns true if :grant_id is present" do + json = { grant_id: { type: Faker::Lorem.word, identifier: @grant.value } } + expect(described_class.send(:valid?, json: json)).to eql(true) + end + end + + describe "#deserialize_grant(plan:, json:)" do + it "returns the Plan as-is if no json is present" do + result = described_class.send(:deserialize_grant, plan: @plan, json: nil) + expect(result).to eql(@plan) + end + it "returns the Plan as-is if no :grant_id is present" do + result = described_class.send(:deserialize_grant, plan: @plan, json: @json) + expect(result).to eql(@plan) + end + it "attaches the the grant to the plan" do + json = @json.merge( + { grant_id: { type: "url", identifier: @grant.value } } + ) + result = described_class.send(:deserialize_grant, plan: @plan, json: json) + expect(result.grant_id.present?).to eql(true) + expect(result.grant.identifier_scheme).to eql(nil) + expect(result.grant.value).to eql(json[:grant_id][:identifier]) + end + end + + end + +end diff --git a/spec/services/api/v1/deserialization/identifier_spec.rb b/spec/services/api/v1/deserialization/identifier_spec.rb new file mode 100644 index 0000000000..4d1715f99c --- /dev/null +++ b/spec/services/api/v1/deserialization/identifier_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Deserialization::Identifier do + + before(:each) do + @scheme = create(:identifier_scheme) + @value = SecureRandom.uuid + @identifiable = build(:org) + @json = { type: @scheme.name, identifier: @value } + end + + describe "#deserialize!(identifiable:, json: {})" do + it "returns nil if json is not valid" do + result = described_class.deserialize!(identifiable: @identifiable, + json: nil) + expect(result).to eql(nil) + end + + context "when :type does not match an IdentifierScheme" do + + it "marshalls an Identifier" do + json = { type: "other", identifier: @value } + rslt = described_class.deserialize!(identifiable: @identifiable, json: json) + validate_identifier(result: rslt, scheme: nil, value: @value) + end + it "marshalls an existing Identifier" do + id = create(:identifier, identifier_scheme: nil, + identifiable: @identifiable, value: @value) + json = { type: "other", identifier: @value } + rslt = described_class.deserialize!(identifiable: @identifiable, json: json) + validate_identifier(result: rslt, scheme: nil, value: @value) + expect(rslt.id).to eql id.id + end + + end + + context "when :type matches an IdentifierScheme" do + it "calls #identifier_for_scheme" do + described_class.expects(:identifier_for_scheme).at_least(1) + described_class.deserialize!(identifiable: @identifiable, json: @json) + end + it "returns an Identifier for that IdentifierScheme" do + result = described_class.deserialize!(identifiable: @identifiable, + json: @json) + expect(result.identifier_scheme).to eql(@scheme) + end + + end + + end + + context "private methods" do + + describe "#valid?(json:)" do + it "returns nil if json is not valid" do + expect(described_class.send(:valid?, json: nil)).to eql(false) + end + it "returns nil if :identifier is not present" do + json = { type: @scheme.name } + expect(described_class.send(:valid?, json: json)).to eql(false) + end + it "returns nil if :type is not present" do + json = { identifier: @value } + expect(described_class.send(:valid?, json: json)).to eql(false) + end + it "returns true" do + expect(described_class.send(:valid?, json: @json)).to eql(true) + end + end + + describe "#identifier_for_scheme(scheme:, identifiable:, json:)" do + it "returns nil if scheme is nil" do + result = described_class.send(:identifier_for_scheme, + scheme: nil, identifiable: @identifiable, + json: @json) + expect(result).to eql(nil) + end + it "returns nil if identifiable is nil" do + result = described_class.send(:identifier_for_scheme, + scheme: @scheme, identifiable: nil, + json: @json) + expect(result).to eql(nil) + end + it "returns nil if json is nil" do + result = described_class.send(:identifier_for_scheme, + scheme: @scheme, identifiable: @identifiable, + json: nil) + expect(result).to eql(nil) + end + it "returns nil if :type does not match an IdentifierScheme" do + json = { type: Faker::Lorem.word, identifier: @value } + result = described_class.send(:identifier_for_scheme, + scheme: @scheme, + identifiable: @identifiable, json: json) + expect(result).to eql(nil) + end + it "updates the existing Identifier for the IdentifierScheme" do + identifier = create(:identifier, identifier_scheme: @scheme, + identifiable: @identifiable, + value: Faker::Number.number) + result = described_class.send(:identifier_for_scheme, + scheme: @scheme, + identifiable: @identifiable, json: @json) + expect(result.id).to eql(identifier.id) + expect(result.value.ends_with?(@json[:identifier])).to eql(true) + end + end + + end + + private + + def validate_identifier(result:, scheme:, value:) + expect(result.is_a?(Identifier)).to eql(true), "expected it to be an Identifier" + expect(result.identifier_scheme).to eql(scheme), "expected schemes to match" + expect(result.value).to eql(value), "expected values to match" + end + +end diff --git a/spec/services/api/v1/deserialization/org_spec.rb b/spec/services/api/v1/deserialization/org_spec.rb new file mode 100644 index 0000000000..c152cdfd6e --- /dev/null +++ b/spec/services/api/v1/deserialization/org_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Deserialization::Org do + + before(:each) do + # Org requires a language, so make sure a default is available! + create(:language, default_language: true) unless Language.default + + @name = Faker::Company.name + @abbrev = Faker::Lorem.word.upcase + @org = create(:org, name: @name, abbreviation: @abbrev) + @scheme = create(:identifier_scheme) + @identifier = create(:identifier, identifiable: @org, + identifier_scheme: @scheme, + value: SecureRandom.uuid) + @org.reload + @json = { name: @name, abbreviation: @abbrev } + end + + describe "#deserialize!(json: {})" do + before(:each) do + described_class.stubs(:find_by_identifier).returns(nil) + described_class.stubs(:find_by_name).returns(@org) + end + + it "returns nil if json is not valid" do + expect(described_class.deserialize!(json: nil)).to eql(nil) + end + it "calls find_by_identifier" do + described_class.expects(:find_by_identifier).at_least(1) + described_class.deserialize!(json: @json) + end + it "calls find_by_name if find_by_identifier finds none" do + result = described_class.deserialize!(json: @json) + expect(result).to eql(@org) + end + it "sets the language to the default" do + default = Language.default || create(:language) + result = described_class.deserialize!(json: @json) + expect(result.language).to eql(default) + end + it "sets the abbreviation" do + result = described_class.deserialize!(json: @json) + expect(result.abbreviation).to eql(@abbrev) + end + it "returns nil if the Org is not valid" do + Org.any_instance.stubs(:valid?).returns(false) + expect(described_class.deserialize!(json: @json)).to eql(nil) + end + it "attaches the identifier to the Org" do + id = SecureRandom.uuid + scheme = create(:identifier_scheme, identifier_prefix: nil, name: "foo") + json = @json.merge( + { affiliation_id: { type: scheme.name, identifier: id } } + ) + result = described_class.deserialize!(json: json) + expect(result.identifiers.length).to eql(2) + expect(result.identifiers.last.value).to eql(id) + end + it "is able to create a new Org" do + described_class.stubs(:find_by_name) + .returns(build(:org, name: Faker::Company.name)) + result = described_class.deserialize!(json: @json) + expect(result.new_record?).to eql(false) + expect(result.abbreviation).to eql(@json[:abbreviation]) + end + end + + context "private methods" do + + describe "#valid?(json:)" do + it "returns false if json is not present" do + expect(described_class.send(:valid?, json: nil)).to eql(false) + end + it "returns false if :name is not present" do + json = { abbreviation: @abbrev } + expect(described_class.send(:valid?, json: json)).to eql(false) + end + it "returns true" do + expect(described_class.send(:valid?, json: @json)).to eql(true) + end + end + + describe "#find_by_identifier(json:)" do + it "returns nil if json is not present" do + expect(described_class.send(:find_by_identifier, json: nil)).to eql(nil) + end + it "returns nil if :affiliation_id and :funder_id are not present" do + expect(described_class.send(:find_by_identifier, json: @json)).to eql(nil) + end + it "finds the Org by :affiliation_id" do + json = @json.merge( + { affiliation_id: { type: @scheme.name, identifier: @identifier.value } } + ) + expect(described_class.send(:find_by_identifier, json: json)).to eql(@org) + end + it "finds the Org by :funder_id" do + json = @json.merge( + { funder_id: { type: @scheme.name, identifier: @identifier.value } } + ) + expect(described_class.send(:find_by_identifier, json: json)).to eql(@org) + end + it "returns nil if no Org was found" do + json = @json.merge( + { affiliation_id: { type: @scheme.name, identifier: SecureRandom.uuid } } + ) + expect(described_class.send(:find_by_identifier, json: json)).to eql(nil) + end + end + + describe "#find_by_name(json:)" do + it "returns nil if json is not present" do + expect(described_class.send(:find_by_name, json: nil)).to eql(nil) + end + it "returns nil if :name is not present" do + json = { abbreviation: @abbrev } + expect(described_class.send(:find_by_name, json: json)).to eql(nil) + end + it "finds the matching Org by name" do + expect(described_class.send(:find_by_name, json: @json)).to eql(@org) + end + it "finds the Org from the OrgSelection::SearchService" do + json = { name: Faker::Company.unique.name } + array = [{ name: @org.name, weight: 0 }] + OrgSelection::SearchService.stubs(:search_externally).returns(array) + OrgSelection::HashToOrgService.stubs(:to_org).returns(@org) + expect(described_class.send(:find_by_name, json: json)).to eql(@org) + end + it "initializes the Org if there were no viable matches" do + json = { name: Faker::Company.unique.name } + OrgSelection::SearchService.stubs(:search_externally).returns([]) + org = build(:org, name: json[:name]) + OrgSelection::HashToOrgService.stubs(:to_org).returns(org) + expect(described_class.send(:find_by_name, json: json)).to eql(org) + end + end + + describe "#attach_identifier!(org:, json:)" do + it "returns the Org as-is if json is not present" do + result = described_class.send(:attach_identifier!, org: @org, json: nil) + expect(result.identifiers).to eql(@org.identifiers) + end + it "returns the Org as-is if the json has no identifier" do + result = described_class.send(:attach_identifier!, org: @org, json: @json) + expect(result.identifiers).to eql(@org.identifiers) + end + it "returns the Org as-is if the Org already has the :affiliation_id" do + json = @json.merge( + { affiliation_id: { type: @scheme.name, identifier: @identifier.value } } + ) + result = described_class.send(:attach_identifier!, org: @org, json: json) + expect(result.identifiers).to eql(@org.identifiers) + end + it "returns the Org as-is if the Org already has the :funder_id" do + json = @json.merge( + { funder_id: { type: @scheme.name, identifier: @identifier.value } } + ) + result = described_class.send(:attach_identifier!, org: @org, json: json) + expect(result.identifiers).to eql(@org.identifiers) + end + it "adds the :affiliation_id to the Org" do + scheme = create(:identifier_scheme) + json = @json.merge( + { affiliation_id: { type: scheme.name, identifier: @identifier.value } } + ) + result = described_class.send(:attach_identifier!, org: @org, json: json) + expect(result.identifiers.length > @org.identifiers.length).not_to eql(true) + expect(result.identifiers.last.identifier_scheme).to eql(scheme) + id = result.identifiers.last.value + expect(id.end_with?(@identifier.value)).to eql(true) + end + it "adds the :funder_id to the Org" do + scheme = create(:identifier_scheme) + json = @json.merge( + { funder_id: { type: scheme.name, identifier: @identifier.value } } + ) + result = described_class.send(:attach_identifier!, org: @org, json: json) + expect(result.identifiers.length > @org.identifiers.length).not_to eql(true) + expect(result.identifiers.last.identifier_scheme).to eql(scheme) + id = result.identifiers.last.value + expect(id.end_with?(@identifier.value)).to eql(true) + end + end + + end + +end diff --git a/spec/services/api/v1/deserialization/plan_spec.rb b/spec/services/api/v1/deserialization/plan_spec.rb new file mode 100644 index 0000000000..ada83fab03 --- /dev/null +++ b/spec/services/api/v1/deserialization/plan_spec.rb @@ -0,0 +1,358 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Deserialization::Plan do + + before(:each) do + # Org requires a language, so make sure a default is available! + create(:language, default_language: true) unless Language.default + + @template = create(:template) + @plan = create(:plan, template: @template) + @scheme = create(:identifier_scheme, name: "doi", + identifier_prefix: Faker::Internet.url) + @doi = "10.9999/45ty5t.345/34t" + @identifier = create(:identifier, identifier_scheme: @scheme, + identifiable: @plan, value: @doi) + + @app_name = ApplicationService.application_name.split("-").first&.downcase + @app_name = "tester" unless @app_name.present? + + contrib = Contributor.new + @json = { + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + ethical_issues_exist: "unknown", + contact: { + name: Faker::Movies::StarWars.character, + mbox: Faker::Internet.email + }, + contributor: [ + { + name: Faker::TvShows::Simpsons.unique.character, + role: ["#{Contributor::ONTOLOGY_BASE_URL}/#{contrib.all_roles.first}"] + }, + { + name: Faker::TvShows::Simpsons.unique.character, + role: [contrib.all_roles.last.to_s] + } + ], + project: [ + { + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + start: Time.now.to_formatted_s(:iso8601), + end: (Time.now + 2.years).to_formatted_s(:iso8601), + funding: [ + { name: Faker::Movies::StarWars.planet } + ] + } + ], + dataset: [ + { title: Faker::Lorem.sentence } + ], + dmp_id: { type: "doi", identifier: @identifier.value }, + extension: [ + "#{@app_name}": { + template: { id: @template.id, title: @template.title } + } + ] + } + + # We need to ensure that the deserializer on Funding is called, but + # no need to check that class' subsequent calls + Api::V1::Deserialization::Org.stubs(:deserialize!).returns(@org) + Api::V1::Deserialization::Identifier.stubs(:deserialize!).returns(@identifier) + end + + describe "#deserialize!(json: {})" do + before(:each) do + described_class.stubs(:marshal_plan).returns(@plan) + described_class.stubs(:deserialize_project).returns(@plan) + described_class.stubs(:deserialize_contact).returns(@plan) + described_class.stubs(:deserialize_contributors).returns(@plan) + described_class.stubs(:deserialize_datasets).returns(@plan) + end + + it "returns nil if json is not valid" do + expect(described_class.deserialize!(json: nil)).to eql(nil) + end + it "returns nil if no :dmp_id, :template or default template available" do + described_class.stubs(:marshal_plan).returns(nil) + described_class.deserialize!(json: @json) + end + it "returns the Plan" do + expect(described_class.deserialize!(json: @json)).to eql(@plan) + end + it "sets the title to the default" do + described_class.stubs(:marshal_plan).returns(Plan.new) + result = described_class.deserialize!(json: @json) + expect(result.title).to eql(@plan.title) + end + it "sets the description" do + described_class.stubs(:marshal_plan).returns(Plan.new) + result = described_class.deserialize!(json: @json) + expect(result.description).to eql(@plan.description) + end + end + + context "private methods" do + + describe "#valid?(json:)" do + it "returns false if json is not present" do + expect(described_class.send(:valid?, json: nil)).to eql(false) + end + it "returns false if :name is not present" do + json = { abbreviation: @abbrev } + expect(described_class.send(:valid?, json: json)).to eql(false) + end + it "returns false if no default template, no :template and no :dmp_id" do + Template.find_by(is_default: true)&.destroy + @json[:dmp_id] = nil + @json[:extension] = [] + expect(described_class.send(:valid?, json: @json)).to eql(false) + end + it "returns true" do + expect(described_class.send(:valid?, json: @json)).to eql(true) + end + end + + describe "#marshal_plan(json:)" do + it "returns nil if json is not present" do + expect(described_class.send(:marshal_plan, json: nil)).to eql(nil) + end + it "returns nil there is no :dmp_id and no :template" do + @json[:dmp_id] = nil + @json[:extension] = [] + expect(described_class.send(:marshal_plan, json: @json)).to eql(nil) + end + it "returns nil if :dmp_id was not found, no :template, no default template" do + @json[:dmp_id][:identifier] = SecureRandom.uuid + @json[:extension] = [] + expect(described_class.send(:marshal_plan, json: @json)).to eql(nil) + end + it "finds the Plan by :dmp_id" do + expect(described_class.send(:marshal_plan, json: @json)).to eql(@plan) + end + it "creates a new Plan with default template if no :dmp_id and no :template" do + @json[:dmp_id] = [] + @json[:extension] = [] + default = Template.find_by(is_default: true) + default = create(:template, is_default: true) unless default.present? + result = described_class.send(:marshal_plan, json: @json) + expect(result.new_record?).to eql(true) + expect(result.template_id).to eql(default.id) + end + it "creates a new Plan if :dmp_id was not present" do + @json[:dmp_id] = [] + result = described_class.send(:marshal_plan, json: @json) + expect(result.new_record?).to eql(true) + expect(result.template_id).to eql(@template.id) + end + it "creates a new Plan if :dmp_id was not found" do + @json[:dmp_id][:identifier] = SecureRandom.uuid + result = described_class.send(:marshal_plan, json: @json) + expect(result.new_record?).to eql(true) + expect(result.template_id).to eql(@template.id) + end + end + + describe "#deserialize_project(plan:, json:)" do + before(:each) do + # clear out the dates set in the factory + @plan.start_date = nil + @plan.end_date = nil + end + + it "returns the Plan as-is if the json is not present" do + result = described_class.send(:deserialize_project, plan: @plan, json: nil) + expect(result).to eql(@plan) + expect(result.start_date).to eql(nil) + end + it "returns the Plan as-is if the json :project is not present" do + json = { title: Faker::Lorem.sentence } + result = described_class.send(:deserialize_project, plan: @plan, json: json) + expect(result).to eql(@plan) + expect(result.start_date).to eql(nil) + end + it "returns the Plan as-is if the json :project is not an array" do + json = { + title: Faker::Lorem.sentence, + project: { start: Time.now.to_formatted_s(:iso8601) } + } + result = described_class.send(:deserialize_project, plan: @plan, json: json) + expect(result).to eql(@plan) + expect(result.start_date).to eql(nil) + end + it "assigns the start_date of the Plan" do + result = described_class.send(:deserialize_project, plan: @plan, json: @json) + expected = Time.new(@json[:project].first[:start]).utc.to_formatted_s(:iso8601) + expect(result.start_date.to_formatted_s(:iso8601)).to eql(expected) + end + it "assigns the end_date of the Plan" do + result = described_class.send(:deserialize_project, plan: @plan, json: @json) + expected = Time.new(@json[:project].first[:end]).utc.to_formatted_s(:iso8601) + expect(result.end_date.to_formatted_s(:iso8601)).to eql(expected) + end + it "does not call the deserializer for Funding if :funding is not present" do + @json[:project].first[:funding] = nil + Api::V1::Deserialization::Funding.expects(:deserialize!).at_most(0) + described_class.send(:deserialize_project, plan: @plan, json: @json) + end + it "calls the deserializer for Funding if :funding present" do + Api::V1::Deserialization::Funding.expects(:deserialize!).at_least(1) + described_class.send(:deserialize_project, plan: @plan, json: @json) + end + end + + describe "#deserialize_contact(plan:, json:)" do + it "returns the Plan as-is if json is not present" do + result = described_class.send(:deserialize_contact, plan: @plan, json: nil) + expect(result).to eql(@plan) + expect(result.contributors.length).to eql(0) + end + it "returns the Plan as-is if json :contact is not present" do + @json[:contact] = nil + result = described_class.send(:deserialize_contact, plan: @plan, json: @json) + expect(result).to eql(@plan) + expect(result.contributors.length).to eql(0) + end + it "calls the Contributor.deserialize! for the contact entry" do + Api::V1::Deserialization::Contributor.expects(:deserialize!).at_least(1) + described_class.send(:deserialize_contact, plan: @plan, json: @json) + end + it "attaches the Contributors to the Plan" do + result = described_class.send(:deserialize_contact, plan: @plan, json: @json) + expect(result.contributors.length).to eql(1) + expect(result.contributors.first.name).to eql(@json[:contact][:name]) + end + end + + describe "#deserialize_contributors(plan:, json:)" do + it "calls the Contributor.deserialize! for each contributor entry" do + Api::V1::Deserialization::Contributor.expects(:deserialize!).at_least(2) + described_class.send(:deserialize_contributors, plan: @plan, json: @json) + end + it "attaches the Contributors to the Plan" do + result = described_class.send(:deserialize_contributors, plan: @plan, + json: @json) + expect(result.contributors.length).to eql(2) + expect(result.contributors.first.name).to eql(@json[:contributor].first[:name]) + expect(result.contributors.last.name).to eql(@json[:contributor].last[:name]) + end + end + + describe "#find_by_identifier(json:)" do + it "returns nil if json is not present" do + expect(described_class.send(:find_by_identifier, json: nil)).to eql(nil) + end + it "returns nil if json has no :dmp_id" do + json = { contact_id: { type: "url", identifier: SecureRandom.uuid } } + expect(described_class.send(:find_by_identifier, json: json)).to eql(nil) + end + it "calls Plan.from_identifiers if the :dmp_id is a DOI/ARK" do + described_class.stubs(:doi?).returns(true) + Plan.expects(:from_identifiers).at_least(1) + described_class.send(:find_by_identifier, json: @json) + end + it "calls Plan.find_by if the :dmp_id is not a DOI/ARK" do + described_class.stubs(:doi?).returns(false) + Plan.expects(:find_by).at_least(1) + described_class.send(:find_by_identifier, json: @json) + end + end + + describe "doi?(value:)" do + it "returns false if value is not present" do + expect(described_class.send(:doi?, value: nil)).to eql(false) + end + it "returns false if the value does not match ARK or DOI pattern" do + url = Faker::Internet.url + expect(described_class.send(:doi?, value: url)).to eql(false) + end + it "returns false if the value does not match a partial ARK/DOI pattern" do + val = "23645gy3d" + expect(described_class.send(:doi?, value: val)).to eql(false) + val = "10.999" + expect(described_class.send(:doi?, value: val)).to eql(false) + end + it "returns false if there is no 'doi' identifier scheme" do + val = "10.999/23645gy3d" + @scheme.destroy + expect(described_class.send(:doi?, value: val)).to eql(false) + end + it "returns false if 'doi' identifier scheme exists but value is not doi" do + expect(described_class.send(:doi?, value: SecureRandom.uuid)).to eql(false) + end + it "returns true (identifier only)" do + val = "10.999/23645gy3d" + expect(described_class.send(:doi?, value: val)).to eql(true) + end + it "returns true (fully qualified ARK/DOI url)" do + url = "#{Faker::Internet.url}/10.999/23645gy3d" + expect(described_class.send(:doi?, value: url)).to eql(true) + end + end + + describe "#find_template(json:)" do + it "returns nil if the json is not present" do + expect(described_class.send(:find_template, json: nil)).to eql(nil) + end + it "returns default template if no template is found for the :id" do + json = { template: { id: 9999, title: Faker::Lorem.sentence } } + expect(described_class.send(:find_template, json: json)).to eql(nil) + end + it "returns the specified template" do + expect(described_class.send(:find_template, json: @json)).to eql(@template) + end + end + + describe "template_id(json:)" do + it "returns nil if json not present" do + expect(described_class.send(:template_id, json: nil)).to eql(nil) + end + it "returns nil if extensions for the app were not found" do + described_class.stubs(:app_extensions).returns({}) + expect(described_class.send(:template_id, json: @json)).to eql(nil) + end + it "returns nil if the extensions have no template info" do + expected = { foo: { title: Faker::Lorem.sentence } } + described_class.stubs(:app_extensions).returns(expected) + expect(described_class.send(:template_id, json: @json)).to eql(nil) + end + it "returns nil if the extensions have no id for the template info" do + expected = { template: { title: Faker::Lorem.sentence } } + described_class.stubs(:app_extensions).returns(expected) + expect(described_class.send(:template_id, json: @json)).to eql(nil) + end + it "returns the template id" do + expect(described_class.send(:template_id, json: @json)).to eql(@template.id) + end + end + + describe "#app_extensions(json:)" do + it "returns an empty hash is json is not present" do + expect(described_class.send(:app_extensions, json: nil)).to eql({}) + end + it "returns an empty hash is json :extended_attributes is not present" do + json = { title: Faker::Lorem.sentence } + expect(described_class.send(:app_extensions, json: json)).to eql({}) + end + it "returns an empty hash if there is no extension for the current application" do + expected = { template: { id: @template.id } } + ApplicationService.expects(:application_name).returns("tester") + json = { extension: [{ foo: expected }] } + expect(described_class.send(:app_extensions, json: json)).to eql({}) + end + it "returns the hash for the current application" do + expected = { template: { id: @template.id } } + json = { extension: [{ "#{@app_name}": expected }] } + result = described_class.send(:app_extensions, json: json) + expect(result).to eql(expected) + end + end + + end + +end diff --git a/spec/services/application_service_spec.rb b/spec/services/application_service_spec.rb new file mode 100644 index 0000000000..524f891b4c --- /dev/null +++ b/spec/services/application_service_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApplicationService do + + describe "#default_language" do + it "returns the default language abbreviation defined in languages table" do + lang = create(:language, default_language: true) + expect(described_class.default_language).to eql(lang.abbreviation) + end + it "returns `en` if no default language is defined" do + Language.destroy_all + expect(described_class.default_language).to eql("en") + end + end + + describe "#application_name" do + it "returns the application name defined in the config/branding.yml" do + Rails.application.config.branding[:application][:name] = "foo" + expect(described_class.application_name).to eql("foo") + end + it "returns the Rails application name if no config/branding.yml entry" do + Rails.application.config.branding[:application].delete(:name) + expected = Rails.application.class.name.split('::').first.downcase + expect(described_class.application_name).to eql(expected) + end + end + +end diff --git a/spec/services/external_apis/base_service_spec.rb b/spec/services/external_apis/base_service_spec.rb new file mode 100644 index 0000000000..08dc9fdcc6 --- /dev/null +++ b/spec/services/external_apis/base_service_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ExternalApis::BaseService do + + before(:each) do + # The base service is meant to abstract, so spoof some config + # variables here so that our tests can function + described_class.stubs(:landing_page_url).returns(Faker::Internet.url) + described_class.stubs(:api_base_url).returns(Faker::Internet.url) + end + + describe "#headers" do + before(:each) do + @headers = described_class.headers + end + it "sets the Content-Type header for JSON" do + expect(@headers[:"Content-Type"]).to eql("application/json") + end + it "sets the Accept header for JSON" do + expect(@headers[:Accept]).to eql("application/json") + end + it "sets the User-Agent header for the default Application name and contact us url" do + expected = "#{described_class.send(:app_name)}" \ + " (#{described_class.send(:app_email)})" + expect(@headers[:"User-Agent"]).to eql(expected) + end + end + + describe "#log_error" do + before(:each) do + @err = Exception.new(Faker::Lorem.sentence) + end + it "does not write to the log if method is undefined" do + expect(described_class.log_error(method: nil, error: @err)).to eql(nil) + end + it "does not write to the log if error is undefined" do + expect(described_class.log_error(method: Faker::Lorem.word, error: nil)).to eql(nil) + end + it "writes to the log" do + Rails.logger.expects(:error).at_least(1) + described_class.log_error(method: Faker::Lorem.word, error: @err) + end + end + + context "private methods" do + context "#config" do + it "returns the branding.yml config" do + expected = Rails.application.config.branding + expect(described_class.send(:config)).to eql(expected) + end + end + context "#app_name" do + it "defaults to the Rails.application.class.name" do + Rails.configuration.branding[:application].delete(:name) + expected = ApplicationService.application_name + expect(described_class.send(:app_name)).to eql(expected) + end + it "returns the application name defined in branding.yml" do + Rails.configuration.branding[:application][:name] = "Foo" + expect(described_class.send(:app_name)).to eql("foo") + end + end + context "#app_email" do + it "defaults to the contact_us url" do + Rails.configuration.branding[:organisation].delete(:helpdesk_email) + expected = Rails.application.routes.url_helpers.contact_us_url + expect(described_class.send(:app_email)).to eql(expected) + end + it "returns the help_desk email defined in branding.yml" do + Rails.configuration.branding[:organisation][:helpdesk_email] = "Foo" + expect(described_class.send(:app_email)).to eql("Foo") + end + end + context "#http_get" do + before(:each) do + @uri = "http://example.org" + end + it "returns nil if no URI is specified" do + expect(described_class.send(:http_get, uri: nil)).to eql(nil) + end + it "returns nil if an error occurs" do + expect(described_class.send(:http_get, uri: "badurl~^(%")).to eql(nil) + end + it "logs an error if an error occurs" do + Rails.logger.expects(:error).at_least(1) + expect(described_class.send(:http_get, uri: "badurl~^(%")).to eql(nil) + end + it "returns an HTTP response" do + stub_request(:get, @uri).with(headers: described_class.headers) + .to_return(status: 200, body: "", headers: {}) + expect(described_class.send(:http_get, uri: @uri).code).to eql(200) + end + it "follows redirects" do + uri2 = "#{@uri}/redirected" + stub_redirect(uri: @uri, redirect_to: uri2) + stub_request(:get, uri2).with(headers: described_class.headers) + .to_return(status: 200, body: "", headers: {}) + + resp = described_class.send(:http_get, uri: @uri) + expect(resp.code).to eql(200) + end + end + + context "#options(additional_headers:, debug:)" do + before(:each) do + described_class.stubs(:headers).returns({ "Accept": "*/*" }) + end + it "headers just include base headers if no :additional_headers" do + result = described_class.send(:options) + expect(result[:headers][:Accept]).to eql("*/*") + end + it "merges additonal headers into the :headers option" do + result = described_class.send(:options, additional_headers: { foo: "bar" }) + expect(result[:headers][:Accept]).to eql("*/*") + expect(result[:headers][:foo]).to eql("bar") + end + it "does not include :debug_output if :debug is false" do + result = described_class.send(:options) + expect(result[:debug_output]).to eql(nil) + end + it "includes :debug_output if :debug is true" do + result = described_class.send(:options, additional_headers: {}, debug: true) + expect(result[:debug_output].nil?).to eql(false) + end + it "includes :follow_redirects option" do + result = described_class.send(:options) + expect(result[:follow_redirects]).to eql(true) + end + end + + end + + def stub_redirect(uri:, redirect_to:) + stub_request(:get, uri).with(headers: described_class.headers) + .to_return(status: 301, body: "", + headers: { "Location": redirect_to }) + end +end diff --git a/spec/services/external_apis/ror_service_spec.rb b/spec/services/external_apis/ror_service_spec.rb new file mode 100644 index 0000000000..193eb28470 --- /dev/null +++ b/spec/services/external_apis/ror_service_spec.rb @@ -0,0 +1,371 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ExternalApis::RorService do + + describe "#ping" do + before(:each) do + @headers = described_class.headers + @heartbeat = URI("#{described_class.api_base_url}#{described_class.heartbeat_path}") + end + it "returns true if an HTTP 200 is returned" do + stub_request(:get, @heartbeat).with(headers: @headers) + .to_return(status: 200, body: "", headers: {}) + expect(described_class.ping).to eql(true) + end + it "returns false if an HTTP 200 is NOT returned" do + stub_request(:get, @heartbeat).with(headers: @headers) + .to_return(status: 404, body: "", headers: {}) + expect(described_class.ping).to eql(false) + end + end + + describe "#search" do + before(:each) do + @headers = described_class.headers + @search = URI("#{described_class.api_base_url}#{described_class.search_path}") + @heartbeat = URI("#{described_class.api_base_url}#{described_class.heartbeat_path}") + stub_request(:get, @heartbeat).with(headers: @headers).to_return(status: 200) + end + + it "returns an empty array if term is blank" do + expect(described_class.search(term: nil)).to eql([]) + end + + context "ROR did not return a 200 status" do + before(:each) do + @term = Faker::Lorem.word + uri = "#{@search}?page=1&query=#{@term}" + stub_request(:get, uri).with(headers: @headers) + .to_return(status: 404, body: "", headers: {}) + end + it "returns an empty array" do + expect(described_class.search(term: @term)).to eql([]) + end + it "logs the response as an error" do + described_class.expects(:handle_http_failure).at_least(1) + described_class.search(term: @term) + end + end + + it "returns an empty string if ROR found no matches" do + results = { + "number_of_results": 0, + "time_taken": 23, + "items": [], + "meta": { "types": [], "countries" => [] } + } + term = Faker::Lorem.word + uri = "#{@search}?page=1&query=#{term}" + stub_request(:get, uri).with(headers: @headers) + .to_return(status: 200, body: results.to_json, headers: {}) + expect(described_class.search(term: term)).to eql([]) + end + + context "Successful response from API" do + before(:each) do + results = { + "number_of_results": 2, + "time_taken": 5, + "items": [ + { + "id": "https://ror.org/1234567890", + "name": "Example University", + "types": ["Education"], + "links": ["http://example.edu/"], + "aliases": ["Example"], + "acronyms": ["EU"], + "status": "active", + "country": { "country_name": "United States", "country_code": "US" }, + "external_ids": { + "GRID": { "preferred": "grid.12345.1", "all": "grid.12345.1" } + } + }, { + "id": "https://ror.org/0987654321", + "name": "Universidade de Example", + "types": ["Education"], + "links": [], + "aliases": ["Example"], + "acronyms": ["EU"], + "status": "active", + "country": { "country_name": "Mexico", "country_code": "MX" }, + "external_ids": { + "GRID": { "preferred": "grid.98765.8", "all": "grid.98765.8" } + } + } + ] + } + term = Faker::Lorem.word + uri = "#{@search}?page=1&query=#{term}" + stub_request(:get, uri).with(headers: @headers) + .to_return(status: 200, body: results.to_json, headers: {}) + @orgs = described_class.search(term: term) + end + + it "returns both results" do + expect(@orgs.length).to eql(2) + end + + it "includes the website in the name (if available)" do + expected = { + id: "https://ror.org/1234567890", + name: "Example University (example.edu)" + } + expect(@orgs.map { |i| i[:name] }.include?(expected[:name])).to eql(true) + end + + it "includes the country in the name (if no website is available)" do + expected = { + id: "https://ror.org/0987654321", + name: "Universidade de Example (Mexico)" + } + expect(@orgs.map { |i| i[:name] }.include?(expected[:name])).to eql(true) + end + end + end + + context "private methods" do + describe "#query_ror" do + before(:each) do + @results = { + "number_of_results": 1, + "time_taken": 5, + "items": [{ + "id": Faker::Internet.url, + "name": Faker::Lorem.word, + "country": { "country_name": Faker::Lorem.word } + }] + } + @term = Faker::Lorem.word + @headers = described_class.headers + search = URI("#{described_class.api_base_url}#{described_class.search_path}") + @uri = "#{search}?page=1&query=#{@term}" + end + + it "returns an empty array if term is blank" do + expect(described_class.send(:query_ror, term: nil)).to eql([]) + end + it "calls the handle_http_failure method if a non 200 response is received" do + stub_request(:get, @uri).with(headers: @headers) + .to_return(status: 403, body: "", headers: {}) + described_class.expects(:handle_http_failure).at_least(1) + expect(described_class.send(:query_ror, term: @term)).to eql([]) + end + it "returns the response body as JSON" do + stub_request(:get, @uri).with(headers: @headers) + .to_return(status: 200, body: @results.to_json, + headers: {}) + expect(described_class.send(:query_ror, term: @term)).not_to eql([]) + end + end + + describe "#query_string" do + it "assigns the search term to the 'query' argument" do + str = described_class.send(:query_string, term: "Foo") + expect(str).to eql("query=Foo&page=1") + end + it "defaults the page number to 1" do + str = described_class.send(:query_string, term: "Foo") + expect(str).to eql("query=Foo&page=1") + end + it "assigns the page number to the 'page' argument" do + str = described_class.send(:query_string, term: "Foo", page: 3) + expect(str).to eql("query=Foo&page=3") + end + it "ignores empty filter options" do + str = described_class.send(:query_string, term: "Foo", filters: []) + expect(str).to eql("query=Foo&page=1") + end + it "assigns a single filter" do + str = described_class.send(:query_string, term: "Foo", filters: ["types:A"]) + expect(str).to eql("query=Foo&page=1&filter=types:A") + end + it "assigns multiple filters" do + str = described_class.send(:query_string, term: "Foo", filters: [ + "types:A", "country.country_code:GB" + ]) + expect(str).to eql("query=Foo&page=1&filter=types:A,country.country_code:GB") + end + end + + describe "#process_pages" do + before(:each) do + described_class.stubs(:max_pages).returns(2) + described_class.stubs(:max_results_per_page).returns(5) + + @search = URI("#{described_class.api_base_url}#{described_class.search_path}") + @term = Faker::Lorem.word + @headers = described_class.headers + end + + it "returns an empty array if json is blank" do + rslts = described_class.send(:process_pages, term: @term, json: nil) + expect(rslts.length).to eql(0) + end + it "properly manages results with only one page" do + items = 4.times.map do + { + "id": Faker::Internet.unique.url, + "name": Faker::Lorem.word, + "country": { "country_name": Faker::Lorem.word } + } + end + results1 = { "number_of_results": 4, "items": items } + + stub_request(:get, "#{@search}?page=1&query=#{@term}") + .with(headers: @headers) + .to_return(status: 200, body: results1.to_json, headers: {}) + + json = JSON.parse({ "items": items, "number_of_results": 4 }.to_json) + rslts = described_class.send(:process_pages, term: @term, json: json) + + expect(rslts.length).to eql(4) + end + it "properly manages results with multiple pages" do + items = 7.times.map do + { + "id": Faker::Internet.unique.url, + "name": Faker::Lorem.word, + "country": { "country_name": Faker::Lorem.word } + } + end + results1 = { "number_of_results": 7, "items": items[0..4] } + results2 = { "number_of_results": 7, "items": items[5..6] } + + stub_request(:get, "#{@search}?page=1&query=#{@term}") + .with(headers: @headers) + .to_return(status: 200, body: results1.to_json, headers: {}) + stub_request(:get, "#{@search}?page=2&query=#{@term}") + .with(headers: @headers) + .to_return(status: 200, body: results2.to_json, headers: {}) + + json = JSON.parse({ "items": items[0..4], "number_of_results": 7 }.to_json) + rslts = described_class.send(:process_pages, term: @term, json: json) + expect(rslts.length).to eql(7) + end + it "does not go beyond the max_pages" do + items = 12.times.map do + { + "id": Faker::Internet.unique.url, + "name": Faker::Lorem.word, + "country": { "country_name": Faker::Lorem.word } + } + end + results1 = { "number_of_results": 12, "items": items[0..4] } + results2 = { "number_of_results": 12, "items": items[5..9] } + + stub_request(:get, "#{@search}?page=1&query=#{@term}") + .with(headers: @headers) + .to_return(status: 200, body: results1.to_json, headers: {}) + stub_request(:get, "#{@search}?page=2&query=#{@term}") + .with(headers: @headers) + .to_return(status: 200, body: results2.to_json, headers: {}) + + json = JSON.parse({ "items": items[0..4], "number_of_results": 12 }.to_json) + rslts = described_class.send(:process_pages, term: @term, json: json) + expect(rslts.length).to eql(10) + end + end + + describe "#parse_results" do + it "returns an empty array if there are no items" do + expect(described_class.send(:parse_results, json: nil)).to eql([]) + end + it "ignores items with no name or id" do + json = { "items": [ + { "id": Faker::Internet.url, "name": Faker::Lorem.word }, + { "id": Faker::Internet.url }, + { "name": Faker::Lorem.word } + ] }.to_json + items = described_class.send(:parse_results, json: JSON.parse(json)) + expect(items.length).to eql(1) + end + it "returns the correct number of results" do + json = { "items": [ + { "id": Faker::Internet.url, "name": Faker::Lorem.word }, + { "id": Faker::Internet.url, "name": Faker::Lorem.word } + ] }.to_json + items = described_class.send(:parse_results, json: JSON.parse(json)) + expect(items.length).to eql(2) + end + end + + describe "#org_name" do + it "returns nil if there is no name" do + json = { "country": { "country_name": "Nowhere" } }.to_json + expect(described_class.send(:org_name, item: JSON.parse(json))).to eql("") + end + it "properly appends the website if available" do + json = { + "name": "Example College", + "links": ["https://example.edu"], + "country": { "country_name": "Nowhere" } + }.to_json + expected = "Example College (example.edu)" + expect(described_class.send(:org_name, item: JSON.parse(json))).to eql(expected) + end + it "properly appends the country if available and no website is available" do + json = { + "name": "Example College", + "country": { "country_name": "Nowhere" } + }.to_json + expected = "Example College (Nowhere)" + expect(described_class.send(:org_name, item: JSON.parse(json))).to eql(expected) + end + it "properly handles an item with no website or country" do + json = { + "name": "Example College", + "links": [], + "country": {} + }.to_json + expected = "Example College" + expect(described_class.send(:org_name, item: JSON.parse(json))).to eql(expected) + end + end + + describe "#org_website" do + it "returns nil if no 'links' are in the json" do + item = JSON.parse({ "links": nil }.to_json) + expect(described_class.send(:org_website, item: item)).to eql(nil) + end + it "returns nil if the item is nil" do + expect(described_class.send(:org_website, item: nil)).to eql(nil) + end + it "returns the domain only" do + item = JSON.parse({ "links": ["https://example.org/path?a=b"] }.to_json) + expect(described_class.send(:org_website, item: item)).to eql("example.org") + end + it "removes the www prefix" do + item = JSON.parse({ "links": ["www.example.org"] }.to_json) + expect(described_class.send(:org_website, item: item)).to eql("example.org") + end + end + + describe "#fundref_id" do + before(:each) do + @hash = { "external_ids": {} } + end + it "returns a blank if no external_ids are present" do + json = JSON.parse(@hash.to_json) + expect(described_class.send(:fundref_id, item: json)).to eql("") + end + it "returns a blank if no FundRef ids are present" do + @hash["external_ids"] = { "FundRef": {} } + json = JSON.parse(@hash.to_json) + expect(described_class.send(:fundref_id, item: json)).to eql("") + end + it "returns the preferred id when specified" do + @hash["external_ids"] = { "FundRef": { "preferred": "1", "all": %w[2 1] } } + json = JSON.parse(@hash.to_json) + expect(described_class.send(:fundref_id, item: json)).to eql("1") + end + it "returns the firstid if no preferred is specified" do + @hash["external_ids"] = { "FundRef": { "preferred": nil, "all": %w[2 1] } } + json = JSON.parse(@hash.to_json) + expect(described_class.send(:fundref_id, item: json)).to eql("2") + end + end + + end +end diff --git a/spec/services/org/create_created_plan_service_spec.rb b/spec/services/org/create_created_plan_service_spec.rb index 3a5072559f..7a705ae5eb 100644 --- a/spec/services/org/create_created_plan_service_spec.rb +++ b/spec/services/org/create_created_plan_service_spec.rb @@ -75,7 +75,7 @@ def find_by_dates(dates:, org_id:) dates.map do |date| - StatCreatedPlan.find_by(date: date, org_id: org_id) + StatCreatedPlan.find_by(date: date, org_id: org_id, filtered: false) end end @@ -118,7 +118,7 @@ def find_by_dates(dates:, org_id:) it "monthly records are either created or updated" do described_class.call(org) - april = StatCreatedPlan.where(date: "2018-04-30", org: org) + april = StatCreatedPlan.where(date: "2018-04-30", org: org, filtered: true) expect(april).to have(1).items expect(april.first.count).to eq(2) @@ -129,7 +129,7 @@ def find_by_dates(dates:, org_id:) described_class.call(org) - april = StatCreatedPlan.where(date: "2018-04-30", org: org) + april = StatCreatedPlan.where(date: "2018-04-30", org: org, filtered: true) expect(april).to have(1).items expect(april.first.count).to eq(3) end @@ -181,7 +181,7 @@ def find_by_dates(dates:, org_id:) described_class.call - april = StatCreatedPlan.where(date: "2018-04-30", org: org) + april = StatCreatedPlan.where(date: "2018-04-30", org: org, filtered: true) expect(april).to have(1).items expect(april.first.count).to eq(2) @@ -192,7 +192,7 @@ def find_by_dates(dates:, org_id:) described_class.call - april = StatCreatedPlan.where(date: "2018-04-30", org: org) + april = StatCreatedPlan.where(date: "2018-04-30", org: org, filtered: true) expect(april).to have(1).items expect(april.first.count).to eq(3) end diff --git a/spec/services/org/create_last_month_created_plan_service_spec.rb b/spec/services/org/create_last_month_created_plan_service_spec.rb index 765243d054..368ff334f1 100644 --- a/spec/services/org/create_last_month_created_plan_service_spec.rb +++ b/spec/services/org/create_last_month_created_plan_service_spec.rb @@ -53,7 +53,7 @@ last_month_count = StatCreatedPlan.find_by( date: Date.today.last_month.end_of_month, - org_id: org.id).count + org_id: org.id, filtered: false).count expect(last_month_count).to eq(3) end @@ -62,7 +62,7 @@ last_month_details = StatCreatedPlan.find_by( date: Date.today.last_month.end_of_month, - org_id: org.id).by_template + org_id: org.id, filtered: false).by_template expect(last_month_details).to match_array( [ @@ -72,12 +72,12 @@ ) end - it "generates counts by template from today's last month" do + it "generates counts using template from today's last month" do described_class.call(org) last_month_details = StatCreatedPlan.find_by( date: Date.today.last_month.end_of_month, - org_id: org.id).using_template + org_id: org.id, filtered: false).using_template expect(last_month_details).to match_array( [ @@ -92,7 +92,7 @@ last_month = StatCreatedPlan.where( date: Date.today.last_month.end_of_month, - org_id: org.id) + org_id: org.id, filtered: false) expect(last_month).to have(1).items expect(last_month.first.count).to eq(3) @@ -106,7 +106,7 @@ last_month = StatCreatedPlan.where( date: Date.today.last_month.end_of_month, - org_id: org.id) + org_id: org.id, filtered: false) expect(last_month).to have(1).items expect(last_month.first.count).to eq(4) @@ -121,7 +121,7 @@ last_month_count = StatCreatedPlan.find_by( date: Date.today.last_month.end_of_month, - org_id: org.id).count + org_id: org.id, filtered: false).count expect(last_month_count).to eq(3) end @@ -133,7 +133,7 @@ last_month_details = StatCreatedPlan.find_by( date: Date.today.last_month.end_of_month, - org_id: org.id).by_template + org_id: org.id, filtered: false).by_template expect(last_month_details).to match_array( [ @@ -150,7 +150,7 @@ last_month_details = StatCreatedPlan.find_by( date: Date.today.last_month.end_of_month, - org_id: org.id).using_template + org_id: org.id, filtered: false).using_template expect(last_month_details).to match_array( [ @@ -167,7 +167,7 @@ last_month = StatCreatedPlan.where( date: Date.today.last_month.end_of_month, - org: org) + org: org, filtered: false) expect(last_month).to have(1).items expect(last_month.first.count).to eq(3) @@ -180,7 +180,7 @@ described_class.call last_month = StatCreatedPlan.where(date: Date.today.last_month.end_of_month, - org: org) + org: org, filtered: false) expect(last_month).to have(1).items expect(last_month.first.count).to eq(4) end diff --git a/spec/services/org_selection/hash_to_org_service_spec.rb b/spec/services/org_selection/hash_to_org_service_spec.rb new file mode 100644 index 0000000000..46092a18d6 --- /dev/null +++ b/spec/services/org_selection/hash_to_org_service_spec.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe OrgSelection::HashToOrgService do + + before(:each) do + @name = Faker::Company.name + @abbrev = Faker::Lorem.word.upcase + @lang = create(:language) + @url = Faker::Internet.url + @attr_key = Faker::Lorem.word + @attr_val = Faker::Lorem.word + @scheme = create(:identifier_scheme, for_orgs: true) + + @hash = { + name: "#{@name} (#{@abbrev})", + sort_name: @name, + score: Faker::Number.number, + weight: Faker::Number.number, + language: @lang.abbreviation, + abbreviation: @abbrev, + url: @url, + "#{@scheme.name}": Faker::Lorem.word, + "#{@attr_key}": @attr_val + } + end + + describe "#to_org(hash:)" do + it "returns nil if the hash is empty" do + expect(described_class.to_org(hash: nil)).to eql(nil) + end + it "returns the Org if the hash contains an Org id and the names match" do + org = create(:org, name: @name) + @hash[:id] = org.id + expect(described_class.to_org(hash: @hash)).to eql(org) + end + it "returns the Org by its identifier and the names match" do + ident = build(:identifier, identifier_scheme: @scheme, + value: @hash[:"#{@scheme.name}"]) + org = create(:org, name: @name, identifiers: [ident]) + expect(described_class.to_org(hash: @hash)).to eql(org) + end + it "returns the Org by name match" do + org = create(:org, name: @name) + expect(described_class.to_org(hash: @hash)).to eql(org) + end + it "returns a new Org instance" do + expect(described_class.to_org(hash: @hash).new_record?).to eql(true) + end + end + + describe "#to_identifiers(hash:)" do + before(:each) do + @rslt = described_class.to_identifiers(hash: @hash) + end + + it "returns an empty array if hash is nil" do + expect(described_class.to_identifiers(hash: nil)).to eql([]) + end + it "skips non-IdentifierScheme entries" do + @hash.delete(:"#{@scheme.name}") + expect(described_class.to_identifiers(hash: @hash)).to eql([]) + end + it "returns an array of new Identifiers" do + expect(@rslt.is_a?(Array)).to eql(true) + expect(@rslt.length).to eql(1) + end + it "returned Identifiers have an identifier scheme" do + expect(@rslt.first.identifier_scheme).to eql(@scheme) + end + it "returned Identifiers have a value" do + expect(@rslt.first.value.ends_with?(@hash[:"#{@scheme.name}"])).to eql(true) + end + it "returned Identifiers have attrs" do + expected = JSON.parse({ + name: @hash[:name], + url: @url, + language: @lang.abbreviation, + abbreviation: @abbrev, + "#{@attr_key}": @attr_val + }.to_json) + expect(JSON.parse(@rslt.first.attrs)).to eql(expected) + end + end + + context "private methods" do + + describe "#initialize_org(hash:)" do + it "returns nil if the hash is nil" do + rslt = described_class.send(:initialize_org, hash: nil) + expect(rslt).to eql(nil) + end + it "returns nil if the hash has no name attribute" do + @hash.delete(:name) + rslt = described_class.send(:initialize_org, hash: @hash) + expect(rslt).to eql(nil) + end + it "returns a new instance of Org" do + rslt = described_class.send(:initialize_org, hash: @hash) + nm = "#{@name} (#{@abbrev})" + lnks = JSON.parse({ "org": [{ "link": @url, "text": nm }] }.to_json) + expect(rslt.is_a?(Org)).to eql(true) + expect(rslt.new_record?).to eql(true) + expect(rslt.name).to eql(nm) + expect(rslt.links).to eql(lnks) + expect(rslt.language).to eql(@lang) + expect(rslt.target_url).to eql(@url) + expect(rslt.institution?).to eql(true) + expect(rslt.abbreviation).to eql(@abbrev) + end + end + + describe "#links_from_hash(name:, website:)" do + before(:each) do + @dflt = { org: [] } + end + + it "returns a default hash if name is blank" do + rslt = described_class.send(:links_from_hash, name: nil, website: @url) + expect(rslt).to eql(@dflt) + end + it "returns a default hash if website is blank" do + rslt = described_class.send(:links_from_hash, name: @name, website: nil) + expect(rslt).to eql(@dflt) + end + it "returns the links hash" do + rslt = described_class.send(:links_from_hash, name: @name, + website: @url) + expect(rslt).to eql({ org: [{ "link": @url, "text": @name }] }) + end + end + + describe "#abbreviation_from_hash(hash:)" do + it "returns nil if the hash is nil" do + rslt = described_class.send(:abbreviation_from_hash, hash: nil) + expect(rslt).to eql(nil) + end + it "returns the hash's abbreviation if it exists" do + rslt = described_class.send(:abbreviation_from_hash, hash: @hash) + expect(rslt).to eql(@abbrev) + end + it "returns the name as an acronym (first letter of each word)" do + @hash.delete(:abbreviation) + rslt = described_class.send(:abbreviation_from_hash, hash: @hash) + expected = @name.split(" ").map { |i| i[0].upcase }.join + expect(rslt).to eql(expected) + end + end + + describe "#language_from_hash(hash:)" do + before(:each) do + @dflt = create(:language, default_language: true) + end + + it "returns the default language if hash is empty" do + rslt = described_class.send(:language_from_hash, hash: nil) + expect(rslt).to eql(@dflt) + end + it "returns the default language if hash does not have a :language" do + rslt = described_class.send(:language_from_hash, hash: {}) + expect(rslt).to eql(@dflt) + end + it "returns the default language if no matching languages exist" do + @lang.destroy + rslt = described_class.send(:language_from_hash, hash: @hash) + expect(rslt).to eql(@dflt) + end + it "returns the correct language" do + rslt = described_class.send(:language_from_hash, hash: @hash) + expect(rslt).to eql(@lang) + end + end + + describe "#identifier_keys" do + before(:each) do + @rslt = described_class.send(:identifier_keys) + end + + it "returns the identifier key" do + expect(@rslt.include?("#{@scheme.name}")).to eql(true) + end + it "does not return the other keys" do + expect(@rslt.include?("name")).to eql(false) + expect(@rslt.include?("sort_name")).to eql(false) + expect(@rslt.include?("weight")).to eql(false) + expect(@rslt.include?("score")).to eql(false) + expect(@rslt.include?("language")).to eql(false) + expect(@rslt.include?("url")).to eql(false) + expect(@rslt.include?("#{@attr_key}")).to eql(false) + end + end + + describe "#attr_keys(hash:)" do + before(:each) do + @rslt = described_class.send(:attr_keys, hash: JSON.parse(@hash.to_json)) + end + + it "returns an empty hash if hash is nil" do + expect(described_class.send(:attr_keys, hash: nil)).to eql({}) + end + it "does not include sort_name, weight or score attributes" do + expect(@rslt.include?("sort_name")).to eql(false) + expect(@rslt.include?("weight")).to eql(false) + expect(@rslt.include?("score")).to eql(false) + end + it "does not include identifier keys" do + expect(@rslt.include?("#{@scheme.name}")).to eql(false) + end + it "returns the other attributes" do + expect(@rslt.include?("name")).to eql(true) + expect(@rslt.include?("language")).to eql(true) + expect(@rslt.include?("url")).to eql(true) + expect(@rslt.include?("#{@attr_key}")).to eql(true) + end + end + + describe "#exact_match?(rec:, name2:)" do + it "returns false if no record is present" do + rslt = described_class.send(:exact_match?, rec: nil, + name2: Faker::Lorem.word) + expect(rslt).to eql(false) + end + it "returns false if the name is blank" do + rslt = described_class.send(:exact_match?, rec: build(:org), name2: "") + expect(rslt).to eql(false) + end + it "calls the SearchService" do + OrgSelection::SearchService.expects(:exact_match?).at_least(1) + described_class.send(:exact_match?, rec: build(:org), + name2: Faker::Lorem.word) + end + end + + end + +end diff --git a/spec/services/org_selection/org_to_hash_service_spec.rb b/spec/services/org_selection/org_to_hash_service_spec.rb new file mode 100644 index 0000000000..7a4fb8b934 --- /dev/null +++ b/spec/services/org_selection/org_to_hash_service_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe OrgSelection::OrgToHashService do + + before(:each) do + @name = Faker::Lorem.word + @scheme = build(:identifier_scheme) + @id = build(:identifier, identifier_scheme: @scheme) + @org = build(:org, name: "#{@name} (ABC)", identifiers: [@id]) + end + + describe "#to_hash(org:)" do + before(:each) do + @rslt = described_class.to_hash(org: @org) + end + + it "returns an empty hash if the Org is nil" do + expect(described_class.to_hash(org: nil)).to eql({}) + end + it "places the Org.id into the :id parameter" do + expect(@rslt[:id]).to eql(@org.id) + end + it "places the Org.name into the :name parameter" do + expect(@rslt[:name]).to eql(@org.name) + end + it "places the Org.name (without an alias) into the :sort_name parameter" do + expect(@rslt[:sort_name]).to eql(@name) + end + it "places identifiers into the correct `[scheme.name]: [value]` format" do + expect(@rslt[:"#{@scheme.name}"]).to eql(@id.value) + end + end + +end diff --git a/spec/services/org_selection/search_service_spec.rb b/spec/services/org_selection/search_service_spec.rb new file mode 100644 index 0000000000..ad07516d8e --- /dev/null +++ b/spec/services/org_selection/search_service_spec.rb @@ -0,0 +1,361 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe OrgSelection::SearchService do + + before(:each) do + @records = [ + { + id: Faker::Internet.url, + name: "Foo College (test.edu)", + sort_name: "Foo College" + }, + { + id: Faker::Internet.url, + name: "Foo College (other.edu)", + sort_name: "Foo College" + }, + { + id: Faker::Internet.url, + name: "Foo University (Ireland)", + sort_name: "Foo University" + }, + { + id: Faker::Internet.url, + name: "University of Foo (Spain)", + sort_name: "University of Foo" + } + ] + + # Mock calls to the RorService + ExternalApis::RorService.stubs(:active).returns(true) + ExternalApis::RorService.stubs(:search).returns(@records) + end + + describe "#search_combined(search_term:)" do + it "returns an empty array if the search term is not provided" do + expect(described_class.search_combined(search_term: nil)).to eql([]) + end + it "returns an empty array if the search term is less than 2 chars" do + expect(described_class.search_combined(search_term: "Ab")).to eql([]) + end + it "only searches locally if an exact match was found" do + org = create(:org) + described_class.expects(:local_search).returns([org]).at_least(1) + described_class.expects(:externals_search).at_least(0) + described_class.search_combined(search_term: org.name) + end + it "calls both search_locally and search_externally" do + described_class.expects(:local_search).at_least(1) + described_class.expects(:externals_search).at_least(1) + described_class.search_combined(search_term: Faker::Company.name) + end + end + + describe "#search_externally(search_term:)" do + it "returns an empty array if the search term is not provided" do + expect(described_class.search_externally(search_term: nil)).to eql([]) + end + it "returns an empty array if the search term is less than 2 chars" do + expect(described_class.search_externally(search_term: "Ab")).to eql([]) + end + it "calls the private externals_search method" do + described_class.expects(:externals_search).at_least(1) + described_class.search_externally(search_term: Faker::Company.name) + end + end + + describe "#search_locally(search_term:)" do + it "returns an empty array if the search term is not provided" do + expect(described_class.search_locally(search_term: nil)).to eql([]) + end + it "returns an empty array if the search term is less than 2 chars" do + expect(described_class.search_locally(search_term: "Ab")).to eql([]) + end + it "calls the private locals_search method" do + described_class.expects(:local_search).at_least(1) + described_class.search_locally(search_term: Faker::Company.name) + end + end + + describe "#name_without_alias(name:)" do + it "returns an empty string if name is not present" do + expect(described_class.name_without_alias(name: nil)).to eql("") + end + it "returns the name without the abbreviation alias" do + name = Faker::Company.name + rslt = described_class.name_without_alias(name: "#{name} (ABC)") + expect(rslt).to eql(name) + end + it "returns the name without the domain alias" do + name = Faker::Company.name + rslt = described_class.name_without_alias(name: "#{name} (example.edu)") + expect(rslt).to eql(name) + end + end + + describe "#exact_match?(name1:, name2:)" do + it "returns false if name1 is nil" do + rslt = described_class.exact_match?(name1: nil, name2: "Foo") + expect(rslt).to eql(false) + end + it "returns false if name2 is nil" do + rslt = described_class.exact_match?(name1: "Foo", name2: nil) + expect(rslt).to eql(false) + end + it "returns false if the names do not match" do + rslt = described_class.exact_match?(name1: "Bar", name2: "Foo") + expect(rslt).to eql(false) + end + it "returns true if the names match" do + rslt = described_class.exact_match?(name1: "Foo", name2: "Foo") + expect(rslt).to eql(true) + end + it "returns true if the names match but their cases do not" do + rslt = described_class.exact_match?(name1: "foo", name2: "Foo") + expect(rslt).to eql(true) + end + end + + context "private methods" do + + describe "#local_search(search_term:)" do + it "returns an empty array if the search term is blank" do + rslts = described_class.send(:local_search, search_term: nil) + expect(rslts).to eql([]) + end + it "returns an empty array if no Orgs were matched" do + rslts = described_class.send(:local_search, search_term: "Bar") + expect(rslts).to eql([]) + end + it "returns an array of matching Orgs" do + create(:org, name: "Foo Bar") + rslts = described_class.send(:local_search, search_term: "Foo") + expect(rslts.length).to eql(1) + expect(rslts.is_a?(Array)).to eql(true) + end + end + + describe "#externals_search(search_term:)" do + before(:each) do + ExternalApis::RorService.stubs(:active).returns(true) + end + + it "returns an empty array if the search term is blank" do + rslts = described_class.send(:externals_search, search_term: nil) + expect(rslts).to eql([]) + end + it "returns an empty array if no external apis are active" do + ExternalApis::RorService.stubs(:active).returns(false) + rslts = described_class.send(:externals_search, search_term: "Foo") + expect(rslts).to eql([]) + end + it "returns an empty array if no Orgs were matched" do + ExternalApis::RorService.stubs(:search).returns([]) + rslts = described_class.send(:externals_search, search_term: "Foo") + expect(rslts).to eql([]) + end + it "returns an array of matching Orgs" do + rslts = described_class.send(:externals_search, search_term: "Foo") + expect(rslts.length).to eql(4) + expect(rslts.is_a?(Array)).to eql(true) + end + end + + describe "#prepare(search_term:, records:)" do + it "returns an empty array if the search term is blank" do + rslts = described_class.send(:prepare, search_term: nil, + records: @records) + expect(rslts).to eql([]) + end + it "returns an empty array if the records is not an array" do + rslts = described_class.send(:prepare, search_term: "Foo", + records: nil) + expect(rslts).to eql([]) + end + it "handles Org models" do + recs = [create(:org, name: "Fooville Community College")] + rslts = described_class.send(:prepare, search_term: "Foo", + records: recs) + expect(rslts.first[:name]).to eql("Fooville Community College") + end + it "handles non-Org models" do + rslts = described_class.send(:prepare, search_term: "Foo", + records: @records) + rec = rslts.select { |item| item[:name].include?("Ireland") }.first + expect(rec[:name]).to eql("Foo University (Ireland)") + end + end + + describe "#deduplicate(records:)" do + it "returns an empty array if the incoming records is not an Array" do + expect(described_class.send(:deduplicate, records: nil)).to eql([]) + end + it "includes all of the unique records" do + rslts = described_class.send(:deduplicate, records: @records) + expect(rslts.length).to eql(3) + end + it "removes the duplicate" do + rslts = described_class.send(:deduplicate, records: @records) + dupe = rslts.select { |rec| rec[:name] == "Foo College (other.edu)" } + expect(dupe).to eql([]) + end + end + + describe "#sort(array:)" do + before(:each) do + @sortable = @records.each_with_index.map do |rec, idx| + rec.merge(weight: idx, score: idx + 1) + end + # Mix up the records since we scored them in order + @sortable = @sortable.sort { |a, b| b[:name] <=> a[:name] } + end + + it "returns an empty array if the incoming array is not an Array" do + expect(described_class.send(:sort, array: nil)).to eql([]) + end + it "places the record with the lowest score + weight first" do + rslts = described_class.send(:sort, array: @sortable) + expect(rslts.first[:score]).to eql(1) + expect(rslts.first[:weight]).to eql(0) + end + it "places the record with the highest score+ weight last" do + rslts = described_class.send(:sort, array: @sortable) + expect(rslts.last[:score]).to eql(4) + expect(rslts.last[:weight]).to eql(3) + end + it "sorts by name ascending when the score and weight match" do + @sortable[1][:score] = 0 + @sortable[1][:weight] = 0 + @sortable[2][:score] = 0 + @sortable[2][:weight] = 0 + + rslts = described_class.send(:sort, array: @sortable) + expect(rslts[0][:sort_name].include?("College")).to eql(true) + expect(rslts[1][:sort_name].include?("University")).to eql(true) + end + end + + describe "#evaluate(reord:, search_term:)" do + before(:each) do + described_class.stubs(:score).returns(0) + described_class.stubs(:weigh).returns(0) + @record = @records.first + end + it "returns the record if search term is nil" do + rslt = described_class.send(:evaluate, record: @record, + search_term: nil) + expect(rslt).to eql(@record) + end + it "returns a nil if record is nil" do + rslt = described_class.send(:evaluate, record: nil, + search_term: "Foo") + expect(rslt).to eql(nil) + end + it "adds a score to each item" do + rslt = described_class.send(:evaluate, record: @record, + search_term: "Foo") + expect(rslt[:score]).to eql(0) + end + it "adds a weight to each item" do + rslt = described_class.send(:evaluate, record: @record, + search_term: "Foo") + expect(rslt[:weight]).to eql(0) + end + end + + describe "#score(search_term:, item_name:)" do + it "returns a high value '99' if term is nil" do + rslt = described_class.send(:score, search_term: nil, + item_name: "Foo") + expect(rslt).to eql(99) + end + it "returns a high value '99' if item_name is nil" do + rslt = described_class.send(:score, search_term: "Foo", + item_name: nil) + expect(rslt).to eql(99) + end + it "calls the base class' natuaral language comparison method" do + Text::Levenshtein.stubs(:distance).returns(0) + rslt = described_class.send(:score, search_term: "Foo", + item_name: "Bar") + expect(rslt).to eql(0) + end + end + + describe "#weigh(search_term:, item_name:)" do + before(:each) do + @term = "Foo" + end + it "expects a weight of 3 if the search_term is blank" do + rslt = described_class.send(:weigh, search_term: nil, + item_name: @term) + expect(rslt).to eql(3) + end + it "expects a weight of 3 if the search_term is blank" do + rslt = described_class.send(:weigh, search_term: @term, + item_name: nil) + expect(rslt).to eql(3) + end + it "expects a result that starts with the search term to weigh zero" do + item = "#{@term.downcase}#{Faker::Lorem.sentence}" + rslt = described_class.send(:weigh, search_term: @term, + item_name: item) + expect(rslt).to eql(0) + end + it "expects a result that contains the search term to weigh one" do + item = "#{Faker::Lorem.sentence}#{@term.downcase}" + rslt = described_class.send(:weigh, search_term: @term, + item_name: item) + expect(rslt).to eql(1) + end + it "expects a result that does not contain the search term to weigh two" do + item = Faker::Lorem.sentence.to_s.gsub(@term, "foo bar") + rslt = described_class.send(:weigh, search_term: @term, + item_name: item) + expect(rslt).to eql(2) + end + end + + describe "#filter(array:)" do + it "returns an empty array if the array in is not an Array" do + expect(described_class.send(:filter, array: nil)).to eql([]) + end + it "returns all records if they do not have a 'score' and 'weight'" do + recs = [ + { name: Faker::Lorem.word }, + { name: Faker::Lorem.word } + ] + rslts = described_class.send(:filter, array: recs) + expect(rslts.length).to eql(2) + end + it "discards any item whose score is > 25 and weight > 1" do + recs = [ + { name: Faker::Lorem.word }, + { name: Faker::Lorem.word, score: 26, weight: 2 } + ] + rslts = described_class.send(:filter, array: recs) + expect(rslts.length).to eql(1) + end + it "does not discard an item whose weight is > 1 but score < 25" do + recs = [ + { name: Faker::Lorem.word }, + { name: Faker::Lorem.word, score: 20, weight: 2 } + ] + rslts = described_class.send(:filter, array: recs) + expect(rslts.length).to eql(2) + end + it "does not discard an item whose weight is < 2 but score > 25" do + recs = [ + { name: Faker::Lorem.word }, + { name: Faker::Lorem.word, score: 26, weight: 1 } + ] + rslts = described_class.send(:filter, array: recs) + expect(rslts.length).to eql(2) + end + end + + end + +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a487656802..3dd74bd384 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,9 +1,11 @@ -require 'simplecov' -SimpleCov.start 'rails' +# frozen_string_literal: true -$LOAD_PATH.unshift(File.expand_path("..", __FILE__)) +require "simplecov" +SimpleCov.start "rails" -require 'mocha' +$LOAD_PATH.unshift(File.expand_path(__dir__)) + +require "mocha" # This file was generated by the `rails generate rspec:install` command. Conventionally, all # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. @@ -94,4 +96,38 @@ # test failures related to randomization by passing the same `--seed` value # as the one that triggered the failure. Kernel.srand config.seed + + config.before(:context) do + # Capture the current time so that we can compare against files afterward + # to clear out any downloaded/exported plans/templates + @start_time = Time.now + end + + config.after(:context) do + # Clean up any files generated by the Export/Download UI pages + path = Rails.root.join("*.{csv,txt,docx,pdf}").to_s + Dir.glob(path).each do |file| + puts "Deleting test file generated by Download/Export: #{file}" + File.delete(file) if File.ctime(file) > @start_time + end + end + + # Enable Capybara webmocks if we are testing a feature + config.before(:each) do |example| + if example.metadata[:type] == :feature + Capybara::Webmock.start + + # Allow Capybara to make localhost requests and also contact the + # google api chromedriver store + WebMock.disable_net_connect!( + allow_localhost: true, + allow: %w[chromedriver.storage.googleapis.com] + ) + end + end + + config.after(:suite) do + Capybara::Webmock.stop + end + end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 5fb9609abb..ea92923b7a 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -4,29 +4,18 @@ require_relative "helpers/capybara_helper" require_relative "helpers/sessions_helper" require_relative "helpers/tiny_mce_helper" -require_relative "helpers/combobox_helper" - -SCREEN_SIZE = [2400, 1350] -DIMENSION = Selenium::WebDriver::Dimension.new(*SCREEN_SIZE) +require_relative "helpers/autocomplete_helper" Capybara.default_driver = :rack_test # Cache for one hour Webdrivers.cache_time = 3600 - # This is a customisation of the default :selenium_chrome_headless config in: # https://github.com/teamcapybara/capybara/blob/master/lib/capybara.rb # # This adds the --no-sandbox flag to fix TravisCI as described here: # https://docs.travis-ci.com/user/chrome#sandboxing -Capybara.register_driver :selenium_chrome_headless do |app| - Capybara::Selenium::Driver.load_selenium - browser_options = ::Selenium::WebDriver::Chrome::Options.new - browser_options.args << '--headless' - browser_options.args << '--no-sandbox' - browser_options.args << '--disable-gpu' if Gem.win_platform? - Capybara::Selenium::Driver.new(app, browser: :chrome, options: browser_options) -end +Capybara.javascript_driver = :capybara_webmock_chrome_headless RSpec.configure do |config| @@ -35,8 +24,7 @@ end config.before(:each, type: :feature, js: true) do - Capybara.current_driver = :selenium_chrome_headless - Capybara.page.driver.browser.manage.window.size = DIMENSION + Capybara.current_driver = :capybara_webmock_chrome_headless end end @@ -51,5 +39,5 @@ config.include(CapybaraHelper, type: :feature) config.include(SessionsHelper, type: :feature) config.include(TinyMceHelper, type: :feature) - config.include(ComboboxHelper, type: :feature) + config.include(AutoCompleteHelper, type: :feature) end diff --git a/spec/support/helpers/api.rb b/spec/support/helpers/api.rb new file mode 100644 index 0000000000..22a97bb2ce --- /dev/null +++ b/spec/support/helpers/api.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ApiHelper + + def mock_authorization_for_api_client + api_client = ApiClient.first + api_client = create(:api_client) unless api_client.present? + + Api::V1::BaseApiController.any_instance.stubs(:authorize_request).returns(true) + Api::V1::BaseApiController.any_instance.stubs(:client).returns(api_client) + end + + # rubocop:disable Metrics/AbcSize + def mock_authorization_for_user + create(:org) unless Org.any? + user = User.org_admins(Org.last).first + + unless user.present? + user = create(:user, :org_admin, api_token: SecureRandom.uuid, org: Org.last) + end + + Api::V1::BaseApiController.any_instance.stubs(:authorize_request).returns(true) + Api::V1::BaseApiController.any_instance.stubs(:client).returns(user) + end + # rubocop:enable Metrics/AbcSize + +end diff --git a/spec/support/helpers/autocomplete_helper.rb b/spec/support/helpers/autocomplete_helper.rb new file mode 100644 index 0000000000..c95ede4eca --- /dev/null +++ b/spec/support/helpers/autocomplete_helper.rb @@ -0,0 +1,33 @@ +module AutoCompleteHelper + + def select_an_org(autocomplete_id, org) + # Set the Org Name + find(autocomplete_id).set org.name + sleep(0.2) + + # The controllers are expecting the org_id though, so lets + # populate it + hidden_id = autocomplete_id.gsub("_name", "_id").gsub("#", "") + hash = { id: org.id, name: org.name }.to_json + + if hidden_id.present? + page.execute_script( + "document.getElementById('#{hidden_id}').value = '#{hash.to_s}'" + ); + end + end + + def choose_suggestion(suggestion_text) + matcher = ".ui-autocomplete .ui-menu-item" + matching_element = all(:css, matcher).detect do |element| + element.text.strip == suggestion_text.strip + end + unless matching_element.present? + raise ArgumentError, "No such suggestion with text '#{suggestion_text}'" + end + matching_element.click + # Wait for the JS to run + sleep(0.2) + end + +end diff --git a/spec/support/helpers/combobox_helper.rb b/spec/support/helpers/combobox_helper.rb deleted file mode 100644 index 4a05741ca5..0000000000 --- a/spec/support/helpers/combobox_helper.rb +++ /dev/null @@ -1,15 +0,0 @@ -module ComboboxHelper - - def choose_suggestion(suggestion_text) - matching_element = all(:css, '.js-suggestion').detect do |element| - element.text.strip == suggestion_text.strip - end - unless matching_element.present? - raise ArgumentError, "No such suggestion with text '#{suggestion_text}'" - end - matching_element.click - # Wait for the JS to run - sleep(0.2) - end - -end \ No newline at end of file diff --git a/spec/support/helpers/dmptool_helper.rb b/spec/support/helpers/dmptool_helper.rb index 1209f53446..16c941900d 100644 --- a/spec/support/helpers/dmptool_helper.rb +++ b/spec/support/helpers/dmptool_helper.rb @@ -1,6 +1,4 @@ -# ------------------------------------------------------------- -# start DMPTool customization -# ------------------------------------------------------------- +# frozen_string_literal: true module DmptoolHelper @@ -15,7 +13,7 @@ def access_sign_in_modal def access_create_account_modal access_sign_in_options_modal - click_on "Create account with email address" + click_on "Create an account" end def access_shib_ds_modal @@ -24,24 +22,24 @@ def access_shib_ds_modal end def generate_shibbolized_orgs(count) - (1..count).each do |idx| - create(:org, :organisation, :shibbolized, is_other: false) + (1..count).each do + create(:org, :organisation, :shibbolized, managed: true) end end + # rubocop:disable Metrics/MethodLength def mock_omniauth_call(scheme, user) - case scheme when "shibboleth" # Mock the OmniAuth payload for Shibboleth { provider: scheme, - uid: "123ABC", + uid: SecureRandom.uuid, info: { email: user.email, givenname: user.firstname, sn: user.surname, - identity_provider: user.org.org_identifiers.first.identifier + identity_provider: user.org.identifiers.first.value } } @@ -49,18 +47,37 @@ def mock_omniauth_call(scheme, user) # Moch the Omniauth payload for Orcid { provider: scheme, - uid: "ORCID123" + uid: 4.times.map { Faker::Number.number(l_digits: 4).to_s }.join("-") } else { provider: scheme, - uid: "testing" + uid: Faker::Lorem.word } end end + # rubocop:enable Metrics/MethodLength -end + # rubocop:disable Metrics/MethodLength + def mock_blog + xml = <<-XML + + + + #{Faker::Lorem.sentence} + + #{Faker::Lorem.sentence} + + + #{Faker::Lorem.sentence} + + + + XML + stub_request(:get, "https://blog.dmptool.org/feed").to_return( + status: 200, body: xml.to_s, headers: {} + ) + end + # rubocop:enable Metrics/MethodLength -# ------------------------------------------------------------- -# end DMPTool customization -# ------------------------------------------------------------- +end diff --git a/spec/support/helpers/roles_helper.rb b/spec/support/helpers/roles_helper.rb index 1705cc647e..44687734b3 100644 --- a/spec/support/helpers/roles_helper.rb +++ b/spec/support/helpers/roles_helper.rb @@ -2,8 +2,8 @@ module RolesHelper def build_plan(administrator = false, editor = false, commenter = false) org = create(:org) + plan = create(:plan, answers: 2, guidance_groups: 2, org: org) creator = create(:user, org: org) - plan = create(:plan, answers: 2, guidance_groups: 2) create(:role, :creator, :active, plan: plan, user: creator) if administrator diff --git a/spec/support/helpers/webmocks.rb b/spec/support/helpers/webmocks.rb new file mode 100644 index 0000000000..734598f28b --- /dev/null +++ b/spec/support/helpers/webmocks.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Webmocks + + def stub_ror_service + url = ExternalApis::RorService.api_base_url + headers = ExternalApis::RorService.headers + + # Mock the results of the ping/heartbeat check + stub_request(:get, "#{url}#{ExternalApis::RorService.heartbeat_path}") + .with(headers: headers).to_return(status: 200, body: "OK", headers: {}) + + # Mock the results of a search. We are only returning the elements of the + # ROR response that we actually care about here + stub_request(:get, /#{url}#{ExternalApis::RorService.search_path}\.*/) + .with(headers: headers) + .to_return(status: 200, body: mocked_ror_response, headers: {}) + end + + def stub_openaire + url = ExternalApis::OpenAireService.api_base_url + url = "#{url}#{ExternalApis::OpenAireService.search_path}" + url = url % ExternalApis::OpenAireService.default_funder + stub_request(:get, url).to_return(status: 200, body: "", headers: {}) + end + + # rubocop:disable Metrics/MethodLength + def mocked_ror_response + body = { number_of_results: 10, time_taken: 10, items: [] } + 10.times.each do + body[:items] << { + id: Faker::Internet.url(host: "ror.org"), + name: Faker::Company.unique.name, + links: [[Faker::Internet.url, nil].sample], + country: { country_name: Faker::Books::Dune.planet }, + external_ids: { + FundRef: { preferred: nil, all: [Faker::Number.number(digits: 6)] } + } + } + end + body.to_json + end + # rubocop:enable Metrics/MethodLength + +end diff --git a/spec/support/mocks/api_json_samples.rb b/spec/support/mocks/api_json_samples.rb new file mode 100644 index 0000000000..ff57408c32 --- /dev/null +++ b/spec/support/mocks/api_json_samples.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +# Mock JSON submissions +module Mocks + + module ApiJsonSamples + + ROLES = %w[Investigation Project_administration Data_curation].freeze + + + def mock_identifier_schemes + create(:identifier_scheme, name: "ror") + create(:identifier_scheme, name: "fundref") + create(:identifier_scheme, name: "orcid") + create(:identifier_scheme, name: "grant") + end + + def minimal_update_json + { + "total_items": 1, + "items": [ + { + "dmp": { + "title": Faker::Lorem.sentence, + "contact": { + "name": Faker::TvShows::Simpsons.character, + "mbox": Faker::Internet.email + }, + "dataset": [{ + "title": Faker::Lorem.sentence + }], + "dmp_id": { + "type": "doi", + "identifier": SecureRandom.uuid + } + } + } + ] + }.to_json + end + + def minimal_create_json + { + "total_items": 1, + "items": [ + { + "dmp": { + "title": Faker::Lorem.sentence, + "contact": { + "name": Faker::TvShows::Simpsons.character, + "mbox": Faker::Internet.email + }, + "dataset": [{ + "title": Faker::Lorem.sentence + }], + "extension": [ + "#{ApplicationService.application_name.split("-").first}": { + "template": { + "id": Template.last.id, + "title": Faker::Lorem.sentence + } + } + ] + } + } + ] + }.to_json + end + + def complete_create_json + lang = Language.all.pluck(:abbreviation).sample || "en-UK" + contact = { + name: Faker::TvShows::Simpsons.character, + email: Faker::Internet.email, + id: SecureRandom.uuid + } + { + "total_items": 1, + "items": [ + { + "dmp": { + "created": (Time.now - 3.months).to_formatted_s(:iso8601), + "title": Faker::Lorem.sentence, + "description": Faker::Lorem.paragraph, + "language": Api::V1::LanguagePresenter.three_char_code(lang: lang), + "ethical_issues_exist": %w[yes no unknown].sample, + "ethical_issues_description": Faker::Lorem.paragraph, + "ethical_issues_report": Faker::Internet.url, + "contact": { + "name": contact[:name], + "mbox": contact[:email], + "affiliation": { + "name": Faker::TvShows::Simpsons.location, + "abbreviation": Faker::Lorem.word.upcase, + "region": Faker::Space.planet, + "affiliation_id": { + "type": "ror", + "identifier": SecureRandom.uuid + } + }, + "contact_id": { + "type": "orcid", + "identifier": contact[:id] + } + }, + "contributor": [{ + "role": [ + "https://dictionary.casrai.org/Contributor_Roles/Project_administration", + "https://dictionary.casrai.org/Contributor_Roles/Investigation" + ], + "name": Faker::Movies::StarWars.character, + "mbox": Faker::Internet.email, + "affiliation": { + "name": Faker::Movies::StarWars.planet, + "abbreviation": Faker::Lorem.word.upcase, + "affiliation_id": { + "type": "ror", + "identifier": SecureRandom.uuid + } + }, + "contributor_id": { + "type": "orcid", + "identifier": SecureRandom.uuid + } + }, { + "role": [ + "https://dictionary.casrai.org/Contributor_Roles/Investigation" + ], + "name": contact[:name], + "mbox": contact[:email], + "affiliation": { + "name": Faker::Movies::StarWars.planet, + "abbreviation": Faker::Lorem.word.upcase, + "affiliation_id": { + "type": "ror", + "identifier": SecureRandom.uuid + } + }, + "contributor_id": { + "type": "orcid", + "identifier": contact[:id] + } + }], + "project": [{ + "title": Faker::Lorem.sentence, + "description": Faker::Lorem.paragraph, + "start": (Time.now + 3.months).to_formatted_s(:iso8601), + "end": (Time.now + 2.years).to_formatted_s(:iso8601), + "funding": [{ + "name": Faker::Movies::StarWars.droid, + "funder_id": { + "type": "fundref", + "identifier": Faker::Number.number + }, + "grant_id": { + "type": "other", + "identifier": SecureRandom.uuid + }, + "funding_status": %w[planned applied granted].sample + }] + }], + "dataset": [{ + "title": Faker::Lorem.sentence, + "personal_data": %w[yes no unknown].sample, + "sensitive_data": %w[yes no unknown].sample, + "dataset_id": { + "type": "url", + "identifier": Faker::Internet.url + } + }], + "extension": [{ + "#{ApplicationService.application_name.split("-").first}": { + "template": { + "id": Template.last.id, + "title": Faker::Lorem.sentence + } + } + }] + } + } + ] + }.to_json + end + end + +end diff --git a/spec/views/api/v1/_standard_response.json_jbuilder_spec.rb b/spec/views/api/v1/_standard_response.json_jbuilder_spec.rb new file mode 100644 index 0000000000..37b0678f59 --- /dev/null +++ b/spec/views/api/v1/_standard_response.json_jbuilder_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/_standard_response.json.jbuilder" do + + before(:each) do + @application = Faker::Lorem.word + @caller = Faker::Lorem.word + @url = Faker::Internet.url + @code = [200, 400, 404, 500].sample + + assign :application, @application + assign :caller, @caller + + @response = OpenStruct.new(status: @code) + @request = Net::HTTPGenericRequest.new("GET", nil, nil, @url) + end + + describe "standard response items - Also the same as: GET /heartbeat" do + + before(:each) do + render partial: "api/v1/standard_response", + locals: { response: @response, request: @request } + @json = JSON.parse(rendered).with_indifferent_access + end + + it "includes the :application" do + expect(@json[:application]).to eql(@application) + end + it "includes the :code" do + expect(@json[:code]).to eql(@code) + end + it "includes the :message" do + expect(@json[:message]).to eql(Rack::Utils::HTTP_STATUS_CODES[@code]) + end + it "includes the :time" do + expect(@json[:time].present?).to eql(true) + end + it ":time is in UTC format" do + expect(Date.parse(@json[:time]).is_a?(Date)).to eql(true) + end + it "includes the :caller" do + expect(@json[:caller]).to eql(@caller) + end + it "includes the :source" do + expect(@json[:source].include?(@url)).to eql(true) + end + it "includes the :total_items" do + expect(@json[:total_items]).to eql(0) + end + + end + + context "responses with pagination" do + + describe "On the 1st page and there is only one page" do + before(:each) do + assign :page, 1 + assign :per_page, 3 + + render partial: "api/v1/standard_response", + locals: { response: @response, request: @request, + total_items: 3 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it "shows the correct page number" do + expect(@json[:page]).to eql(1) + end + it "includes the per_page number" do + expect(@json[:per_page]).to eql(3) + end + it "includes the :total_items" do + expect(@json[:total_items]).to eql(3) + end + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to eql(false) + end + it "does not show a 'next' page link" do + expect(@json[:prev].present?).to eql(false) + end + end + + describe "On the 1st page and there multiple pages" do + before(:each) do + assign :page, 1 + assign :per_page, 3 + + render partial: "api/v1/standard_response", + locals: { response: @response, request: @request, + total_items: 4 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it "shows the correct page number" do + expect(@json[:page]).to eql(1) + end + it "includes the per_page number" do + expect(@json[:per_page]).to eql(3) + end + it "includes the :total_items" do + expect(@json[:total_items]).to eql(4) + end + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to eql(false) + end + it "does not show a 'next' page link" do + expect(@json[:next].present?).to eql(true) + end + end + + describe "On the 2nd page and there more than 2 pages" do + before(:each) do + assign :page, 2 + assign :per_page, 3 + + render partial: "api/v1/standard_response", + locals: { response: @response, request: @request, + total_items: 7 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it "shows the correct page number" do + expect(@json[:page]).to eql(2) + end + it "includes the per_page number" do + expect(@json[:per_page]).to eql(3) + end + it "includes the :total_items" do + expect(@json[:total_items]).to eql(7) + end + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to eql(true) + end + it "does not show a 'next' page link" do + expect(@json[:next].present?).to eql(true) + end + end + + describe "On the last page" do + before(:each) do + assign :page, 2 + assign :per_page, 3 + + render partial: "api/v1/standard_response", + locals: { response: @response, request: @request, + total_items: 5 } + @json = JSON.parse(rendered).with_indifferent_access + end + + it "shows the correct page number" do + expect(@json[:page]).to eql(2) + end + it "includes the per_page number" do + expect(@json[:per_page]).to eql(3) + end + it "includes the :total_items" do + expect(@json[:total_items]).to eql(5) + end + it "does not show a 'prev' page link" do + expect(@json[:prev].present?).to eql(true) + end + it "does not show a 'next' page link" do + expect(@json[:next].present?).to eql(false) + end + end + + end + +end diff --git a/spec/views/api/v1/contributors/_show.json.jbuilder_spec.rb b/spec/views/api/v1/contributors/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..5879c9ee8b --- /dev/null +++ b/spec/views/api/v1/contributors/_show.json.jbuilder_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/contributors/_show.json.jbuilder" do + + before(:each) do + @plan = create(:plan) + scheme = create(:identifier_scheme, name: "orcid") + @contact = create(:contributor, org: create(:org), plan: @plan, roles_count: 0, + data_curation: true) + @ident = create(:identifier, identifiable: @contact, value: Faker::Lorem.word, + identifier_scheme: scheme) + @contact.reload + end + + describe "includes all of the Contributor attributes" do + before(:each) do + render partial: "api/v1/contributors/show", locals: { contributor: @contact } + @json = JSON.parse(rendered).with_indifferent_access + end + + it "includes the :name" do + expect(@json[:name]).to eql(@contact.name) + end + it "includes the :mbox" do + expect(@json[:mbox]).to eql(@contact.email) + end + + it "includes the :role" do + expect(@json[:role].first.ends_with?("Data_curation")).to eql(true) + end + + it "includes :affiliation" do + expect(@json[:affiliation][:name]).to eql(@contact.org.name) + end + + it "includes :contributor_id" do + expect(@json[:contributor_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contributor_id][:identifier]).to eql(@ident.value) + end + it "ignores non-orcid identifiers :contributor_id" do + scheme = create(:identifier_scheme, name: "shibboleth") + create(:identifier, value: Faker::Lorem.word, identifiable: @contact, + identifier_scheme: scheme) + @contact.reload + expect(@json[:contributor_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contributor_id][:identifier]).to eql(@ident.value) + end + end + + describe "includes all of the Contact attributes" do + before(:each) do + render partial: "api/v1/contributors/show", locals: { contributor: @contact, + is_contact: true } + @json = JSON.parse(rendered).with_indifferent_access + end + + it "includes the :name" do + expect(@json[:name]).to eql(@contact.name) + end + it "includes the :mbox" do + expect(@json[:mbox]).to eql(@contact.email) + end + + it "does NOT include the :role" do + expect(@json[:role]).to eql(nil) + end + + it "includes :affiliation" do + expect(@json[:affiliation][:name]).to eql(@contact.org.name) + end + + it "includes :contact_id" do + expect(@json[:contact_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contact_id][:identifier]).to eql(@ident.value) + end + it "ignores non-orcid identifiers :contact_id" do + scheme = create(:identifier_scheme, name: "shibboleth") + create(:identifier, value: Faker::Lorem.word, identifiable: @contact, + identifier_scheme: scheme) + @contact.reload + expect(@json[:contact_id][:type]).to eql(@ident.identifier_format) + expect(@json[:contact_id][:identifier]).to eql(@ident.value) + end + end + +end diff --git a/spec/views/api/v1/datasets/_show.json.jbuilder_spec.rb b/spec/views/api/v1/datasets/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..5f9bd63a40 --- /dev/null +++ b/spec/views/api/v1/datasets/_show.json.jbuilder_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/datasets/_show.json.jbuilder" do + + before(:each) do + # TODO: Implement this once the Dataset models are in place + @plan = create(:plan) + render partial: "api/v1/datasets/show", locals: { plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe "includes all of the dataset attributes" do + it "includes :title" do + expect(@json[:title]).to eql("Generic Dataset") + end + it "includes :personal_data" do + expect(@json[:personal_data]).to eql("unknown") + end + it "includes :sensitive_data" do + expect(@json[:sensitive_data]).to eql("unknown") + end + it "includes :dataset_id" do + expect(@json[:dataset_id][:type]).to eql("url") + url = Rails.application.routes.url_helpers.api_v1_plan_url(@plan) + expect(@json[:dataset_id][:identifier]).to eql(url) + end + end + +end diff --git a/spec/views/api/v1/error.json.jbuilder_spec.rb b/spec/views/api/v1/error.json.jbuilder_spec.rb new file mode 100644 index 0000000000..de74a43ff3 --- /dev/null +++ b/spec/views/api/v1/error.json.jbuilder_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/error.json.jbuilder" do + + before(:each) do + @url = Faker::Internet.url + @code = [200, 400, 404, 500].sample + @errors = [Faker::Lorem.sentence, Faker::Lorem.sentence] + + assign :payload, { errors: @errors } + + @resp = OpenStruct.new(status: @code) + @req = Net::HTTPGenericRequest.new("GET", nil, nil, @url) + + render template: "api/v1/error", locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe "error responses from controllers" do + + it "renders the standard_response partial" do + expect(response).to render_template(partial: "api/v1/_standard_response") + end + + it ":items is an empty array" do + expect(@json[:items]).to eql([]) + end + + it ":errors contains an array of error messages" do + expect(@json[:errors]).to eql(@errors) + end + + end + +end diff --git a/spec/views/api/v1/identifiers/_show.json.jbuilder_spec.rb b/spec/views/api/v1/identifiers/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..d9116a3b21 --- /dev/null +++ b/spec/views/api/v1/identifiers/_show.json.jbuilder_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/identifiers/_show.json.jbuilder" do + + before(:each) do + @scheme = create(:identifier_scheme) + @identifier = create(:identifier, value: Faker::Lorem.word, + identifier_scheme: @scheme) + render partial: "api/v1/identifiers/show", locals: { identifier: @identifier } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe "includes all of the identifier attributes" do + it "includes :type" do + expect(@json[:type]).to eql(@identifier.identifier_format) + end + it "includes :identifier" do + expect(@json[:identifier]).to eql(@identifier.value) + end + end + +end diff --git a/spec/views/api/v1/orgs/_show.json.jbuilder_spec.rb b/spec/views/api/v1/orgs/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..fc77811862 --- /dev/null +++ b/spec/views/api/v1/orgs/_show.json.jbuilder_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/orgs/_show.json.jbuilder" do + + before(:each) do + scheme = create(:identifier_scheme, name: "ror") + @org = create(:org) + @ident = create(:identifier, value: Faker::Lorem.word, identifiable: @org, + identifier_scheme: scheme) + @org.reload + render partial: "api/v1/orgs/show", locals: { org: @org } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe "includes all of the org attributes" do + it "includes :name" do + expect(@json[:name]).to eql(@org.name) + end + it "includes :abbreviation" do + expect(@json[:abbreviation]).to eql(@org.abbreviation) + end + it "includes :region" do + expect(@json[:region]).to eql(@org.region.abbreviation) + end + it "includes :affiliation_id" do + expect(@json[:affiliation_id][:type]).to eql(@ident.identifier_format) + expect(@json[:affiliation_id][:identifier]).to eql(@ident.value) + end + it "uses the ROR over the FundRef :affiliation_id" do + scheme = create(:identifier_scheme, name: "fundref") + create(:identifier, value: Faker::Lorem.word, identifiable: @org, + identifier_scheme: scheme) + @org.reload + expect(@json[:affiliation_id][:type]).to eql(@ident.identifier_format) + expect(@json[:affiliation_id][:identifier]).to eql(@ident.value) + end + end + +end diff --git a/spec/views/api/v1/plans/_cost.json.jbuilder_spec.rb b/spec/views/api/v1/plans/_cost.json.jbuilder_spec.rb new file mode 100644 index 0000000000..415b8b7df2 --- /dev/null +++ b/spec/views/api/v1/plans/_cost.json.jbuilder_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/plans/_cost.json.jbuilder" do + + before(:each) do + # TODO: Implement this once the Currency question and Cost theme are in place + # and the PlanPresenter is extracting the info + @cost = { + title: Faker::Lorem.sentence, + description: Faker::Lorem.paragraph, + currency_code: Faker::Currency.code, + value: Faker::Number.decimal(l_digits: 2) + }.with_indifferent_access + + render partial: "api/v1/plans/cost", locals: { cost: @cost } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe "includes all of the cost attributes" do + it "includes :title" do + expect(@json[:title]).to eql(@cost[:title]) + end + it "includes :description" do + expect(@json[:description]).to eql(@cost[:description]) + end + it "includes :currency_code" do + expect(@json[:currency_code]).to eql(@cost[:currency_code]) + end + it "includes :value" do + expect(@json[:value]).to eql(@cost[:value]) + end + end + +end diff --git a/spec/views/api/v1/plans/_funding.json.jbuilder_spec.rb b/spec/views/api/v1/plans/_funding.json.jbuilder_spec.rb new file mode 100644 index 0000000000..3f200f6274 --- /dev/null +++ b/spec/views/api/v1/plans/_funding.json.jbuilder_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/plans/_funding.json.jbuilder" do + + before(:each) do + @funder = create(:org, :funder) + create(:identifier, identifiable: @funder, + identifier_scheme: create(:identifier_scheme, name: "fundref")) + @funder.reload + @plan = create(:plan, funder: @funder) + @grant = create(:identifier, identifiable: @plan) + @plan.update(grant_id: @grant.id) + @plan.reload + + render partial: "api/v1/plans/funding", locals: { plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe "includes all of the funding attributes" do + it "includes :name" do + expect(@json[:name]).to eql(@funder.name) + end + it "includes :funding_status" do + expected = Api::V1::FundingPresenter.status(plan: @plan) + expect(@json[:funding_status]).to eql(expected) + end + it "includes :funder_ids" do + id = @funder.identifiers.first + expect(@json[:funder_id][:type]).to eql(id.identifier_format) + expect(@json[:funder_id][:identifier]).to eql(id.value) + end + it "includes :grant_ids" do + expect(@json[:grant_id][:type]).to eql(@grant.identifier_format) + expect(@json[:grant_id][:identifier]).to eql(@grant.value) + end + end + +end diff --git a/spec/views/api/v1/plans/_project.json.jbuilder_spec.rb b/spec/views/api/v1/plans/_project.json.jbuilder_spec.rb new file mode 100644 index 0000000000..6e73ff20a7 --- /dev/null +++ b/spec/views/api/v1/plans/_project.json.jbuilder_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/plans/_project.json.jbuilder" do + + before(:each) do + @plan = build(:plan, funder: build(:org, :funder)) + render partial: "api/v1/plans/project", locals: { plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe "includes all of the project attributes" do + it "includes :title" do + expect(@json[:title]).to eql(@plan.title) + end + it "includes :description" do + expect(@json[:description]).to eql(@plan.description) + end + it "includes :start" do + expect(@json[:start]).to eql(@plan.start_date.to_formatted_s(:iso8601)) + end + it "includes :end" do + expect(@json[:end]).to eql(@plan.end_date.to_formatted_s(:iso8601)) + end + + it "includes the :funder" do + expect(@json[:funding].length).to eql(1) + end + end + +end diff --git a/spec/views/api/v1/plans/_show.json.jbuilder_spec.rb b/spec/views/api/v1/plans/_show.json.jbuilder_spec.rb new file mode 100644 index 0000000000..19b75d8aef --- /dev/null +++ b/spec/views/api/v1/plans/_show.json.jbuilder_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/plans/_show.json.jbuilder" do + + before(:each) do + @plan = create(:plan) + @data_contact = create(:contributor, data_curation: true, plan: @plan) + @pi = create(:contributor, investigation: true, plan: @plan) + @plan.contributors = [@data_contact, @pi] + create(:identifier, identifiable: @plan) + @plan.reload + end + + describe "includes all of the DMP attributes" do + + before(:each) do + render partial: "api/v1/plans/show", locals: { plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + it "includes the :title" do + expect(@json[:title]).to eql(@plan.title) + end + it "includes the :description" do + expect(@json[:description]).to eql(@plan.description) + end + it "includes the :language" do + expected = Api::V1::LanguagePresenter.three_char_code( + lang: ApplicationService.default_language + ) + expect(@json[:language]).to eql(expected) + end + it "includes the :created" do + expect(@json[:created]).to eql(@plan.created_at.to_formatted_s(:iso8601)) + end + it "includes the :modified" do + expect(@json[:modified]).to eql(@plan.updated_at.to_formatted_s(:iso8601)) + end + + it "returns the URL of the plan as the :dmp_id if no DOI is defined" do + expected = Rails.application.routes.url_helpers.api_v1_plan_url(@plan) + expect(@json[:dmp_id][:type]).to eql("url") + expect(@json[:dmp_id][:identifier]).to eql(expected) + end + + it "includes the :contact" do + expect(@json[:contact][:mbox]).to eql(@data_contact.email) + end + it "includes the :contributors" do + emails = @json[:contributor].collect { |c| c[:mbox] } + expect(emails.include?(@pi.email)).to eql(true) + end + + # TODO: make sure this is working once the new Cost theme and Currency + # question type have been implemented + it "includes the :cost" do + expect(@json[:cost]).to eql(nil) + end + + it "includes the :project" do + expect(@json[:project].length).to eql(1) + end + it "includes the :dataset" do + expect(@json[:dataset].length).to eql(1) + end + it "includes the :extension" do + expect(@json[:extension].length).to eql(1) + end + it "includes the :template in :extension" do + app = ApplicationService.application_name.split("-").first + @section = @json[:extension].select { |hash| hash.keys.first == app }.first + expect(@section[app.to_sym].present?).to eql(true) + tmplt = @plan.template + expect(@section[app.to_sym][:template][:id]).to eql(tmplt.id) + expect(@section[app.to_sym][:template][:title]).to eql(tmplt.title) + end + + end + + describe "when the system mints DOIs" do + before(:each) do + @doi = create(:identifier, value: "10.9999/123abc.zy/x23", identifiable: @plan) + @plan.reload + render partial: "api/v1/plans/show", locals: { plan: @plan } + @json = JSON.parse(rendered).with_indifferent_access + end + + it "returns the DOI for the :dmp_id if one is present" do + expect(@json[:dmp_id][:type]).to eql("doi") + expect(@json[:dmp_id][:identifier]).to eql(@doi.value) + end + end + +end diff --git a/spec/views/api/v1/templates/index.json.jbuilder_spec.rb b/spec/views/api/v1/templates/index.json.jbuilder_spec.rb new file mode 100644 index 0000000000..1fb6f8057a --- /dev/null +++ b/spec/views/api/v1/templates/index.json.jbuilder_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/templates/index.json.jbuilder" do + + before(:each) do + @application = Faker::Lorem.word + @url = Faker::Internet.url + @code = [200, 400, 404, 500].sample + + @template1 = create(:template, :published, org: create(:org)) + @template2 = create(:template, :published) + + assign :application, @application + assign :items, [@template1, @template2] + + @resp = OpenStruct.new(status: @code) + @req = Net::HTTPGenericRequest.new("GET", nil, nil, @url) + + render template: "api/v1/templates/index", + locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + end + + it "includes both templates" do + expect(@json[:items].length).to eql(2) + end + + describe "includes all of the Template attributes" do + before(:each) do + @template = @json[:items].first[:dmp_template] + end + + it "includes the :title" do + expect(@template[:title]).to eql(@template1.title) + end + it "includes the :description" do + expect(@template[:description]).to eql(@template1.description) + end + it "includes the :version" do + expect(@template[:version]).to eql(@template1.version) + end + it "includes the :created" do + expect(@template[:created]).to eql(@template1.created_at.to_formatted_s(:iso8601)) + end + it "includes the :modified" do + expect(@template[:modified]).to eql(@template1.updated_at.to_formatted_s(:iso8601)) + end + it "includes the :affiliation" do + expect(@template[:affiliation][:name]).to eql(@template1.org.name) + end + it "includes the :template_ids" do + expect(@template[:template_id][:identifier]).to eql(@template1.id.to_s) + expect(@template[:template_id][:type]).to eql("other") + end + end + +end diff --git a/spec/views/api/v1/token.json.jbuilder_spec.rb b/spec/views/api/v1/token.json.jbuilder_spec.rb new file mode 100644 index 0000000000..d05c39c61d --- /dev/null +++ b/spec/views/api/v1/token.json.jbuilder_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "api/v1/token.json.jbuilder" do + + before(:each) do + @url = Faker::Internet.url + @payload = { client_id: "foo" } + @token = Api::V1::Auth::Jwt::JsonWebToken.encode(payload: @payload) + @exp = @payload[:exp] + @type = Faker::Lorem.word.capitalize + + assign :token, @token + assign :expiration, @exp + assign :token_type, @type + + @resp = OpenStruct.new(status: 200) + @req = Net::HTTPGenericRequest.new("GET", nil, nil, @url) + + render template: "api/v1/token", + locals: { response: @resp, request: @req } + @json = JSON.parse(rendered).with_indifferent_access + end + + describe "authentication responses from controllers" do + it "renders the token template" do + expect(response).to render_template("api/v1/token") + end + it ":access_token is the JSON Web Token" do + expect(@json[:access_token]).to eql(@token) + end + it ":token_type is set" do + expect(@json[:token_type]).to eql(@type) + end + it ":expires_in is set" do + expect(@json[:expires_in]).to eql(@exp) + end + it ":created_at is set" do + expect(@json[:created_at].present?).to eql(true) + end + end + +end diff --git a/spec/views/branded/contact_us/contacts/_new_right.html.erb_spec.rb b/spec/views/branded/contact_us/contacts/_new_right.html.erb_spec.rb new file mode 100644 index 0000000000..82d612e402 --- /dev/null +++ b/spec/views/branded/contact_us/contacts/_new_right.html.erb_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "contact_us/contacts/_new_right.html.erb" do + + it "renders the panel correctly" do + controller.prepend_view_path "app/views/branded" + # rubocop:disable Metrics/LineLength + org = { + name: Faker::Company.name, + address_line1: Faker::Address.street_address, + address_line2: Faker::Address.secondary_address, + address_line3: Faker::Address.community, + address_line4: "#{Faker::Address.city}, #{Faker::Address.state_abbr} #{Faker::Address.zip_code}", + address_country: Faker::Address.country, + google_maps_link: Faker::Internet.url + } + # rubocop:enable Metrics/LineLength + Rails.configuration.branding[:organisation] = org + render + expect(rendered.include?("#{org[:name]}")).to eql(true) + expect(rendered.include?("#{org[:address_line1]}
    ")).to eql(true) + expect(rendered.include?("#{org[:address_line2]}
    ")).to eql(true) + expect(rendered.include?("#{org[:address_line3]}
    ")).to eql(true) + expect(rendered.include?("#{org[:address_line4]}
    ")).to eql(true) + expect(rendered.include?("#{org[:address_country]}
    ")).to eql(true) + expect(rendered.include?("Users

    ")).to eql(true) + expect(rendered.include?("

    Plans

    ")).to eql(true) + expect(rendered.include?("

    Participating Institutions

    ")).to eql(true) + expect(rendered.include?("Top Templates")).to eql(true) + expect(rendered.include?("
  • #{top_five.first}
  • ")).to eql(true) + expect(rendered.include?("View the list of funder requirements")).to eql(true) + expect(rendered.include?("DMPTool News")).to eql(true) + expect(rendered.include?("News is currently unavailable")).to eql(true) + expect(rendered.include?("Go to the blog")).to eql(true) + expect(rendered.include?("RSS")).to eql(true) + end + +end diff --git a/spec/views/branded/layouts/_analytics.html.erb_spec.rb b/spec/views/branded/layouts/_analytics.html.erb_spec.rb new file mode 100644 index 0000000000..caafe6692e --- /dev/null +++ b/spec/views/branded/layouts/_analytics.html.erb_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_analytics.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + @keys = { + usersnap_key: SecureRandom.uuid, + google_analytics_key: SecureRandom.uuid + } + @expected_usersnap = "//api.usersnap.com/load/#{@keys[:usersnap_key]}.js" + gkey = @keys[:google_analytics_key] + @expected_google = "https://www.googletagmanager.com/gtag/js?id=#{gkey}" + end + + context "renders nothing" do + it "when Rails.configuration.branding[:keys] is empty" do + Rails.configuration.branding[:keys] = [] + render + expect(rendered.include?(@expected_usersnap)).to eql(false) + expect(rendered.include?(@expected_google)).to eql(false) + end + it "when :usersnap_key and :google_analytics_key are not present" do + Rails.configuration.branding[:keys] = [] + render + expect(rendered.include?(@expected_usersnap)).to eql(false) + expect(rendered.include?(@expected_google)).to eql(false) + end + it "when Rails.env.stage? and Rails.env.production? are false" do + Rails.configuration.branding[:keys] = @keys + Rails.env.stubs(:stage?).returns(false) + Rails.env.stubs(:production?).returns(false) + render + expect(rendered.include?(@expected_usersnap)).to eql(false) + expect(rendered.include?(@expected_google)).to eql(false) + end + end + + it "Rails.env.stage?" do + Rails.configuration.branding[:keys] = @keys + Rails.env.stubs(:stage?).returns(true) + render + expect(rendered.include?(@expected_usersnap)).to eql(true) + expect(rendered.include?(@expected_google)).to eql(true) + end + + it "Rails.env.production?" do + Rails.configuration.branding[:keys] = @keys + Rails.env.stubs(:production?).returns(true) + render + expect(rendered.include?(@expected_usersnap)).to eql(false) + expect(rendered.include?(@expected_google)).to eql(true) + end + +end diff --git a/spec/views/branded/layouts/_app_menu.html.erb_spec.rb b/spec/views/branded/layouts/_app_menu.html.erb_spec.rb new file mode 100644 index 0000000000..bd5884b946 --- /dev/null +++ b/spec/views/branded/layouts/_app_menu.html.erb_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_app_menu.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "renders nothing if user is not logged in" do + render + expect(rendered).to eql("") + end + + it "renders our version of the page" do + sign_in create(:user) + render + expect(response).to render_template(partial: "layouts/_app_menu_links") + end + +end diff --git a/spec/views/branded/layouts/_app_menu_links.html.erb_spec.rb b/spec/views/branded/layouts/_app_menu_links.html.erb_spec.rb new file mode 100644 index 0000000000..41a8a9c4b6 --- /dev/null +++ b/spec/views/branded/layouts/_app_menu_links.html.erb_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_app_menu_links.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "renders correctly for a regular user" do + sign_in create(:user) + render + expect(rendered.include?("My Dashboard")).to eql(true) + expect(rendered.include?("Create plan")).to eql(true) + expect(rendered.include?("Admin Features")).to eql(false) + end + + it "renders correctly for a org admin" do + sign_in create(:user, :org_admin) + render + expect(rendered.include?("My Dashboard")).to eql(true) + expect(rendered.include?("Create plan")).to eql(true) + expect(rendered.include?("Admin Features")).to eql(true) + expect(rendered.include?("Organisations")).to eql(false) + expect(rendered.include?("Organisation details")).to eql(true) + expect(rendered.include?("Users")).to eql(true) + expect(rendered.include?("Plans")).to eql(true) + expect(rendered.include?("Usage")).to eql(true) + expect(rendered.include?("Templates")).to eql(true) + expect(rendered.include?("Guidance")).to eql(true) + expect(rendered.include?("Themes")).to eql(false) + expect(rendered.include?("Notifications")).to eql(false) + # TODO: enable this one once that code is merged in + # expect(rendered.include?("Api Clients")).to eql(false) + end + + it "renders correctly for a super admin" do + sign_in create(:user, :super_admin) + render + expect(rendered.include?("My Dashboard")).to eql(true) + expect(rendered.include?("Create plan")).to eql(true) + expect(rendered.include?("Admin Features")).to eql(true) + expect(rendered.include?("Organisations")).to eql(true) + expect(rendered.include?("Organisation details")).to eql(false) + expect(rendered.include?("Users")).to eql(true) + expect(rendered.include?("Plans")).to eql(true) + expect(rendered.include?("Usage")).to eql(true) + expect(rendered.include?("Templates")).to eql(true) + expect(rendered.include?("Guidance")).to eql(true) + expect(rendered.include?("Themes")).to eql(true) + expect(rendered.include?("Notifications")).to eql(true) + # TODO: enable this one once that code is merged in + # expect(rendered.include?("Api Clients")).to eql(true) + end + +end diff --git a/spec/views/branded/layouts/_branding.html.erb_spec.rb b/spec/views/branded/layouts/_branding.html.erb_spec.rb new file mode 100644 index 0000000000..8080be48d2 --- /dev/null +++ b/spec/views/branded/layouts/_branding.html.erb_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_branding.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "renders correctly when user is NOT logged in" do + render + expect(rendered.include?("branding-name")).to eql(false) + expect(response).to render_template(partial: "layouts/_logo") + expect(response).not_to render_template(partial: "layouts/_org_links") + end + + it "renders correctly when user is logged in" do + sign_in create(:user, org: create(:org)) + render + expect(rendered.include?("branding-name")).to eql(true) + expect(response).to render_template(partial: "layouts/_logo") + expect(response).to render_template(partial: "layouts/_org_links") + end + +end diff --git a/spec/views/branded/layouts/_constants.html.erb_spec.rb b/spec/views/branded/layouts/_constants.html.erb_spec.rb new file mode 100644 index 0000000000..9b8fd47b98 --- /dev/null +++ b/spec/views/branded/layouts/_constants.html.erb_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_constants.html.erb" do + + it "renders all of the constants properly" do + controller.prepend_view_path "app/views/branded" + render + expect(rendered.include?("HOST")).to eql(true) + expect(rendered.include?("PASSWORD_MIN_LENGTH")).to eql(true) + expect(rendered.include?("PASSWORD_MAX_LENGTH")).to eql(true) + expect(rendered.include?("MAX_NUMBER_ORG_URLS")).to eql(true) + expect(rendered.include?("MAX_NUMBER_GUIDANCE_SELECTIONS")).to eql(true) + expect(rendered.include?("REQUIRED_FIELD_TEXT")).to eql(true) + expect(rendered.include?("SHOW_PASSWORD_MESSAGE")).to eql(true) + expect(rendered.include?("SHOW_SELECT_ORG_MESSAGE")).to eql(true) + expect(rendered.include?("SHOW_OTHER_ORG_MESSAGE")).to eql(true) + expect(rendered.include?("VALIDATION_MESSAGE_PASSWORDS_MATCH")).to eql(true) + expect(rendered.include?("PLAN_VISIBILITY_WHEN_TEST")).to eql(true) + expect(rendered.include?("PLAN_VISIBILITY_WHEN_NOT_TEST")).to eql(true) + expect(rendered.include?("PLAN_VISIBILITY_WHEN_NOT_TEST_TOOLTIP")).to eql(true) + expect(rendered.include?("SHIBBOLETH_DISCOVERY_SERVICE_HIDE_LIST")).to eql(true) + expect(rendered.include?("SHIBBOLETH_DISCOVERY_SERVICE_SHOW_LIST")).to eql(true) + expect(rendered.include?("NO_TEMPLATE_FOUND_ERROR")).to eql(true) + expect(rendered.include?("NEW_PLAN_DISABLED_TOOLTIP")).to eql(true) + expect(rendered.include?("OPENS_IN_A_NEW_WINDOW_TEXT")).to eql(true) + expect(rendered.include?("AJAX_LOADING")).to eql(true) + expect(rendered.include?("AJAX_UNABLE_TO_LOAD_TEMPLATE_SECTION")).to eql(true) + expect( + rendered.include?("AJAX_UNABLE_TO_LOAD_TEMPLATE_SECTION_QUESTION") + ).to eql(true) + expect(rendered.include?("AUTOCOMPLETE_ARIA_HELPER")).to eql(true) + expect(rendered.include?("AUTOCOMPLETE_ARIA_HELPER_EMPTY")).to eql(true) + expect(rendered.include?("js-constants")).to eql(true) + end + +end diff --git a/spec/views/branded/layouts/_fixed_menu.html.erb_spec.rb b/spec/views/branded/layouts/_fixed_menu.html.erb_spec.rb new file mode 100644 index 0000000000..b9246803d1 --- /dev/null +++ b/spec/views/branded/layouts/_fixed_menu.html.erb_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_footer.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "renders our version of the page" do + Rails.configuration.x.dmptool.version = Faker::Number.number + render + expect(rendered.include?("About")).to eql(true) + expect(rendered.include?("Terms of use")).to eql(true) + expect(rendered.include?("Privacy statement")).to eql(true) + expect(rendered.include?("Accessibility")).to eql(true) + expect(rendered.include?("Github")).to eql(true) + expect(rendered.include?("Contact us")).to eql(true) + expect(rendered.include?("Twitter")).to eql(true) + expect(rendered.include?("RSS")).to eql(true) + expect(rendered.include?("DMPTool_logo_blue_shades_v1b3b_no_tag.svg")).to eql(true) + expect(rendered.include?("DMPTool is a service of")).to eql(true) + expect(rendered.include?("The Regents of the University of California")).to eql(true) + expect(rendered.include?("Version:")).to eql(true) + end + +end diff --git a/spec/views/branded/layouts/_footer.html.erb_spec.rb b/spec/views/branded/layouts/_footer.html.erb_spec.rb new file mode 100644 index 0000000000..b9246803d1 --- /dev/null +++ b/spec/views/branded/layouts/_footer.html.erb_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_footer.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "renders our version of the page" do + Rails.configuration.x.dmptool.version = Faker::Number.number + render + expect(rendered.include?("About")).to eql(true) + expect(rendered.include?("Terms of use")).to eql(true) + expect(rendered.include?("Privacy statement")).to eql(true) + expect(rendered.include?("Accessibility")).to eql(true) + expect(rendered.include?("Github")).to eql(true) + expect(rendered.include?("Contact us")).to eql(true) + expect(rendered.include?("Twitter")).to eql(true) + expect(rendered.include?("RSS")).to eql(true) + expect(rendered.include?("DMPTool_logo_blue_shades_v1b3b_no_tag.svg")).to eql(true) + expect(rendered.include?("DMPTool is a service of")).to eql(true) + expect(rendered.include?("The Regents of the University of California")).to eql(true) + expect(rendered.include?("Version:")).to eql(true) + end + +end diff --git a/spec/views/branded/layouts/_header.html.erb_spec.rb b/spec/views/branded/layouts/_header.html.erb_spec.rb new file mode 100644 index 0000000000..9fb1e95c60 --- /dev/null +++ b/spec/views/branded/layouts/_header.html.erb_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_header.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "renders correctly when user is NOT logged in" do + render + expect(response).to render_template(partial: "layouts/_fixed_menu") + expect(response).to render_template(partial: "layouts/mobile/_fixed_menu") + expect(response).to render_template(partial: "layouts/_branding") + expect(response).not_to render_template(partial: "layouts/_app_menu") + end + + it "renders correctly when user is logged in" do + sign_in create(:user, org: create(:org)) + render + expect(response).to render_template(partial: "layouts/_fixed_menu") + expect(response).to render_template(partial: "layouts/mobile/_fixed_menu") + expect(response).to render_template(partial: "layouts/_branding") + expect(response).to render_template(partial: "layouts/_app_menu") + end + +end diff --git a/spec/views/branded/layouts/_homepage_image.html.erb_spec.rb b/spec/views/branded/layouts/_homepage_image.html.erb_spec.rb new file mode 100644 index 0000000000..986c68be7e --- /dev/null +++ b/spec/views/branded/layouts/_homepage_image.html.erb_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_homepage_image.html.erb" do + + it "renders correctly" do + controller.prepend_view_path "app/views/branded" + render + expect(rendered.include?("Welcome to the DMPTool")).to eql(true) + expect(rendered.include?("Get started")).to eql(true) + end + +end diff --git a/spec/views/branded/layouts/_language.html.erb_spec.rb b/spec/views/branded/layouts/_language.html.erb_spec.rb new file mode 100644 index 0000000000..ee37a948e7 --- /dev/null +++ b/spec/views/branded/layouts/_language.html.erb_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_language_menu.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "renders nothing if only one Language is available" do + render + expect(rendered.include?("language-menu-button")).to eql(false) + end + + it "renders correctly when multiple Languages are available" do + 3.times { create(:language) } + render + expect(rendered.include?("language-menu-button")).to eql(true) + Language.all.each { |l| expect(rendered.include?(l.name)).to eql(true) } + end + +end diff --git a/spec/views/branded/layouts/_learn_menu.html.erb_spec.rb b/spec/views/branded/layouts/_learn_menu.html.erb_spec.rb new file mode 100644 index 0000000000..b4344eaaa3 --- /dev/null +++ b/spec/views/branded/layouts/_learn_menu.html.erb_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_learn_menu.html.erb" do + + it "renders correctly" do + controller.prepend_view_path "app/views/branded" + render + expect(rendered.include?("learn-menu-button")).to eql(true) + expect(rendered.include?("Funder Requirements")).to eql(true) + expect(rendered.include?("Public Plans")).to eql(true) + expect(rendered.include?("Participating Institutions")).to eql(true) + expect(rendered.include?("FAQ")).to eql(true) + expect(rendered.include?("Quick Start Guide")).to eql(true) + expect(rendered.include?("Data Management General Guidance")).to eql(true) + expect(rendered.include?("For Administrators")).to eql(true) + expect(rendered.include?("Promote the DMPTool")).to eql(true) + end + +end diff --git a/spec/views/branded/layouts/_logo.html.erb_spec.rb b/spec/views/branded/layouts/_logo.html.erb_spec.rb new file mode 100644 index 0000000000..f85450e1f9 --- /dev/null +++ b/spec/views/branded/layouts/_logo.html.erb_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_logo.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + # stub the logo method + @org = create(:org) + logo = OpenStruct.new({ present?: true }) + logo.stubs(:thumb).returns(OpenStruct.new({ url: Faker::Internet.url })) + Org.any_instance.stubs(:logo).returns(logo) + end + + it "renders correctly when user is NOT logged in" do + render + expect(rendered.include?("org-logo")).to eql(false) + expect(rendered.include?("app-logo")).to eql(true) + expect(rendered.include?("DMPTool_logo_blue_shades_v1b3b.svg")).to eql(true) + end + + it "renders correctly when user is logged in" do + sign_in create(:user, org: @org) + render + +p rendered + + expect(rendered.include?("org-logo")).to eql(true) + expect(rendered.include?(@org.name)).to eql(true) + expect(rendered.include?("app-logo")).to eql(false) + expect(rendered.include?("DMPTool_logo_blue_shades_v1b3b.svg")).to eql(false) + end + +end diff --git a/spec/views/branded/layouts/_notifications.html.erb_spec.rb b/spec/views/branded/layouts/_notifications.html.erb_spec.rb new file mode 100644 index 0000000000..752948b6b3 --- /dev/null +++ b/spec/views/branded/layouts/_notifications.html.erb_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_notifications.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + context "flash notifications" do + it "renders correctly when there is no flash[:alert] or flash[:notice]" do + render + expect(rendered.include?("notification-area")).to eql(true) + expect(rendered.include?("Notice:")).to eql(true) + expect(rendered.include?("alert-info")).to eql(true) + expect(rendered.include?("fa-check-circle")).to eql(true) + expect(rendered.include?("")).to eql(true) + expect(rendered.include?("Error:")).to eql(false) + expect(rendered.include?("alert-warning")).to eql(false) + expect(rendered.include?("fa-times-circle")).to eql(false) + end + + it "renders correctly when there is a flash[:notice]" do + flash[:notice] = Faker::Lorem.sentence + render + expect(rendered.include?("notification-area")).to eql(true) + expect(rendered.include?("Notice:")).to eql(true) + expect(rendered.include?("alert-info")).to eql(true) + expect(rendered.include?("fa-check-circle")).to eql(true) + expect(rendered.include?("#{flash[:notice]}")).to eql(true) + expect(rendered.include?("Error:")).to eql(false) + expect(rendered.include?("alert-warning")).to eql(false) + expect(rendered.include?("fa-times-circle")).to eql(false) + end + + it "renders correctly when there is an flash[:alert]" do + flash[:alert] = Faker::Lorem.sentence + render + expect(rendered.include?("notification-area")).to eql(true) + expect(rendered.include?("Notice:")).to eql(false) + expect(rendered.include?("alert-info")).to eql(false) + expect(rendered.include?("fa-check-circle")).to eql(false) + expect(rendered.include?("Error:")).to eql(true) + expect(rendered.include?("alert-warning")).to eql(true) + expect(rendered.include?("fa-times-circle")).to eql(true) + expect(rendered.include?("#{flash[:alert]}")).to eql(true) + end + end + + context "global notifications" do + it "displays nothing when user is not logged in and there are no messages" do + render + expect(rendered.include?("global-notification-area\">\n

    ")).to eql(true) + end + + it "displays nothing when user is not logged in and no enabled messages" do + create(:notification, dismissable: false, enabled: false) + render + expect(rendered.include?("global-notification-area\">\n")).to eql(true) + end + + it "displays the non-dismissable notification when user not logged in" do + notification = create(:notification, dismissable: false, enabled: true) + render + expect(rendered.include?("global-notification-area")).to eql(true) + expect(rendered.include?(notification.body)).to eql(true) + expect(rendered.include?("notification_id=#{notification.id}")).to eql(false) + end + + it "displays the non-dismissable notification when user is logged in" do + notification = create(:notification, dismissable: false, enabled: true) + sign_in create(:user) + render + expect(rendered.include?("global-notification-area")).to eql(true) + expect(rendered.include?(notification.body)).to eql(true) + expect(rendered.include?("notification_id=#{notification.id}")).to eql(false) + end + + it "does not display the dismissable notification when user not logged in" do + create(:notification, dismissable: true, enabled: true) + render + expect(rendered.include?("global-notification-area\">\n")).to eql(true) + end + + it "displays the dismissable notification when user is logged in" do + notification = create(:notification, dismissable: true, enabled: true) + sign_in create(:user) + render + expect(rendered.include?("global-notification-area")).to eql(true) + expect(rendered.include?(notification.body)).to eql(true) + expect(rendered.include?("notification_id=#{notification.id}")).to eql(true) + end + + it "does not display the dismissable notification when user has already dismissed" do + notification = create(:notification, dismissable: true, enabled: true) + user = create(:user) + notification.users << user + notification.save + sign_in user + render + expect(rendered.include?("global-notification-area\">\n")).to eql(true) + end + end + +end diff --git a/spec/views/branded/layouts/_org_links.html.erb_spec.rb b/spec/views/branded/layouts/_org_links.html.erb_spec.rb new file mode 100644 index 0000000000..8eb19ec05a --- /dev/null +++ b/spec/views/branded/layouts/_org_links.html.erb_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_org_links.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "displays nothing if user is not logged in" do + render + expect(rendered).to eql("") + end + + it "correctly displays the Org links" do + links = [{ text: Faker::Lorem.word, link: Faker::Internet.url }] + org = create(:org, links: { org: links }) + sign_in create(:user, org: org) + render + expect(rendered.include?(links.first[:text])).to eql(true) + expect(rendered.include?(links.first[:link])).to eql(true) + end + + it "correctly displays the Org contact email" do + org = create(:org, contact_email: Faker::Internet.email) + sign_in create(:user, org: org) + render + expect(rendered.include?("mailto:#{org.contact_email}")).to eql(true) + expect(rendered.include?(org.contact_name)).to eql(true) + end + +end diff --git a/spec/views/branded/layouts/_profile_menu.html.erb_spec.rb b/spec/views/branded/layouts/_profile_menu.html.erb_spec.rb new file mode 100644 index 0000000000..7595decd4a --- /dev/null +++ b/spec/views/branded/layouts/_profile_menu.html.erb_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/_profile_menu.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "renders nothing when user is NOT logged in" do + render + expect(rendered).to eql("") + end + + it "renders correctly when user is logged in" do + user = create(:user) + sign_in user + render + expect(rendered.include?(user.name(false))).to eql(true) + expect(rendered.include?("Edit profile")).to eql(true) + expect(rendered.include?("Logout")).to eql(true) + end + +end diff --git a/spec/views/branded/layouts/application.html.erb_spec.rb b/spec/views/branded/layouts/application.html.erb_spec.rb new file mode 100644 index 0000000000..80c75697e3 --- /dev/null +++ b/spec/views/branded/layouts/application.html.erb_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/application.html.erb" do + + before(:each) do + @app_name = Faker::Company.name + Rails.configuration.branding[:application][:name] = @app_name + controller.prepend_view_path "app/views/branded" + end + + it "displays correctly when user is not logged in and Shib is NOT enabled" do + Rails.application.config.shibboleth_use_filtered_discovery_service = false + render + expect(response).to render_template(partial: "layouts/_analytics") + expect(rendered.include?("#{@app_name}")).to eql(true) + expect(rendered.include?("Skip to main content")).to eql(true) + expect(rendered.include?("<div class=\"dmptool\">")).to eql(true) + expect(response).to render_template(partial: "layouts/_header") + expect(response).to render_template(partial: "layouts/_notifications") + expect(rendered.include?("<div class=\"content\">")).to eql(true) + expect(response).not_to render_template(partial: "shared/_shib_ds_form") + expect(response).to render_template(partial: "shared/_signin_create_form") + expect(response).to render_template(partial: "shared/_get_started") + expect(rendered.include?("<footer class=blue>")).to eql(false) + expect(response).to render_template(partial: "layouts/_footer") + expect(response).to render_template(partial: "layouts/mobile/_footer") + expect(response).to render_template(partial: "layouts/_constants") + end + + it "displays correctly when user is not logged in and Shib is enabled" do + Rails.application.config.shibboleth_use_filtered_discovery_service = true + render + expect(response).to render_template(partial: "layouts/_analytics") + expect(rendered.include?("<title>#{@app_name}")).to eql(true) + expect(rendered.include?("Skip to main content")).to eql(true) + expect(rendered.include?("<div class=\"dmptool\">")).to eql(true) + expect(response).to render_template(partial: "layouts/_header") + expect(response).to render_template(partial: "layouts/_notifications") + expect(rendered.include?("<div class=\"content\">")).to eql(true) + expect(response).to render_template(partial: "shared/_shib_ds_form") + expect(response).to render_template(partial: "shared/_signin_create_form") + expect(response).to render_template(partial: "shared/_get_started") + expect(rendered.include?("<footer class=blue>")).to eql(false) + expect(response).to render_template(partial: "layouts/_footer") + expect(response).to render_template(partial: "layouts/mobile/_footer") + expect(response).to render_template(partial: "layouts/_constants") + end + + it "displays correctly when user is logged in" do + Rails.application.config.shibboleth_use_filtered_discovery_service = true + sign_in create(:user) + render + expect(response).to render_template(partial: "layouts/_analytics") + expect(rendered.include?("<title>#{@app_name}")).to eql(true) + expect(rendered.include?("Skip to main content")).to eql(true) + expect(rendered.include?("<div class=\"dmptool\">")).to eql(true) + expect(response).to render_template(partial: "layouts/_header") + expect(response).to render_template(partial: "layouts/_notifications") + expect(rendered.include?("<div class=\"content\">")).to eql(true) + expect(response).not_to render_template(partial: "shared/_shib_ds_form") + expect(response).not_to render_template(partial: "shared/_signin_create_form") + expect(response).not_to render_template(partial: "shared/_get_started") + expect(rendered.include?("<footer class=blue>")).to eql(true) + expect(response).to render_template(partial: "layouts/_footer") + expect(response).to render_template(partial: "layouts/mobile/_footer") + expect(response).to render_template(partial: "layouts/_constants") + end + +end diff --git a/spec/views/branded/layouts/mobile/_fixed_menu.html.erb_spec.rb b/spec/views/branded/layouts/mobile/_fixed_menu.html.erb_spec.rb new file mode 100644 index 0000000000..6bce8e3c51 --- /dev/null +++ b/spec/views/branded/layouts/mobile/_fixed_menu.html.erb_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/mobile/_fixed_menu.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + context "user not logged in when Shibboleth is enabled" do + it "renders our version of the page" do + Rails.application.config.shibboleth_enabled = true + render + expect(rendered.include?("DMPTool_logo_blue_shades_v1b3b.svg")).to eql(true) + expect(rendered.include?("Sign in")).to eql(true) + expect(rendered.include?("Sign in through an affiliated institution")).to eql(true) + expect(rendered.include?("Sign in with your email address")).to eql(true) + expect(rendered.include?("Create account with email address")).to eql(true) + expect(response).to render_template(partial: "layouts/_learn_menu") + expect(response).not_to render_template(partial: "layouts/_app_menu_links") + expect(response).not_to render_template(partial: "layouts/_org_links") + expect(response).not_to render_template(partial: "layouts/_profile_menu") + expect(response).to render_template(partial: "layouts/_language_menu") + end + end + + context "user not logged in when Shibboleth is NOT enabled" do + it "renders our version of the page" do + Rails.application.config.shibboleth_enabled = false + render + expect(rendered.include?("DMPTool_logo_blue_shades_v1b3b.svg")).to eql(true) + expect(rendered.include?("Sign in")).to eql(true) + expect(rendered.include?("Sign in through an affiliated institution")).to eql(false) + expect(rendered.include?("Sign in with your email address")).to eql(true) + expect(rendered.include?("Create account with email address")).to eql(true) + expect(response).to render_template(partial: "layouts/_learn_menu") + expect(response).not_to render_template(partial: "layouts/_app_menu_links") + expect(response).not_to render_template(partial: "layouts/_org_links") + expect(response).not_to render_template(partial: "layouts/_profile_menu") + expect(response).to render_template(partial: "layouts/_language_menu") + end + end + + context "user is logged in" do + it "renders our version of the page" do + user = create(:user, org: create(:org)) + sign_in user + render + expect(rendered.include?("<p><strong>#{user.org.name}</strong></p>")).to eql(true) + expect(rendered.include?(user.org.abbreviation)).to eql(true) + expect(rendered.include?("Sign in")).to eql(false) + expect(rendered.include?("Sign in through an affiliated institution")).to eql(false) + expect(rendered.include?("Sign in with your email address")).to eql(false) + expect(rendered.include?("Create account with email address")).to eql(false) + expect(response).to render_template(partial: "layouts/_learn_menu") + expect(response).to render_template(partial: "layouts/_app_menu_links") + expect(response).to render_template(partial: "layouts/_org_links") + expect(response).to render_template(partial: "layouts/_profile_menu") + expect(response).to render_template(partial: "layouts/_language_menu") + end + end + +end diff --git a/spec/views/branded/layouts/mobile/_footer.html.erb_spec.rb b/spec/views/branded/layouts/mobile/_footer.html.erb_spec.rb new file mode 100644 index 0000000000..4a6d2e89db --- /dev/null +++ b/spec/views/branded/layouts/mobile/_footer.html.erb_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "layouts/mobile/_footer.html.erb" do + + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "renders our version of the page" do + Rails.configuration.x.dmptool.version = Faker::Number.number + render + expect(rendered.include?("About")).to eql(true) + expect(rendered.include?("Terms of use")).to eql(true) + expect(rendered.include?("Privacy statement")).to eql(true) + expect(rendered.include?("Accessibility")).to eql(true) + expect(rendered.include?("Github")).to eql(true) + expect(rendered.include?("Contact us")).to eql(true) + expect(rendered.include?("Twitter")).to eql(true) + expect(rendered.include?("RSS")).to eql(true) + expect(rendered.include?("DMPTool_logo_blue_shades_v1b3b_no_tag.svg")).to eql(true) + expect(rendered.include?("DMPTool is a service of")).to eql(true) + expect(rendered.include?("The Regents of the University of California")).to eql(true) + expect(rendered.include?("Version:")).to eql(true) + end + +end diff --git a/spec/views/branded/paginable/orgs/_public.html.erb_spec.rb b/spec/views/branded/paginable/orgs/_public.html.erb_spec.rb new file mode 100644 index 0000000000..561906d2e8 --- /dev/null +++ b/spec/views/branded/paginable/orgs/_public.html.erb_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "paginable/orgs/_public.html.erb" do + + it "renders our version of the page" do + generate_shibbolized_orgs(2) + shib = Org.last + non_shib = create(:org, managed: true, identifiers: []) + + controller.prepend_view_path "app/views/branded" + assign :paginable_path_params, { sort_field: "orgs.name", sort_direction: :asc } + assign :paginable_params, { controller: "paginable/orgs", action: "public" } + # Paginable is expecting `scope` to be a local not an instance variable + render partial: "paginable/orgs/public", locals: { scope: Org.participating } + expect(rendered.include?("Institutional Signin Enabled")).to eql(true) + expect(rendered.include?(shib.name)).to eql(true) + expect(rendered.include?(non_shib.name)).to eql(true) + expect(rendered.scan("fa-check").length).to eql(2) + end + +end diff --git a/spec/views/branded/paginable/templates/_publicly_visible.html.erb_spec.rb b/spec/views/branded/paginable/templates/_publicly_visible.html.erb_spec.rb new file mode 100644 index 0000000000..a47136977d --- /dev/null +++ b/spec/views/branded/paginable/templates/_publicly_visible.html.erb_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "paginable/templates/_publicly_visible.html.erb" do + + it "renders our version of the page" do + org = create(:org, :funder) + 2.times { create(:template, :published, org: org) } + controller.prepend_view_path "app/views/branded" + assign :paginable_path_params, { sort_field: "templates.title", sort_direction: :asc } + assign :paginable_params, { controller: "paginable/orgs", action: "public" } + # Paginable is expecting `scope` to be a local not an instance variable + render partial: "paginable/templates/publicly_visible", locals: { scope: Template.all } + expect(rendered.include?("Template")).to eql(true) + expect(rendered.include?("Funder")).to eql(true) + Template.all.each { |t| expect(rendered.include?(t.title)).to eql(true) } + end + +end diff --git a/spec/views/branded/public_pages/orgs.html.erb_spec.rb b/spec/views/branded/public_pages/orgs.html.erb_spec.rb new file mode 100644 index 0000000000..451651edf6 --- /dev/null +++ b/spec/views/branded/public_pages/orgs.html.erb_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "public_pages/orgs.html.erb" do + + it "renders our version of the page" do + generate_shibbolized_orgs(3) + controller.prepend_view_path "app/views/branded" + assign :orgs, Org.participating + render + expect(rendered.include?("Participating Institutions")).to eql(true) + expect(response).to render_template(partial: "paginable/orgs/_public") + end + +end diff --git a/spec/views/branded/public_pages/template_index.html.erb_spec.rb b/spec/views/branded/public_pages/template_index.html.erb_spec.rb new file mode 100644 index 0000000000..c900276e79 --- /dev/null +++ b/spec/views/branded/public_pages/template_index.html.erb_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "public_pages/template_index.html.erb" do + + it "renders our version of the page when Funders are available" do + (1..3).each { create(:template, :publicly_visible) } + controller.prepend_view_path "app/views/branded" + assign :templates, Template.all + render + expect(rendered.include?("Funder Requirements")).to eql(true) + expect(rendered.include?("Templates for data management plans")).to eql(true) + expect(response).to render_template(partial: "paginable/templates/_publicly_visible") + end + + it "renders our version of the page when NO Funders are available" do + controller.prepend_view_path "app/views/branded" + assign :templates, Template.all + render + expect(rendered.include?("Funder Requirements")).to eql(true) + expect(rendered.include?("There are currently no public Templates")).to eql(true) + expect(response).not_to( + render_template(partial: "paginable/templates/_publicly_visible") + ) + end +end diff --git a/spec/views/branded/shared/_get_started.html.erb_spec.rb b/spec/views/branded/shared/_get_started.html.erb_spec.rb new file mode 100644 index 0000000000..ca43db0209 --- /dev/null +++ b/spec/views/branded/shared/_get_started.html.erb_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "shared/_get_started.html.erb" do + before(:each) do + @shib = "is affiliated with DMPTool." + @login = "is not affiliated with DMPTool." + @create = "If not affiliated and you need an account." + controller.prepend_view_path "app/views/branded" + end + + it "renders the correct sign in options when Shibboleth is not enabled" do + Rails.application.config.shibboleth_enabled = false + render + expect(rendered.include?("Sign in options")).to eql(true) + expect(rendered.include?(@shib)).to eql(false) + expect(rendered.include?(@login)).to eql(true) + expect(rendered.include?(@create)).to eql(true) + expect(rendered.include?("Option 3")).to eql(false) + end + + it "renders the correct sign in options when Shibboleth is enabled" do + Rails.application.config.shibboleth_enabled = true + render + expect(rendered.include?("Sign in options")).to eql(true) + expect(rendered.include?(@shib)).to eql(true) + expect(rendered.include?(@login)).to eql(true) + expect(rendered.include?(@create)).to eql(true) + expect(rendered.include?("Option 3")).to eql(true) + end + +end diff --git a/spec/views/branded/shared/_shib_ds_form.html.erb_spec.rb b/spec/views/branded/shared/_shib_ds_form.html.erb_spec.rb new file mode 100644 index 0000000000..298d3c94b4 --- /dev/null +++ b/spec/views/branded/shared/_shib_ds_form.html.erb_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "shared/_shib_ds_form.html.erb" do + before(:each) do + create(:identifier_scheme, name: "shibboleth", identifier_prefix: nil, + for_orgs: true, for_authentication: true) + generate_shibbolized_orgs(3) + controller.prepend_view_path "app/views/branded" + end + + it "renders the Org Selector" do + render + expect(rendered.include?("Look up your institution here")).to eql(true) + expect(rendered.include?("Go")).to eql(true) + expect(response).to render_template(partial: "shared/org_selectors/_local_only") + end + + it "does not render the Selection List if there are less than 10 Orgs" do + render + expect(rendered.include?("full list of participating institutions")).to eql(false) + end + + it "renders the Selection List if there are more than 10 Orgs" do + generate_shibbolized_orgs(10) + render + expect(rendered.include?("full list of participating institutions")).to eql(true) + expect(rendered.include?(Org.participating.first.name)).to eql(true) + expect(rendered.include?(Org.participating.last.name)).to eql(true) + end + + it "renders the link to create an account" do + render + expect(rendered.include?("Create an account with any email address")).to eql(true) + end + +end diff --git a/spec/views/branded/shared/_sign_in_form.html.erb_spec.rb b/spec/views/branded/shared/_sign_in_form.html.erb_spec.rb new file mode 100644 index 0000000000..827e2168a2 --- /dev/null +++ b/spec/views/branded/shared/_sign_in_form.html.erb_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "shared/_sign_in_form.html.erb" do + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "renders the sign in form" do + render + expect(rendered.include?("Email")).to eql(true) + expect(rendered.include?("Password")).to eql(true) + expect(rendered.include?("Forgot password?")).to eql(true) + expect(rendered.include?("Remember email")).to eql(true) + expect(rendered.include?("Sign in")).to eql(true) + end + + it "does NOT render the Institutional Credentials button" do + render + expect(rendered.include?("institutional credentials")).to eql(false) + end + +end diff --git a/spec/views/branded/shared/_signin_create_form.html.erb_spec.rb b/spec/views/branded/shared/_signin_create_form.html.erb_spec.rb new file mode 100644 index 0000000000..0cb913d95c --- /dev/null +++ b/spec/views/branded/shared/_signin_create_form.html.erb_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "shared/_signin_create_form.html.erb" do + before(:each) do + controller.prepend_view_path "app/views/branded" + end + + it "renders the Signin / Create Account tabs" do + render + expect(rendered.include?("Sign in")).to eql(true) + expect(rendered.include?("Create account")).to eql(true) + end + + it "renders the form partials" do + render + expect(response).to render_template(partial: "shared/_sign_in_form") + expect(response).to render_template(partial: "shared/_signin_create_form") + end + +end diff --git a/spec/views/branded/shared/org_branding.html.erb_spec.rb b/spec/views/branded/shared/org_branding.html.erb_spec.rb new file mode 100644 index 0000000000..19ea6d2911 --- /dev/null +++ b/spec/views/branded/shared/org_branding.html.erb_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "shared/org_branding.html.erb" do + before(:each) do + @org = build(:org, managed: true) + controller.prepend_view_path "app/views/branded" + end + + it "renders the Org name if no logo is available" do + @org.logo = nil + assign :user, build(:user, org: @org) + render + expect(rendered.include?("<h1>#{@org.name}</h1>")).to eql(true) + end + + it "renders the Org logo if available" do + # stub the logo method + logo = OpenStruct.new({ present?: true }) + logo.stubs(:thumb).returns(OpenStruct.new({ url: Faker::Internet.url })) + @org.stubs(:logo).returns(logo) + assign :user, build(:user, org: @org) + render + expect(rendered.include?("class=\"org-logo\"")).to eql(true) + end + + it "renders the Signin / Create Account forms" do + assign :user, build(:user, org: @org) + render + expect(rendered.include?("Sign in")).to eql(true) + expect(response).to render_template(partial: "shared/_sign_in_form") + expect(rendered.include?("Create account")).to eql(true) + expect(response).to render_template(partial: "shared/_create_account_form") + end + +end diff --git a/spec/views/branded/static_pages/about_us.html.erb_spec.rb b/spec/views/branded/static_pages/about_us.html.erb_spec.rb new file mode 100644 index 0000000000..28d30537b5 --- /dev/null +++ b/spec/views/branded/static_pages/about_us.html.erb_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "static_pages/about_us.html.erb" do + + it "renders our version of the page" do + controller.prepend_view_path "app/views/branded" + render + expect(rendered.include?("What is the DMPTool?")).to eql(true) + end + +end diff --git a/spec/views/branded/static_pages/faq.html.erb_spec.rb b/spec/views/branded/static_pages/faq.html.erb_spec.rb new file mode 100644 index 0000000000..c0d7d6c246 --- /dev/null +++ b/spec/views/branded/static_pages/faq.html.erb_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "static_pages/faq.html.erb" do + + it "renders our version of the page" do + controller.prepend_view_path "app/views/branded" + render + expect(rendered.include?("About the DMPTool")).to eql(true) + end + +end diff --git a/spec/views/branded/static_pages/general_guidance.html.erb_spec.rb b/spec/views/branded/static_pages/general_guidance.html.erb_spec.rb new file mode 100644 index 0000000000..e8d46d2ad3 --- /dev/null +++ b/spec/views/branded/static_pages/general_guidance.html.erb_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "static_pages/general_guidance.html.erb" do + + it "renders our version of the page" do + controller.prepend_view_path "app/views/branded" + render + expect(rendered.include?("The National Science Foundation (NSF)")).to eql(true) + end + +end diff --git a/spec/views/branded/static_pages/help.html.erb_spec.rb b/spec/views/branded/static_pages/help.html.erb_spec.rb new file mode 100644 index 0000000000..0ca33dbf46 --- /dev/null +++ b/spec/views/branded/static_pages/help.html.erb_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "static_pages/help.html.erb" do + + it "renders our version of the page" do + controller.prepend_view_path "app/views/branded" + render + expect(rendered.include?("DMPTool is free for anyone")).to eql(true) + end + +end diff --git a/spec/views/branded/static_pages/promote.html.erb_spec.rb b/spec/views/branded/static_pages/promote.html.erb_spec.rb new file mode 100644 index 0000000000..76b361bac7 --- /dev/null +++ b/spec/views/branded/static_pages/promote.html.erb_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "static_pages/promote.html.erb" do + + it "renders our version of the page" do + controller.prepend_view_path "app/views/branded" + render + expect(rendered.include?("DMPTool logo")).to eql(true) + end + +end diff --git a/spec/views/branded/static_pages/termsuse.html.erb_spec.rb b/spec/views/branded/static_pages/termsuse.html.erb_spec.rb new file mode 100644 index 0000000000..224805b151 --- /dev/null +++ b/spec/views/branded/static_pages/termsuse.html.erb_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "static_pages/termsuse.html.erb" do + + it "renders our version of the page" do + controller.prepend_view_path "app/views/branded" + render + expect(rendered.include?("The CDL holds your plans on your behalf")).to eql(true) + end + +end diff --git a/spec/views/branded/user_mailer/admin_privileges.html.erb_spec.rb b/spec/views/branded/user_mailer/admin_privileges.html.erb_spec.rb new file mode 100644 index 0000000000..f3815e9b99 --- /dev/null +++ b/spec/views/branded/user_mailer/admin_privileges.html.erb_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "user_mailer/admin_privileges.html.erb" do + before(:each) do + @url = "https://github.com/CDLUC3/dmptool/wiki/Help-for-Administrators" + controller.prepend_view_path "app/views/branded" + end + + it "renders the email for a user who received Admin privileges" do + user = create(:user, :org_admin) + assign :user, user + render + expect(rendered.include?("Hello #{user.name(false)}")).to eql(true) + expect(rendered.include?("granted")).to eql(true) + expect(rendered.include?(@url)).to eql(true) + end + + it "renders the email for the user whose Admin privileges have been revoked" do + user = create(:user) + assign :user, user + render + expect(rendered.include?("Hello #{user.name(false)}")).to eql(true) + expect(rendered.include?("revoked")).to eql(true) + expect(rendered.include?(@url)).to eql(true) + end + +end diff --git a/spec/views/branded/user_mailer/api_plan_creation.html.erb_spec.rb b/spec/views/branded/user_mailer/api_plan_creation.html.erb_spec.rb new file mode 100644 index 0000000000..b6a6fbf41f --- /dev/null +++ b/spec/views/branded/user_mailer/api_plan_creation.html.erb_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "user_mailer/api_plan_creation.html.erb" do + + xit "renders the email" do + controller.prepend_view_path "app/views/branded" + plan = create(:plan) + contributor = build(:contributor, plan: plan) + assign :plan, plan + assign :contributor, contributor + render + expect(rendered.include?(plan.id.to_s)).to eql(true) + expect(rendered.include?(plan.title)).to eql(true) + expect(rendered.include?(contributor.email)).to eql(true) + end + +end diff --git a/yarn.lock b/yarn.lock index efea5c64f6..2bdf0389e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,23 +3,23 @@ "@babel/code-frame@^7.0.0": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e" - integrity sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g== + version "7.10.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.1.tgz#d5481c5095daa1c57e16e54c6f9198443afb49ff" + integrity sha512-IGhtTmpjGbYzcEDOw7DcQtbQSXcG9ftmAXtWTu9V936vDye4xjjekktFAtgZsWpzTj/X01jocB46mTywm/4SZw== dependencies: - "@babel/highlight" "^7.8.3" + "@babel/highlight" "^7.10.1" -"@babel/helper-validator-identifier@^7.9.0": - version "7.9.5" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.9.5.tgz#90977a8e6fbf6b431a7dc31752eee233bf052d80" - integrity sha512-/8arLKUFq882w4tWGj9JYzRpAlZgiWUJ+dtteNTDqrRBz9Iguck9Rn3ykuBDoUwh2TO4tSAJlrxDUOXWklJe4g== +"@babel/helper-validator-identifier@^7.10.1": + version "7.10.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.1.tgz#5770b0c1a826c4f53f5ede5e153163e0318e94b5" + integrity sha512-5vW/JXLALhczRCWP0PnFDMCJAchlBvM7f4uk/jXritBnIa6E1KmqmtrS3yn1LAnxFBypQ3eneLuXjsnfQsgILw== -"@babel/highlight@^7.8.3": - version "7.9.0" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.9.0.tgz#4e9b45ccb82b79607271b2979ad82c7b68163079" - integrity sha512-lJZPilxX7Op3Nv/2cvFdnlepPXDxi29wxteT57Q965oc5R9v86ztx0jfxVrTcBk8C2kcPkkDa2Z4T3ZsPPVWsQ== +"@babel/highlight@^7.10.1": + version "7.10.1" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.1.tgz#841d098ba613ba1a427a2b383d79e35552c38ae0" + integrity sha512-8rMof+gVP8mxYZApLF/JgNDAkdKa+aJt3ZYxF8z6+j/hpeXL7iMsKCPHa2jNMHu/qqBwzQF4OHNoYi8dMA/rYg== dependencies: - "@babel/helper-validator-identifier" "^7.9.0" + "@babel/helper-validator-identifier" "^7.10.1" chalk "^2.0.0" js-tokens "^4.0.0" @@ -79,17 +79,11 @@ resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== -"@types/events@*": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" - integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== - "@types/glob@^7.1.1": - version "7.1.1" - resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" - integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w== + version "7.1.2" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.2.tgz#06ca26521353a545d94a0adc74f38a59d232c987" + integrity sha512-VgNIkxK+j7Nz5P7jvUZlRvhuPSmsEfS03b0alKcq5V/STUKAa3Plemsn5mrQUO7am6OErJ4rhGEGJbACclrtRA== dependencies: - "@types/events" "*" "@types/minimatch" "*" "@types/node" "*" @@ -98,30 +92,35 @@ resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-2.0.54.tgz#d7999245f77c3fab5d84e7d32b8a6c20bfd1f072" integrity sha512-D/PomKwNkDfSKD13DEVQT/pq2TUjN54c6uB341fEZanIzkjfGe7UaFuuaLZbpEiS5j7Wk2MUHAZqZIoECw29lg== +"@types/json5@^0.0.29": + version "0.0.29" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" + integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= + "@types/minimatch@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== "@types/node@*": - version "13.11.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-13.11.0.tgz#390ea202539c61c8fa6ba4428b57e05bc36dc47b" - integrity sha512-uM4mnmsIIPK/yeO+42F2RQhGUIs39K2RFmugcJANppXe6J1nvH87PvzPZYpza7Xhhs8Yn9yIAVdLZ84z61+0xQ== + version "14.0.13" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.13.tgz#ee1128e881b874c371374c1f72201893616417c9" + integrity sha512-rouEWBImiRaSJsVA+ITTFM6ZxibuAlTuNOCyxVbwreu6k6+ujs7DfnU9o+PShFhET78pMBl3eH+AGSI5eOTkPA== "@types/node@^8.10.50": - version "8.10.59" - resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.59.tgz#9e34261f30183f9777017a13d185dfac6b899e04" - integrity sha512-8RkBivJrDCyPpBXhVZcjh7cQxVBSmRk9QM7hOketZzp6Tg79c0N8kkpAIito9bnJ3HCVCHVYz+KHTEbfQNfeVQ== + version "8.10.61" + resolved "https://registry.yarnpkg.com/@types/node/-/node-8.10.61.tgz#d299136ce54bcaf1abaa4a487f9e4bedf6b0d393" + integrity sha512-l+zSbvT8TPRaCxL1l9cwHCb0tSqGAGcjPJFItGGYat5oCTiq1uQQKYg5m7AF1mgnEBzFXGLJ2LRmNjtreRX76Q== "@types/q@^1.5.1": - version "1.5.2" - resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" - integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== + version "1.5.4" + resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" + integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== "@types/request@^2.48.2": - version "2.48.4" - resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.4.tgz#df3d43d7b9ed3550feaa1286c6eabf0738e6cf7e" - integrity sha512-W1t1MTKYR8PxICH+A4HgEIPuAC3sbljoEVfyZbeFJJDbr30guDspJri2XOaM2E+Un7ZjrihaDi7cf6fPa2tbgw== + version "2.48.5" + resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.5.tgz#019b8536b402069f6d11bee1b2c03e7f232937a0" + integrity sha512-/LO7xRVnL3DxJ1WkPGDQrp4VTV1reX9RkC85mJ+Qzykj2Bdw+mG15aAfDahc76HtknjzE16SX/Yddn6MxVbmGQ== dependencies: "@types/caseless" "*" "@types/node" "*" @@ -222,9 +221,9 @@ acorn@^6.0.7: integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA== acorn@^7.0.0: - version "7.1.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.1.tgz#e35668de0b402f359de515c5482a1ab9f89a69bf" - integrity sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg== + version "7.3.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.3.1.tgz#85010754db53c3fbaf3b9ea3e083aa5c5d147ffd" + integrity sha512-tLc0wSnatxAQHVHUapaHdz72pi9KUyHjq5KyHjGg9Y8Ifdc79pTh2XvI6I1/chZbnM7QtNKzh66ooDogPZSleA== after@0.8.2: version "0.8.2" @@ -252,9 +251,9 @@ ajv@^5.0.0: json-schema-traverse "^0.3.0" ajv@^6.1.0, ajv@^6.10.2, ajv@^6.5.5, ajv@^6.9.1: - version "6.12.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.0.tgz#06d60b96d87b8454a5adaba86e7854da629db4b7" - integrity sha512-D6gFiFA0RRLyUbvijN74DWAjXSFxWKaWP7mldxkVhyhAV3+SWA9HEJPHQ2c9soIeTFJqcSdFDGFgdqs1iUU2Hw== + version "6.12.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd" + integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -280,10 +279,10 @@ amdefine@>=0.0.4: resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= -ansi-colors@3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-3.2.3.tgz#57d35b8686e851e2cc04c403f1c00203976a1813" - integrity sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw== +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== ansi-colors@^3.0.0: version "3.2.4" @@ -437,7 +436,7 @@ array-flatten@^2.1.0: resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== -array-includes@^3.0.3: +array-includes@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348" integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ== @@ -468,7 +467,7 @@ array-unique@^0.3.2: resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= -array.prototype.flat@^1.2.1: +array.prototype.flat@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz#0de82b426b0318dbfdb940089e38b043d37f6c7b" integrity sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ== @@ -476,6 +475,16 @@ array.prototype.flat@^1.2.1: define-properties "^1.1.3" es-abstract "^1.17.0-next.1" +array.prototype.map@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array.prototype.map/-/array.prototype.map-1.0.2.tgz#9a4159f416458a23e9483078de1106b2ef68f8ec" + integrity sha512-Az3OYxgsa1g7xDYp86l0nnN4bcmuEITGe1rbdEBVkrqkzMgDcbdQ2R7r41pNzti+4NMces3H8gMmuioZUilLgw== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + es-array-method-boxes-properly "^1.0.0" + is-string "^1.0.4" + arraybuffer.slice@~0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675" @@ -594,9 +603,9 @@ aws-sign2@~0.7.0: integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= aws4@^1.8.0: - version "1.9.1" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e" - integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug== + version "1.10.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.10.0.tgz#a17b3a8ea811060e74d47d306122400ad4497ae2" + integrity sha512-3YDiu347mtVtjpyV3u5kVqQLP242c06zwDOgpeRnybmXlYYsLbtTrUBUm8i8srONt+FWobl5aibnU1030PeeuA== babel-code-frame@^6.26.0: version "6.26.0" @@ -1318,10 +1327,15 @@ bluebird@^3.3.0, bluebird@^3.5.1: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0: - version "4.11.8" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" - integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== +bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0: + version "4.11.9" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828" + integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw== + +bn.js@^5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.2.tgz#c9686902d3c9a27729f43ab10f9d79c2004da7b0" + integrity sha512-40rZaf3bUNKTVYu9sIeeEGOg7g14Yvnj9kH7b50EiwX0Q7A6umbvfI5tvHaOERH0XigqKkfLkFQxzb4e6CIXnA== body-parser@1.19.0, body-parser@^1.16.1: version "1.19.0" @@ -1366,10 +1380,15 @@ bootstrap-sass@^3.3.7: resolved "https://registry.yarnpkg.com/bootstrap-sass/-/bootstrap-sass-3.4.1.tgz#6843c73b1c258a0ac5cb2cc6f6f5285b664a8e9a" integrity sha512-p5rxsK/IyEDQm2CwiHxxUi0MZZtvVFbhWmyMOt4lLkA4bujDA1TGoKT0i1FKIWiugAdP+kK8T5KMDFIKQCLYIA== +bootstrap-select@^1.13.10: + version "1.13.17" + resolved "https://registry.yarnpkg.com/bootstrap-select/-/bootstrap-select-1.13.17.tgz#2cc8312fd88bd7900d4eb881f0a7e95e00ad71e9" + integrity sha512-LbzSQumoZNYGuoYMpShKIHbJ2qUWFLI0qVHVeKqw5+nfhtMxz+Gre1+IuI3X74bTzQfalBqDKc8fS8tZMdciWg== + bootstrap@^4.1.3: - version "4.4.1" - resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.4.1.tgz#8582960eea0c5cd2bede84d8b0baf3789c3e8b01" - integrity sha512-tbx5cHubwE6e2ZG7nqM3g/FZ5PQEDMWmMGNrCUBVRPHXTJaH7CBDdsLeu3eCh3B1tzAxTnAbtmrzvWEvT2NNEA== + version "4.5.0" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.5.0.tgz#97d9dbcb5a8972f8722c9962483543b907d9b9ec" + integrity sha512-Z93QoXvodoVslA+PWNdk23Hze4RBYIkpb5h8I2HY2Tu2h7A0LpAgLcyrhrSUyo2/Oxm2l1fRZPs1e5hnxnliXA== brace-expansion@^1.1.7: version "1.1.11" @@ -1487,7 +1506,7 @@ browserify-des@^1.0.0: inherits "^2.0.1" safe-buffer "^5.1.2" -browserify-rsa@^4.0.0: +browserify-rsa@^4.0.0, browserify-rsa@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524" integrity sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ= @@ -1496,17 +1515,19 @@ browserify-rsa@^4.0.0: randombytes "^2.0.1" browserify-sign@^4.0.0: - version "4.0.4" - resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298" - integrity sha1-qk62jl17ZYuqa/alfmMMvXqT0pg= - dependencies: - bn.js "^4.1.1" - browserify-rsa "^4.0.0" - create-hash "^1.1.0" - create-hmac "^1.1.2" - elliptic "^6.0.0" - inherits "^2.0.1" - parse-asn1 "^5.0.0" + version "4.2.0" + resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.0.tgz#545d0b1b07e6b2c99211082bf1b12cce7a0b0e11" + integrity sha512-hEZC1KEeYuoHRqhGhTy6gWrpJA3ZDjFWv0DE61643ZnOXAKJb3u7yWcrU0mMc9SwAqK1n7myPGndkp0dFG7NFA== + dependencies: + bn.js "^5.1.1" + browserify-rsa "^4.0.1" + create-hash "^1.2.0" + create-hmac "^1.1.7" + elliptic "^6.5.2" + inherits "^2.0.4" + parse-asn1 "^5.1.5" + readable-stream "^3.6.0" + safe-buffer "^5.2.0" browserify-zlib@^0.2.0, browserify-zlib@~0.2.0: version "0.2.0" @@ -1653,12 +1674,12 @@ browserslist@^3.2.6: electron-to-chromium "^1.3.47" browserslist@^4.0.0: - version "4.11.1" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.11.1.tgz#92f855ee88d6e050e7e7311d987992014f1a1f1b" - integrity sha512-DCTr3kDrKEYNw6Jb9HFxVLQNaue8z+0ZfRBRjmCunKDEXEBajKDj2Y+Uelg+Pi29OnvaSGwjOsnRyNEkXzHg5g== + version "4.12.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.12.0.tgz#06c6d5715a1ede6c51fc39ff67fd647f740b656d" + integrity sha512-UH2GkcEDSI0k/lRkuDSzFl9ZZ87skSy9w2XAn1MsZnL+4c4rqbBd3e82UWHbYDpztABrPBhZsTEeuxVfHppqDg== dependencies: - caniuse-lite "^1.0.30001038" - electron-to-chromium "^1.3.390" + caniuse-lite "^1.0.30001043" + electron-to-chromium "^1.3.413" node-releases "^1.1.53" pkg-up "^2.0.0" @@ -1746,9 +1767,9 @@ buffer@^4.3.0: isarray "^1.0.0" buffer@^5.0.2, buffer@^5.1.0, buffer@^5.5.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.5.0.tgz#9c3caa3d623c33dd1c7ef584b89b88bf9c9bc1ce" - integrity sha512-9FTEDjLjwoAkEwyMGDjYJQN2gfRgOKBKRfiglhvibGbpeeU/pQn1bJxQqm32OD/AIeEuHxU9roxXxg34Byp/Ww== + version "5.6.0" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" + integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== dependencies: base64-js "^1.0.2" ieee754 "^1.1.4" @@ -1859,11 +1880,6 @@ camelcase@^2.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8= -camelcase@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" - integrity sha1-MvxLn82vhF/N9+c7uXysImHwqwo= - camelcase@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" @@ -1905,14 +1921,14 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000634, caniuse-db@^1.0.30000639: - version "1.0.30001039" - resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30001039.tgz#b5e8c3bb07a144341644729fa2a5eb2c0deaf47d" - integrity sha512-XVk5KMAi8/DI28tQXKuq1PDyuPoD9Ypnda3ctF04TlB+LYIb+bgHq0ZDfNOn0+4cwLENJC0093Vuf0dhkjXQ7Q== + version "1.0.30001081" + resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30001081.tgz#67f27341439278df6ad5fec8cbc0c7f38af60b76" + integrity sha512-x0yvbyZV5VTI/+s3C/pDjh2S21po0WnEkQOuPxOK7u5GNn31fZTJ++CrLTG5lx7BxwHbRyaHpoKoV6Our3bkgg== -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000792, caniuse-lite@^1.0.30000805, caniuse-lite@^1.0.30000844, caniuse-lite@^1.0.30001038: - version "1.0.30001039" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001039.tgz#b3814a1c38ffeb23567f8323500c09526a577bbe" - integrity sha512-SezbWCTT34eyFoWHgx8UWso7YtvtM7oosmFoXbCkdC6qJzRfBTeTgE9REtKtiuKXuMwWTZEvdnFNGAyVMorv8Q== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000792, caniuse-lite@^1.0.30000805, caniuse-lite@^1.0.30000844, caniuse-lite@^1.0.30001043: + version "1.0.30001081" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001081.tgz#40615a3c416a047c5a4d45673e5257bf128eb3b5" + integrity sha512-iZdh3lu09jsUtLE6Bp8NAbJskco4Y3UDtkR3GTCJGsbMowBU5IWDFF79sV2ws7lSqTzWyKazxam2thasHymENQ== case-sensitive-paths-webpack-plugin@^2.1.2: version "2.3.0" @@ -2004,10 +2020,10 @@ check-error@^1.0.2: resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII= -chokidar@*, chokidar@^3.0.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450" - integrity sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg== +chokidar@*, chokidar@^3.0.0, chokidar@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.0.tgz#b30611423ce376357c765b9b8f904b9fba3c0be8" + integrity sha512-aXAaho2VJtisB/1fg1+3nlLJqGOuewTzQpd/Tz0yTg2R0e4IGtshYvtjowyEumcBv2z+y4+kc75Mz7j5xJskcQ== dependencies: anymatch "~3.1.1" braces "~3.0.2" @@ -2015,14 +2031,14 @@ chokidar@*, chokidar@^3.0.0: is-binary-path "~2.1.0" is-glob "~4.0.1" normalize-path "~3.0.0" - readdirp "~3.3.0" + readdirp "~3.4.0" optionalDependencies: fsevents "~2.1.2" -chokidar@3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6" - integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A== +chokidar@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450" + integrity sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg== dependencies: anymatch "~3.1.1" braces "~3.0.2" @@ -2030,9 +2046,9 @@ chokidar@3.3.0: is-binary-path "~2.1.0" is-glob "~4.0.1" normalize-path "~3.0.0" - readdirp "~3.2.0" + readdirp "~3.3.0" optionalDependencies: - fsevents "~2.1.1" + fsevents "~2.1.2" chokidar@^1.0.5: version "1.7.0" @@ -2069,7 +2085,7 @@ chokidar@^2.1.8: optionalDependencies: fsevents "^1.2.7" -chownr@^1.0.1, chownr@^1.1.1: +chownr@^1.0.1: version "1.1.4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== @@ -2107,9 +2123,9 @@ cli-cursor@^2.1.0: restore-cursor "^2.0.0" cli-width@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" - integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk= + version "2.2.1" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48" + integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw== cliui@^2.1.0: version "2.1.0" @@ -2129,15 +2145,6 @@ cliui@^3.2.0: strip-ansi "^3.0.1" wrap-ansi "^2.0.0" -cliui@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49" - integrity sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ== - dependencies: - string-width "^2.1.1" - strip-ansi "^4.0.0" - wrap-ansi "^2.0.0" - cliui@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" @@ -2539,7 +2546,7 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" -create-hash@^1.1.0, create-hash@^1.1.2: +create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== @@ -2550,7 +2557,7 @@ create-hash@^1.1.0, create-hash@^1.1.2: ripemd160 "^2.0.1" sha.js "^2.4.0" -create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4: +create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== @@ -2696,14 +2703,14 @@ css-tree@1.0.0-alpha.39: source-map "^0.6.1" css-unit-converter@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/css-unit-converter/-/css-unit-converter-1.1.1.tgz#d9b9281adcfd8ced935bdbaba83786897f64e996" - integrity sha1-2bkoGtz9jO2TW9urqDeGiX9k6ZY= + version "1.1.2" + resolved "https://registry.yarnpkg.com/css-unit-converter/-/css-unit-converter-1.1.2.tgz#4c77f5a1954e6dbff60695ecb214e3270436ab21" + integrity sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA== css-what@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.2.1.tgz#f4a8f12421064621b456755e34a03a2c22df5da1" - integrity sha512-WwOrosiQTvyms+Ti5ZC5vGEK0Vod3FTt1ca+payZqvKuGJF+dq7bG63DstxtN0dpm6FxY27a/zS3Wten+gEtGw== + version "3.3.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.3.0.tgz#10fec696a9ece2e591ac772d759aacabac38cd39" + integrity sha512-pv9JPyatiPaQ6pf4OvD/dbfm0o5LviWmwxNWzblYf/1u9QZd0ihV+PMwy5jdQWQ3349kZmKEx9WXuSka2dM4cg== cssesc@^3.0.0: version "3.0.0" @@ -2943,11 +2950,6 @@ deep-equal@^1.0.1: object-keys "^1.1.1" regexp.prototype.flags "^1.2.0" -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== - deep-is@~0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" @@ -3058,11 +3060,6 @@ detect-indent@^4.0.0: dependencies: repeating "^2.0.0" -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detect-node@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" @@ -3081,10 +3078,10 @@ di@^0.0.1: resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" integrity sha1-gGZJMmzqp8qjMG112YXqJ0i6kTw= -diff@3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" - integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA== +diff@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== diffie-hellman@^5.0.0: version "5.0.3" @@ -3218,12 +3215,12 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -electron-to-chromium@^1.2.7, electron-to-chromium@^1.3.30, electron-to-chromium@^1.3.390, electron-to-chromium@^1.3.47: - version "1.3.398" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.398.tgz#4c01e29091bf39e578ac3f66c1f157d92fa5725d" - integrity sha512-BJjxuWLKFbM5axH3vES7HKMQgAknq9PZHBkMK/rEXUQG9i1Iw5R+6hGkm6GtsQSANjSUrh/a6m32nzCNDNo/+w== +electron-to-chromium@^1.2.7, electron-to-chromium@^1.3.30, electron-to-chromium@^1.3.413, electron-to-chromium@^1.3.47: + version "1.3.467" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.467.tgz#84eeb332134d49f0e49b88588824e56b20af9e27" + integrity sha512-U+QgsL8TZDU/n+rDnYDa3hY5uy3C4iry9mrJS0PNBBGwnocuQ+aHSfgY44mdlaK9744X5YqrrGUvD9PxCLY1HA== -elliptic@^6.0.0: +elliptic@^6.0.0, elliptic@^6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.2.tgz#05c5678d7173c049d8ca433552224a495d0e3762" integrity sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw== @@ -3314,9 +3311,9 @@ ent@~2.2.0: integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0= entities@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4" - integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw== + version "2.0.3" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f" + integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ== errno@^0.1.3, errno@~0.1.7: version "0.1.7" @@ -3332,7 +3329,7 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.5: +es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstract@^1.17.4, es-abstract@^1.17.5: version "1.17.5" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.5.tgz#d8c9d1d66c8981fb9200e2251d799eee92774ae9" integrity sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg== @@ -3349,6 +3346,24 @@ es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.2, es-abstrac string.prototype.trimleft "^2.1.1" string.prototype.trimright "^2.1.1" +es-array-method-boxes-properly@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" + integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== + +es-get-iterator@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.0.tgz#bb98ad9d6d63b31aacdc8f89d5d0ee57bcb5b4c8" + integrity sha512-UfrmHuWQlNMTs35e1ypnvikg6jCz3SK8v8ImvmDsh36fCVUR1MqoFDiyn0/k52C8NqO3YsO8Oe0azeesNuqSsQ== + dependencies: + es-abstract "^1.17.4" + has-symbols "^1.0.1" + is-arguments "^1.0.4" + is-map "^2.0.1" + is-set "^2.0.1" + is-string "^1.0.5" + isarray "^2.0.5" + es-to-primitive@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" @@ -3454,7 +3469,7 @@ eslint-config-airbnb-base@^13.1.0: object.assign "^4.1.0" object.entries "^1.1.0" -eslint-import-resolver-node@^0.3.2: +eslint-import-resolver-node@^0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.3.tgz#dbaa52b6b2816b50bc6711af75422de808e98404" integrity sha512-b8crLDo0M5RSe5YG8Pu2DYBj71tSB6OvXkfzwbJU2w7y8P4/yo0MyF8jU26IEuEuHF2K5/gcAJE3LhQGqBBbVg== @@ -3473,7 +3488,7 @@ eslint-loader@^2.1.1: object-hash "^1.1.4" rimraf "^2.6.1" -eslint-module-utils@^2.4.1: +eslint-module-utils@^2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz#579ebd094f56af7797d19c9866c9c9486629bfa6" integrity sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA== @@ -3482,22 +3497,23 @@ eslint-module-utils@^2.4.1: pkg-dir "^2.0.0" eslint-plugin-import@^2.14.0: - version "2.20.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.20.2.tgz#91fc3807ce08be4837141272c8b99073906e588d" - integrity sha512-FObidqpXrR8OnCh4iNsxy+WACztJLXAHBO5hK79T1Hc77PgQZkyDGA5Ag9xAvRpglvLNxhH/zSmZ70/pZ31dHg== + version "2.21.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.21.2.tgz#8fef77475cc5510801bedc95f84b932f7f334a7c" + integrity sha512-FEmxeGI6yaz+SnEB6YgNHlQK1Bs2DKLM+YF+vuTk5H8J9CLbJLtlPvRFgZZ2+sXiKAlN5dpdlrWOjK8ZoZJpQA== dependencies: - array-includes "^3.0.3" - array.prototype.flat "^1.2.1" + array-includes "^3.1.1" + array.prototype.flat "^1.2.3" contains-path "^0.1.0" debug "^2.6.9" doctrine "1.5.0" - eslint-import-resolver-node "^0.3.2" - eslint-module-utils "^2.4.1" + eslint-import-resolver-node "^0.3.3" + eslint-module-utils "^2.6.0" has "^1.0.3" minimatch "^3.0.4" - object.values "^1.1.0" + object.values "^1.1.1" read-pkg-up "^2.0.0" - resolve "^1.12.0" + resolve "^1.17.0" + tsconfig-paths "^3.9.0" eslint-scope@^4.0.3: version "4.0.3" @@ -3515,9 +3531,9 @@ eslint-utils@^1.3.1, eslint-utils@^1.4.1: eslint-visitor-keys "^1.1.0" eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" - integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== + version "1.2.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.2.0.tgz#74415ac884874495f78ec2a97349525344c981fa" + integrity sha512-WFb4ihckKil6hu3Dp798xdzSfddwKKU3+nGniKF6HfeW6OLd2OUDEPP7TcHtB5+QXOKg2s6B2DaMPE1Nn/kxKQ== eslint@^5.8.0: version "5.16.0" @@ -3581,11 +3597,11 @@ esprima@^4.0.0: integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== esquery@^1.0.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.2.0.tgz#a010a519c0288f2530b3404124bfb5f02e9797fe" - integrity sha512-weltsSqdeWIX9G2qQZz7KlTRJdkkOCTPgLYJUz1Hacf48R4YOwGPHO3+ORfWedqJKbq5WQmsgK90n+pFLIKt/Q== + version "1.3.1" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.3.1.tgz#b78b5828aa8e214e29fb74c4d5b752e1c033da57" + integrity sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ== dependencies: - estraverse "^5.0.0" + estraverse "^5.1.0" esrecurse@^4.1.0: version "4.2.1" @@ -3599,10 +3615,10 @@ estraverse@^4.1.0, estraverse@^4.1.1: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== -estraverse@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.0.0.tgz#ac81750b482c11cca26e4b07e83ed8f75fbcdc22" - integrity sha512-j3acdrMzqrxmJTNj5dbr1YbjacrYgAxVMeF0gK16E3j494mOe7xygM/ZLIguEQ0ETwAg2hlJCtHRGav+y0Ny5A== +estraverse@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.1.0.tgz#374309d39fd935ae500e7b92e8a6b4c720e59642" + integrity sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw== esutils@^2.0.2: version "2.0.3" @@ -3623,9 +3639,9 @@ event-emitter@~0.3.5: es5-ext "~0.10.14" eventemitter3@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" - integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== + version "4.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384" + integrity sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ== events@^3.0.0: version "3.1.0" @@ -3706,9 +3722,9 @@ expand-range@^1.8.1: fill-range "^2.1.0" express-session@^1.15.3: - version "1.17.0" - resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.0.tgz#9b50dbb5e8a03c3537368138f072736150b7f9b3" - integrity sha512-t4oX2z7uoSqATbMfsxWMbNjAL0T5zpvcJCk3Z9wnPPN7ibddhnmDZXHfEcoBMG2ojKXZoCyPMc5FbtK+G7SoDg== + version "1.17.1" + resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.1.tgz#36ecbc7034566d38c8509885c044d461c11bf357" + integrity sha512-UbHwgqjxQZJiWRTMyhvWGvjBQduGCSBDhhZXYenziMFjxst5rMV+aJZ6hKPHZnPyHGsrqRICxtX8jtEbm/z36Q== dependencies: cookie "0.4.0" cookie-signature "1.0.6" @@ -3838,9 +3854,9 @@ fast-deep-equal@^1.0.0: integrity sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ= fast-deep-equal@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4" - integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA== + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== fast-json-stable-stringify@^2.0.0: version "2.1.0" @@ -3972,12 +3988,13 @@ find-package-json@^1.0.0: resolved "https://registry.yarnpkg.com/find-package-json/-/find-package-json-1.2.0.tgz#4057d1b943f82d8445fe52dc9cf456f6b8b58083" integrity sha512-+SOGcLGYDJHtyqHd87ysBhmaeQ95oWspDKnMXBrnQ9Eq4OkLNqejgoaD8xVWu6GPa0B6roa6KinCMEMcVeqONw== -find-up@3.0.0, find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== +find-up@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== dependencies: - locate-path "^3.0.0" + locate-path "^5.0.0" + path-exists "^4.0.0" find-up@^1.0.0: version "1.1.2" @@ -3994,6 +4011,13 @@ find-up@^2.0.0, find-up@^2.1.0: dependencies: locate-path "^2.0.0" +find-up@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" + integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== + dependencies: + locate-path "^3.0.0" + flat-cache@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" @@ -4139,13 +4163,6 @@ fs-extra@^7.0.0, fs-extra@^7.0.1: jsonfile "^4.0.0" universalify "^0.1.0" -fs-minipass@^1.2.5: - version "1.2.7" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7" - integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA== - dependencies: - minipass "^2.6.0" - fs-write-stream-atomic@^1.0.8: version "1.0.10" resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9" @@ -4162,17 +4179,17 @@ fs.realpath@^1.0.0: integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= fsevents@^1.0.0, fsevents@^1.2.7: - version "1.2.12" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.12.tgz#db7e0d8ec3b0b45724fd4d83d43554a8f1f0de5c" - integrity sha512-Ggd/Ktt7E7I8pxZRbGIs7vwqAPscSESMrCSkx2FtWeqmheJgCo2R74fTsZFCifr0VTPwqRpPv17+6b8Zp7th0Q== + version "1.2.13" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38" + integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw== dependencies: bindings "^1.5.0" nan "^2.12.1" -fsevents@~2.1.1, fsevents@~2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" - integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== +fsevents@~2.1.2: + version "2.1.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" + integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== fstream@^1.0.0, fstream@^1.0.12: version "1.0.12" @@ -4294,10 +4311,10 @@ glob-parent@~5.1.0: dependencies: is-glob "^4.0.1" -glob@7.1.3: - version "7.1.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" - integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== +glob@7.1.6, glob@^7.0.0, glob@^7.0.3, glob@^7.1.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@~7.1.1: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" @@ -4317,18 +4334,6 @@ glob@^5.0.15: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.0, glob@^7.0.3, glob@^7.1.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@~7.1.1: - version "7.1.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - globals@^11.7.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -4351,18 +4356,18 @@ globby@^6.1.0: pinkie-promise "^2.0.0" globule@^1.0.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/globule/-/globule-1.3.1.tgz#90a25338f22b7fbeb527cee63c629aea754d33b9" - integrity sha512-OVyWOHgw29yosRHCHo7NncwR1hW5ew0W/UrvtwvjefVJeQ26q4/8r8FmPsSF1hJ93IgWkyv16pCTz6WblMzm/g== + version "1.3.2" + resolved "https://registry.yarnpkg.com/globule/-/globule-1.3.2.tgz#d8bdd9e9e4eef8f96e245999a5dee7eb5d8529c4" + integrity sha512-7IDTQTIu2xzXkT+6mlluidnWo+BypnbSoEVVQCGfzqnl5Ik8d3e1d4wycb8Rj9tWW+Z39uPWsdlquqiqPCd/pA== dependencies: glob "~7.1.1" - lodash "~4.17.12" + lodash "~4.17.10" minimatch "~3.0.2" graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0: - version "4.2.3" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" - integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== + version "4.2.4" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" + integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== growl@1.10.5: version "1.10.5" @@ -4421,6 +4426,11 @@ has-flag@^3.0.0: resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + has-symbols@^1.0.0, has-symbols@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8" @@ -4470,12 +4480,13 @@ has@^1.0.0, has@^1.0.1, has@^1.0.3: function-bind "^1.1.1" hash-base@^3.0.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918" - integrity sha1-X8hoaEfs1zSZQDMZprCj8/auSRg= + version "3.1.0" + resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" + integrity sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA== dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" + inherits "^2.0.4" + readable-stream "^3.6.0" + safe-buffer "^5.2.0" hash.js@^1.0.0, hash.js@^1.0.3: version "1.1.7" @@ -4542,10 +4553,10 @@ html-comment-regex@^1.1.0: resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== -html-entities@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" - integrity sha1-DfKTUfByEWNRXfueVUPl9u7VFi8= +html-entities@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.3.1.tgz#fb9a1a4b5b14c5daba82d3e34c6ae4fe701a0e44" + integrity sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA== htmlescape@^1.1.0: version "1.1.1" @@ -4589,10 +4600,10 @@ http-errors@~1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" -"http-parser-js@>=0.4.0 <0.4.11": - version "0.4.10" - resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.4.10.tgz#92c9c1374c35085f75db359ec56cc257cbb93fa4" - integrity sha1-ksnBN0w1CF912zWexWzCV8u5P6Q= +http-parser-js@>=0.5.1: + version "0.5.2" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.2.tgz#da2e31d237b393aae72ace43882dd7e270a8ff77" + integrity sha512-opCO9ASqg5Wy2FNo7A0sxy71yGbbkJJXLdgMK04Tcypw9jr2MgWbyubb0+WdmDmGnFflO7fRbqbaihh/ENDlRQ== http-proxy-middleware@0.19.1: version "0.19.1" @@ -4605,9 +4616,9 @@ http-proxy-middleware@0.19.1: micromatch "^3.1.10" http-proxy@^1.13.0, http-proxy@^1.17.0: - version "1.18.0" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a" - integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ== + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== dependencies: eventemitter3 "^4.0.0" follow-redirects "^1.0.0" @@ -4682,7 +4693,7 @@ icon-windows@*: resolved "https://registry.yarnpkg.com/icon-windows/-/icon-windows-0.0.1.tgz#abe463f50f10d6dfc3bea2d5ffd4a1b350b57fcd" integrity sha1-q+Rj9Q8Q1t/DvqLV/9Shs1C1f80= -iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4: +iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -4711,13 +4722,6 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= -ignore-walk@^3.0.1: - version "3.0.3" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37" - integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw== - dependencies: - minimatch "^3.0.4" - ignore@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" @@ -4811,11 +4815,6 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -ini@~1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== - inline-source-map@~0.6.0: version "0.6.2" resolved "https://registry.yarnpkg.com/inline-source-map/-/inline-source-map-0.6.2.tgz#f9393471c18a79d1724f863fa38b586370ade2a5" @@ -4867,9 +4866,9 @@ internal-ip@^4.3.0: ipaddr.js "^1.9.0" interpret@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" - integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== + version "1.4.0" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" + integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== invariant@^2.2.2: version "2.2.4" @@ -4883,11 +4882,6 @@ invert-kv@^1.0.0: resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= -invert-kv@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" - integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== - ip-regex@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" @@ -4967,9 +4961,9 @@ is-buffer@~2.0.3: integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A== is-callable@^1.1.4, is-callable@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.5.tgz#f7e46b596890456db74e7f6e976cb3273d06faab" - integrity sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q== + version "1.2.0" + resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb" + integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw== is-color-stop@^1.0.0: version "1.1.0" @@ -5097,6 +5091,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-map@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1" + integrity sha512-T/S49scO8plUiAOA2DBTBG3JHpn1yiw0kRp6dgiZ0v2/6twi5eiB0rHtHFH9ZIrvlWc6+4O+m4zg5+Z833aXgw== + is-number@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" @@ -5167,29 +5166,29 @@ is-primitive@^2.0.0: resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU= -is-promise@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" - integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o= - is-regex@^1.0.4, is-regex@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae" - integrity sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ== + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.0.tgz#ece38e389e490df0dc21caea2bd596f987f767ff" + integrity sha512-iI97M8KTWID2la5uYXlkbSDQIg4F6o1sYboZKKTDpnDQMLtUL86zxhgDet3Q2SriaYsyGqZ6Mn2SjbRKeLHdqw== dependencies: - has "^1.0.3" + has-symbols "^1.0.1" is-resolvable@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88" integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg== +is-set@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.1.tgz#d1604afdab1724986d30091575f54945da7e5f43" + integrity sha512-eJEzOtVyenDs1TMzSQ3kU3K+E0GUS9sno+F0OBT97xsgcJsF9nXMBtkT9/kut5JEpM7oL7X/0qxR17K3mcwIAA== + is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= -is-string@^1.0.5: +is-string@^1.0.4, is-string@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6" integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ== @@ -5250,6 +5249,11 @@ isarray@2.0.1: resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e" integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4= +isarray@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" + integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== + isbinaryfile@^3.0.0: version "3.0.3" resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.3.tgz#5d6def3edebf6e8ca8cae9c30183a804b5f8be80" @@ -5284,6 +5288,19 @@ isstream@~0.1.2: resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= +iterate-iterator@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/iterate-iterator/-/iterate-iterator-1.0.1.tgz#1693a768c1ddd79c969051459453f082fe82e9f6" + integrity sha512-3Q6tudGN05kbkDQDI4CqjaBf4qf85w6W6GnuZDtUVYwKgtC1q8yxYX7CZed7N+tLzQqS6roujWvszf13T+n9aw== + +iterate-value@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/iterate-value/-/iterate-value-1.0.2.tgz#935115bd37d006a52046535ebc8d07e9c9337f57" + integrity sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ== + dependencies: + es-get-iterator "^1.0.2" + iterate-iterator "^1.0.1" + jasmine-core@^3.3.0, jasmine-core@~3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-3.5.0.tgz#132c23e645af96d85c8bca13c8758b18429fc1e4" @@ -5317,9 +5334,9 @@ jquery-ujs@^1.2.2: jquery ">=1.8.0" jquery@>=1.0.0, jquery@>=1.8.0, jquery@^3.3.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.4.1.tgz#714f1f8d9dde4bdfa55764ba37ef214630d80ef2" - integrity sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw== + version "3.5.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.5.1.tgz#d7b4d08e1bfdb86ad2f1a3d039ea17304717abb5" + integrity sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg== js-base64@^2.1.8, js-base64@^2.1.9: version "2.5.2" @@ -5341,7 +5358,15 @@ js-tokens@^3.0.2: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@*, js-yaml@3.13.1, js-yaml@^3.12.0, js-yaml@^3.13.0, js-yaml@^3.13.1: +js-yaml@*, js-yaml@^3.12.0, js-yaml@^3.13.0, js-yaml@^3.13.1: + version "3.14.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" + integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@3.13.1: version "3.13.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== @@ -5618,13 +5643,6 @@ lcid@^1.0.0: dependencies: invert-kv "^1.0.0" -lcid@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" - integrity sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA== - dependencies: - invert-kv "^2.0.0" - levn@^0.3.0, levn@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" @@ -5692,6 +5710,13 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + lodash._reinterpolate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" @@ -5762,7 +5787,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@~4.17.12: +"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.4, lodash@~4.17.10: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== @@ -5792,10 +5817,10 @@ log4js@^4.0.0: rfdc "^1.1.4" streamroller "^1.0.6" -loglevel@^1.6.6: - version "1.6.7" - resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.7.tgz#b3e034233188c68b889f5b862415306f565e2c56" - integrity sha512-cY2eLFrQSAfVPhCgH1s7JI73tMbg9YC3v3+ZHVW67sBS7UxWzNEk/ZBbSfLykBWHp33dqqtOv82gjhKEi81T/A== +loglevel@^1.6.8: + version "1.6.8" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" + integrity sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA== loglevelnext@^1.0.1: version "1.0.5" @@ -5859,13 +5884,6 @@ make-dir@^1.0.0: dependencies: pify "^3.0.0" -map-age-cleaner@^0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" - integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== - dependencies: - p-defer "^1.0.0" - map-cache@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" @@ -5924,15 +5942,6 @@ mem@^1.1.0: dependencies: mimic-fn "^1.0.0" -mem@^4.0.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-4.3.0.tgz#461af497bc4ae09608cdb2e60eefb69bff744178" - integrity sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w== - dependencies: - map-age-cleaner "^0.1.1" - mimic-fn "^2.0.0" - p-is-promise "^2.0.0" - memory-fs@^0.4.0, memory-fs@^0.4.1, memory-fs@~0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552" @@ -6013,17 +6022,17 @@ miller-rabin@^4.0.0: bn.js "^4.0.0" brorand "^1.0.1" -mime-db@1.43.0, "mime-db@>= 1.43.0 < 2": - version "1.43.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58" - integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ== +mime-db@1.44.0, "mime-db@>= 1.43.0 < 2": + version "1.44.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92" + integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg== mime-types@^2.1.12, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24: - version "2.1.26" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.26.tgz#9c921fc09b7e149a65dfdc0da4d20997200b0a06" - integrity sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ== + version "2.1.27" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f" + integrity sha512-JIhqnCasI9yD+SsmkquHBxTSEuZdQX5BuQnS2Vc7puQQQ+8yiP5AY5uWhpdv4YL4VM5c6iliiYWPgJ/nJQLp7w== dependencies: - mime-db "1.43.0" + mime-db "1.44.0" mime@1.6.0: version "1.6.0" @@ -6031,20 +6040,15 @@ mime@1.6.0: integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== mime@^2.1.0, mime@^2.3.1, mime@^2.4.4: - version "2.4.4" - resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" - integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== + version "2.4.6" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1" + integrity sha512-RZKhC3EmpBchfTGBVb8fb+RL2cWyw/32lshnsETttkBAyAUXSGHxbEJWWRXc751DrIxG1q04b8QwMbAwkRPpUA== mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== -mimic-fn@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" @@ -6072,21 +6076,6 @@ minimist@~0.0.1: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8= -minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6" - integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - -minizlib@^1.2.1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" - integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q== - dependencies: - minipass "^2.9.0" - mississippi@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f" @@ -6119,13 +6108,6 @@ mixin-object@^2.0.1: for-in "^0.1.3" is-extendable "^0.1.1" -mkdirp@0.5.3: - version "0.5.3" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.3.tgz#5a514b7179259287952881e94410ec5465659f8c" - integrity sha512-P+2gwrFqx8lhew375MQHHeTlY8AuOJSrGf0R5ddkEndUkmwpgUob/vQuBD1V22/Cw1/lJr4x+EjllSezBThzBg== - dependencies: - minimist "^1.2.5" - "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" @@ -6139,31 +6121,32 @@ mkpath@^0.1.0: integrity sha1-dVSm+Nhxg0zJe1RisSLEwSTW3pE= mocha@*: - version "7.1.1" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.1.1.tgz#89fbb30d09429845b1bb893a830bf5771049a441" - integrity sha512-3qQsu3ijNS3GkWcccT5Zw0hf/rWvu1fTN9sPvEd81hlwsr30GX2GcDSSoBxo24IR8FelmrAydGC6/1J5QQP4WA== + version "8.0.1" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-8.0.1.tgz#fe01f0530362df271aa8f99510447bc38b88d8ed" + integrity sha512-vefaXfdYI8+Yo8nPZQQi0QO2o+5q9UIMX1jZ1XMmK3+4+CQjc7+B0hPdUeglXiTlr8IHMVRo63IhO9Mzt6fxOg== dependencies: - ansi-colors "3.2.3" + ansi-colors "4.1.1" browser-stdout "1.3.1" - chokidar "3.3.0" + chokidar "3.3.1" debug "3.2.6" - diff "3.5.0" + diff "4.0.2" escape-string-regexp "1.0.5" - find-up "3.0.0" - glob "7.1.3" + find-up "4.1.0" + glob "7.1.6" growl "1.10.5" he "1.2.0" js-yaml "3.13.1" log-symbols "3.0.0" minimatch "3.0.4" - mkdirp "0.5.3" - ms "2.1.1" - node-environment-flags "1.0.6" + ms "2.1.2" object.assign "4.1.0" - strip-json-comments "2.0.1" - supports-color "6.0.0" - which "1.3.1" + promise.allsettled "1.0.2" + serialize-javascript "3.0.0" + strip-json-comments "3.0.1" + supports-color "7.1.0" + which "2.0.2" wide-align "1.1.3" + workerpool "6.0.0" yargs "13.3.2" yargs-parser "13.1.2" yargs-unparser "1.6.0" @@ -6190,9 +6173,9 @@ module-deps@^4.0.2, module-deps@^4.0.8: xtend "^4.0.0" moment@^2.10.2, moment@^2.22.2: - version "2.24.0" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" - integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== + version "2.26.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.26.0.tgz#5e1f82c6bafca6e83e808b30c8705eed0dcbd39a" + integrity sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw== move-concurrently@^1.0.1: version "1.0.1" @@ -6216,7 +6199,7 @@ ms@2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -ms@^2.1.1: +ms@2.1.2, ms@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== @@ -6240,9 +6223,9 @@ mute-stream@0.0.7: integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= nan@^2.12.1, nan@^2.13.2: - version "2.14.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" - integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== + version "2.14.1" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" + integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== nanomatch@^1.2.9: version "1.2.13" @@ -6271,15 +6254,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -needle@^2.2.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.1.tgz#14af48732463d7475696f937626b1b993247a56a" - integrity sha512-x/gi6ijr4B7fwl6WYL9FwlCvRQKGlUNvnceho8wxkwXqN8jvVmmmATTmZPRRG7b/yC1eode26C2HO9jl78Du9g== - dependencies: - debug "^3.2.6" - iconv-lite "^0.4.4" - sax "^1.2.4" - negotiator@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" @@ -6312,14 +6286,6 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -node-environment-flags@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088" - integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw== - dependencies: - object.getownpropertydescriptors "^2.0.3" - semver "^5.7.0" - node-forge@0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" @@ -6372,31 +6338,15 @@ node-libs-browser@^2.0.0: util "^0.11.0" vm-browserify "^1.0.1" -node-pre-gyp@*: - version "0.14.0" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz#9a0596533b877289bcad4e143982ca3d904ddc83" - integrity sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4.4.2" - node-releases@^1.1.53: - version "1.1.53" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.53.tgz#2d821bfa499ed7c5dffc5e2f28c88e78a08ee3f4" - integrity sha512-wp8zyQVwef2hpZ/dJH7SfSrIPD6YoJz6BDQDpGEkcA0s3LpAQoxBIYmfIq6QAhC1DhwsyCgTaTTcONwX8qzCuQ== + version "1.1.58" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.58.tgz#8ee20eef30fa60e52755fcc0942def5a734fe935" + integrity sha512-NxBudgVKiRh/2aPWMgPR7bPTX0VPmGx5QBwCtdHitnqFE5/O8DeBXuIMH1nwNnw/aMo6AjOrpsHzfY3UbUJ7yg== node-sass@^4.9.2: - version "4.13.1" - resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.13.1.tgz#9db5689696bb2eec2c32b98bfea4c7a2e992d0a3" - integrity sha512-TTWFx+ZhyDx1Biiez2nB0L3YrCZ/8oHagaDalbuBSlqXgUPsdkUSzJsVxeDO9LtPB49+Fh3WQl3slABo6AotNw== + version "4.14.1" + resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.14.1.tgz#99c87ec2efb7047ed638fb4c9db7f3a42e2217b5" + integrity sha512-sjCuOlvGyCJS40R8BscF5vhVlQjNN069NtQ1gSxyK1u9iqvn6tf7O1R4GNowVZfiZUCRt5MmMs1xd+4V/7Yr0g== dependencies: async-foreach "^0.1.3" chalk "^1.1.1" @@ -6412,7 +6362,7 @@ node-sass@^4.9.2: node-gyp "^3.8.0" npmlog "^4.0.0" request "^2.88.0" - sass-graph "^2.2.4" + sass-graph "2.2.5" stdout-stream "^1.4.0" "true-case-path" "^1.0.2" @@ -6423,14 +6373,6 @@ node-sass@^4.9.2: dependencies: abbrev "1" -nopt@^4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48" - integrity sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg== - dependencies: - abbrev "1" - osenv "^0.1.4" - nopt@~1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" @@ -6480,27 +6422,6 @@ normalize-url@^3.0.0: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== -npm-bundled@^1.0.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b" - integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA== - dependencies: - npm-normalize-package-bin "^1.0.1" - -npm-normalize-package-bin@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" - integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== - -npm-packlist@^1.1.6: - version "1.4.8" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e" - integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - npm-normalize-package-bin "^1.0.1" - npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" @@ -6508,7 +6429,7 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" -"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.2: +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -6580,9 +6501,12 @@ object-inspect@^1.7.0: integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw== object-is@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.2.tgz#6b80eb84fe451498f65007982f035a5b445edec4" - integrity sha512-Epah+btZd5wrrfjkJZq1AOB9O6OxUQto45hzFd7lXGrpHPGE0W1k+426yrZV+k6NJOzLNNW/nVsmZdIWsAqoOQ== + version "1.1.2" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.2.tgz#c5d2e87ff9e119f78b7a088441519e2eec1573b6" + integrity sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: version "1.1.1" @@ -6607,16 +6531,15 @@ object.assign@4.1.0, object.assign@^4.1.0: object-keys "^1.0.11" object.entries@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.1.tgz#ee1cf04153de02bb093fec33683900f57ce5399b" - integrity sha512-ilqR7BgdyZetJutmDPfXCDffGa0/Yzl2ivVNpbx/g4UeWrCdRnFDUBrKJGLhGieRHDATnyZXWBeCb29k9CJysQ== + version "1.1.2" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.2.tgz#bc73f00acb6b6bb16c203434b10f9a7e797d3add" + integrity sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA== dependencies: define-properties "^1.1.3" - es-abstract "^1.17.0-next.1" - function-bind "^1.1.1" + es-abstract "^1.17.5" has "^1.0.3" -object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0: +object.getownpropertydescriptors@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649" integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg== @@ -6639,7 +6562,7 @@ object.pick@^1.3.0: dependencies: isobject "^3.0.1" -object.values@^1.1.0: +object.values@^1.1.0, object.values@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e" integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA== @@ -6742,13 +6665,6 @@ os-homedir@^1.0.0, os-homedir@^1.0.1: resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= -os-locale@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" - integrity sha1-IPnxeuKe00XoveWDsT0gCYA8FNk= - dependencies: - lcid "^1.0.0" - os-locale@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2" @@ -6758,21 +6674,12 @@ os-locale@^2.0.0: lcid "^1.0.0" mem "^1.1.0" -os-locale@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a" - integrity sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q== - dependencies: - execa "^1.0.0" - lcid "^2.0.0" - mem "^4.0.0" - os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= -osenv@0, osenv@^0.1.4: +osenv@0: version "0.1.5" resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== @@ -6780,21 +6687,11 @@ osenv@0, osenv@^0.1.4: os-homedir "^1.0.0" os-tmpdir "^1.0.0" -p-defer@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" - integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= - p-finally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= -p-is-promise@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" - integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== - p-limit@^1.1.0: version "1.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" @@ -6802,7 +6699,7 @@ p-limit@^1.1.0: dependencies: p-try "^1.0.0" -p-limit@^2.0.0: +p-limit@^2.0.0, p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== @@ -6823,6 +6720,13 @@ p-locate@^3.0.0: dependencies: p-limit "^2.0.0" +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + p-map@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" @@ -6878,7 +6782,7 @@ parents@^1.0.0, parents@^1.0.1: dependencies: path-platform "~0.11.15" -parse-asn1@^5.0.0: +parse-asn1@^5.0.0, parse-asn1@^5.1.5: version "5.1.5" resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.5.tgz#003271343da58dc94cace494faef3d2147ecea0e" integrity sha512-jkMYn1dcJqF6d5CpU689bq7w/b5ALS9ROVSpQDPrZsqqesUJii9qutvoT5ltGedNXMO2e16YUWIghG9KxaViTQ== @@ -6966,6 +6870,11 @@ path-exists@^3.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + path-is-absolute@^1.0.0, path-is-absolute@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -7018,9 +6927,9 @@ pathval@^1.1.0: integrity sha1-uULm1L3mUwBe9rcTYd74cn0GReA= pbkdf2@^3.0.3: - version "3.0.17" - resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.17.tgz#976c206530617b14ebb32114239f7b09336e93a6" - integrity sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA== + version "3.1.1" + resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.1.tgz#cb8724b0fada984596856d1a6ebafd3584654b94" + integrity sha512-4Ejy1OPxi9f2tt1rRV7Go7zmfDQ+ZectEQz3VGUQhgq62HtIRPDyG/JtnwIxs6x3uNMwo2V7q1fMvKjb+Tnpqg== dependencies: create-hash "^1.1.2" create-hmac "^1.1.4" @@ -7033,7 +6942,7 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= -picomatch@^2.0.4, picomatch@^2.0.7: +picomatch@^2.0.4, picomatch@^2.0.7, picomatch@^2.2.1: version "2.2.2" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== @@ -7147,10 +7056,10 @@ popper@^1.0.1: optionalDependencies: ngrok "*" -portfinder@^1.0.25: - version "1.0.25" - resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.25.tgz#254fd337ffba869f4b9d37edc298059cb4d35eca" - integrity sha512-6ElJnHBbxVA1XSLgBp7G1FiCkQdlqGzuF7DswL5tcea+E8UpuvPU7beVAjjRwCioTS9ZluNbu+ZyRvgTsmqEBg== +portfinder@^1.0.26: + version "1.0.26" + resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.26.tgz#475658d56ca30bed72ac7f1378ed350bd1b64e70" + integrity sha512-Xi7mKxJHHMI3rIUrnm/jjUgwhbYMkp/XKEcZX3aG4BrumLpq3nmoQMX+ClYnDZnZ/New7IatC1no5RX0zo1vXQ== dependencies: async "^2.6.2" debug "^3.1.1" @@ -7953,9 +7862,9 @@ postcss-value-parser@^3.0.0, postcss-value-parser@^3.0.1, postcss-value-parser@^ integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== postcss-value-parser@^4.0.2: - version "4.0.3" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz#651ff4593aa9eda8d5d0d66593a2417aeaeb325d" - integrity sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg== + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" + integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== postcss-values-parser@^1.5.0: version "1.5.0" @@ -7995,9 +7904,9 @@ postcss@^6.0, postcss@^6.0.0, postcss@^6.0.1, postcss@^6.0.11, postcss@^6.0.14, supports-color "^5.4.0" postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.2, postcss@^7.0.27: - version "7.0.27" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.27.tgz#cc67cdc6b0daa375105b7c424a85567345fc54d9" - integrity sha512-WuQETPMcW9Uf1/22HWUWP9lgsIC+KEHg2kozMflKjbeUtw9ujvFX6QmIfozaErDkmLWS9WEnEdEe6Uo9/BNTdQ== + version "7.0.32" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d" + integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw== dependencies: chalk "^2.4.2" source-map "^0.6.1" @@ -8048,6 +7957,17 @@ promise-inflight@^1.0.1: resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= +promise.allsettled@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/promise.allsettled/-/promise.allsettled-1.0.2.tgz#d66f78fbb600e83e863d893e98b3d4376a9c47c9" + integrity sha512-UpcYW5S1RaNKT6pd+s9jp9K9rlQge1UXKskec0j6Mmuq7UJCvlS2J2/s/yuPN8ehftf9HXMxWlKiPbGGUzpoRg== + dependencies: + array.prototype.map "^1.0.1" + define-properties "^1.1.3" + es-abstract "^1.17.0-next.1" + function-bind "^1.1.1" + iterate-value "^1.0.0" + proxy-addr@~2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf" @@ -8218,16 +8138,6 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" -rc@^1.2.7: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - read-cache@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/read-cache/-/read-cache-1.0.0.tgz#e664ef31161166c9751cdbe8dbcf86b5fb58f774" @@ -8299,7 +8209,7 @@ readable-stream@^1.1.8: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0: +readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== @@ -8329,13 +8239,6 @@ readdirp@^2.0.0, readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" -readdirp@~3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.2.0.tgz#c30c33352b12c96dfb4b895421a49fd5a9593839" - integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ== - dependencies: - picomatch "^2.0.4" - readdirp@~3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17" @@ -8343,6 +8246,13 @@ readdirp@~3.3.0: dependencies: picomatch "^2.0.7" +readdirp@~3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada" + integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ== + dependencies: + picomatch "^2.2.1" + redent@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde" @@ -8383,9 +8293,9 @@ regenerate-unicode-properties@^8.2.0: regenerate "^1.4.0" regenerate@^1.2.1, regenerate@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11" - integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg== + version "1.4.1" + resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.1.tgz#cad92ad8e6b591773485fbe05a485caf4f457e6f" + integrity sha512-j2+C8+NtXQgEKWk49MMP5P/u2GhnahTtVkRIHr5R5lVRlbKvmQ+oS+A5aLKWp2ma5VkT8sh6v+v4hbH0YHR66A== regenerator-runtime@^0.10.5: version "0.10.5" @@ -8461,9 +8371,9 @@ regjsgen@^0.2.0: integrity sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc= regjsgen@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.1.tgz#48f0bf1a5ea205196929c0d9798b42d1ed98443c" - integrity sha512-5qxzGZjDs9w4tzT3TPhCJqWdCc3RLYwy9J2NB0nm5Lz+S273lvWcpjaTGHsT1dc6Hhfq41uSEOw8wBmxrKOuyg== + version "0.5.2" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733" + integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A== regjsparser@^0.1.4: version "0.1.5" @@ -8616,10 +8526,10 @@ resolve@1.1.7: resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" integrity sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs= -resolve@^1.1.3, resolve@^1.1.4, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1: - version "1.15.1" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.1.tgz#27bdcdeffeaf2d6244b95bb0f9f4b4653451f3e8" - integrity sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w== +resolve@^1.1.3, resolve@^1.1.4, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.13.1, resolve@^1.17.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444" + integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w== dependencies: path-parse "^1.0.6" @@ -8820,11 +8730,9 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: inherits "^2.0.1" run-async@^2.2.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.0.tgz#e59054a5b86876cfae07f431d18cbaddc594f1e8" - integrity sha512-xJTbh/d7Lm7SBhc1tNvTpeCHaEzoyxPrqNlvSdMfBTYwaY++UJFyXUOxAtsRUXjlqOfj8luNaR9vjCh4KeV+pg== - dependencies: - is-promise "^2.1.0" + version "2.4.1" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" + integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== run-queue@^1.0.0, run-queue@^1.0.3: version "1.0.3" @@ -8845,11 +8753,16 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.0, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: +safe-buffer@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== +safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + safe-regex@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" @@ -8862,15 +8775,15 @@ safe-regex@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sass-graph@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49" - integrity sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k= +sass-graph@2.2.5: + version "2.2.5" + resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.5.tgz#a981c87446b8319d96dce0671e487879bd24c2e8" + integrity sha512-VFWDAHOe6mRuT4mZRd4eKE+d8Uedrk6Xnh7Sh9b4NGufQLQjOrvf/MQoOdx+0s92L89FeyUUNfU597j/3uNpag== dependencies: glob "^7.0.0" lodash "^4.0.0" scss-tokenizer "^0.2.3" - yargs "^7.0.0" + yargs "^13.3.2" sass-loader@^6.0.7: version "6.0.7" @@ -8883,7 +8796,7 @@ sass-loader@^6.0.7: neo-async "^2.5.0" pify "^3.0.0" -sax@^1.2.4, sax@~1.2.1, sax@~1.2.4: +sax@~1.2.1, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -8932,7 +8845,7 @@ selfsigned@^1.10.7: dependencies: node-forge "0.9.0" -"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.5.0, semver@^5.5.1, semver@^5.7.0: +"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.5.0, semver@^5.5.1: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -8966,6 +8879,11 @@ send@0.17.1: range-parser "~1.2.1" statuses "~1.5.0" +serialize-javascript@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-3.0.0.tgz#492e489a2d77b7b804ad391a5f5d97870952548e" + integrity sha512-skZcHYw2vEX4bw90nAr2iTTsz6x2SrHEnfxgKYmZlvJYBEZrvbKtobJWlQ20zczKb3bsHHXXTYt48zBA7ni9cw== + serialize-javascript@^1.4.0: version "1.9.1" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-1.9.1.tgz#cfc200aef77b600c47da9bb8149c943e798c2fdb" @@ -9192,13 +9110,14 @@ sockjs-client@1.4.0: json3 "^3.3.2" url-parse "^1.4.3" -sockjs@0.3.19: - version "0.3.19" - resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.19.tgz#d976bbe800af7bd20ae08598d582393508993c0d" - integrity sha512-V48klKZl8T6MzatbLlzzRNhMepEys9Y4oGFpypBFFn1gLI/QQ9HtLLyWJNbPlwGLelOVOEijUbTTJeLLI59jLw== +sockjs@0.3.20: + version "0.3.20" + resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.20.tgz#b26a283ec562ef8b2687b44033a4eeceac75d855" + integrity sha512-SpmVOVpdq0DJc0qArhF3E5xsxvaiqGNb73XfgBpK1y3UD5gs8DSo8aCTsuT5pX8rssdc2NDIzANwP9eCAiSdTA== dependencies: faye-websocket "^0.10.0" - uuid "^3.0.1" + uuid "^3.4.0" + websocket-driver "0.6.5" sort-keys@^1.0.0: version "1.1.2" @@ -9253,22 +9172,22 @@ source-map@^0.6.1, source-map@~0.6.1: integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== spdx-correct@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4" - integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q== + version "3.1.1" + resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" + integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w== dependencies: spdx-expression-parse "^3.0.0" spdx-license-ids "^3.0.0" spdx-exceptions@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977" - integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA== + version "2.3.0" + resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d" + integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A== spdx-expression-parse@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0" - integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg== + version "3.0.1" + resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679" + integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q== dependencies: spdx-exceptions "^2.1.0" spdx-license-ids "^3.0.0" @@ -9290,7 +9209,7 @@ spdy-transport@^3.0.0: readable-stream "^3.0.6" wbuf "^1.7.3" -spdy@^4.0.1: +spdy@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/spdy/-/spdy-4.0.2.tgz#b74f466203a3eda452c02492b91fb9e84a27677b" integrity sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA== @@ -9429,7 +9348,7 @@ strict-uri-encode@^1.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= -string-width@^1.0.1, string-width@^1.0.2: +string-width@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= @@ -9438,7 +9357,7 @@ string-width@^1.0.1, string-width@^1.0.2: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: +"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== @@ -9456,9 +9375,9 @@ string-width@^3.0.0, string-width@^3.1.0: strip-ansi "^5.1.0" string.prototype.trimend@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.0.tgz#ee497fd29768646d84be2c9b819e292439614373" - integrity sha512-EEJnGqa/xNfIg05SxiPSqRS7S9qwDhYts1TSLR1BQfYUfPe1stofgGKvwERK9+9yf+PpfBMlpBaCHucXGPQfUA== + version "1.0.1" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913" + integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g== dependencies: define-properties "^1.1.3" es-abstract "^1.17.5" @@ -9482,9 +9401,9 @@ string.prototype.trimright@^2.1.1: string.prototype.trimend "^1.0.0" string.prototype.trimstart@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.0.tgz#afe596a7ce9de905496919406c9734845f01a2f2" - integrity sha512-iCP8g01NFYiiBOnwG1Xc3WZLyoo+RuBymwIlWncShXDDJYWN6DbnM3odslBJdgCdRlq94B5s63NWAZlcn2CS4w== + version "1.0.1" + resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54" + integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw== dependencies: define-properties "^1.1.3" es-abstract "^1.17.5" @@ -9560,7 +9479,12 @@ strip-indent@^1.0.1: dependencies: get-stdin "^4.0.1" -strip-json-comments@2.0.1, strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: +strip-json-comments@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7" + integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw== + +strip-json-comments@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= @@ -9589,12 +9513,12 @@ subarg@^1.0.0: dependencies: minimist "^1.1.0" -supports-color@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.0.0.tgz#76cfe742cf1f41bb9b1c29ad03068c05b4c0e40a" - integrity sha512-on9Kwidc1IUQo+bQdhi8+Tijpo0e1SS6RoGo2guUwn5vdaxw8RXOF9Vb2ws+ihWOmh4JnCJOvaziZWP1VABaLg== +supports-color@7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1" + integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g== dependencies: - has-flag "^3.0.0" + has-flag "^4.0.0" supports-color@^2.0.0: version "2.0.0" @@ -9708,19 +9632,6 @@ tar@^2.0.0: fstream "^1.0.12" inherits "2" -tar@^4.4.2: - version "4.4.13" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" - integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.8.6" - minizlib "^1.2.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.3" - text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -9770,10 +9681,10 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= -tinymce@^4.9.7: - version "4.9.9" - resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-4.9.9.tgz#329659d2a8008170aba5c936a8d07bd3c07478cb" - integrity sha512-7Wqh4PGSAWm6FyNwyI1uFAaZyzeQeiwd9Gg2R89SpFIqoMrSzNHIYBqnZnlDm4Bd2DJ0wcC6uJhwFrabIE8puw== +tinymce@^4.9.10: + version "4.9.10" + resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-4.9.10.tgz#47bd7b4d27d80d53a464356eb7c72b97e5c3aadd" + integrity sha512-vyzGG04Q44Y7zWIKA4c+G7MxMCsed6JkrhU+k0TaDs9XKAiS+e+D3Fzz5OIJ7p5keF7lbRK5czgI8T1JtouZqw== tmp@0.0.33, tmp@0.0.x, tmp@^0.0.33: version "0.0.33" @@ -9879,10 +9790,20 @@ trim-right@^1.0.1: dependencies: glob "^7.1.2" +tsconfig-paths@^3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b" + integrity sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw== + dependencies: + "@types/json5" "^0.0.29" + json5 "^1.0.1" + minimist "^1.2.0" + strip-bom "^3.0.0" + tslib@^1.9.0: - version "1.11.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" - integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== + version "1.13.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" + integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== tty-browserify@0.0.0: version "0.0.0" @@ -10203,7 +10124,7 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -uuid@^3.0.1, uuid@^3.1.0, uuid@^3.3.2: +uuid@^3.1.0, uuid@^3.3.2, uuid@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== @@ -10272,14 +10193,23 @@ void-elements@^2.0.0: resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w= -watchpack@^1.4.0: - version "1.6.1" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.1.tgz#280da0a8718592174010c078c7585a74cd8cd0e2" - integrity sha512-+IF9hfUFOrYOOaKyfaI7h7dquUIOgyEMoQMLA7OP5FxegKA2+XdXThAZ9TU2kucfhDH7rfMHs1oPYziVGWRnZA== +watchpack-chokidar2@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz#9948a1866cbbd6cb824dea13a7ed691f6c8ddff0" + integrity sha512-9TyfOyN/zLUbA288wZ8IsMZ+6cbzvsNyEzSBp6e/zkifi6xxbl8SmQ/CxQq32k8NNqrdVEVUVSEf56L4rQ/ZxA== dependencies: chokidar "^2.1.8" + +watchpack@^1.4.0: + version "1.7.2" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.2.tgz#c02e4d4d49913c3e7e122c3325365af9d331e9aa" + integrity sha512-ymVbbQP40MFTp+cNMvpyBpBtygHnPzPkHqoIwRRj/0B8KhqQwV8LaKjtbaxF2lK4vl8zN9wCxS46IFCU5K4W0g== + dependencies: graceful-fs "^4.1.2" neo-async "^2.5.0" + optionalDependencies: + chokidar "^3.4.0" + watchpack-chokidar2 "^2.0.0" wbuf@^1.1.0, wbuf@^1.7.3: version "1.7.3" @@ -10326,9 +10256,9 @@ webpack-dev-middleware@^3.7.2: webpack-log "^2.0.0" webpack-dev-server@^3.1.11: - version "3.10.3" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.10.3.tgz#f35945036813e57ef582c2420ef7b470e14d3af0" - integrity sha512-e4nWev8YzEVNdOMcNzNeCN947sWJNd43E5XvsJzbAL08kGc2frm1tQ32hTJslRS+H65LCb/AaUCYU7fjHCpDeQ== + version "3.11.0" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.11.0.tgz#8f154a3bce1bcfd1cc618ef4e703278855e7ff8c" + integrity sha512-PUxZ+oSTxogFQgkTtFndEtJIPNmml7ExwufBZ9L2/Xyyd5PnOL5UreWe5ZT7IU25DSdykL9p1MLQzmLh2ljSeg== dependencies: ansi-html "0.0.7" bonjour "^3.5.0" @@ -10338,31 +10268,31 @@ webpack-dev-server@^3.1.11: debug "^4.1.1" del "^4.1.1" express "^4.17.1" - html-entities "^1.2.1" + html-entities "^1.3.1" http-proxy-middleware "0.19.1" import-local "^2.0.0" internal-ip "^4.3.0" ip "^1.1.5" is-absolute-url "^3.0.3" killable "^1.0.1" - loglevel "^1.6.6" + loglevel "^1.6.8" opn "^5.5.0" p-retry "^3.0.1" - portfinder "^1.0.25" + portfinder "^1.0.26" schema-utils "^1.0.0" selfsigned "^1.10.7" semver "^6.3.0" serve-index "^1.9.1" - sockjs "0.3.19" + sockjs "0.3.20" sockjs-client "1.4.0" - spdy "^4.0.1" + spdy "^4.0.2" strip-ansi "^3.0.1" supports-color "^6.1.0" url "^0.11.0" webpack-dev-middleware "^3.7.2" webpack-log "^2.0.0" ws "^6.2.1" - yargs "12.0.5" + yargs "^13.3.2" webpack-log@^1.0.1: version "1.2.0" @@ -10443,42 +10373,51 @@ webpack@^3.12.0: webpack-sources "^1.0.1" yargs "^8.0.2" +websocket-driver@0.6.5: + version "0.6.5" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.6.5.tgz#5cb2556ceb85f4373c6d8238aa691c8454e13a36" + integrity sha1-XLJVbOuF9Dc8bYI4qmkchFThOjY= + dependencies: + websocket-extensions ">=0.1.1" + websocket-driver@>=0.5.1: - version "0.7.3" - resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.3.tgz#a2d4e0d4f4f116f1e6297eba58b05d430100e9f9" - integrity sha512-bpxWlvbbB459Mlipc5GBzzZwhoZgGEZLuqPaR0INBGnPAY1vdBX6hPnoFXiw+3yWxDuHyQjO2oXTMyS8A5haFg== + version "0.7.4" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== dependencies: - http-parser-js ">=0.4.0 <0.4.11" + http-parser-js ">=0.5.1" safe-buffer ">=5.1.0" websocket-extensions ">=0.1.1" websocket-extensions@>=0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.3.tgz#5d2ff22977003ec687a4b87073dfbbac146ccf29" - integrity sha512-nqHUnMXmBzT0w570r2JpJxfiSD1IzoI+HGVdd3aZ0yNi3ngvQ4jv1dtHt5VGxfI2yj5yqImPhOK4vmIh2xMbGg== + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== whet.extend@~0.9.9: version "0.9.9" resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1" integrity sha1-+HfVv2SMl+WqVC+twW1qJZucEaE= -which-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" - integrity sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8= - which-module@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= -which@1, which@1.3.1, which@^1.2.1, which@^1.2.9: +which@1, which@^1.2.1, which@^1.2.9: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== dependencies: isexe "^2.0.0" +which@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + wide-align@1.1.3, wide-align@^1.1.0: version "1.1.3" resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" @@ -10513,6 +10452,11 @@ worker-farm@^1.5.2: dependencies: errno "~0.1.7" +workerpool@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.0.0.tgz#85aad67fa1a2c8ef9386a1b43539900f61d03d58" + integrity sha512-fU2OcNA/GVAJLLyKUoHkAgIhKb0JoCpSjLC/G2vYKxUjVmQwGbRVeoPJ1a8U4pnVofz4AQV5Y/NEw8oKqxEBtA== + wrap-ansi@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" @@ -10584,7 +10528,7 @@ y18n@^3.2.1: resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= -"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: +y18n@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== @@ -10594,11 +10538,6 @@ yallist@^2.1.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= -yallist@^3.0.0, yallist@^3.0.3: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" - integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== - yargs-parser@13.1.2, yargs-parser@^13.1.2: version "13.1.2" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" @@ -10607,21 +10546,6 @@ yargs-parser@13.1.2, yargs-parser@^13.1.2: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^11.1.1: - version "11.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" - integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" - -yargs-parser@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a" - integrity sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo= - dependencies: - camelcase "^3.0.0" - yargs-parser@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-7.0.0.tgz#8d0ac42f16ea55debd332caf4c4038b3e3f5dfd9" @@ -10638,25 +10562,7 @@ yargs-unparser@1.6.0: lodash "^4.17.15" yargs "^13.3.0" -yargs@12.0.5: - version "12.0.5" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13" - integrity sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw== - dependencies: - cliui "^4.0.0" - decamelize "^1.2.0" - find-up "^3.0.0" - get-caller-file "^1.0.1" - os-locale "^3.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1 || ^4.0.0" - yargs-parser "^11.1.1" - -yargs@13.3.2, yargs@^13.3.0: +yargs@13.3.2, yargs@^13.3.0, yargs@^13.3.2: version "13.3.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== @@ -10672,25 +10578,6 @@ yargs@13.3.2, yargs@^13.3.0: y18n "^4.0.0" yargs-parser "^13.1.2" -yargs@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8" - integrity sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg= - dependencies: - camelcase "^3.0.0" - cliui "^3.2.0" - decamelize "^1.1.1" - get-caller-file "^1.0.1" - os-locale "^1.4.0" - read-pkg-up "^1.0.1" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^1.0.2" - which-module "^1.0.0" - y18n "^3.2.1" - yargs-parser "^5.0.0" - yargs@^8.0.2: version "8.0.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-8.0.2.tgz#6299a9055b1cefc969ff7e79c1d918dceb22c360"