diff --git a/.gitignore b/.gitignore index abffb6d..1204e8b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,6 @@ app/assets config/locales/static_pages/*.yml -# Ignore gemfile.lock -#Gemfile.lock # Ignore db schema.rb # db/schema.rb @@ -41,6 +39,12 @@ db/test.sqlite3 db/test.sqlite3-journal +# Ingore DB dump files +*.sql +*.psql +*.sql.gz +*.psql.gz + # Ignore the SimpleCov output coverage @@ -84,3 +88,9 @@ lib/assets/npm-debug.log lib/assets/.eslintcache !.keep +.byebug_history +lib/data_cleanup/rules/org/fix_blank_abbreviation.yml +.rspec +node_modules +.env +public/packs diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..43d23b3 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,3 @@ +inherit_gem: + rubocop-dmp_roadmap: + - config/default.yml diff --git a/.travis.yml b/.travis.yml index 94cf431..7f367db 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,20 +1,73 @@ -language: ruby -rvm: - - 2.4.4 -before_install: - - curl -sL https://deb.nodesource.com/setup_8.x | sudo -E bash - && sudo apt-get install -y nodejs -before_script: - - cd lib/assets && npm install && npm run bundle -- -p && cd - - - cp config/database_example.yml config/database.yml - - cp config/secrets_example.yml config/secrets.yml - - cp config/branding_example.yml config/branding.yml - - cp config/initializers/devise.rb.example config/initializers/devise.rb - - cp config/initializers/recaptcha.rb.example config/initializers/recaptcha.rb - - cp config/initializers/wicked_pdf.rb.example config/initializers/wicked_pdf.rb - - bundle exec rake db:drop RAILS_ENV=test - - bundle exec rake db:create RAILS_ENV=test - - bundle exec rake db:schema:load RAILS_ENV=test - - bundle exec rake db:migrate RAILS_ENV=test +sudo: false +# Ruby is the main language of the project. +language: ruby + +bundler_args: --with development,ci + +# Cache third party dependencies for faster builds +cache: + apt: true + bundler: true + directories: + # Cache NPM packages + - lib/assets/node_modules + - $HOME/.npm + +addons: + chrome: stable + apt: + packages: + - nodejs + - wkhtmltopdf + + addons: + artifacts: + s3_region: "eu-west-2" + +matrix: + fast_finish: true + include: + +rvm: + # Use 2.4.1, since this is installed by default on Travis (1st Aug, 2018) + - 2.4.1 + +# These env variables will set up a separate testing environment for each +# combination of variables. +env: + # Run specs once with each database adapter we support + - DB_ADAPTER=postgresql + - DB_ADAPTER=mysql2 + +# Main test script script: - - bundle exec rake test + - export WICKED_PDF_PATH=./vendor/bundle/ruby/2.4.0/bin/wkhtmltopdf + # Copy over config files needed for setup, and create DB + - bin/setup + # Precompile the assets + - bundle exec rake assets:precompile + # Default test stage: Run all specs, listing the 10 slowest. + - bundle exec rspec spec --profile=10 + +# Run these stages in this order: +stages: + - security + - test + - hygiene + +# Define each stage (test is already defined automatically) +jobs: + include: + # Run Brakeman check with warning level 2, except these two checks: + - stage: security + name: "Brakeman check" + script: bundle exec brakeman -w2 --except=Redirect,CrossSiteScripting + + - stage: security + name: "Bundle audit" + script: bundle exec bundle-audit check --update + + - stage: hygiene + name: "Check seeds are valid" + script: bin/setup && bundle exec rake db:seed diff --git a/Gemfile b/Gemfile index 314e27e..aa164e2 100644 --- a/Gemfile +++ b/Gemfile @@ -1,87 +1,238 @@ source 'https://rubygems.org' -ruby '>= 2.4.4' +ruby '>= 2.4.0' # ------------------------------------------------ -# RAILS +# RAILS +# Full-stack web application framework. (http://www.rubyonrails.org) gem 'rails', '~> 4.2.10' + +# 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 -# 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 -gem 'protected_attributes', '~> 1.1.3' # Provides attr_accessor functions -gem 'responders', '~> 2.0' # Allows use of respond_with and respond_to in controllers +# A set of Rails responders to dry up your application (http://github.com/plataformatec/responders) +gem 'responders', '~> 2.0' # ------------------------------------------------ # DATABASE/SERVER -gem 'mysql2', '~> 0.4.10' -gem 'pg', '~> 0.19.0' + +group :mysql do + # 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) + gem 'pg', '~> 0.19.0' +end + +group :thin do + # A thin and fast web server (http://code.macournoyer.com/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 +end + +# Bit fields for ActiveRecord (https://github.com/pboling/flag_shih_tzu) gem 'flag_shih_tzu' # Allows for bitfields in activereccord # ------------------------------------------------ # JSON DSL - USED BY API +# 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' + +# 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' + +# OmniAuth Shibboleth strategies for OmniAuth 1.x gem 'omniauth-shibboleth' + +# ORCID OAuth 2.0 Strategy for OmniAuth 1.0 (https://github.com/datacite/omniauth-orcid) gem 'omniauth-orcid' + +# 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) gem 'pundit' # ------------------------------------------------ -# SETTINGS FOR TEMPLATES AND PLANS (FONTS, COLUMN LAYOUTS, ETC) +# 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' # ------------------------------------------------ -# VIEWS +# 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 + +# Helpers for the reCAPTCHA API (http://github.com/ambethia/recaptcha) gem 'recaptcha' -gem 'dragonfly' # LOGO UPLOAD + +# Ideal gem for handling attachments in Rails, Sinatra and Rack applications. (http://github.com/markevans/dragonfly) +gem 'dragonfly' # ------------------------------------------------ -# EXPORTING +# EXPORTING +# Provides binaries for WKHTMLTOPDF project in an easily accessible package. gem 'wkhtmltopdf-binary' -gem 'thin' + +# PDF generator (from HTML) gem for Ruby on Rails (https://github.com/mileszs/wicked_pdf) gem 'wicked_pdf' + +# 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' + +# A feed fetching and parsing library (http://feedjira.com) gem 'feedjira' # ------------------------------------------------ -# INTERNATIONALIZATION +# INTERNATIONALIZATION +# 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' -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 +# PAGINATION +# A pagination engine plugin for Rails 4+ and other modern frameworks (https://github.com/kaminari/kaminari) gem 'kaminari' -# ------------------------------------------------ -# ENVIRONMENT SPECIFIC DEPENDENCIES +# ------------------------------------------------ +# ENVIRONMENT SPECIFIC DEPENDENCIES group :development, :test do + # 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) + # rspec-collection_matchers-1.1.3 (https://github.com/rspec/rspec-collection_matchers) + gem "rspec-collection_matchers" + + # 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) + gem "fuubar" + + # Guard keeps an eye on your file modifications (http://guardgem.org) + gem "guard" + + # Guard gem for RSpec (https://github.com/guard/guard-rspec) + gem "guard-rspec" end group :test do - gem 'minitest-reporters' - gem 'rack-test' + # Library for stubbing HTTP requests in Ruby. (http://github.com/bblimke/webmock) gem 'webmock' - gem 'sqlite3' + + # 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 + + # 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) + gem "mocha", require: false + + # Rails application preloader (https://github.com/rails/spring) + gem "spring" + + # 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) + gem "capybara" + + # Automatically create snapshots when Cucumber steps fail with Capybara and Rails (http://github.com/mattheworiordan/capybara-screenshot) + gem "capybara-screenshot" + + # The next generation developer focused tool for automated testing of webapps (https://github.com/SeleniumHQ/selenium) + gem 'selenium-webdriver', '>= 3.13.1' + + # Easy installation and use of chromedriver. (https://github.com/flavorjones/chromedriver-helper) + gem 'chromedriver-helper', ">= 1.2.0" +end + +group :ci, :development do + # 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) + gem "rubocop-dmp_roadmap", ">= 1.1.0" + + # 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) + 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) 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) gem "binding_of_caller" + + # 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' + + # 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) + gem "annotate_gem" + + # 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) + gem "yard" + + # TomDoc for YARD (http://rubyworks.github.com/yard-tomdoc) + gem "yard-tomdoc" + + gem "dotenv-rails" + end diff --git a/Gemfile.lock b/Gemfile.lock index 29f1a92..28e6e20 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -35,9 +35,17 @@ minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) - addressable (2.4.0) - ansi (1.5.0) + addressable (2.5.2) + public_suffix (>= 2.0.2, < 4.0) + annotate (2.7.4) + activerecord (>= 3.2, < 6.0) + rake (>= 10.4, < 13.0) + annotate_gem (0.0.12) + bundler (~> 1.1) + archive-zip (0.11.0) + io-like (~> 0.3.0) arel (6.0.4) + ast (2.4.0) bcrypt (3.1.11) better_errors (2.4.0) coderay (>= 1.0.0) @@ -45,8 +53,32 @@ rack (>= 0.9.0) binding_of_caller (0.8.0) debug_inspector (>= 0.0.1) + brakeman (4.3.1) builder (3.2.3) + bullet (5.7.5) + activesupport (>= 3.0.0) + uniform_notifier (~> 1.11.0) + bundle-audit (0.1.0) + bundler-audit + bundler-audit (0.6.0) + bundler (~> 1.2) + thor (~> 0.18) byebug (10.0.2) + capybara (3.4.2) + addressable + mini_mime (>= 0.1.3) + nokogiri (~> 1.8) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + xpath (~> 3.1) + capybara-screenshot (1.0.21) + capybara (>= 1.0, < 4) + launchy + childprocess (0.9.0) + ffi (~> 1.0, >= 1.0.11) + chromedriver-helper (1.2.0) + archive-zip (~> 0.10) + nokogiri (~> 1.8) coderay (1.1.2) concurrent-ruby (1.0.5) contact_us (1.2.0) @@ -55,6 +87,7 @@ safe_yaml (~> 1.0.0) crass (1.0.4) daemons (1.2.6) + database_cleaner (1.7.0) debug_inspector (0.0.3) devise (4.4.3) bcrypt (~> 3.0) @@ -65,7 +98,12 @@ devise_invitable (1.7.4) actionmailer (>= 4.1.0) devise (>= 4.0.0) + diff-lcs (1.3) docile (1.3.0) + dotenv (2.5.0) + dotenv-rails (2.5.0) + dotenv (= 2.5.0) + railties (>= 3.2, < 6.0) dragonfly (1.1.5) addressable (~> 2.3) multi_json (~> 1.0) @@ -73,6 +111,13 @@ erubi (1.7.1) erubis (2.7.0) eventmachine (1.2.6) + factory_bot (4.10.0) + activesupport (>= 3.0.0) + factory_bot_rails (4.10.0) + factory_bot (~> 4.10.0) + railties (>= 3.0.0) + faker (1.9.1) + i18n (>= 0.7) faraday (0.9.2) multipart-post (>= 1.2, < 3) faraday_middleware (0.12.2) @@ -83,7 +128,12 @@ faraday_middleware (>= 0.9) loofah (>= 2.0) sax-machine (>= 1.0) + ffi (1.9.25) flag_shih_tzu (0.3.19) + formatador (0.2.5) + fuubar (2.3.1) + rspec-core (~> 3.0) + ruby-progressbar (~> 1.4) gettext (3.2.9) locale (>= 2.0.5) text (>= 1.3.0) @@ -96,14 +146,31 @@ rails (>= 3.2.0) globalid (0.4.1) activesupport (>= 4.2.0) + guard (2.14.2) + formatador (>= 0.2.4) + listen (>= 2.7, < 4.0) + lumberjack (>= 1.0.12, < 2.0) + nenv (~> 0.1) + notiffany (~> 0.0) + pry (>= 0.9.12) + shellany (~> 0.0) + thor (>= 0.18.1) + guard-compat (1.2.1) + guard-rspec (4.7.3) + guard (~> 2.1) + guard-compat (~> 1.1) + rspec (>= 2.99.0, < 4.0) hashdiff (0.3.7) hashie (3.5.7) + highline (1.7.10) htmltoword (1.0.0) actionpack nokogiri rubyzip (>= 1.0) i18n (0.9.5) concurrent-ruby (~> 1.0) + io-like (0.3.0) + jaro_winkler (1.5.1) jbuilder (2.6.0) activesupport (>= 3.0.0, < 5.1) multi_json (~> 1.2) @@ -121,28 +188,38 @@ activerecord kaminari-core (= 1.1.1) kaminari-core (1.1.1) + launchy (2.4.3) + addressable (~> 2.3) ledermann-rails-settings (2.4.2) activerecord (>= 3.1) + listen (3.1.5) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + ruby_dep (~> 1.2) locale (2.1.2) loofah (2.2.2) crass (~> 1.0.2) nokogiri (>= 1.5.9) + lumberjack (1.0.13) mail (2.7.0) mini_mime (>= 0.1.1) + metaclass (0.0.4) + method_source (0.9.0) mini_mime (1.0.0) mini_portile2 (2.3.0) minitest (5.11.3) - minitest-reporters (1.2.0) - ansi - builder - minitest (>= 5.0) - ruby-progressbar + mocha (1.5.0) + metaclass (~> 0.0.1) multi_json (1.13.1) multi_xml (0.6.0) multipart-post (2.0.0) mysql2 (0.4.10) - nokogiri (1.8.2) + nenv (0.3.0) + nokogiri (1.8.5) mini_portile2 (~> 2.3.0) + notiffany (0.1.1) + nenv (~> 0.1) + shellany (~> 0.0) oauth2 (1.4.0) faraday (>= 0.8, < 0.13) jwt (~> 1.0) @@ -160,12 +237,23 @@ ruby_dig (~> 0.0.2) omniauth-shibboleth (1.3.0) omniauth (>= 1.0.0) + options (2.3.2) orm_adapter (0.5.0) + parallel (1.12.1) + parser (2.5.1.2) + ast (~> 2.4.0) pg (0.19.0) po_to_json (1.0.1) json (>= 1.6.0) - protected_attributes (1.1.3) - activemodel (>= 4.0.1, < 5.0) + powerpack (0.1.2) + progress_bar (1.2.0) + highline (~> 1.6) + options (~> 2.3.0) + pry (0.11.3) + coderay (~> 1.1.0) + method_source (~> 0.9.0) + public_suffix (3.0.2) + puma (3.12.0) pundit (1.1.0) activesupport (>= 3.0.0) rack (1.6.10) @@ -197,29 +285,87 @@ activesupport (= 4.2.10) rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) + rainbow (3.0.0) rake (12.3.1) + rb-fsevent (0.10.3) + rb-inotify (0.9.10) + ffi (>= 0.5.0, < 2) recaptcha (4.8.0) json responders (2.3.0) railties (>= 4.2.0, < 5.1) + rspec (3.7.0) + rspec-core (~> 3.7.0) + rspec-expectations (~> 3.7.0) + rspec-mocks (~> 3.7.0) + rspec-collection_matchers (1.1.3) + rspec-expectations (>= 2.99.0.beta1) + rspec-core (3.7.1) + rspec-support (~> 3.7.0) + rspec-expectations (3.7.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.7.0) + rspec-mocks (3.7.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.7.0) + rspec-rails (3.7.2) + actionpack (>= 3.0) + activesupport (>= 3.0) + railties (>= 3.0) + rspec-core (~> 3.7.0) + rspec-expectations (~> 3.7.0) + rspec-mocks (~> 3.7.0) + rspec-support (~> 3.7.0) + rspec-support (3.7.1) + rubocop (0.58.2) + jaro_winkler (~> 1.5.1) + parallel (~> 1.10) + parser (>= 2.5, != 2.5.1.1) + powerpack (~> 0.1) + rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.7) + unicode-display_width (~> 1.0, >= 1.0.1) + rubocop-dmp_roadmap (1.1.0) + rubocop (>= 0.58.2) + rubocop-rails_config (>= 0.2.2) + rubocop-rspec (>= 1.27.0) + rubocop-rails_config (0.2.3) + railties (>= 3.0) + rubocop (~> 0.56) + rubocop-rspec (1.28.0) + rubocop (>= 0.58.0) ruby-progressbar (1.9.0) + ruby_dep (1.5.0) ruby_dig (0.0.2) - rubyzip (1.2.1) + rubyzip (1.2.2) safe_yaml (1.0.4) sax-machine (1.3.2) + selenium-webdriver (3.13.1) + childprocess (~> 0.5) + rubyzip (~> 1.2) + shellany (0.0.1) + shoulda (3.6.0) + shoulda-context (~> 1.0, >= 1.0.1) + shoulda-matchers (~> 3.0) + shoulda-context (1.2.2) + shoulda-matchers (3.1.2) + activesupport (>= 4.0.0) simplecov (0.16.1) docile (~> 1.1) json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) - sprockets (3.7.1) + spring (2.0.2) + activesupport (>= 4.2) + spring-commands-rspec (1.0.4) + spring (>= 0.9.1) + sprockets (3.7.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) sprockets-rails (3.2.1) actionpack (>= 4.0) activesupport (>= 4.0) sprockets (>= 3.0.0) - sqlite3 (1.3.13) text (1.3.1) thin (1.7.2) daemons (~> 1.0, >= 1.0.9) @@ -227,8 +373,11 @@ rack (>= 1, < 3) thor (0.20.0) thread_safe (0.3.6) + tomparse (0.4.2) tzinfo (1.2.5) thread_safe (~> 0.1) + unicode-display_width (1.4.0) + uniform_notifier (1.11.0) warden (1.2.7) rack (>= 1.0) web-console (3.3.0) @@ -241,52 +390,82 @@ hashdiff wicked_pdf (1.1.0) wkhtmltopdf-binary (0.12.3.1) + xpath (3.1.0) + nokogiri (~> 1.8) + yard (0.9.14) + yard-tomdoc (0.7.1) + tomparse (>= 0.4.0) + yard PLATFORMS ruby DEPENDENCIES + annotate + annotate_gem better_errors binding_of_caller + brakeman + bullet + bundle-audit byebug + capybara + capybara-screenshot + chromedriver-helper (>= 1.2.0) contact_us + database_cleaner devise devise_invitable + dotenv-rails dragonfly + factory_bot_rails + faker feedjira flag_shih_tzu + fuubar gettext gettext_i18n_rails gettext_i18n_rails_js + guard + guard-rspec htmltoword jbuilder (~> 2.6.0) kaminari ledermann-rails-settings - minitest-reporters + mocha mysql2 (~> 0.4.10) omniauth omniauth-orcid omniauth-shibboleth pg (~> 0.19.0) - protected_attributes (~> 1.1.3) + progress_bar + puma pundit rack-mini-profiler - rack-test rails (~> 4.2.10) railties recaptcha responders (~> 2.0) + rspec-collection_matchers + rspec-rails + rubocop-dmp_roadmap (>= 1.1.0) ruby_dig + selenium-webdriver (>= 3.13.1) + shoulda simplecov - sqlite3 + spring + spring-commands-rspec + text thin web-console webmock wicked_pdf wkhtmltopdf-binary + yard + yard-tomdoc RUBY VERSION ruby 2.4.4p296 BUNDLED WITH - 1.16.2 + 1.16.3 diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000..2303c69 --- /dev/null +++ b/Guardfile @@ -0,0 +1,56 @@ +# A sample Guardfile +# More info at https://github.com/guard/guard#readme + +# Note: The cmd option is now required due to the increasing number of ways +# rspec may be run, below are examples of the most common uses. +# * bundler: 'bundle exec rspec' +# * bundler binstubs: 'bin/rspec' +# * spring: 'bin/rspec' (This will use spring if running and you have +# installed the spring binstubs per the docs) +# * zeus: 'zeus rspec' (requires the server to be started separately) +# * 'just' rspec: 'rspec' + +guard :rspec, cmd: "bundle exec rspec" do + require "guard/rspec/dsl" + dsl = Guard::RSpec::Dsl.new(self) + + # Feel free to open issues for suggestions and improvements + + # RSpec files + rspec = dsl.rspec + watch(rspec.spec_helper) { rspec.spec_dir } + watch(rspec.spec_support) { rspec.spec_dir } + watch(rspec.spec_files) + + # Ruby files + ruby = dsl.ruby + dsl.watch_spec_files_for(ruby.lib_files) + + # Rails files + rails = dsl.rails(view_extensions: %w(erb haml slim)) + dsl.watch_spec_files_for(rails.app_files) + dsl.watch_spec_files_for(rails.views) + + watch(rails.controllers) do |m| + [ + rspec.spec.call("routing/#{m[1]}_routing"), + rspec.spec.call("controllers/#{m[1]}_controller"), + rspec.spec.call("acceptance/#{m[1]}") + ] + end + + # Rails config changes + watch(rails.spec_helper) { rspec.spec_dir } + watch(rails.routes) { "#{rspec.spec_dir}/routing" } + watch(rails.app_controller) { "#{rspec.spec_dir}/controllers" } + + # Capybara features specs + watch(rails.view_dirs) { |m| rspec.spec.call("features/#{m[1]}") } + watch(rails.layouts) { |m| rspec.spec.call("features/#{m[1]}") } + + # Turnip features and steps + watch(%r{^spec/acceptance/(.+)\.feature$}) + watch(%r{^spec/acceptance/steps/(.+)_steps\.rb$}) do |m| + Dir[File.join("**/#{m[1]}.feature")][0] || "spec/acceptance" + end +end diff --git a/app/controllers/answers_controller.rb b/app/controllers/answers_controller.rb index 28b3fe3..e7afbfb 100644 --- a/app/controllers/answers_controller.rb +++ b/app/controllers/answers_controller.rb @@ -1,96 +1,150 @@ +# frozen_string_literal: true + class AnswersController < ApplicationController + respond_to :html - # POST /answers/create_or_update + # POST /answers/create_or_update def create_or_update p_params = permitted_params() - #First it is checked plan exists and question exist for that plan + # First it is checked plan exists and question exist for that plan begin p = Plan.find(p_params[:plan_id]) if !p.question_exists?(p_params[:question_id]) - render(status: :not_found, json: - { msg: _("There is no question with id %{question_id} associated to plan id %{plan_id} for which to create or update an answer") % { :question_id => p_params[:question_id], :plan_id => p_params[:plan_id] }}) + # rubocop:disable LineLength + render(status: :not_found, json: { + msg: _("There is no question with id %{question_id} associated to plan id %{plan_id} for which to create or update an answer") % { + question_id: p_params[:question_id], + plan_id: p_params[:plan_id] + } + }) + # rubocop:enable LineLength return end rescue ActiveRecord::RecordNotFound - render(status: :not_found, json: - { msg: _('There is no plan with id %{id} for which to create or update an answer') %{ :id => p_params[:plan_id] }}) + # rubocop:disable LineLength + render(status: :not_found, json: { + msg: _("There is no plan with id %{id} for which to create or update an answer") % { + id: p_params[:plan_id] + } + }) + # rubocop:enable LineLength return end q = Question.find(p_params[:question_id]) + # rubocop:disable BlockLength Answer.transaction do begin - @answer = Answer.find_by!({ plan_id: p_params[:plan_id], question_id: p_params[:question_id] }) + @answer = Answer.find_by!( + plan_id: p_params[:plan_id], + question_id: p_params[:question_id] + ) authorize @answer - @answer.update(p_params.merge({ user_id: current_user.id })) + @answer.update(p_params.merge(user_id: current_user.id)) if p_params[:question_option_ids].present? - @answer.touch() # Saves the record with the updated_at set to the current time. Needed if only answer.question_options is updated + # Saves the record with the updated_at set to the current time. + # Needed if only answer.question_options is updated + @answer.touch() end if q.question_format.rda_metadata? - @answer.update_answer_hash(JSON.parse(params[:standards]), p_params[:text]) + @answer.update_answer_hash( + JSON.parse(params[:standards]), p_params[:text] + ) @answer.save! end rescue ActiveRecord::RecordNotFound - @answer = Answer.new(p_params.merge({ user_id: current_user.id })) + @answer = Answer.new(p_params.merge(user_id: current_user.id)) @answer.lock_version = 1 authorize @answer if q.question_format.rda_metadata? - @answer.update_answer_hash(JSON.parse(params[:standards]), p_params[:text]) + @answer.update_answer_hash( + JSON.parse(params[:standards]), p_params[:text] + ) end @answer.save! rescue ActiveRecord::StaleObjectError @stale_answer = @answer - @answer = Answer.find_by({plan_id: p_params[:plan_id], question_id: p_params[:question_id]}) + @answer = Answer.find_by( + plan_id: p_params[:plan_id], + question_id: p_params[:question_id] + ) end end + # rubocop:enable BlockLength if @answer.present? - @plan = Plan.includes({ + @plan = Plan.includes( sections: { questions: [ :answers, :question_format ] } - }).find(p_params[:plan_id]) + ).find(p_params[:plan_id]) @question = @answer.question - @section = @plan.get_section(@question.section_id) + @section = @plan.sections.find_by(id: @question.section_id) template = @section.phase.template + # rubocop:disable LineLength render json: { "question" => { "id" => @question.id, "answer_lock_version" => @answer.lock_version, "locking" => @stale_answer ? - render_to_string(partial: 'answers/locking', locals: { question: @question, answer: @stale_answer, user: @answer.user }, formats: [:html]) : + render_to_string(partial: "answers/locking", locals: { + question: @question, + answer: @stale_answer, + user: @answer.user + }, formats: [:html]) : nil, - "form" => render_to_string(partial: 'answers/new_edit', locals: { template: template, question: @question, answer: @answer, readonly: false, locking: false, base_template_org: template.base_org }, formats: [:html]), - "answer_status" => render_to_string(partial: 'answers/status', locals: { answer: @answer}, formats: [:html]) + "form" => render_to_string(partial: "answers/new_edit", locals: { + template: template, + question: @question, + answer: @answer, + readonly: false, + locking: false, + base_template_org: template.base_org + }, formats: [:html]), + "answer_status" => render_to_string(partial: "answers/status", locals: { + answer: @answer + }, formats: [:html]) }, "section" => { "id" => @section.id, - "progress" => render_to_string(partial: '/org_admin/sections/progress', locals: { section: @section, plan: @plan }, formats: [:html]) + "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: { plan: @plan, current_phase: @section.phase }, formats: [:html]) + "progress" => render_to_string(partial: "plans/progress", locals: { + plan: @plan, + current_phase: @section.phase + }, formats: [:html]) } }.to_json + # rubocop:enable LineLength end - - end # End update + end private - def permitted_params - permitted = params.require(:answer).permit(:id, :text, :plan_id, :user_id, :question_id, :lock_version, :question_option_ids => []) - if !params[:answer][:question_option_ids].nil? && !permitted[:question_option_ids].present? #If question_option_ids has been filtered out because it was a scalar value (e.g. radiobutton answer) - permitted[:question_option_ids] = [params[:answer][:question_option_ids]] # then convert to an Array - end - if !permitted[:id].present? - permitted.delete(:id) - end - return permitted - end # End permitted_params + def permitted_params + permitted = params.require(:answer).permit(:id, :text, :plan_id, :user_id, + :question_id, :lock_version, + question_option_ids: []) + # If question_option_ids has been filtered out because it was a + # scalar value (e.g. radiobutton answer) + if !params[:answer][:question_option_ids].nil? && + !permitted[:question_option_ids].present? + permitted[:question_option_ids] = [params[:answer][:question_option_ids]] + end + if !permitted[:id].present? + permitted.delete(:id) + end + permitted + end + end diff --git a/app/controllers/api/v0/base_controller.rb b/app/controllers/api/v0/base_controller.rb index c8edddd..ef10f85 100644 --- a/app/controllers/api/v0/base_controller.rb +++ b/app/controllers/api/v0/base_controller.rb @@ -1,126 +1,128 @@ -module Api - module V0 - class BaseController < ApplicationController - protect_from_forgery with: :null_session - before_action :set_resource, only: [:destroy, :show, :update] - respond_to :json +# frozen_string_literal: true - public - # POST /api/{plural_resource_name} - def create - set_resource(resource_class.new(resource_params)) +class Api::V0::BaseController < ApplicationController - if get_resource.save - render :show, status: :created - else - render json: get_resource.errors, status: :unprocessable_entity - end - end + protect_from_forgery with: :null_session + before_action :set_resource, only: [:destroy, :show, :update] + respond_to :json - # DELETE /api/{plural_resource_name}/1 - def destroy - get_resource.destroy - head :no_content - end + # POST /api/{plural_resource_name} + def create + set_resource(resource_class.new(resource_params)) - # GET /api/{plural_resource_name} - def index - plural_resource_name = "@#{resource_name.pluralize}" - resources = resource_class.where(query_params) - .page(page_params[:page]) - .per(page_params[:page_size]) - - instance_variable_set(plural_resource_name, resources) - respond_with instance_variable_get(plural_resource_name) - end - - # GET /api/{plural_resource_name}/1 - def show - respond_with get_resource - end - - # PATCH/PUT /api/{plural_resource_name}/1 - def update - if get_resource.update(resource_params) - render :show - else - render json: get_resource.errors, status: :unprocessable_entity - end - end - - private - # returns the resource from the created instance variable - # @return [Object] - def get_resource - instance_variable_get("@#{resource_name}") - end - - # Returns the allowed parameters for searching - # Override this method in each API controller - # to permit additional parameters to search on - # @return [Hash] - def query_params - {} - end - - # Returns the allowed parameters for pagination - # @return [Hash] - def page_params - params.permit(:page, :page_size) - end - - # The resource class based on the controller - # @return [Class] - def resource_class - @resource_class ||= resource_name.classify.constantize - end - - # The singular name for the resource class based on the controller - # @return [String] - def resource_name - @resource_name ||= self.controller_name.singularize - end - - # Only allow a trusted parameter "white list" through. - # If a single resource is loaded for #create or #update, - # then the controller for the resource must implement - # the method "#{resource_name}_params" to limit permitted - # parameters for the individual model. - def resource_params - @resource_params ||= self.send("#{resource_name}_params") - end - - # Use callbacks to share common setup or constraints between actions. - def set_resource(resource = nil) - resource ||= resource_class.find(params[:id]) - instance_variable_set("@#{resource_name}", resource) - end - - def authenticate - authenticate_token || render_bad_credentials - end - - def authenticate_token - authenticate_with_http_token do |token, options| - # reject the empty string as it is our base empty token - if token != "" - @token = token - @user = User.find_by(api_token: token) - # if no user found, return false, otherwise true - !@user.nil? && @user.can_use_api? - else - false - end - end - end - - - def render_bad_credentials - self.headers['WWW-Authenticate'] = "Token realm=\"\"" - render json: _("Bad Credentials"), status: 401 - end - - + if get_resource.save + render :show, status: :created + else + render json: get_resource.errors, status: :unprocessable_entity end end + + # DELETE /api/{plural_resource_name}/1 + def destroy + get_resource.destroy + head :no_content + end + + # GET /api/{plural_resource_name} + def index + plural_resource_name = "@#{resource_name.pluralize}" + resources = resource_class.where(query_params) + .page(page_params[:page]) + .per(page_params[:page_size]) + + instance_variable_set(plural_resource_name, resources) + respond_with instance_variable_get(plural_resource_name) + end + + # GET /api/{plural_resource_name}/1 + def show + respond_with get_resource + end + + # PATCH/PUT /api/{plural_resource_name}/1 + def update + if get_resource.update(resource_params) + render :show + else + render json: get_resource.errors, status: :unprocessable_entity + end + end + + private + + # The resource from the created instance variable + # + # Returns Object + def get_resource + instance_variable_get("@#{resource_name}") + end + + # The allowed parameters for searching. Override this method in each API + # controller to permit additional parameters to search on + # + # Returns Hash + def query_params + {} + end + + # The allowed parameters for pagination + # + # Returns Hash + def page_params + params.permit(:page, :page_size) + end + + # The resource class based on the controller + # + # Returns Object + def resource_class + @resource_class ||= resource_name.classify.constantize + end + + # The singular name for the resource class based on the controller + # + # Returns String + def resource_name + @resource_name ||= self.controller_name.singularize + end + + # Only allow a trusted parameter "white list" through. + # If a single resource is loaded for #create or #update, + # then the controller for the resource must implement + # the method "#{resource_name}_params" to limit permitted + # parameters for the individual model. + def resource_params + @resource_params ||= self.send("#{resource_name}_params") + end + + # Use callbacks to share common setup or constraints between actions. + def set_resource(resource = nil) + resource ||= resource_class.find(params[:id]) + instance_variable_set("@#{resource_name}", resource) + end + + def authenticate + authenticate_token || render_bad_credentials + end + + def authenticate_token + authenticate_with_http_token do |token, options| + # reject the empty string as it is our base empty token + if token != "" + @token = token + @user = User.find_by(api_token: token) + # if no user found, return false, otherwise true + !@user.nil? && @user.can_use_api? + else + false + end + end + end + + + def render_bad_credentials + self.headers["WWW-Authenticate"] = "Token realm=\"\"" + render json: _("Bad Credentials"), status: 401 + end + end diff --git a/app/controllers/api/v0/guidance_groups_controller.rb b/app/controllers/api/v0/guidance_groups_controller.rb index 65899b9..24e245b 100644 --- a/app/controllers/api/v0/guidance_groups_controller.rb +++ b/app/controllers/api/v0/guidance_groups_controller.rb @@ -1,25 +1,25 @@ -module Api - module V0 - class GuidanceGroupsController < Api::V0::BaseController - before_action :authenticate - #after_action :verify_authorized +# frozen_string_literal: true - def index - raise Pundit::NotAuthorizedError unless Api::V0::GuidanceGroupPolicy.new(@user, :guidance_group).index? - @all_viewable_groups = GuidanceGroup.all_viewable(@user) - respond_with @all_viewable_groups - end +class Api::V0::GuidanceGroupsController < Api::V0::BaseController - def pundit_user - return @user - end + before_action :authenticate - - private - def query_params - params.permit(:id) - end - + def index + unless Api::V0::GuidanceGroupPolicy.new(@user, :guidance_group).index? + raise Pundit::NotAuthorizedError end + @all_viewable_groups = GuidanceGroup.all_viewable(@user) + respond_with @all_viewable_groups end + + def pundit_user + @user + end + + + private + def query_params + params.permit(:id) + end + end diff --git a/app/controllers/api/v0/plans_controller.rb b/app/controllers/api/v0/plans_controller.rb index 156782f..df947f1 100644 --- a/app/controllers/api/v0/plans_controller.rb +++ b/app/controllers/api/v0/plans_controller.rb @@ -1,48 +1,57 @@ -module Api - module V0 - class PlansController < Api::V0::BaseController - before_action :authenticate +# frozen_string_literal: true - ## - # Creates a new plan based on the information passed in JSON to the API - def create - @template = Template.live(params[:template_id]) - raise Pundit::NotAuthorizedError unless Api::V0::PlansPolicy.new(@user, @template).create? +class Api::V0::PlansController < Api::V0::BaseController - plan_user = User.find_by(email: params[:plan][:email]) - # ensure user exists - if plan_user.blank? - User.invite!({email: params[:plan][:email]}, ( @user)) - plan_user = User.find_by(email: params[:plan][:email]) - plan_user.org = @user.org - plan_user.save - end - # ensure user's organisation is the same as api user's - raise Pundit::NotAuthorizedError, _("user must be in your organisation") unless plan_user.org == @user.org + before_action :authenticate - # initialize the plan - @plan = Plan.new - @plan.principal_investigator = plan_user.surname.blank? ? nil : "#{plan_user.firstname} #{plan_user.surname}" - @plan.data_contact = plan_user.email - # set funder name to template's org, or original template's org - if @template.customization_of.nil? - @plan.funder_name = @template.org.name - else - @plan.funder_name = Template.where(family_id: @template.customization_of).first.org.name - end - @plan.template = @template - @plan.title = params[:plan][:title] - if @plan.save - @plan.assign_creator(plan_user) - respond_with @plan - else - # the plan did not save - self.headers['WWW-Authenticate'] = "Token realm=\"\"" - render json: _("Bad Parameters"), status: 400 - end - end + ## + # Creates a new plan based on the information passed in JSON to the API + def create + @template = Template.live(params[:template_id]) + unless Api::V0::PlansPolicy.new(@user, @template).create? + raise Pundit::NotAuthorizedError + end + plan_user = User.find_by(email: params[:plan][:email]) + # ensure user exists + if plan_user.blank? + User.invite!({ email: params[:plan][:email] }, @user) + plan_user = User.find_by(email: params[:plan][:email]) + plan_user.org = @user.org + plan_user.save + end + # ensure user's organisation is the same as api user's + unless plan_user.org == @user.org + raise Pundit::NotAuthorizedError, _("user must be in your organisation") + end + # 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 + # set funder name to template's org, or original template's org + if @template.customization_of.nil? + @plan.funder_name = @template.org.name + else + @plan.funder_name = Template.where( + family_id: @template.customization_of + ).first.org.name + end + @plan.template = @template + @plan.title = params[:plan][:title] + if @plan.save + @plan.assign_creator(plan_user) + respond_with @plan + else + # the plan did not save + self.headers["WWW-Authenticate"] = "Token realm=\"\"" + render json: _("Bad Parameters"), status: 400 end end + end diff --git a/app/controllers/api/v0/statistics_controller.rb b/app/controllers/api/v0/statistics_controller.rb index a826122..07e554b 100644 --- a/app/controllers/api/v0/statistics_controller.rb +++ b/app/controllers/api/v0/statistics_controller.rb @@ -1,234 +1,272 @@ -module Api - module V0 - class StatisticsController < Api::V0::BaseController - before_action :authenticate +# frozen_string_literal: true - # GET /api/v0/statistics/users_joined?start_date=&end_date=&org_id= - # Returns the number of users joined for the user's org. - # If start_date is passed, only counts those with created_at is >= than start_date - # If end_date is passed, only counts those with created_at is <= than end_date are - # If org_id is passed and user has super_admin privileges that counter is performed against org_id param instead of user's org - # @return - def users_joined - raise Pundit::NotAuthorizedError unless Api::V0::StatisticsPolicy.new(@user, :statistics).users_joined? +class Api::V0::StatisticsController < Api::V0::BaseController - if @user.can_super_admin? && params[:org_id].present? - scoped = User.unscoped.where(org_id: params[:org_id]) - else - scoped = User.unscoped.where(org_id: @user.org_id) - end + before_action :authenticate - if params[:range_dates].present? - r = {} - params[:range_dates].each_pair do |k, v| - r[k] = scoped - .where('created_at >=?', v['start_date']) - .where('created_at <=?', v['end_date']).count - end - respond_to do |format| - format.json { render(json: r.to_json) } - format.csv { - send_data(CSV.generate do |csv| - csv << [_('Month'), _('No. Users joined')] - total = 0 - r.each_pair{ |k,v| csv << [k,v]; total+=v } - csv << [_('Total'), total] - end, filename: "#{_('users_joined')}.csv") } - end - else - scoped = scoped.where('created_at >= ?', Date.parse(params[:start_date])) if params[:start_date].present? - scoped = scoped.where('created_at <= ?', Date.parse(params[:end_date])) if params[:end_date].present? - @users_count = scoped.count - respond_with @users_count - end + # GET /api/v0/statistics/users_joined?start_date=&end_date=&org_id= + # + # Returns the number of users joined for the user's org. + # If start_date is passed, only counts those with created_at is >= than start_date + # If end_date is passed, only counts those with created_at is <= than end_date are + # If org_id is passed and user has super_admin privileges that counter is performed + # against org_id param instead of user's org + def users_joined + unless Api::V0::StatisticsPolicy.new(@user, :statistics).users_joined? + raise Pundit::NotAuthorizedError + end + + if @user.can_super_admin? && params[:org_id].present? + scoped = User.unscoped.where(org_id: params[:org_id]) + else + scoped = User.unscoped.where(org_id: @user.org_id) + end + + if params[:range_dates].present? + r = {} + params[:range_dates].each_pair do |k, v| + r[k] = scoped.where("created_at >=?", v["start_date"]) + .where("created_at <=?", v["end_date"]).count end - # GET - # Returns the number of completed plans within the user's org for the data start_date and end_date specified - def completed_plans - raise Pundit::NotAuthorizedError unless Api::V0::StatisticsPolicy.new(@user, :statistics).completed_plans? - - roles = Role.where("#{Role.creator_condition} OR #{Role.administrator_condition}") - - users = User.unscoped - if @user.can_super_admin? && params[:org_id].present? - users = users.where(org_id: params[:org_id]) - else - users = users.where(org_id: @user.org_id) - end - - plans = Plan.where(complete: true) - if params[:range_dates].present? - r = {} - params[:range_dates].each_pair do |k, v| - range_date_plans = plans - .where('plans.updated_at >=?', v['start_date']) - .where('plans.updated_at <=?', v['end_date']) - r[k] = roles.joins(:user, :plan).merge(users).merge(range_date_plans).select(:plan_id).distinct.count - end - respond_to do |format| - format.json { render(json: r.to_json) } - format.csv { - send_data(CSV.generate do |csv| - csv << [_('Month'), _('No. Completed Plans')] - total = 0 - r.each_pair{ |k,v| csv << [k,v]; total+=v } - csv << [_('Total'), total] - end, filename: "#{_('completed_plans')}.csv") } - end - else - plans = plans.where('plans.updated_at >= ?', Date.parse(params[:start_date])) if params[:start_date].present? - plans = plans.where('plans.updated_at <= ?', Date.parse(params[:end_date])) if params[:end_date].present? - count = roles.joins(:user, :plan).merge(users).merge(plans).select(:plan_id).distinct.count - render(json: { completed_plans: count }) - end + respond_to do |format| + format.json { render(json: r.to_json) } + format.csv { + send_data(CSV.generate do |csv| + csv << [_("Month"), _("No. Users joined")] + total = 0 + r.each_pair { |k, v| csv << [k, v]; total += v } + csv << [_("Total"), total] + end, filename: "#{_('users_joined')}.csv") } end - - # /api/v0/statistics/created_plans - # Returns the number of created plans within the user's org for the data start_date and end_date specified - def created_plans - raise Pundit::NotAuthorizedError unless Api::V0::StatisticsPolicy.new(@user, :statistics).plans? - - roles = Role.where("#{Role.creator_condition} OR #{Role.administrator_condition}") - - users = User.unscoped - if @user.can_super_admin? && params[:org_id].present? - users = users.where(org_id: params[:org_id]) - else - users = users.where(org_id: @user.org_id) - end - - plans = Plan.all - if params[:range_dates].present? - r = {} - params[:range_dates].each_pair do |k, v| - range_date_plans = plans - .where('plans.created_at >=?', v['start_date']) - .where('plans.created_at <=?', v['end_date']) - r[k] = roles.joins(:user, :plan).merge(users).merge(range_date_plans).select(:plan_id).distinct.count - end - respond_to do |format| - format.json { render(json: r.to_json) } - format.csv { - send_data(CSV.generate do |csv| - csv << [_('Month'), _('No. Plans')] - total = 0 - r.each_pair{ |k,v| csv << [k,v]; total+=v } - csv << [_('Total'), total] - end, filename: "#{_('plans')}.csv") } - end - else - plans = plans.where('plans.created_at >= ?', Date.parse(params[:start_date])) if params[:start_date].present? - plans = plans.where('plans.created_at <= ?', Date.parse(params[:end_date])) if params[:end_date].present? - count = roles.joins(:user, :plan).merge(users).merge(plans).select(:plan_id).distinct.count - render(json: { created_plans: count }) - end + else + if params[:start_date].present? + scoped = scoped.where("created_at >= ?", Date.parse(params[:start_date])) end - - ## - # GET - # @return the number of DMPs using the specified template between the optional specified dates - # ensures that the template is owned/created by the caller's organisation - def using_template - org_templates = @user.org.templates.where(customization_of: nil) - raise Pundit::NotAuthorizedError unless Api::V0::StatisticsPolicy.new(@user, org_templates.first).using_template? - @templates = {} - org_templates.each do |template| - if @templates[template.title].blank? - @templates[template.title] = {} - @templates[template.title][:title] = template.title - @templates[template.title][:id] = template.family_id - if template.plans.present? - @templates[template.title][:uses] = restrict_date_range(template.plans).length - else - @templates[template.title][:uses] = 0 - end - else - if template.plans.present? - @templates[template.title][:uses] += restrict_date_range(template.plans).length - end - end - end - respond_with @templates + if params[:end_date].present? + scoped = scoped.where("created_at <= ?", Date.parse(params[:end_date])) end - - ## - # GET - # @return a list of templates with their titles, ids, and uses between the optional specified dates - # the uses are restricted to DMPs created by users of the same organisation - # as the user who ititiated the call - def plans_by_template - raise Pundit::NotAuthorizedError unless Api::V0::StatisticsPolicy.new(@user, :statistics).plans_by_template? - org_projects = [] - @user.org.users.each do |user| - user.plans.each do |plan| - unless org_projects.include? plan - org_projects += [plan] - end - end - end - org_projects = restrict_date_range(org_projects) - @templates = {} - org_projects.each do |plan| - # if hash exists - if @templates[plan.template.title].blank? - @templates[plan.template.title] = {} - @templates[plan.template.title][:title] = plan.template.title - @templates[plan.template.title][:id] = plan.template.family_id - @templates[plan.template.title][:uses] = 1 - else - @templates[plan.template.title][:uses] += 1 - end - end - respond_with @templates - end - - ## - # GET - # @return a list of DMPs metadata, provided the DMPs were created between the optional specified dates - # DMPs must be owned by a user who's organisation is the same as the user - # who generates the call - def plans - raise Pundit::NotAuthorizedError unless Api::V0::StatisticsPolicy.new(@user, :statistics).plans? - @org_plans = [] - @user.org.users.each do |user| - user.plans.each do |plan| - unless @org_plans.include? plan - @org_plans += [plan] - end - end - end - @org_plans = restrict_date_range(@org_plans) - respond_with @org_plans - end - - - private - ## - # takes in an array of active_reccords and restricts the range of dates - # to those specified in the params - # - # @param objects [Array] any active_reccord reccords which - # have the "created_at" field specified - # @return [Array] filtered list of objects - def restrict_date_range( objects ) - # set start_date to either passed param, or beginning of time - start_date = params[:start_date].blank? ? Date.new(0) : Date.strptime(params[:start_date], "%Y-%m-%d") - # set end_date to either passed param or now - end_date = params[:end_date].blank? ? Date.today : Date.strptime(params[:end_date], "%Y-%m-%d") - - filtered = [] - objects.each do |obj| - # apperantly things can have nil created_at - if obj.created_at.blank? - if params[:start_date].blank? && params[:end_date].blank? - filtered += [obj] - end - elsif start_date <= obj.created_at.to_date && end_date >= obj.created_at.to_date - filtered += [obj] - end - end - return filtered - end + @users_count = scoped.count + respond_with @users_count end end + # GET + # Returns the number of completed plans within the user's org for the data + # start_date and end_date specified + def completed_plans + unless Api::V0::StatisticsPolicy.new(@user, :statistics).completed_plans? + raise Pundit::NotAuthorizedError + end + + roles = Role.with_access_flags(:administrator, :creator) + + users = User.unscoped + if @user.can_super_admin? && params[:org_id].present? + users = users.where(org_id: params[:org_id]) + else + users = users.where(org_id: @user.org_id) + end + + plans = Plan.where(complete: true) + if params[:range_dates].present? + r = {} + params[:range_dates].each_pair do |k, v| + range_date_plans = plans + .where("plans.updated_at >=?", v["start_date"]) + .where("plans.updated_at <=?", v["end_date"]) + r[k] = roles.joins(:user, :plan).merge(users).merge(range_date_plans) + .select(:plan_id).distinct.count + end + respond_to do |format| + format.json { render(json: r.to_json) } + format.csv { + send_data(CSV.generate do |csv| + csv << [_("Month"), _("No. Completed Plans")] + total = 0 + r.each_pair { |k, v| csv << [k, v]; total += v } + csv << [_("Total"), total] + end, filename: "#{_('completed_plans')}.csv") } + end + else + if params[:start_date].present? + plans = plans.where("plans.updated_at >= ?", Date.parse(params[:start_date])) + end + if params[:end_date].present? + plans = plans.where("plans.updated_at <= ?", Date.parse(params[:end_date])) + end + count = roles.joins(:user, :plan).merge(users).merge(plans) + .select(:plan_id).distinct.count + render(json: { completed_plans: count }) + end + end + + # /api/v0/statistics/created_plans + # Returns the number of created plans within the user's org for the data + # start_date and end_date specified + def created_plans + unless Api::V0::StatisticsPolicy.new(@user, :statistics).plans? + raise Pundit::NotAuthorizedError + end + roles = Role.with_access_flags(:administrator, :creator) + + users = User.unscoped + if @user.can_super_admin? && params[:org_id].present? + users = users.where(org_id: params[:org_id]) + else + users = users.where(org_id: @user.org_id) + end + + plans = Plan.all + if params[:range_dates].present? + r = {} + params[:range_dates].each_pair do |k, v| + range_date_plans = plans + .where("plans.created_at >= ?", v["start_date"]) + .where("plans.created_at <= ?", v["end_date"]) + r[k] = roles.joins(:user, :plan).merge(users).merge(range_date_plans) + .select(:plan_id).distinct.count + end + respond_to do |format| + format.json { render(json: r.to_json) } + format.csv { + send_data(CSV.generate do |csv| + csv << [_("Month"), _("No. Plans")] + total = 0 + r.each_pair { |k, v| csv << [k, v]; total += v } + csv << [_("Total"), total] + end, filename: "#{_('plans')}.csv") } + end + else + if params[:start_date].present? + plans = plans.where("plans.created_at >= ?", Date.parse(params[:start_date])) + end + if params[:end_date].present? + plans = plans.where("plans.created_at <= ?", Date.parse(params[:end_date])) + end + count = roles.joins(:user, :plan).merge(users).merge(plans) + .select(:plan_id).distinct.count + render(json: { created_plans: count }) + end + end + + ## + # Displays the number of DMPs using the specified template between the optional + # specified dates ensures that the template is owned/created by the caller's + # organisation + def using_template + org_templates = @user.org.templates.where(customization_of: nil) + unless Api::V0::StatisticsPolicy.new(@user, org_templates.first).using_template? + raise Pundit::NotAuthorizedError + end + @templates = {} + org_templates.each do |template| + if @templates[template.title].blank? + @templates[template.title] = {} + @templates[template.title][:title] = template.title + @templates[template.title][:id] = template.family_id + if template.plans.present? + @templates[template.title][:uses] = restrict_date_range(template.plans).length + else + @templates[template.title][:uses] = 0 + end + else + if template.plans.present? + @templates[template.title][:uses] += restrict_date_range(template.plans).length + end + end + end + respond_with @templates + end + + ## + # GET + # Renders a list of templates with their titles, ids, and uses between the optional + # specified dates the uses are restricted to DMPs created by users of the same + # organisation as the user who ititiated the call. + def plans_by_template + unless Api::V0::StatisticsPolicy.new(@user, :statistics).plans_by_template? + raise Pundit::NotAuthorizedError + end + org_projects = [] + @user.org.users.each do |user| + user.plans.each do |plan| + unless org_projects.include? plan + org_projects += [plan] + end + end + end + org_projects = restrict_date_range(org_projects) + @templates = {} + org_projects.each do |plan| + # if hash exists + if @templates[plan.template.title].blank? + @templates[plan.template.title] = {} + @templates[plan.template.title][:title] = plan.template.title + @templates[plan.template.title][:id] = plan.template.family_id + @templates[plan.template.title][:uses] = 1 + else + @templates[plan.template.title][:uses] += 1 + end + end + respond_with @templates + end + + # GET + # + # Renders a list of DMPs metadata, provided the DMPs were created between the + # optional specified dates DMPs must be owned by a user who's organisation is the + # same as the user who generates the call. + def plans + unless Api::V0::StatisticsPolicy.new(@user, :statistics).plans? + raise Pundit::NotAuthorizedError + end + @org_plans = [] + @user.org.users.each do |user| + user.plans.each do |plan| + unless @org_plans.include? plan + @org_plans += [plan] + end + end + end + @org_plans = restrict_date_range(@org_plans) + respond_with @org_plans + end + + + private + + ## + # Takes in an array of active_reccords and restricts the range of dates + # to those specified in the params + # + # objects - any active_reccord reccords which have the "created_at" field specified + # + # Returns Array + def restrict_date_range(objects) + # set start_date to either passed param, or beginning of time + if params[:start_date].blank? + start_date = Date.new(0) + else + start_date = Date.strptime(params[:start_date], "%Y-%m-%d") + end + # set end_date to either passed param or now + if params[:end_date].blank? + end_date = Date.today + else + end_date = Date.strptime(params[:end_date], "%Y-%m-%d") + end + + filtered = [] + objects.each do |obj| + # apperantly things can have nil created_at + if obj.created_at.blank? + if params[:start_date].blank? && params[:end_date].blank? + filtered += [obj] + end + elsif start_date <= obj.created_at.to_date && end_date >= obj.created_at.to_date + filtered += [obj] + end + end + filtered + end + end diff --git a/app/controllers/api/v0/templates_controller.rb b/app/controllers/api/v0/templates_controller.rb index 03999f8..e38bbe9 100644 --- a/app/controllers/api/v0/templates_controller.rb +++ b/app/controllers/api/v0/templates_controller.rb @@ -1,47 +1,55 @@ -module Api - module V0 - class TemplatesController < Api::V0::BaseController - before_action :authenticate +# frozen_string_literal: true +class Api::V0::TemplatesController < Api::V0::BaseController - ## - # GET - # @return a list of templates ordered by organisation - def index - # check if the user has permissions to use the templates API - raise Pundit::NotAuthorizedError unless Api::V0::TemplatePolicy.new(@user, :guidance_group).index? + before_action :authenticate - @org_templates = {} - - published_templates = Template.includes(:org).unarchived.where(customization_of: nil, published: true).order(:org_id, :version) - customized_templates = Template.includes(:org).unarchived.where(org_id: @user.org_id, published: true).where.not(customization_of: nil) - - Template.published.order(:org_id, :version).each do |temp| - if @org_templates[temp.org].present? - if @org_templates[temp.org][:own][temp.family_id].nil? - @org_templates[temp.org][:own][temp.family_id] = temp - end - else - @org_templates[temp.org] = {} - @org_templates[temp.org][:own] = {} - @org_templates[temp.org][:cust] = {} - @org_templates[temp.org][:own][temp.family_id] = temp - end - end - Template.published_customization(@user.org_id).each do |temp| - if @org_templates[temp.org].present? - if @org_templates[temp.org][:cust][temp.family_id].nil? - @org_templates[temp.org][:cust][temp.family_id] = temp - end - else - @org_templates[temp.org] = {} - @org_templates[temp.org][:own] = {} - @org_templates[temp.org][:cust] = {} - @org_templates[temp.org][:cust][temp.family_id] = temp - end - end - respond_with @org_templates + # GET + # + # Renders a list of templates ordered by organisation + def index + # check if the user has permissions to use the templates API + unless Api::V0::TemplatePolicy.new(@user, :guidance_group).index? + raise Pundit::NotAuthorizedError end + + @org_templates = {} + + published_templates = Template.includes(:org) + .unarchived + .where(customization_of: nil, published: true) + .order(:org_id, :version) + + customized_templates = Template.includes(:org) + .unarchived + .where(org_id: @user.org_id, published: true) + .where.not(customization_of: nil) + + Template.published.order(:org_id, :version).each do |temp| + if @org_templates[temp.org].present? + if @org_templates[temp.org][:own][temp.family_id].nil? + @org_templates[temp.org][:own][temp.family_id] = temp + end + else + @org_templates[temp.org] = {} + @org_templates[temp.org][:own] = {} + @org_templates[temp.org][:cust] = {} + @org_templates[temp.org][:own][temp.family_id] = temp + end end + Template.published_customization(@user.org_id).each do |temp| + if @org_templates[temp.org].present? + if @org_templates[temp.org][:cust][temp.family_id].nil? + @org_templates[temp.org][:cust][temp.family_id] = temp + end + else + @org_templates[temp.org] = {} + @org_templates[temp.org][:own] = {} + @org_templates[temp.org][:cust] = {} + @org_templates[temp.org][:cust][temp.family_id] = temp + end + end + respond_with @org_templates end -end \ No newline at end of file + +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d8e5aad..a342123 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,44 +1,53 @@ +# frozen_string_literal: true + class ApplicationController < ActionController::Base + protect_from_forgery with: :exception # Look for template overrides before rendering before_filter :prepend_view_paths + before_filter :set_gettext_locale + + after_filter :store_location include GlobalHelpers include Pundit helper_method GlobalHelpers.instance_methods - rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized + # When we are in production reroute Record Not Found errors to the branded 404 page + if Rails.env.production? + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found + end + + private + + def current_org + current_user.org + end + def user_not_authorized if user_signed_in? - redirect_to plans_url, alert: _('You are not authorized to perform this action.') + redirect_to plans_url, alert: _("You are not authorized to perform this action.") else - redirect_to root_url, alert: _('You need to sign in or sign up before continuing.') + redirect_to root_url, alert: _("You need to sign in or sign up before continuing.") end end - before_filter :set_gettext_locale - - after_filter :store_location - # Sets FastGettext locale for every request made def set_gettext_locale - FastGettext.locale = session[:locale] || FastGettext.default_locale + FastGettext.locale = LocaleFormatter.new(current_locale, format: :fast_gettext).to_s end - # PATCH /locale/:locale REST method - def set_locale_session - if FastGettext.default_available_locales.include?(params[:locale]) - session[:locale] = params[:locale] - end - redirect_to(request.referer || root_path) #redirects the user to URL where she/he was when the request to this resource was made or root if none is encountered + def current_locale + session[:locale] || FastGettext.default_locale end def store_location - # store last url - this is needed for post-login redirect to whatever the user last visited. + # store last url - this is needed for post-login redirect to whatever the user last + # visited. unless ["/users/sign_in", "/users/sign_up", "/users/password", @@ -51,7 +60,9 @@ def after_sign_in_path_for(resource) referer_path = URI(request.referer).path unless request.referer.nil? or nil - if from_external_domain? || referer_path.eql?(new_user_session_path) || referer_path.eql?(new_user_registration_path) || referer_path.nil? + if from_external_domain? || referer_path.eql?(new_user_session_path) || + referer_path.eql?(new_user_registration_path) || + referer_path.nil? root_path else request.referer @@ -59,8 +70,10 @@ end def after_sign_up_path_for(resource) - referer_path = URI(request.referer).path unless request.referer.nil? or nil - if from_external_domain? || referer_path.eql?(new_user_session_path) || referer_path.nil? + referer_path = URI(request.referer).path unless request.referer.nil? + if from_external_domain? || + referer_path.eql?(new_user_session_path) || + referer_path.nil? root_path else request.referer @@ -77,83 +90,83 @@ def authenticate_admin! # currently if admin has any super-admin task, they can view the super-admin - redirect_to root_path unless user_signed_in? && (current_user.can_add_orgs? || current_user.can_change_org? || current_user.can_super_admin?) - end - - def failed_create_error(obj, obj_name) - "#{_('Could not create your %{o}.') % {o: obj_name}} #{errors_to_s(obj)}" - end - - def failed_update_error(obj, obj_name) - "#{_('Could not update your %{o}.') % {o: obj_name}} #{errors_to_s(obj)}" - end - - def failed_destroy_error(obj, obj_name) - "#{_('Could not delete the %{o}.') % {o: obj_name}} #{errors_to_s(obj)}" - end - - def success_message(obj_name, action) - "#{_('Successfully %{action} your %{object}.') % {object: obj_name, action: action}}" - end - - # Check whether the string is a valid array of JSON objects - def is_json_array_of_objects?(string) - if string.present? - begin - json = JSON.parse(string) - return (json.is_a?(Array) && json.all?{ |o| o.is_a?(Hash) }) - rescue JSON::ParserError - return false - end + unless user_signed_in? && (current_user.can_add_orgs? || + current_user.can_change_org? || + current_user.can_super_admin?) + redirect_to root_path end end - private - # Override rails default render action to look for a branded version of a - # template instead of using the default one. If no override exists, the - # default version in ./app/views/[:controller]/[:action] will be used - # - # The path in the app/views/branded/ directory must match the the file it is - # replacing. For example: - # app/views/branded/layouts/_header.html.erb -> app/views/layouts/_header.html.erb - def prepend_view_paths - prepend_view_path "app/views/branded" - end + def failure_message(obj, action = "save") + _("Unable to %{action} the %{object}.%{errors}") % { + object: obj_name_for_display(obj), + action: action || "save", + errors: errors_for_display(obj), + } + end - def errors_to_s(obj) - if obj.errors.count > 0 - msg = "
" - obj.errors.each do |e,m| - if m.include?('empty') || m.include?('blank') - msg += "#{_(e)} - #{_(m)}
" - else - msg += "'#{obj[e]}' - #{_(m)}
" - end - end - msg - end - end + def success_message(obj, action = "saved") + _("Successfully %{action} the %{object}.") % { + object: obj_name_for_display(obj), + action: action || "save", + } + end - ## - # Sign out of Shibboleth SP local session too. - # ------------------------------------------------------------- - def after_sign_out_path_for(resource_or_scope) - if Rails.application.config.shibboleth_enabled - return Rails.application.config.shibboleth_logout_url + root_url - super - else - super - end + def errors_for_display(obj) + if obj.present? && obj.errors.any? + msgs = obj.errors.full_messages.uniq.collect { |msg| "
  • #{msg}
  • " } + "" end - # ------------------------------------------------------------- + end - def from_external_domain? - if request.referer.present? - referer = URI.parse(request.referer) - home = URI.parse(root_url) - referer.host != home.host - else - false - end + def obj_name_for_display(obj) + display_name = { + ExportedPlan: _("plan"), + GuidanceGroup: _("guidance group"), + Note: _("comment"), + Org: _("organisation"), + Perm: _("permission"), + Pref: _("preferences"), + User: obj == current_user ? _("profile") : _("user") + } + if obj.respond_to?(:customization_of) && obj.send(:customization_of).present? + display_name[:Template] = "customization" end + display_name[obj.class.name.to_sym] || obj.class.name.downcase || "record" + end + + # Override rails default render action to look for a branded version of a + # template instead of using the default one. If no override exists, the + # default version in ./app/views/[:controller]/[:action] will be used + # + # The path in the app/views/branded/ directory must match the the file it is + # replacing. For example: + # app/views/branded/layouts/_header.html.erb -> app/views/layouts/_header.html.erb + def prepend_view_paths + prepend_view_path "app/views/branded" + end + + ## + # Sign out of Shibboleth SP local session too. + # ------------------------------------------------------------- + def after_sign_out_path_for(resource_or_scope) + if Rails.application.config.shibboleth_enabled + return Rails.application.config.shibboleth_logout_url + root_url + super + else + super + end + end + # ------------------------------------------------------------- + + def from_external_domain? + if request.referer.present? + referer = URI.parse(request.referer) + home = URI.parse(root_url) + referer.host != home.host + else + false + end + end + end diff --git a/app/controllers/concerns/allowed_question_formats.rb b/app/controllers/concerns/allowed_question_formats.rb new file mode 100644 index 0000000..10f2dfe --- /dev/null +++ b/app/controllers/concerns/allowed_question_formats.rb @@ -0,0 +1,9 @@ +module AllowedQuestionFormats + + private + + # The QuestionFormat "Multi select box" is no longer being used for new templates + def allowed_question_formats + QuestionFormat.where.not(title: "Multi select box").order(:title) + end +end \ No newline at end of file diff --git a/app/controllers/concerns/conditional_user_mailer.rb b/app/controllers/concerns/conditional_user_mailer.rb index aa7622b..da80183 100644 --- a/app/controllers/concerns/conditional_user_mailer.rb +++ b/app/controllers/concerns/conditional_user_mailer.rb @@ -1,35 +1,25 @@ -module ConditionalUserMailer - extend ActiveSupport::Concern +# frozen_string_literal: true - # Adds following methods as class methods for the class that include this module - included do - # Executes a given block passed if the recipient user has the preference email key enabled - # @param recipients {User | Enumerable } User object or any object that includes Enumerable class - # @param key {String} - A key (dot notation) whose value is true/false and belongs to prefences.email (see config/branding.yml) - def deliver_if(recipients: [], key:) - raise(ArgumentError, 'key must be String') unless key.is_a?(String) - if block_given? - split_key = key.split('.') - if !recipients.respond_to?(:each) - recipients = Array(recipients) - end - recipients.each do |r| - if r.respond_to?(:get_preferences) - email_hash = r.get_preferences('email') - should_deliver = split_key.reduce(email_hash) do |m,o| - if m.is_a?(Hash) - m[o.to_sym] - else - break - end - end - yield r if should_deliver.is_a?(TrueClass) - end - end - true - else # Block not given - false - end +module ConditionalUserMailer + + # Executes a given block passed if the recipient user has the preference + # email key enabled + # + # recipients - User or Enumerable object or any object that includes Enumerable class + # key - A key (dot notation) whose value is true/false and belongs to + # prefences.email (see config/branding.yml) + # + # Returns Boolean + def deliver_if(recipients: [], key:, &block) + return false unless block_given? + + Array(recipients).each do |recipient| + email_hash = recipient.get_preferences("email").with_indifferent_access + preference_value = !!email_hash.dig(*key.to_s.split(".")) + block.call(recipient) if preference_value end + + true end -end \ No newline at end of file + +end diff --git a/app/controllers/concerns/paginable.rb b/app/controllers/concerns/paginable.rb index ef6fcc9..9dbd9ad 100644 --- a/app/controllers/concerns/paginable.rb +++ b/app/controllers/concerns/paginable.rb @@ -1,137 +1,191 @@ -module Paginable - extend ActiveSupport::Concern - - included do - # Renders paginable layout with the partial view passed - # partial {String} - Represents a path to where the partial view is stored - # controller {String} - Represents the name of the controller to handles the pagination - # action {String} - Represents the method name within the controller - # path_params {Hash} - A hash of additional URL path parameters (e.g. path_paths = { id: 'foo' } for /paginable/templates/:id/history/:page) - # query_params {Hash} - A hash of query parameters used to merge with params object from the controller for which this concern is included - # scope {ActiveRecord::Relation} - Represents scope variable - # locals {Hash} - A hash objects with any additional local variables to be passed to the partial view - def paginable_renderise(partial: nil, controller: nil, action: nil, path_params: {}, query_params: {}, scope: nil, locals: {}, **options) - raise ArgumentError, _('scope should be an ActiveRecord::Relation object') unless scope.is_a?(ActiveRecord::Relation) - raise ArgumentError, _('path_params should be a Hash object') unless path_params.is_a?(Hash) - raise ArgumentError, _('query_params should be a Hash object') unless query_params.is_a?(Hash) - raise ArgumentError, _('locals should be a Hash object') unless locals.is_a?(Hash) +# frozen_string_literal: true - # Default options - @paginable_options = {}.merge(options) - @paginable_options[:view_all] = options.fetch(:view_all, true) - # Assignment for paginable_params based on arguments passed to the method - @paginable_params = params.symbolize_keys - @paginable_params[:controller] = controller if controller - @paginable_params[:action] = action if action - @paginable_params = query_params.symbolize_keys.merge(@paginable_params) # if duplicate keys, those from @paginable_params take precedence - # Additional path_params passed to this function got special treatment (e.g. it is taking into account when building base_url) - @paginable_path_params = path_params.symbolize_keys - if @paginable_params[:page] == 'ALL' && @paginable_params[:search].blank? && @paginable_options[:view_all] == false - render(status: :forbidden, html: _('Restricted access to View All the records')) - else - @refined_scope = refine_query(scope) - render(layout: "/layouts/paginable", - partial: partial, - locals: locals.merge({ - scope: @refined_scope, - search_term: @paginable_params[:search] })) - end +module Paginable + + extend ActiveSupport::Concern + + ## + # Regex to validate sort_field param is safe + SORT_COLUMN_FORMAT = /[\w\_]+\.[\w\_]/ + + PAGINATION_QUERY_PARAMS = [:page, :sort_field, :sort_direction, + :search, :controller, :action] + + private + + # Renders paginable layout with the partial view passed + # + # partial - A String, represents a path to where the partial view is stored + # controller - A String, represents the name of the controller to handles the + # pagination + # action - A String, represents the action name within the controller + # path_params - A Hash of additional URL path parameters + # (e.g. path_paths = { id: 'foo' } for + # /paginable/templates/:id/history/:page) + # query_params - A hash of query parameters used to merge with params object + # from the controller for which this concern is included + # scope - An {ActiveRecord::Relation}, represents scope variable + # locals - A Hash objects with any additional local variables to be passed to + # the partial view + # + # Returns String of valid HTML + # Raises ArgumentError + def paginable_renderise(partial: nil, controller: nil, action: nil, + path_params: {}, query_params: {}, scope: nil, + locals: {}, **options) + unless scope.is_a?(ActiveRecord::Relation) + raise ArgumentError, _("scope should be an ActiveRecord::Relation object") end - # Returns the base url of the paginable route for a given page passed - def paginable_base_url(page = 1) - return url_for(@paginable_path_params.merge({ controller: @paginable_params[:controller], - action: @paginable_params[:action], page: page })) + unless path_params.is_a?(Hash) + raise ArgumentError, _("path_params should be a Hash object") end - # Returns the base url of the paginable router for a given page passed together with its query_params. - # It is used to retain context, i.e. search, sort_field, sort_direction, etc - def paginable_base_url_with_query_params(page: 1, **stringify_query_params_options) - base_url = paginable_base_url(page) - stringified_query_params = stringify_query_params(stringify_query_params_options) - if stringified_query_params.present? - return "#{base_url}?#{stringified_query_params}" - end - return base_url + unless query_params.is_a?(Hash) + raise ArgumentError, _("query_params should be a Hash object") end - # Generates an HTML link to sort given a sort field. - # sort_field {String} - Represents the column name for a table - def paginable_sort_link(sort_field) - return link_to(sort_link_name(sort_field), sort_link_url(sort_field), 'data-remote': true, class: 'paginable-action', "aria-label": "#{sort_field}") + unless locals.is_a?(Hash) + raise ArgumentError, _("locals should be a Hash object") end - # Determines whether or not the latest request included the search functionality - def searchable? - return @paginable_params[:search].present? - end - # Determines whether or not the scoped query is paginated or not - def paginable? - return @refined_scope.respond_to?(:total_pages) + + # Default options + @paginable_options = {}.merge(options) + @paginable_options[:view_all] = options.fetch(:view_all, true) + # Assignment for paginable_params based on arguments passed to the method + @paginable_params = params.symbolize_keys + @paginable_params[:controller] = controller if controller + @paginable_params[:action] = action if action + # if duplicate keys, those from @paginable_params take precedence + @paginable_params = query_params.symbolize_keys.merge(@paginable_params) + # Additional path_params passed to this function got special treatment + # (e.g. it is taking into account when building base_url) + @paginable_path_params = path_params.symbolize_keys + if @paginable_params[:page] == "ALL" && + @paginable_params[:search].blank? && + @paginable_options[:view_all] == false + render( + status: :forbidden, + html: _("Restricted access to View All the records") + ) + else + @refined_scope = refine_query(scope) + render(layout: "/layouts/paginable", + partial: partial, + locals: locals.merge( + scope: @refined_scope, + search_term: @paginable_params[:search]) + ) end end - private - # Returns the upcase string (e.g ASC or DESC) if sort_direction param is present in any of the forms 'asc', 'desc', 'ASC', 'DESC' - # otherwise returns ASC - def upcasing_sort_direction(direction = @paginable_params[:sort_direction]) - directions = ['asc', 'desc', 'ASC', 'DESC'] - return directions.include?(direction) ? direction.upcase : 'ASC' - end - # Returns DESC when ASC is passed and vice versa, otherwise nil - def swap_sort_direction(direction = @paginable_params[:sort_direction]) - direction_upcased = upcasing_sort_direction(direction) - return 'DESC' if direction_upcased == 'ASC' - return 'ASC' if direction_upcased == 'DESC' - end - # Refine a scope passed to this concern if any of the params (search, sort_field or page) are present - def refine_query(scope) - scope = scope.search(@paginable_params[:search]) if @paginable_params[:search].present? # Can raise NoMethodError if the scope does not define a search method - if @paginable_params[:sort_field].present? - scope = scope.order("#{@paginable_params[:sort_field]} #{upcasing_sort_direction}") # Can raise ActiveRecord::StatementInvalid (e.g. column does not exist, ambiguity on column, etc) - end - if @paginable_params[:page] != 'ALL' - scope = scope.page(@paginable_params[:page]) # Can raise error if page is not a number - end - return scope - end - # Returns the sort link name for a given sort_field. The link name includes html prevented of being escaped - def sort_link_name(sort_field) - className = 'fa-sort' - if @paginable_params[:sort_field] == sort_field - className = upcasing_sort_direction == 'ASC'? 'fa-sort-asc' : 'fa-sort-desc' - end - return raw("Sort by #{sort_field.split('.').first}") - end - # Returns the sort url for a given sort_field. - def sort_link_url(sort_field) - page = @paginable_params[:page] == 'ALL' ? 'ALL' : 1 - if @paginable_params[:sort_field] == sort_field - sort_url = paginable_base_url_with_query_params( - page: page, - sort_field: sort_field, - sort_direction: swap_sort_direction) - else - sort_url = paginable_base_url_with_query_params( - page: page, - sort_field: sort_field) - end - return "#{sort_url}#{stringify_nonpagination_query_params}" - end - # Retrieve any query params that are not a part of the paginable concern - def stringify_nonpagination_query_params - other_params = @paginable_params.select do |param| - ![:page, :sort_field, :sort_direction, :search, :controller, :action].include?(param) - end - return other_params.empty? ? '' : "&#{other_params.collect{ |k, v| "#{k}=#{v}" }.join('&')}" - end - def stringify_query_params( - search: @paginable_params[:search], - sort_field: @paginable_params[:sort_field], - sort_direction: nil) - query_string = [] - query_string << "search=#{search}" if search.present? - if sort_field.present? - query_string << "sort_field=#{sort_field}" - direction = sort_direction || upcasing_sort_direction - query_string << "sort_direction=#{direction}" - end - return query_string.join('&') + # Returns the base url of the paginable route for a given page passed + def paginable_base_url(page = 1) + url_params = @paginable_path_params.merge( + controller: @paginable_params[:controller], + action: @paginable_params[:action], + page: page + ) + url_for(url_params) + end + + # Generates an HTML link to sort given a sort field. + # sort_field {String} - Represents the column name for a table + def paginable_sort_link(sort_field) + link_to( + sort_link_name(sort_field), + sort_link_url(sort_field), + class: "paginable-action", + data: { remote: true }, + aria: { label: sort_field } + ) + end + + # Determines whether or not the latest request included the search functionality + def searchable? + @paginable_params[:search].present? + end + + # Determines whether or not the scoped query is paginated or not + def paginable? + @refined_scope.respond_to?(:total_pages) + end + + # Refine a scope passed to this concern if any of the params (search, + # sort_field or page) are present + def refine_query(scope) + if @paginable_params[:search].present? + scope = scope.search(@paginable_params[:search]) end -end \ No newline at end of file + # Can raise NoMethodError if the scope does not define a search method + if @paginable_params[:sort_field].present? + unless @paginable_params[:sort_field][SORT_COLUMN_FORMAT] + raise ArgumentError, "sort_field param looks unsafe" + end + # Can raise ActiveRecord::StatementInvalid (e.g. column does not + # exist, ambiguity on column, etc) + scope = scope.order("#{@paginable_params[:sort_field]} #{sort_direction}") + end + if @paginable_params[:page] != "ALL" + # Can raise error if page is not a number + scope = scope.page(@paginable_params[:page]) + end + scope + end + + def sort_direction + @sort_direction ||= SortDirection.new(@paginable_params[:sort_direction]) + end + + # Returns the sort link name for a given sort_field. The link name includes + # html prevented of being escaped + def sort_link_name(sort_field) + class_name = "fa-sort" + if @paginable_params[:sort_field] == sort_field + class_name = "fa-sort-#{sort_direction.downcase}" + end + <<~HTML.html_safe + + HTML + end + + # Returns the sort url for a given sort_field. + def sort_link_url(sort_field) + query_params = {} + query_params[:page] = @paginable_params[:page] == "ALL" ? "ALL" : 1 + query_params[:sort_field] = sort_field + if @paginable_params[:sort_field] == sort_field + query_params[:sort_direction] = sort_direction.opposite + else + query_params[:sort_direction] = sort_direction + end + base_url = paginable_base_url(query_params[:page]) + sort_url = URI(base_url) + sort_url.query = stringify_query_params(query_params) + sort_url.to_s + "#{sort_url}&#{stringify_nonpagination_query_params}" + end + + # Retrieve any query params that are not a part of the paginable concern + def stringify_nonpagination_query_params + @paginable_params.except(*PAGINATION_QUERY_PARAMS).to_param + end + + def stringify_query_params(page: 1, search: @paginable_params[:search], + sort_field: @paginable_params[:sort_field], + sort_direction: nil) + + query_string = {} + query_string["search"] = search if search.present? + if sort_field.present? + query_string["sort_field"] = sort_field + query_string["sort_direction"] = SortDirection.new(sort_direction) + end + query_string.to_param + end + +end diff --git a/app/controllers/concerns/template_methods.rb b/app/controllers/concerns/template_methods.rb new file mode 100644 index 0000000..bebd824 --- /dev/null +++ b/app/controllers/concerns/template_methods.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# This module holds helper controller methods for controllers that deal with Templates +# +module TemplateMethods + + private + + def template_type(template) + template.customization_of.present? ? _("customisation") : _("template") + end + +end diff --git a/app/controllers/concerns/versionable.rb b/app/controllers/concerns/versionable.rb index 676d4d2..69cbd9a 100644 --- a/app/controllers/concerns/versionable.rb +++ b/app/controllers/concerns/versionable.rb @@ -1,106 +1,127 @@ +# frozen_string_literal: true + module Versionable - extend ActiveSupport::Concern - - included do - ## - # Takes in a Template, phase, Section, Question, or Annotaion - # IF the template is published, generates a new template - # finds the passed object in the new template - # @param obj - Template, Phase, Section, Question, Annotation - # @return type_of(obj) - def get_modifiable(obj) - if obj.respond_to?(:template) - template = obj.template - elsif obj.is_a?(Template) - template = obj - else - raise ArgumentError, _('obj should be a Template, Phase, Section, Question, or Annotation') - end - - new_template = Template.find_or_generate_version!(template) # raises RuntimeError if template is not latest - - if new_template != template - if obj.is_a?(Template) - obj = new_template - else - obj = find_in_space(obj,new_template.phases) - end - end - return obj - end - ## - # Takes in a phase, Section, Question, or Annotation which is newly - # generated and returns a modifiable version of that object - # NOTE: the obj passed is still not saved however it should belongs to a parent already - def get_new(obj) - raise ArgumentError, _('obj should be a Phase, Section, Question, or Annotation') unless obj.respond_to?(:template) - - template = obj.template - new_template = Template.find_or_generate_version!(template) # raises RuntimeError if template is not latest - - if new_template != template # Copied version - case obj - when Phase - belongs = :template - when Section - belongs = :phase - when Question - belongs = :section - when Annotation - belongs = :question - else - raise ArgumentError, _('obj should be a Phase, Section, Question, or Annotation') - end - - if belongs == :template - obj = obj.send(:deep_copy) - obj.template = new_template - else - found = find_in_space(obj.send(belongs), new_template.phases) - obj = obj.send(:deep_copy) - obj.send("#{belongs}=", found) - end - end - return obj - end - end private - # Locates an object (e.g. phase, section, question, annotation) in a search_space - # (e.g. phases/sections/questions/annotations) by comparing either the number method or - # the org_id and text for annotations - def find_in_space(obj, search_space) - raise ArgumentError, _('The search_space does not respond to each') unless search_space.respond_to?(:each) - raise ArgumentError, _('The search space does not have elements associated') unless search_space.length > 0 - if obj.is_a?(search_space.first.class) - if obj.respond_to?(:number) # object is an instance of Phase, Section or Question - return search_space.find{ |search| search.number == obj.number } - elsif obj.respond_to?(:org_id) && obj.respond_to?(:text) # object is an instance of Annotation - return search_space.find{ |annotation| annotation.org_id == obj.org_id && annotation.text == obj.text } + # Takes in a Template, phase, Section, Question, or Annotaion + # IF the template is published, generates a new template + # finds the passed object in the new template + # + # obj - Template, Phase, Section, Question, Annotation + # + # Returns ActiveRecord::Base + def get_modifiable(obj) + if obj.respond_to?(:template) + template = obj.template + elsif obj.is_a?(Template) + template = obj + else + raise ArgumentError, + _("obj should be a Template, Phase, Section, Question, or Annotation") + end + + # raises RuntimeError if template is not latest + new_template = Template.find_or_generate_version!(template) + + if new_template != template + if obj.is_a?(Template) + obj = new_template + else + obj = find_in_space(obj, new_template.phases) + end + end + obj + end + + ## + # Takes in a phase, Section, Question, or Annotation which is newly + # generated and returns a modifiable version of that object + # NOTE: the obj passed is still not saved however it should belongs to a + # parent already + def get_new(obj) + unless obj.respond_to?(:template) + raise ArgumentError, + _("obj should be a Phase, Section, Question, or Annotation") + end + + template = obj.template + # raises RuntimeError if template is not latest + new_template = Template.find_or_generate_version!(template) + + if new_template != template # Copied version + case obj + when Phase + belongs = :template + when Section + belongs = :phase + when Question + belongs = :section + when Annotation + belongs = :question + else + raise ArgumentError, + _("obj should be a Phase, Section, Question, or Annotation") + end + + if belongs == :template + obj = obj.send(:deep_copy) + obj.template = new_template + else + found = find_in_space(obj.send(belongs), new_template.phases) + obj = obj.send(:deep_copy) + obj.send("#{belongs}=", found) + end + end + obj + end + + # Locates an object (e.g. phase, section, question, annotation) in a + # search_space + # (e.g. phases/sections/questions/annotations) by comparing either the number + # method or the org_id and text for annotations + def find_in_space(obj, search_space) + unless search_space.respond_to?(:each) + raise ArgumentError, _("The search_space does not respond to each") + end + unless search_space.length > 0 + raise ArgumentError, + _("The search space does not have elements associated") + end + + if obj.is_a?(search_space.first.class) + # object is an instance of Phase, Section or Question + if obj.respond_to?(:number) + return search_space.find { |search| search.number == obj.number } + # object is an instance of Annotation + elsif obj.respond_to?(:org_id) && obj.respond_to?(:text) + return search_space.find do |annotation| + annotation.org_id == obj.org_id && annotation.text == obj.text end - return nil - end - - case search_space.first - when Phase - number = obj.phase.number - relation = :sections - when Section - number = obj.section.number - relation = :questions - when Question - number = obj.question.number - relation = :annotations - else - return nil - end - - search_space = search_space.find{ |search| search.number == number } - - if search_space.present? - return find_in_space(obj, search_space.send(relation)) end return nil end + + case search_space.first + when Phase + number = obj.phase.number + relation = :sections + when Section + number = obj.section.number + relation = :questions + when Question + number = obj.question.number + relation = :annotations + else + return nil + end + + search_space = search_space.find { |search| search.number == number } + + if search_space.present? + return find_in_space(obj, search_space.send(relation)) + end + nil + end + end diff --git a/app/controllers/contacts_controller.rb b/app/controllers/contacts_controller.rb index 872733e..ef575c0 100644 --- a/app/controllers/contacts_controller.rb +++ b/app/controllers/contacts_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class ContactUs::ContactsController < ApplicationController def create @@ -5,14 +7,15 @@ if !user_signed_in? unless verify_recaptcha(model: @contact) && @contact.save - flash[:alert] = _('Captcha verification failed, please retry.') + flash[:alert] = _("Captcha verification failed, please retry.") render_new_page and return end end if @contact.save - redirect_to(ContactUs.success_redirect || '/', :notice => _('Contact email was successfully sent.')) + redirect_to(ContactUs.success_redirect || "/", + notice: _("Contact email was successfully sent.")) else - flash[:alert] = _('Unable to submit your request') + flash[:alert] = _("Unable to submit your request") render_new_page end end @@ -24,13 +27,13 @@ protected - def render_new_page - case ContactUs.form_gem - when 'formtastic' then render 'new_formtastic' - when 'simple_form' then render 'new_simple_form' - else - render 'new' - end + def render_new_page + case ContactUs.form_gem + when "formtastic" then render "new_formtastic" + when "simple_form" then render "new_simple_form" + else + render "new" end + end end diff --git a/app/controllers/feedback_requests_controller.rb b/app/controllers/feedback_requests_controller.rb new file mode 100644 index 0000000..241c6ad --- /dev/null +++ b/app/controllers/feedback_requests_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class FeedbackRequestsController < ApplicationController + + include FeedbacksHelper + + after_action :verify_authorized + + ALERT = _("Unable to submit your request for feedback at this time.") + ERROR = _("An error occurred when requesting feedback for this plan.") + + def create + @plan = Plan.find(params[:plan_id]) + authorize @plan, :request_feedback? + begin + if @plan.request_feedback(current_user) + redirect_to share_plan_path(@plan), notice: _(request_feedback_flash_notice) + else + redirect_to share_plan_path(@plan), alert: ALERT + end + rescue Exception + redirect_to share_plan_path(@plan), alert: ERROR + end + end + + private + + # Flash notice for successful feedback requests + # + # Returns String + def request_feedback_flash_notice + # Use the generic feedback confirmation message unless the Org has + # specified one + text = current_user.org.feedback_email_msg || feedback_confirmation_default_message + feedback_constant_to_text(text, current_user, @plan, current_user.org) + end + +end diff --git a/app/controllers/guidance_groups_controller.rb b/app/controllers/guidance_groups_controller.rb index 9bbfae9..6e6cd19 100644 --- a/app/controllers/guidance_groups_controller.rb +++ b/app/controllers/guidance_groups_controller.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class GuidanceGroupsController < ApplicationController + after_action :verify_authorized respond_to :html @@ -19,7 +22,7 @@ # POST /guidance_groups # POST /guidance_groups.json def admin_create - @guidance_group = GuidanceGroup.new(params[:guidance_group]) + @guidance_group = GuidanceGroup.new(guidance_group_params) authorize @guidance_group @guidance_group.org_id = current_user.org_id if params[:save_publish] @@ -27,10 +30,11 @@ end if @guidance_group.save - redirect_to admin_index_guidance_path, notice: success_message(_('guidance group'), _('created')) + flash.now[:notice] = success_message(@guidance_group, _("created")) + render :admin_edit else - flash[:alert] = failed_create_error(@guidance_group, _('guidance group')) - render 'admin_new' + flash.now[:alert] = failure_message(@guidance_group, _("create")) + render :admin_new end end @@ -49,11 +53,12 @@ @guidance_group.org_id = current_user.org_id @guidance_group.published = true unless params[:save_publish].nil? - if @guidance_group.update_attributes(params[:guidance_group]) - redirect_to admin_index_guidance_path(params[:guidance_group]), notice: success_message(_('guidance group'), _('saved')) + if @guidance_group.update(guidance_group_params) + flash.now[:notice] = success_message(@guidance_group, _("saved")) + render :admin_edit else - flash[:alert] = failed_update_error(@guidance_group, _('guidance group')) - render 'admin_edit' + flash.now[:alert] = failure_message(@guidance_group, _("save")) + render :admin_edit end end @@ -64,8 +69,13 @@ @guidance_group.org.id = current_user.org.id @guidance_group.published = true - @guidance_group.save - flash[:notice] = _('Your guidance group has been published and is now available to users.') + if @guidance_group.save + # rubocop:disable LineLength + flash[:notice] = _("Your guidance group has been published and is now available to users.") + # rubocop:enable LineLength + else + flash[:alert] = failure_message(@guidance_group, _("publish")) + end redirect_to admin_index_guidance_path end @@ -76,8 +86,13 @@ @guidance_group.org.id = current_user.org.id @guidance_group.published = false - @guidance_group.save - flash[:notice] = _('Your guidance group is no longer published and will not be available to users.') + if @guidance_group.save + # rubocop:disable LineLength + flash[:notice] = _("Your guidance group is no longer published and will not be available to users.") + # rubocop:enable LineLength + else + flash[:alert] = failure_message(@guidance_group, _("unpublish")) + end redirect_to admin_index_guidance_path end @@ -87,10 +102,18 @@ @guidance_group = GuidanceGroup.find(params[:id]) authorize @guidance_group if @guidance_group.destroy - redirect_to admin_index_guidance_path, notice: success_message(_('guidance group'), _('deleted')) + flash[:notice] = success_message(@guidance_group, _("deleted")) else - redirect_to admin_index_guidance_path, alert: failed_destroy_error(@guidance_group, _('guidance group')) + flash[:alert] = failure_message(@guidance_group, _("delete")) end + redirect_to admin_index_guidance_path end -end \ No newline at end of file + private + + def guidance_group_params + params.require(:guidance_group) + .permit(:org_id, :name, :optional_subset, :published, :org, :guidances) + end + +end diff --git a/app/controllers/guidances_controller.rb b/app/controllers/guidances_controller.rb index 19e2b89..28fa7c9 100644 --- a/app/controllers/guidances_controller.rb +++ b/app/controllers/guidances_controller.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class GuidancesController < ApplicationController + after_action :verify_authorized respond_to :html @@ -6,136 +9,133 @@ # GET /guidances def admin_index authorize Guidance - @guidances = Guidance.by_org(current_user.org).includes(:guidance_group, :themes).page(1) + @guidances = Guidance.by_org(current_user.org) + .includes(:guidance_group, :themes).page(1) @guidance_groups = GuidanceGroup.by_org(current_user.org).page(1) end def admin_new - guidance = Guidance.new - authorize guidance - themes = Theme.all.order('title') - guidance_groups = GuidanceGroup.where(org_id: current_user.org_id).order('name ASC') - render(:new_edit, locals: { guidance: guidance, themes: themes, - guidance_groups: guidance_groups, options: { url: admin_create_guidance_path, method: :post }}) + @guidance = Guidance.new + authorize @guidance + render :new_edit end ## # GET /guidances/1/edit def admin_edit - guidance = Guidance.eager_load(:themes, :guidance_group).find(params[:id]) - authorize guidance - themes = Theme.all.order('title') - guidance_groups = GuidanceGroup.where(org_id: current_user.org_id).order('name ASC') - render(:new_edit, locals: { guidance: guidance, themes: themes, - guidance_groups: guidance_groups, options: { url: admin_update_guidance_path(guidance), method: :put }}) + @guidance = Guidance.eager_load(:themes, :guidance_group) + .find(params[:id]) + authorize @guidance + render :new_edit end ## # POST /guidances def admin_create - guidance = Guidance.new(guidance_params) - authorize guidance - guidance.text = params["guidance-text"] - - if guidance.save + @guidance = Guidance.new(guidance_params) + authorize @guidance + @guidance.text = params["guidance-text"] - if guidance.published? - guidance_group = GuidanceGroup.find(guidance.guidance_group_id) + if @guidance.save + + if @guidance.published? + guidance_group = GuidanceGroup.find(@guidance.guidance_group_id) if !guidance_group.published? || guidance_group.published.nil? guidance_group.published = true guidance_group.save end end - flash[:notice] = success_message(_('guidance'), _('created')) - redirect_to(action: :admin_index) - + flash.now[:notice] = success_message(@guidance, _("created")) else - flash[:alert] = failed_create_error(guidance, _('guidance')) - redirect_to(action: :admin_index) + flash.now[:alert] = failure_message(@guidance, _("create")) end + render :new_edit end ## # PUT /guidances/1 def admin_update - guidance = Guidance.find(params[:id]) - authorize guidance - guidance.text = params["guidance-text"] - - attrs = guidance_params - - if guidance.update_attributes(attrs) - if guidance.published? - guidance_group = GuidanceGroup.find(guidance.guidance_group_id) + @guidance = Guidance.find(params[:id]) + authorize @guidance + @guidance.text = params["guidance-text"] + + if @guidance.update_attributes(guidance_params) + if @guidance.published? + guidance_group = GuidanceGroup.find(@guidance.guidance_group_id) if !guidance_group.published? || guidance_group.published.nil? guidance_group.published = true guidance_group.save end end - flash[:notice] = success_message(_('guidance'), _('saved')) - redirect_to(action: :admin_index) + flash.now[:notice] = success_message(@guidance, _("saved")) else - flash[:alert] = failed_update_error(guidance, _('guidance')) - redirect_to(action: :admin_edit, id: params[:id]) + flash.now[:alert] = failure_message(@guidance, _("save")) end + render :new_edit end ## # DELETE /guidances/1 def admin_destroy - guidance = Guidance.find(params[:id]) - authorize guidance - guidance_group = GuidanceGroup.find(guidance.guidance_group_id) - if guidance.destroy - unless guidance_group.guidances.where(published: true).exists? + @guidance = Guidance.find(params[:id]) + authorize @guidance + guidance_group = GuidanceGroup.find(@guidance.guidance_group_id) + if @guidance.destroy + unless guidance_group.guidances.where(published: true).exists? guidance_group.published = false guidance_group.save end - flash[:notice] = success_message(_('guidance'), _('deleted')) + flash[:notice] = success_message(@guidance, _("deleted")) redirect_to(action: :admin_index) else - flash[:alert] = failed_destroy_error(guidance, _('guidance')) + flash[:alert] = failure_message(@guidance, _("delete")) redirect_to(action: :admin_index) end end # PUT /guidances/1 def admin_publish - guidance = Guidance.find(params[:id]) - authorize guidance - - guidance.published = true - guidance_group = GuidanceGroup.find(guidance.guidance_group_id) - if !guidance_group.published? || guidance_group.published.nil? - guidance_group.published = true - guidance_group.save + @guidance = Guidance.find(params[:id]) + authorize @guidance + if @guidance.update_attributes(published: true) + guidance_group = GuidanceGroup.find(@guidance.guidance_group_id) + if !guidance_group.published? || guidance_group.published.nil? + guidance_group.published = true + guidance_group.save + end + # rubocop:disable LineLength + flash[:notice] = _("Your guidance has been published and is now available to users.") + # rubocop:enable LineLength + else + flash[:alert] = failure_message(@guidance, _("publish")) end - guidance.save - - flash[:notice] = _('Your guidance has been published and is now available to users.') redirect_to(action: :admin_index) end # PUT /guidances/1 def admin_unpublish - guidance = Guidance.find(params[:id]) - authorize guidance - - guidance.published = false - guidance.save - guidance_group = GuidanceGroup.find(guidance.guidance_group_id) - unless guidance_group.guidances.where(published: true).exists? - guidance_group.published = false - guidance_group.save + @guidance = Guidance.find(params[:id]) + authorize @guidance + if @guidance.update_attributes(published: false) + guidance_group = GuidanceGroup.find(@guidance.guidance_group_id) + unless guidance_group.guidances.where(published: true).exists? + guidance_group.published = false + guidance_group.save + end + # rubocop:disable LineLength + flash[:notice] = _("Your guidance is no longer published and will not be available to users.") + # rubocop:enable LineLength + else + flash[:alert] = failure_message(@guidance, _("unpublish")) end - - flash[:notice] = _('Your guidance is no longer published and will not be available to users.') redirect_to(action: :admin_index) end private - def guidance_params - # The form on the page is weird. The text and template/section/question stuff is outside of the normal form params - params.require(:guidance).permit(:guidance_group_id, :published, theme_ids: []) - end -end \ No newline at end of file + def guidance_params + # The form on the page is weird. The text and template/section/question stuff is + # outside of the normal form params + params.require(:guidance).permit(:guidance_group_id, :published, theme_ids: []) + end + +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 7a75e3c..e771179 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class HomeController < ApplicationController + respond_to :html ## @@ -9,16 +12,18 @@ # User's contact name is not filled in # Is this the desired behavior? def index - if user_signed_in? - name = current_user.name(false) -# TODO: Investigate if this is even relevant anymore. The name var will never be blank here because the logic in -# User says to return the email if the firstname and surname are empty regardless of the flag passed in - if name.blank? - redirect_to edit_user_registration_path - else - redirect_to plans_url - end - end + if user_signed_in? + name = current_user.name(false) + # TODO: Investigate if this is even relevant anymore. + # The name var will never be blank here because the logic in + # User says to return the email if the firstname and surname are empty + # regardless of the flag passed in + if name.blank? + redirect_to edit_user_registration_path + else + redirect_to plans_url + end + end end end diff --git a/app/controllers/notes_controller.rb b/app/controllers/notes_controller.rb index 32211b3..0ac5285 100644 --- a/app/controllers/notes_controller.rb +++ b/app/controllers/notes_controller.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class NotesController < ApplicationController + include ConditionalUserMailer require "pp" after_action :verify_authorized @@ -6,54 +9,62 @@ def create @note = Note.new - @note.user_id = params[:note][:user_id] - - # create answer if we don't have one already - @answer = nil # if defined within the transaction block, was not accessable afterward + @note.user_id = note_params[:user_id] # ensure user has access to plan BEFORE creating/finding answer - raise Pundit::NotAuthorizedError unless Plan.find(params[:note][:plan_id]).readable_by?(@note.user_id) + unless Plan.find_by(id: note_params[:plan_id]).readable_by?(@note.user_id) + raise Pundit::NotAuthorizedError + end Answer.transaction do - @answer = Answer.find_by(plan_id: params[:note][:plan_id], question_id: params[:note][:question_id]) + @answer = Answer.find_by( + plan_id: note_params[:plan_id], + question_id: note_params[:question_id] + ) if @answer.blank? - @answer = Answer.new - @answer.plan_id = params[:note][:plan_id] - @answer.question_id = params[:note][:question_id] - @answer.user_id = @note.user_id + @answer = Answer.new + @answer.plan_id = note_params[:plan_id] + @answer.question_id = note_params[:question_id] + @answer.user_id = @note.user_id @answer.save! end end @note.answer = @answer - @note.text = params[:note][:text] + @note.text = note_params[:text] authorize @note @plan = @answer.plan - @question = Question.find(params[:note][:question_id]) + @question = Question.find(note_params[:question_id]) if @note.save @status = true answer = @note.answer plan = answer.plan owner = plan.owner - deliver_if(recipients: owner, key: 'users.new_comment') do |r| + deliver_if(recipients: owner, key: "users.new_comment") do |r| UserMailer.new_comment(current_user, plan).deliver_now() end - @notice = success_message(_('comment'), _('created')) + @notice = success_message(@note, _("created")) render(json: { "notes" => { - "id" => params[:note][:question_id], - "html" => render_to_string(partial: 'layout', locals: {plan: @plan, question: @question, answer: @answer }, formats: [:html]) + "id" => note_params[:question_id], + "html" => render_to_string(partial: "layout", locals: { + plan: @plan, + question: @question, + answer: @answer + }, formats: [:html]) }, "title" => { - "id" => params[:note][:question_id], - "html" => render_to_string(partial: 'title', locals: { answer: @answer}, formats: [:html]) + "id" => note_params[:question_id], + "html" => render_to_string(partial: "title", locals: { + answer: @answer + }, formats: [:html]) } }.to_json, status: :created) else @status = false - @notice = failed_create_error(@note, _('note')) + @notice = failure_message(@note, _("create")) render json: { "msg" => @notice }.to_json, status: :bad_request @@ -63,7 +74,7 @@ def update @note = Note.find(params[:id]) authorize @note - @note.text = params[:note][:text] + @note.text = note_params[:text] @answer = @note.answer @question = @answer.question @@ -71,20 +82,26 @@ question_id = @note.answer.question_id.to_s - if @note.update_attributes(params[:note]) - @notice = success_message(_('comment'), _('saved')) + if @note.update(note_params) + @notice = success_message(@note, _("saved")) render(json: { "notes" => { "id" => question_id, - "html" => render_to_string(partial: 'layout', locals: {plan: @plan, question: @question, answer: @answer }, formats: [:html]) + "html" => render_to_string(partial: "layout", locals: { + plan: @plan, + question: @question, + answer: @answer + }, formats: [:html]) }, "title" => { "id" => question_id, - "html" => render_to_string(partial: 'title', locals: { answer: @answer}, formats: [:html]) + "html" => render_to_string(partial: "title", locals: { + answer: @answer + }, formats: [:html]) } }.to_json, status: :ok) else - @notice = failed_update_error(@note, _('note')) + @notice = failure_message(@note, _("save")) render json: { "msg" => @notice }.to_json, status: :bad_request @@ -103,23 +120,38 @@ question_id = @note.answer.question_id.to_s - if @note.update_attributes(params[:note]) - @notice = success_message(_('comment'), _('removed')) + if @note.update(note_params) + @notice = success_message(@note, _("removed")) render(json: { "notes" => { "id" => question_id, - "html" => render_to_string(partial: 'layout', locals: {plan: @plan, question: @question, answer: @answer }, formats: [:html]) + "html" => render_to_string(partial: "layout", locals: { + plan: @plan, + question: @question, + answer: @answer + }, formats: [:html]) }, "title" => { "id" => question_id, - "html" => render_to_string(partial: 'title', locals: { answer: @answer}, formats: [:html]) + "html" => render_to_string(partial: "title", locals: { + answer: @answer + }, formats: [:html]) } }.to_json, status: :ok) else - @notice = failed_destroy_error(@note, _('note')) + @notice = failure_message(@note, _("remove")) render json: { "msg" => @notice }.to_json, status: :bad_request end end + + private + + def note_params + params.require(:note) + .permit(:text, :archived_by, :user_id, :answer_id, :plan_id, + :question_id) + end + end diff --git a/app/controllers/org_admin/phase_versions_controller.rb b/app/controllers/org_admin/phase_versions_controller.rb new file mode 100644 index 0000000..644dcf5 --- /dev/null +++ b/app/controllers/org_admin/phase_versions_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class OrgAdmin::PhaseVersionsController < ApplicationController + + include Versionable + + def create + @phase = Phase.find(params[:phase_id]) + authorize @phase, :create? + @new_phase = get_modifiable(@phase) + flash[:notice] = if @new_phase == @phase + "This template is already a draft" + else + "New version of Template created" + end + redirect_to org_admin_template_phase_url(@new_phase.template, @new_phase) + end + +end diff --git a/app/controllers/org_admin/phases_controller.rb b/app/controllers/org_admin/phases_controller.rb index ed47690..8a6d306 100644 --- a/app/controllers/org_admin/phases_controller.rb +++ b/app/controllers/org_admin/phases_controller.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + module OrgAdmin + class PhasesController < ApplicationController + include Versionable - + after_action :verify_authorized # GET /org_admin/templates/:template_id/phases/[:id] @@ -9,16 +13,19 @@ phase = Phase.includes(:template, :sections).order(:number).find(params[:id]) authorize phase if !phase.template.latest? - flash[:notice] = _('You are viewing a historical version of this template. You will not be able to make changes.') + # rubocop:disable Metrics/LineLength + flash[:notice] = _("You are viewing a historical version of this template. You will not be able to make changes.") + # rubocop:enable Metrics/LineLength end - section = params.fetch(:section, nil) - render('container', - locals: { - partial_path: 'show', + render("container", + locals: { + partial_path: "show", template: phase.template, phase: phase, - sections: phase.sections.order(:number).select(:id, :title, :modifiable), - current_section: section.present? ? Section.find_by(id: section, phase_id: phase.id) : nil + prefix_section: phase.prefix_section, + sections: phase.template_sections.order(:number), + suffix_sections: phase.suffix_sections.order(:number), + current_section: Section.find_by(id: params[:section], phase_id: phase.id) }) end @@ -26,59 +33,68 @@ def edit phase = Phase.includes(:template).find(params[:id]) authorize phase - section = params.fetch(:section, nil) # User cannot edit a phase if its a customization so redirect to show if phase.template.customization_of.present? || !phase.template.latest? - redirect_to org_admin_template_phase_path(template_id: phase.template, id: phase.id, section: section) + redirect_to org_admin_template_phase_path( + template_id: phase.template, + id: phase.id, + section: params[:section] + ) else - render('container', - locals: { - partial_path: 'edit', + render("container", + locals: { + partial_path: "edit", template: phase.template, phase: phase, - sections: phase.sections.order(:number).select(:id, :title, :modifiable), - current_section: section.present? ? Section.find_by(id: section, phase_id: phase.id) : nil + prefix_section: phase.prefix_section, + sections: phase.sections.order(:number) + .select(:id, :title, :modifiable, :phase_id), + suffix_sections: phase.suffix_sections.order(:number), + current_section: Section.find_by(id: params[:section], phase_id: phase.id) }) end end - #preview a phase + # preview a phase # GET /org_admin/phases/[:id]/preview def preview - phase = Phase.includes(:template).find(params[:id]) - authorize phase - render('/org_admin/phases/preview', - locals: { - template: phase.template, - phase: phase - }) + @phase = Phase.includes(:template).find(params[:id]) + authorize @phase + @template = @phase.template + @guidance_presenter = GuidancePresenter.new(Plan.new(template: @phase.template)) end - #add a new phase to a passed template + # add a new phase to a passed template # GET /org_admin/phases/new def new template = Template.includes(:phases).find(params[:template_id]) if template.latest? nbr = template.phases.maximum(:number) - phase = Phase.new({ + phase = Phase.new( template: template, modifiable: true, number: (nbr.present? ? nbr + 1 : 1) - }) + ) authorize phase - render('/org_admin/templates/container', + local_referrer = if request.referrer.present? + request.referrer + else + org_admin_templates_path + end + render("/org_admin/templates/container", locals: { - partial_path: 'new', + partial_path: "new", template: template, phase: phase, - referrer: request.referrer.present? ? request.referrer : org_admin_templates_path + referrer: local_referrer }) else - render org_admin_templates_path, alert: _('You canot add a phase to a historical version of a template.') + render org_admin_templates_path, + alert: _("You canot add a phase to a historical version of a template.") end end - - #create a phase + + # create a phase # POST /org_admin/phases def create phase = Phase.new(phase_params) @@ -87,40 +103,49 @@ begin phase = get_new(phase) phase.modifiable = true - if phase.save! - flash[:notice] = success_message(_('phase'), _('created')) + if phase.save + flash[:notice] = success_message(phase, _("created")) else - flash[:alert] = failed_create_error(phase, _('phase')) + 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.") end if flash[:alert].present? - redirect_to edit_org_admin_template_path(id: phase.template_id) + redirect_to new_org_admin_template_phase_path(template_id: phase.template.id) else - redirect_to edit_org_admin_template_phase_path(template_id: phase.template.id, id: phase.id) + redirect_to edit_org_admin_template_phase_path(template_id: phase.template.id, + id: phase.id) end end - #update a phase of a template + # update a phase of a template # PUT /org_admin/phases/[:id] def update phase = Phase.find(params[:id]) authorize phase begin phase = get_modifiable(phase) - if phase.update!(phase_params) - flash[:notice] = success_message(_('phase'), _('updated')) + if phase.update(phase_params) + flash[:notice] = success_message(phase, _("updated")) else - flash[:alert] = failed_update_error(phase, _('phase')) + 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.") end - redirect_to edit_org_admin_template_phase_path(template_id: phase.template.id, id: phase.id) + redirect_to edit_org_admin_template_phase_path(template_id: phase.template.id, + id: phase.id) end - #delete a phase + def sort + @phase = Phase.find(params[:id]) + authorize @phase + Section.update_numbers!(*params.fetch(:sort_order, []), parent: @phase) + head :ok + end + + # delete a phase # DELETE org_admin/phases/[:id] def destroy phase = Phase.includes(:template).find(params[:id]) @@ -129,14 +154,14 @@ phase = get_modifiable(phase) template = phase.template if phase.destroy! - flash[:notice] = success_message(_('phase'), _('deleted')) + flash[:notice] = success_message(phase, _("deleted")) else - flash[:alert] = failed_destroy_error(phase, _('phase')) + 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.") end - + if flash[:alert].present? redirect_to org_admin_template_phase_path(template.id, phase.id) else @@ -145,8 +170,11 @@ end private - def phase_params - params.require(:phase).permit(:title, :description, :number) - end + + def phase_params + params.require(:phase).permit(:title, :description, :number) + end + end -end \ No newline at end of file + +end diff --git a/app/controllers/org_admin/plans_controller.rb b/app/controllers/org_admin/plans_controller.rb index b8bd89d..de40ed3 100644 --- a/app/controllers/org_admin/plans_controller.rb +++ b/app/controllers/org_admin/plans_controller.rb @@ -1,65 +1,88 @@ -module OrgAdmin - class PlansController < ApplicationController - # GET org_admin/plans - def index - # Test auth directly and throw Pundit error sincePundit is unaware of namespacing - raise Pundit::NotAuthorizedError unless current_user.present? && current_user.can_org_admin? - - vals = Role.access_values_for(:reviewer) - @feedback_plans = Plan.joins(:roles).where('roles.user_id = ? and roles.access IN (?)', current_user.id, vals) - @plans = current_user.org.plans - end - - # GET org_admin/plans/:id/feedback_complete - def feedback_complete - plan = Plan.find(params[:id]) - # Test auth directly and throw Pundit error sincePundit is unaware of namespacing - raise Pundit::NotAuthorizedError unless current_user.present? && current_user.can_org_admin? - raise Pundit::NotAuthorizedError unless plan.reviewable_by?(current_user.id) - - if plan.complete_feedback(current_user) - redirect_to org_admin_plans_path, notice: _('%{plan_owner} has been notified that you have finished providing feedback') % { plan_owner: plan.owner.name(false) } - else - redirect_to org_admin_plans_path, alert: _('Unable to notify user that you have finished providing feedback.') - end - end - - # GET /org_admin/download_plans - def download_plans - # Test auth directly and throw Pundit error sincePundit is unaware of namespacing - raise Pundit::NotAuthorizedError unless current_user.present? && current_user.can_org_admin? - - org = current_user.org - file_name = org.name.gsub(/ /, "_") - header_cols = [ - "#{_('Project title')}", - "#{_('Template')}", - "#{_('Organisation')}", - "#{_('Owner name')}", - "#{_('Owner email')}", - "#{_('Updated')}", - "#{_('Visibility')}" - ] - - plans = CSV.generate do |csv| - csv << header_cols - org.plans.includes(template: :org).order(updated_at: :desc).each do |plan| - owner = plan.owner - csv << [ - "#{plan.title}", - "#{plan.template.title}", - "#{plan.owner.org.present? ? plan.owner.org.name : ''}", - "#{plan.owner.name(false)}", - "#{plan.owner.email}", - "#{l(plan.latest_update.to_date, formats: :short)}", - "#{Plan.visibility_message(plan.visibility.to_sym).capitalize}" - ] - end - end +# frozen_string_literal: true - respond_to do |format| - format.csv { send_data plans, filename: "#{file_name}.csv" } - end +class OrgAdmin::PlansController < ApplicationController + + # GET org_admin/plans + def index + # Test auth directly and throw Pundit error sincePundit + # is unaware of namespacing + unless current_user.present? && current_user.can_org_admin? + raise Pundit::NotAuthorizedError + end + + vals = Role.access_values_for(:reviewer) + @feedback_plans = Plan.joins(:roles).where( + "roles.user_id = ? and roles.access IN (?)", current_user.id, vals + ) + @plans = current_user.org.plans + end + + # GET org_admin/plans/:id/feedback_complete + def feedback_complete + plan = Plan.find(params[:id]) + # Test auth directly and throw Pundit error sincePundit is + # unaware of namespacing + unless current_user.present? && current_user.can_org_admin? + raise Pundit::NotAuthorizedError + end + unless plan.reviewable_by?(current_user.id) + raise Pundit::NotAuthorizedError + end + + if plan.complete_feedback(current_user) + # rubocop:disable LineLength + redirect_to(org_admin_plans_path, + notice: _("%{plan_owner} has been notified that you have finished providing feedback") % { + plan_owner: plan.owner.name(false) + } + ) + # rubocop:enable LineLength + else + redirect_to org_admin_plans_path, + alert: _("Unable to notify user that you have finished providing feedback." + ) end end -end \ No newline at end of file + + # GET /org_admin/download_plans + def download_plans + # Test auth directly and throw Pundit error sincePundit + # is unaware of namespacing + unless current_user.present? && current_user.can_org_admin? + raise Pundit::NotAuthorizedError + end + + org = current_user.org + file_name = org.name.gsub(/ /, "_") + header_cols = [ + "#{_('Project title')}", + "#{_('Template')}", + "#{_('Organisation')}", + "#{_('Owner name')}", + "#{_('Owner email')}", + "#{_('Updated')}", + "#{_('Visibility')}" + ] + + plans = CSV.generate do |csv| + csv << header_cols + org.plans.includes(template: :org).order(updated_at: :desc).each do |plan| + owner = plan.owner + csv << [ + "#{plan.title}", + "#{plan.template.title}", + "#{plan.owner.org.present? ? plan.owner.org.name : ''}", + "#{plan.owner.name(false)}", + "#{plan.owner.email}", + "#{l(plan.latest_update.to_date, format: :csv)}", + "#{Plan::VISIBILITY_MESSAGE[plan.visibility.to_sym].capitalize}" + ] + end + end + + respond_to do |format| + format.csv { send_data plans, filename: "#{file_name}.csv" } + end + end + +end diff --git a/app/controllers/org_admin/questions_controller.rb b/app/controllers/org_admin/questions_controller.rb index 69d008b..a309fa1 100644 --- a/app/controllers/org_admin/questions_controller.rb +++ b/app/controllers/org_admin/questions_controller.rb @@ -1,66 +1,84 @@ +# frozen_string_literal: true + module OrgAdmin + class QuestionsController < ApplicationController + + include AllowedQuestionFormats include Versionable respond_to :html after_action :verify_authorized - # GET /org_admin/templates/[:template_id]/phases/[:phase_id]/sections/[:section_id]/question/[:id] def show - question = Question.includes(:annotations, :question_options, section: { phase: :template }).find(params[:id]) + question = Question.includes(:annotations, + :question_options, + section: { phase: :template }) + .find(params[:id]) authorize question - render partial: 'show', locals: { + render partial: "show", locals: { template: question.section.phase.template, section: question.section, question: question } end - # GET /org_admin/templates/[:template_id]/phases/[:phase_id]/sections/[:section_id]/question/[:id]/edit def edit - question = Question.includes(:annotations, :question_options, section: { phase: :template }).find(params[:id]) + question = Question.includes(:annotations, + :question_options, + section: { phase: :template }) + .find(params[:id]) authorize question - render partial: 'edit', locals: { + render partial: "edit", locals: { template: question.section.phase.template, section: question.section, question: question } end - # GET /org_admin/templates/[:template_id]/phases/[:phase_id]/sections/[:section_id]/questions/new def new section = Section.includes(:questions, phase: :template).find(params[:section_id]) nbr = section.questions.maximum(:number) - question = Question.new({ section_id: section.id, question_format: QuestionFormat.find_by(title: 'Text area'), number: nbr.present? ? nbr + 1 : 1 }) + question_format = QuestionFormat.find_by(title: "Text area") + question = Question.new(section_id: section.id, + question_format: question_format, + number: nbr.present? ? nbr + 1 : 1) + question_formats = allowed_question_formats authorize question - render partial: 'form', locals: { + render partial: "form", locals: { template: section.phase.template, section: section, question: question, - method: 'post', - url: org_admin_template_phase_section_questions_path(template_id: section.phase.template.id, phase_id: section.phase.id, id: section.id) + method: "post", + url: org_admin_template_phase_section_questions_path( + template_id: section.phase.template.id, + phase_id: section.phase.id, + id: section.id), + question_formats: question_formats } end - # POST /org_admin/templates/[:template_id]/phases/[:phase_id]/sections/[:section_id]/questions def create - question = Question.new(question_params.merge({ section_id: params[:section_id] })) + question = Question.new(question_params.merge(section_id: params[:section_id])) authorize question begin question = get_new(question) section = question.section - if question.save! - flash[:notice] = success_message(_('question'), _('created')) + if question.save + flash[:notice] = success_message(question, _("created")) else - flash[:alert] = failed_create_error(question, _('question')) + flash[:alert] = failure_message(question, _("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.") end - redirect_to edit_org_admin_template_phase_path(template_id: section.phase.template.id, id: section.phase.id, section: section.id) + redirect_to edit_org_admin_template_phase_path( + template_id: section.phase.template.id, + id: section.phase.id, + section: section.id + ) end - # PUT /org_admin/templates/[:template_id]/phases/[:phase_id]/sections/[:section_id]/questions/[:id] def update question = Question.find(params[:id]) authorize question @@ -75,31 +93,30 @@ if attrs[:theme_ids].blank? && attrs[:number].present? attrs[:theme_ids] = [] end - if question.update!(attrs) - flash[:notice] = success_message(_('question'), _('updated')) + if question.update(attrs) + flash[:notice] = success_message(question, _("updated")) else - flash[:alert] = failed_update_error(question, _('question')) + 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.') + flash[:alert] = _("Unable to create a new version of this template.") end if question.section.phase.template.customization_of.present? - redirect_to org_admin_template_phase_path({ + redirect_to org_admin_template_phase_path( template_id: question.section.phase.template.id, id: question.section.phase.id, section: question.section.id - }) + ) else - redirect_to edit_org_admin_template_phase_path({ + redirect_to edit_org_admin_template_phase_path( template_id: question.section.phase.template.id, id: question.section.phase.id, section: question.section.id - }) + ) end end - # DELETE /org_admin/templates/[:template_id]/phases/[:phase_id]/sections/[:section_id]/questions/[:id] def destroy question = Question.find(params[:id]) authorize question @@ -107,47 +124,60 @@ question = get_modifiable(question) section = question.section if question.destroy! - flash[:notice] = success_message(_('question'), _('deleted')) + flash[:notice] = success_message(question, _("deleted")) else - flash[:alert] = failed_destroy_error(question, 'question') + flash[:alert] = flash[:alert] = failure_message(question, _("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.") end - redirect_to edit_org_admin_template_phase_path({ + redirect_to edit_org_admin_template_phase_path( template_id: section.phase.template.id, id: section.phase.id, section: section.id - }) + ) end private - def question_params - params.require(:question).permit(:number, :text, :question_format_id, :option_comment_display, :default_value, question_options_attributes: [:id, :number, :text, :is_default, :_destroy], annotations_attributes: [:id, :text, :org_id, :org, :type], theme_ids: []) - 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 - if attrs[:annotations_attributes].present? - attrs[:annotations_attributes].each_key do |key| - old_annotation = question.annotations.select do |a| - a.org_id.to_s == attrs[:annotations_attributes][key][:org_id] && a.type.to_s == attrs[:annotations_attributes][key][:type] - end - attrs[:annotations_attributes][key][:id] = old_annotation.first.id unless old_annotation.empty? + def question_params + params.require(:question) + .permit(:number, :text, :question_format_id, :option_comment_display, + :default_value, + question_options_attributes: %i[id number text is_default _destroy], + annotations_attributes: %i[id text org_id org type _destroy], + theme_ids: []) + 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 + if attrs[:annotations_attributes].present? + attrs[:annotations_attributes].each_key do |key| + old_annotation = question.annotations.select do |a| + a.org_id.to_s == attrs[:annotations_attributes][key][:org_id] && + a.type.to_s == attrs[:annotations_attributes][key][:type] + end + unless old_annotation.empty? + attrs[:annotations_attributes][key][:id] = old_annotation.first.id 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| - attrs[:question_options_attributes][key][:id] = question.question_options[key.to_i].id.to_s if question.question_options[key.to_i].present? - end - end - attrs 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 38bfd62..96bea20 100644 --- a/app/controllers/org_admin/sections_controller.rb +++ b/app/controllers/org_admin/sections_controller.rb @@ -1,7 +1,11 @@ +# frozen_string_literal: true + module OrgAdmin + class SectionsController < ApplicationController + include Versionable - + respond_to :html after_action :verify_authorized @@ -9,64 +13,78 @@ def index authorize Section.new phase = Phase.includes(:template, :sections).find(params[:phase_id]) - edit = phase.template.latest? && (current_user.can_modify_templates? && (phase.template.org_id == current_user.org_id)) - render partial: 'index', - locals: { - template: phase.template, - phase: phase, - sections: phase.sections, + edit = phase.template.latest? && + (current_user.can_modify_templates? && + (phase.template.org_id == current_user.org_id)) + render partial: "index", + locals: { + template: phase.template, + phase: phase, + prefix_section: phase.prefix_section, + sections: phase.sections.order(:number), + suffix_sections: phase.suffix_sections, current_section: phase.sections.first, modifiable: edit, - edit: edit + edit: edit } end # GET /org_admin/templates/[:template_id]/phases/[:phase_id]/sections/[:id] def show - section = Section.find(params[:id]) - authorize section - section = Section.includes(questions: [:annotations, :question_options]).find(params[:id]) - render partial: 'show', locals: { - template: Template.find(params[:template_id]), - section: section - } + @section = Section.find(params[:id]) + authorize @section + @section = Section.includes(questions: [:annotations, :question_options]) + .find(params[:id]) + @template = Template.find(params[:template_id]) + render partial: "show", locals: { template: @template, section: @section } end # GET /org_admin/templates/[:template_id]/phases/[:phase_id]/sections/[:id]/edit def edit - section = Section.includes({phase: :template}, questions: [:question_options, { annotations: :org }]).find(params[:id]) + section = Section.includes(phase: :template, + questions: [:question_options, { annotations: :org }]) + .find(params[:id]) authorize section - # User cannot edit a section if its not modifiable or the template is not the latest redirect to show - render partial: (section.modifiable? && section.phase.template.latest? ? 'edit' : 'show'), - locals: { - template: section.phase.template, - phase: section.phase, + # User cannot edit a section if its not modifiable or the template is not the + # latest redirect to show + partial_name = if section.modifiable? && section.phase.template.latest? + "edit" + else + "show" + end + render partial: partial_name, + locals: { + template: section.phase.template, + phase: section.phase, section: section } end # POST /org_admin/templates/[:template_id]/phases/[:phase_id]/sections def create - phase = Phase.find(params[:phase_id]) - if phase.present? - section = Section.new(section_params.merge({ phase_id: phase.id })) - authorize section - begin - section = get_new(section) - if section.save - flash[:notice] = success_message(_('section'), _('created')) - redirect_to edit_org_admin_template_phase_path(template_id: section.phase.template.id, id: section.phase.id, section: section.id) - else - flash[:alert] = failed_create_error(section, _('section')) - redirect_to edit_org_admin_template_phase_path(template_id: section.phase.template.id, id: section.phase.id) - end - rescue StandardError => e - flash[:alert] = _('Unable to create a new version of this template.') - redirect_to edit_org_admin_template_phase_path(template_id: section.phase.template.id, id: section.phase.id) - end - else - flash[:alert] = _('Unable to create a new section because the phase you specified does not exist.') + @phase = Phase.find_by(id: params[:phase_id]) + if @phase.nil? + flash[:alert] = + _("Unable to create a new section. The phase you specified does not exist.") redirect_to edit_org_admin_template_path(template_id: params[:template_id]) + return + end + @section = @phase.sections.new(section_params) + authorize @section + @section = get_new(@section) + if @section.save + flash[:notice] = success_message(@section, _("created")) + redirect_to edit_org_admin_template_phase_path( + id: @section.phase_id, + template_id: @phase.template_id, + section: @section.id + ) + else + flash[:alert] = failure_message(@section, _("create")) + redirect_to edit_org_admin_template_phase_path( + template_id: @phase.template_id, + id: @section.phase_id + ) end end @@ -76,19 +94,25 @@ authorize section begin section = get_modifiable(section) - if section.update!(section_params) - flash[:notice] = success_message(_('section'), _('saved')) + if section.update(section_params) + flash[:notice] = success_message(section, _("saved")) else - flash[:alert] = failed_update_error(section, _('section')) + 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.") end - + if flash[:alert].present? - redirect_to edit_org_admin_template_phase_path(template_id: section.phase.template.id, id: section.phase.id, section: section.id) + redirect_to edit_org_admin_template_phase_path( + template_id: section.phase.template.id, + id: section.phase.id, section: section.id + ) else - redirect_to edit_org_admin_template_phase_path(template_id: section.phase.template.id, id: section.phase.id, section: section.id) + redirect_to edit_org_admin_template_phase_path( + template_id: section.phase.template.id, + id: section.phase.id, section: section.id + ) end end @@ -100,24 +124,33 @@ section = get_modifiable(section) phase = section.phase if section.destroy! - flash[:notice] = success_message(_('section'), _('deleted')) + flash[:notice] = success_message(section, _("deleted")) else - flash[:alert] = failed_destroy_error(section, _('section')) + 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.") end - + if flash[:alert].present? - redirect_to(edit_org_admin_template_phase_path(template_id: phase.template.id, id: phase.id)) + redirect_to(edit_org_admin_template_phase_path( + template_id: phase.template.id, + id: phase.id + )) else - redirect_to(edit_org_admin_template_phase_path(template_id: phase.template.id, id: phase.id)) + redirect_to(edit_org_admin_template_phase_path( + template_id: phase.template.id, + id: phase.id + )) end end - + private - def section_params - params.require(:section).permit(:title, :description, :number) - end + + def section_params + params.require(:section).permit(:title, :description) + end + end -end \ No newline at end of file + +end diff --git a/app/controllers/org_admin/template_copies_controller.rb b/app/controllers/org_admin/template_copies_controller.rb new file mode 100644 index 0000000..ae50d91 --- /dev/null +++ b/app/controllers/org_admin/template_copies_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class OrgAdmin::TemplateCopiesController < ApplicationController + + include TemplateMethods + + after_action :verify_authorized + + # POST /org_admin/templates/:id/copy (AJAX) + def create + @template = Template.find(params[:template_id]) + authorize @template, :copy? + begin + new_copy = @template.generate_copy!(current_user.org) + flash[:notice] = "#{template_type(@template).capitalize} was successfully copied." + redirect_to edit_org_admin_template_path(new_copy) + rescue StandardError => e + flash[:alert] = failure_message(_("copy"), template_type(@template)) + if request.referrer.present? + redirect_to :back + else + redirect_to org_admin_templates_path + end + end + end + +end diff --git a/app/controllers/org_admin/template_customization_transfers_controller.rb b/app/controllers/org_admin/template_customization_transfers_controller.rb new file mode 100644 index 0000000..2842b83 --- /dev/null +++ b/app/controllers/org_admin/template_customization_transfers_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class OrgAdmin::TemplateCustomizationTransfersController < ApplicationController + + after_action :verify_authorized + + # POST /org_admin/templates/:id/transfer_customization + # + # The funder template's id is passed through here + def create + @template = Template.find(params[:template_id]) + authorize @template, :transfer_customization? + if @template.upgrade_customization? + @new_customization = @template.upgrade_customization! + redirect_to org_admin_template_path(@new_customization) + else + flash[:alert] = _("That template is no longer customizable.") + redirect_to :back + end + end + +end diff --git a/app/controllers/org_admin/template_customizations_controller.rb b/app/controllers/org_admin/template_customizations_controller.rb new file mode 100644 index 0000000..f4e6f9d --- /dev/null +++ b/app/controllers/org_admin/template_customizations_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class OrgAdmin::TemplateCustomizationsController < ApplicationController + + include Paginable + include Versionable + after_action :verify_authorized + + # POST /org_admin/templates/:id/customize + def create + @template = Template.find(params[:template_id]) + authorize(@template, :customize?) + if @template.customize?(current_user.org) + begin + @customisation = @template.customize!(current_user.org) + redirect_to org_admin_template_path(@customisation) + return + rescue ArgumentError => e + flash[:alert] = _("Unable to customize that template.") + end + else + flash[:notice] = _("That template is not customizable.") + end + redirect_to :back + end + +end diff --git a/app/controllers/org_admin/templates_controller.rb b/app/controllers/org_admin/templates_controller.rb index 56ff633..2fdc41a 100644 --- a/app/controllers/org_admin/templates_controller.rb +++ b/app/controllers/org_admin/templates_controller.rb @@ -1,7 +1,13 @@ +# frozen_string_literal: true + module OrgAdmin + class TemplatesController < ApplicationController + include Paginable include Versionable + include TemplateMethods + after_action :verify_authorized # The root version of index which returns all templates @@ -10,37 +16,47 @@ def index authorize Template templates = Template.latest_version.where(customization_of: nil) - published = templates.select{|t| t.published? || t.draft? }.length - render 'index', locals: { - orgs: Org.all, - title: _('All Templates'), - templates: templates.includes(:org), - action: 'index', - query_params: { sort_field: 'templates.title', sort_direction: 'asc' }, - all_count: templates.length, - published_count: published.present? ? published : 0, - unpublished_count: published.present? ? (templates.length - published): templates.length - } + published = templates.select { |t| t.published? || t.draft? }.length + + @orgs = Org.all + @title = _("All Templates") + @templates = templates.includes(:org) + @query_params = { sort_field: "templates.title", sort_direction: "asc" } + @all_count = templates.length + @published_count = published.present? ? published : 0 + @unpublished_count = if published.present? + (templates.length - published) + else + templates.length + end + render :index end - + # A version of index that displays only templates that belong to the user's org # GET /org_admin/templates/organisational # ----------------------------------------------------- def organisational authorize Template - templates = Template.latest_version_per_org(current_user.org.id).where(customization_of: nil, org_id: current_user.org.id) - published = templates.select{|t| t.published? || t.draft? }.length - title = current_user.can_super_admin? ? _('%{org_name} Templates') % { org_name: current_user.org.name } : _('Own Templates') - render 'index', locals: { - orgs: current_user.can_super_admin? ? Org.all : nil, - title: title, - templates: templates, - action: 'organisational', - query_params: { sort_field: 'templates.title', sort_direction: 'asc' }, - all_count: templates.length, - published_count: published.present? ? published : 0, - unpublished_count: published.present? ? (templates.length - published): templates.length - } + templates = Template.latest_version_per_org(current_user.org.id) + .where(customization_of: nil, org_id: current_user.org.id) + published = templates.select { |t| t.published? || t.draft? }.length + + @orgs = current_user.can_super_admin? ? Org.all : nil + @title = if current_user.can_super_admin? + _("%{org_name} Templates") % { org_name: current_user.org.name } + else + _("Own Templates") + end + @templates = templates + @query_params = { sort_field: "templates.title", sort_direction: "asc" } + @all_count = templates.length + @published_count = published.present? ? published : 0 + @unpublished_count = if published.present? + templates.length - published + else + templates.length + end + render :index end # A version of index that displays only templates that are customizable @@ -48,207 +64,196 @@ # ----------------------------------------------------- def customisable authorize Template - customizations = Template.latest_customized_version_per_org(current_user.org.id).where(org_id: current_user.org.id) + customizations = Template.latest_customized_version_per_org(current_user.org.id) + .where(org_id: current_user.org.id) funder_templates = Template.latest_customizable.includes(:org) - # We use this to validate the counts below in the event that a template was customized but the base template - # org is no longer a funder + # We use this to validate the counts below in the event that a template was + # customized but the base template org is no longer a funder funder_template_families = funder_templates.collect(&:family_id) - published = customizations.select{|t| t.published? || t.draft? }.length - render 'index', locals: { - orgs: (current_user.can_super_admin? ? Org.all : []), - title: _('Customizable Templates'), - templates: funder_templates, - customizations: customizations, - action: 'customisable', - query_params: { sort_field: 'templates.title', sort_direction: 'asc' }, - all_count: funder_templates.length, - published_count: published.present? ? published : 0, - unpublished_count: published.present? ? (customizations.length - published): customizations.length, - not_customized_count: funder_templates.length - customizations.length - } + # filter only customizations of valid(published) funder templates + customizations = customizations.select { |t| + funder_template_families.include?(t.customization_of) } + published = customizations.select { |t| t.published? || t.draft? }.length + + @orgs = current_user.can_super_admin? ? Org.all : [] + @title = _("Customizable Templates") + @templates = funder_templates + @customizations = customizations + @query_params = { sort_field: "templates.title", sort_direction: "asc" } + @all_count = funder_templates.length + @published_count = published.present? ? published : 0 + @unpublished_count = if published.present? + (customizations.length - published) + else + customizations.length + end + @not_customized_count = funder_templates.length - customizations.length + + render :index end - + # GET /org_admin/templates/[:id] def show template = Template.find(params[:id]) authorize template # Load the info needed for the overview section if the authorization check passes! - phases = template.phases.includes(sections: { questions: :question_options }). - order('phases.number', 'sections.number', 'questions.number', 'question_options.number'). - select('phases.title', 'phases.description', 'sections.title', 'questions.text', 'question_options.text') + phases = template.phases + .includes(sections: { questions: :question_options }) + .order("phases.number", "sections.number", "questions.number", + "question_options.number") + .select("phases.title", "phases.description", "sections.title", + "questions.text", "question_options.text") if !template.latest? - flash[:notice] = _('You are viewing a historical version of this template. You will not be able to make changes.') + # rubocop:disable Metrics/LineLength + flash[:notice] = _("You are viewing a historical version of this template. You will not be able to make changes.") + # rubocop:enable Metrics/LineLength end - render 'container', locals: { - partial_path: 'show', + render "container", locals: { + partial_path: "show", template: template, phases: phases, referrer: get_referrer(template, request.referrer) } end - + # GET /org_admin/templates/:id/edit - # ----------------------------------------------------- def edit template = Template.includes(:org, :phases).find(params[:id]) authorize template # Load the info needed for the overview section if the authorization check passes! phases = template.phases.includes(sections: { questions: :question_options }). - order('phases.number', 'sections.number', 'questions.number', 'question_options.number'). - select('phases.title', 'phases.description', 'sections.title', 'questions.text', 'question_options.text') + order("phases.number", + "sections.number", + "questions.number", + "question_options.number"). + select("phases.title", + "phases.description", + "sections.title", + "questions.text", + "question_options.text") if !template.latest? redirect_to org_admin_template_path(id: template.id) else - render 'container', locals: { - partial_path: 'edit', + render "container", locals: { + partial_path: "edit", template: template, phases: phases, referrer: get_referrer(template, request.referrer) } end end - + # GET /org_admin/templates/new - # ----------------------------------------------------- def new authorize Template - render 'container', locals: { - partial_path: 'new', - template: Template.new(org: current_user.org), - referrer: request.referrer.present? ? request.referrer : org_admin_templates_path } + @template = current_org.templates.new end - + # POST /org_admin/templates - # ----------------------------------------------------- def create authorize Template # creates a new template with version 0 and new family_id - template = Template.new(template_params) - template.org_id = current_user.org.id - template.links = (params["template-links"].present? ? ActiveSupport::JSON.decode(params["template-links"]) : {"funder": [], "sample_plan": []}) - if template.save! - redirect_to edit_org_admin_template_path(template), notice: success_message(template_type(template), _('created')) + @template = Template.new(template_params) + @template.org_id = current_user.org.id + @template.locale = current_org.language.abbreviation + @template.links = if params["template-links"].present? + ActiveSupport::JSON.decode(params["template-links"]) + else + { "funder": [], "sample_plan": [] } + end + if @template.save + redirect_to edit_org_admin_template_path(@template), + notice: success_message(@template, _("created")) else - flash[:alert] = failed_create_error(template, template_type(template)) - render partial: "org_admin/templates/new", locals: { template: template, hash: hash } + flash[:alert] = flash[:alert] = failure_message(@template, _("create")) + render :new end end - + # PUT /org_admin/templates/:id (AJAXable) # ----------------------------------------------------- def update template = Template.find(params[:id]) - authorize template + authorize template begin template.assign_attributes(template_params) - template.links = ActiveSupport::JSON.decode(params["template-links"]) if params["template-links"].present? - if template.save! - render(status: :ok, json: { msg: success_message(template_type(template), _('saved'))}) + if params["template-links"].present? + template.links = ActiveSupport::JSON.decode(params["template-links"]) + end + if template.save + render(json: { + status: 200, + msg: success_message(template, _("saved")) + }) else - # Note failed_update_error may return HTML tags (e.g.
    ) and therefore the client should parse them accordingly - render(status: :bad_request, json: { msg: failed_update_error(template, template_type(template))}) + render(json: { + status: :bad_request, + msg: failure_message(template, _("save")) + }) end rescue ActiveSupport::JSON.parse_error - render(status: :bad_request, json: { msg: _("Error parsing links for a #{template_type(template)}") }) and return + render(json: { + status: :bad_request, + msg: _("Error parsing links for a #{template_type(template)}") + }) + return rescue => e - render(status: :forbidden, json: { msg: e.message }) and return + render(json: { + status: :forbidden, + msg: e.message + }) and return end end - + # DELETE /org_admin/templates/:id - # ----------------------------------------------------- def destroy template = Template.find(params[:id]) authorize template versions = Template.includes(:plans).where(family_id: template.family_id) - if versions.select{|t| t.plans.length > 0 }.empty? + if versions.select { |t| t.plans.length > 0 }.empty? versions.each do |version| if version.destroy! - flash[:notice] = success_message(template_type(template), _('removed')) + flash[:notice] = success_message(template, _("removed")) else - flash[:alert] = failed_destroy_error(template, template_type(template)) + flash[:alert] = failure_message(template, _("remove")) end end else + # rubocop:disable Metrics/LineLength flash[:alert] = _("You cannot delete a #{template_type(template)} that has been used to create plans.") + # rubocop:enable Metrics/LineLength end - redirect_to request.referrer.present? ? request.referrer : org_admin_templates_path + if request.referrer.present? + redirect_to request.referrer + else + redirect_to org_admin_templates_path + end end # GET /org_admin/templates/:id/history - # ----------------------------------------------------- def history template = Template.find(params[:id]) authorize template templates = Template.where(family_id: template.family_id) - render 'history', locals: { - templates: templates, - query_params: { sort_field: 'templates.version', sort_direction: 'desc' }, - referrer: template.customization_of.present? ? customisable_org_admin_templates_path : organisational_org_admin_templates_path, + local_referrer = if template.customization_of.present? + customisable_org_admin_templates_path + else + organisational_org_admin_templates_path + end + render "history", locals: { + templates: templates, + query_params: { sort_field: "templates.version", sort_direction: "desc" }, + referrer: local_referrer, current: templates.maximum(:version) } end - - # POST /org_admin/templates/:id/customize - # ----------------------------------------------------- - def customize - template = Template.find(params[:id]) - authorize template - if template.customize?(current_user.org) - begin - customisation = template.customize!(current_user.org) - redirect_to org_admin_template_path(customisation) - rescue StandardError => e - flash[:alert] = _('Unable to customize that template.') - redirect_to request.referrer.present? ? request.referrer : org_admin_templates_path - end - else - flash[:notice] = _('That template is not customizable.') - redirect_to request.referrer.present? ? request.referrer : org_admin_templates_path - end - end - # POST /org_admin/templates/:id/transfer_customization - # the funder template's id is passed through here - # ----------------------------------------------------- - def transfer_customization - template = Template.includes(:org).find(params[:id]) - authorize template - if template.upgrade_customization? - begin - new_customization = template.upgrade_customization! - redirect_to org_admin_template_path(new_customization) - rescue StandardError => e - flash[:alert] = _('Unable to transfer your customizations.') - redirect_to request.referrer.present? ? request.referrer : org_admin_templates_path - end - else - flash[:notice] = _('That template is no longer customizable.') - redirect_to request.referrer.present? ? request.referrer : org_admin_templates_path - end - end - - # POST /org_admin/templates/:id/copy (AJAX) - # ----------------------------------------------------- - def copy - template = Template.find(params[:id]) - authorize template - begin - new_copy = template.generate_copy!(current_user.org) - flash[:notice] = "#{template_type(template).capitalize} was successfully copied." - redirect_to edit_org_admin_template_path(new_copy) - rescue StandardError => e - flash[:alert] = failed_create_error(template, template_type(template)) - redirect_to request.referrer.present? ? request.referrer : org_admin_templates_path - end - end - # PATCH /org_admin/templates/:id/publish (AJAX) - # ----------------------------------------------------- def publish template = Template.find(params[:id]) authorize template + # rubocop:disable Metrics/LineLength if template.latest? # Now make the current version published - if template.update_attributes!({ published: true }) + if template.publish! flash[:notice] = _("Your #{template_type(template)} has been published and is now available to users.") else flash[:alert] = _("Unable to publish your #{template_type(template)}.") @@ -256,97 +261,48 @@ else flash[:alert] = _("You can not publish a historical version of this #{template_type(template)}.") end + # rubocop:enable Metrics/LineLength redirect_to request.referrer.present? ? request.referrer : org_admin_templates_path end # PATCH /org_admin/templates/:id/unpublish (AJAX) - # ----------------------------------------------------- def unpublish template = Template.find(params[:id]) authorize template versions = Template.where(family_id: template.family_id) versions.each do |version| - unless version.update_attributes!({ published: false }) + unless version.update_attributes!(published: false) flash[:alert] = _("Unable to unpublish your #{template_type(template)}.") end end - flash[:notice] = _("Successfully unpublished your #{template_type(template)}") unless flash[:alert].present? + unless flash[:alert].present? + flash[:notice] = _("Successfully unpublished your #{template_type(template)}") + end redirect_to request.referrer.present? ? request.referrer : org_admin_templates_path end - - # GET /org_admin/template_options (AJAX) - # Collect all of the templates available for the org+funder combination - # -------------------------------------------------------------------------- - def template_options() - org_id = (plan_params[:org_id] == '-1' ? '' : plan_params[:org_id]) - funder_id = (plan_params[:funder_id] == '-1' ? '' : plan_params[:funder_id]) - authorize Template.new - templates = [] - if org_id.present? || funder_id.present? - unless funder_id.blank? - # 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).select{ |t| !t.is_default? } - unless org_id.blank? - # 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 - # Only provide the customized version if its still up to date with the funder template! - if customization.present? && !customization.upgrade_customization? - customization - else - tmplt - end - end - end - end - - # If the no funder was specified OR the funder matches the org - if funder_id.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 - end - templates = templates.flatten.uniq - end - - # If no templates were available use the default template - if templates.empty? - default = Template.default - if default.present? - customization = Template.published.latest_customized_version(default.family_id, org_id).first - templates << (customization.present? ? customization : default) - end - end - - templates = (templates.count > 0 ? templates.sort{|x,y| x.title <=> y.title} : []) - render json: {"templates": templates.collect{|t| {id: t.id, title: t.title} }}.to_json - end - - - # ====================================================== private - def plan_params - params.require(:plan).permit(:org_id, :funder_id) - end - + def template_params params.require(:template).permit(:title, :description, :visibility, :links) end - - def template_type(template) - template.customization_of.present? ? _('customisation') : _('template') - end - + def get_referrer(template, referrer) - if referrer.present? - if referrer.end_with?(new_org_admin_template_path) || referrer.end_with?(edit_org_admin_template_path) || referrer.end_with?(org_admin_template_path) - template.customization_of.present? ? customisable_org_admin_templates_path : organisational_org_admin_templates_path - else - request.referrer + return org_admin_templates_path unless referrer.present? + if referrer.end_with?(new_org_admin_template_path) || + referrer.end_with?(edit_org_admin_template_path) || + referrer.end_with?(org_admin_template_path) + + if template.customization_of.present? + customisable_org_admin_templates_path + else + organisational_org_admin_templates_path end else - org_admin_templates_path + request.referrer end end + end -end \ No newline at end of file + +end diff --git a/app/controllers/orgs_controller.rb b/app/controllers/orgs_controller.rb index 135fc5c..fe7c48e 100644 --- a/app/controllers/orgs_controller.rb +++ b/app/controllers/orgs_controller.rb @@ -1,5 +1,8 @@ +# frozen_string_literal: true + class OrgsController < ApplicationController - after_action :verify_authorized, except: ['shibboleth_ds', 'shibboleth_ds_passthru'] + + after_action :verify_authorized, except: ["shibboleth_ds", "shibboleth_ds_passthru"] respond_to :html ## @@ -8,9 +11,9 @@ org = Org.find(params[:id]) authorize org languages = Language.all.order("name") - org.links = {"org": []} unless org.links.present? - render 'admin_edit', locals: {org: org, languages: languages, method: 'PUT', - url: admin_update_org_path(org) } + org.links = { "org": [] } unless org.links.present? + render "admin_edit", locals: { org: org, languages: languages, method: "PUT", + url: admin_update_org_path(org) } end ## @@ -20,42 +23,43 @@ @org = Org.find(params[:id]) authorize @org @org.logo = attrs[:logo] if attrs[:logo] - tab = (attrs[:feedback_enabled].present? ? 'feedback' : 'profile') + tab = (attrs[:feedback_enabled].present? ? "feedback" : "profile") if params[:org_links].present? @org.links = JSON.parse(params[:org_links]) end - begin - # 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 - if Rails.application.config.shibboleth_use_filtered_discovery_service && params[:shib_id].present? - shib = IdentifierScheme.find_by(name: 'shibboleth') - shib_settings = @org.org_identifiers.select{ |ids| ids.identifier_scheme == shib}.first + # 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 + if Rails.application.config.shibboleth_use_filtered_discovery_service && + params[:shib_id].present? + 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 = OrgIdentifier.new(org: @org, identifier_scheme: shib) unless shib_settings.present? + if !params[:shib_id].blank? + unless shib_settings.present? + shib_settings = OrgIdentifier.new(org: @org, identifier_scheme: shib) shib_settings.identifier = params[:shib_id] - shib_settings.attrs = {domain: params[:shib_domain]} + shib_settings.attrs = { domain: params[:shib_domain] } shib_settings.save - else - if shib_settings.present? - # The user cleared the shib values so delete the object - shib_settings.destroy - end + end + else + if shib_settings.present? + # The user cleared the shib values so delete the object + shib_settings.destroy end end end + end - if @org.update_attributes(attrs) - flash[:notice] = success_message(_('organisation'), _('saved')) - redirect_to "#{admin_edit_org_path(@org)}\##{tab}" - else - failure = failed_update_error(@org, _('organisation')) if failure.blank? - redirect_to "#{admin_edit_org_path(@org)}\##{tab}", alert: failure - end - rescue Dragonfly::Job::Fetch::NotFound => dflye - redirect_to "#{admin_edit_org_path(@org)}\##{tab}", alert: _('There seems to be a problem with your logo. Please upload it again.') + if @org.update_attributes(attrs) + redirect_to "#{admin_edit_org_path(@org)}\##{tab}", + notice: success_message(@org, _("saved")) + else + failure = failure_message(@org, _("save")) if failure.blank? + redirect_to "#{admin_edit_org_path(@org)}\##{tab}", alert: failure end end @@ -66,10 +70,13 @@ @user = User.new # Display the custom Shibboleth discovery service page. - @orgs = Org.joins(:identifier_schemes).where('identifier_schemes.name = ?', 'shibboleth').sort{|x,y| x.name <=> y.name } + @orgs = Org.joins(:identifier_schemes) + .where("identifier_schemes.name = ?", "shibboleth").sort do |x, y| + x.name <=> y.name + end if @orgs.empty? - flash[:alert] = _('No organisations are currently registered.') + flash.now[:alert] = _("No organisations are currently registered.") redirect_to user_shibboleth_omniauth_authorize_path end end @@ -77,33 +84,36 @@ # POST /orgs/shibboleth_ds # ---------------------------------------------------------------- def shibboleth_ds_passthru - if !params['shib-ds'][:org_name].blank? - session['org_id'] = params['shib-ds'][:org_name] + 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) + 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 - url = "#{request.base_url.gsub('http:', 'https:')}#{Rails.application.config.shibboleth_login}" + 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 + # initiate shibboleth login sequence redirect_to "#{url}?target=#{target}&entityID=#{shib_entity.first.identifier}" else - flash[:alert] = _('Your organisation does not seem to be properly configured.') - redirect_to shibboleth_ds_path + failure = _("Your organisation does not seem to be properly configured.") + redirect_to shibboleth_ds_path, alert: failure end else - flash[:notice] = _('Please choose an organisation') - redirect_to shibboleth_ds_path + redirect_to shibboleth_ds_path, notice: _("Please choose an organisation") end end private - def org_params - params.require(:org).permit(:name, :abbreviation, :logo, :contact_email, :contact_name, :remove_logo, :org_type, - :feedback_enabled, :feedback_email_msg) - end -end \ No newline at end of file + def org_params + params.require(:org).permit(:name, :abbreviation, :logo, :contact_email, + :contact_name, :remove_logo, :org_type, + :feedback_enabled, :feedback_email_msg) + end + +end diff --git a/app/controllers/paginable/guidance_groups_controller.rb b/app/controllers/paginable/guidance_groups_controller.rb index a6400d1..4605ffe 100644 --- a/app/controllers/paginable/guidance_groups_controller.rb +++ b/app/controllers/paginable/guidance_groups_controller.rb @@ -1,11 +1,14 @@ -module Paginable - class GuidanceGroupsController < ApplicationController - include Paginable - # /paginable/guidance_groups/index/:page - def index - authorize(Guidance) - paginable_renderise(partial: 'index', - scope: GuidanceGroup.by_org(current_user.org)) - end +# frozen_string_literal: true + +class Paginable::GuidanceGroupsController < ApplicationController + + include Paginable + + # /paginable/guidance_groups/index/:page + def index + authorize(Guidance) + paginable_renderise(partial: "index", + scope: GuidanceGroup.by_org(current_user.org)) end -end \ No newline at end of file + +end diff --git a/app/controllers/paginable/guidances_controller.rb b/app/controllers/paginable/guidances_controller.rb index 5887df5..4ad8492 100644 --- a/app/controllers/paginable/guidances_controller.rb +++ b/app/controllers/paginable/guidances_controller.rb @@ -1,11 +1,15 @@ -module Paginable - class GuidancesController < ApplicationController - include Paginable - # /paginable/guidances/index/:page - def index - authorize(Guidance) - paginable_renderise(partial: 'index', - scope: Guidance.by_org(current_user.org).includes(:guidance_group, :themes)) - end +# frozen_string_literal: true + +class Paginable::GuidancesController < ApplicationController + + include Paginable + + # /paginable/guidances/index/:page + def index + authorize(Guidance) + paginable_renderise(partial: "index", + scope: Guidance.by_org(current_user.org) + .includes(:guidance_group, :themes)) end -end \ No newline at end of file + +end diff --git a/app/controllers/paginable/notifications_controller.rb b/app/controllers/paginable/notifications_controller.rb index 8cda8df..4ef28ca 100644 --- a/app/controllers/paginable/notifications_controller.rb +++ b/app/controllers/paginable/notifications_controller.rb @@ -1,10 +1,13 @@ -module Paginable - class NotificationsController < ApplicationController - include Paginable - # /paginable/notifications/index/:page - def index - authorize(Notification) - paginable_renderise(partial: 'index', scope: Notification.all) - end +# frozen_string_literal: true + +class Paginable::NotificationsController < ApplicationController + + include Paginable + + # /paginable/notifications/index/:page + def index + authorize(Notification) + paginable_renderise(partial: "index", scope: Notification.all) end + end diff --git a/app/controllers/paginable/orgs_controller.rb b/app/controllers/paginable/orgs_controller.rb index fc78fa6..17c8e26 100644 --- a/app/controllers/paginable/orgs_controller.rb +++ b/app/controllers/paginable/orgs_controller.rb @@ -1,11 +1,16 @@ +# frozen_string_literal: true + class Paginable::OrgsController < ApplicationController + include Paginable + # /paginable/guidances/index/:page def index authorize(Org) paginable_renderise( - partial: 'index', + partial: "index", scope: Org.includes(:templates, :users), - query_params: { sort_field: 'orgs.name', sort_direction: :asc }) + query_params: { sort_field: "orgs.name", sort_direction: :asc }) end + end diff --git a/app/controllers/paginable/plans_controller.rb b/app/controllers/paginable/plans_controller.rb index 95e72c5..515a5fe 100644 --- a/app/controllers/paginable/plans_controller.rb +++ b/app/controllers/paginable/plans_controller.rb @@ -1,27 +1,48 @@ +# frozen_string_literal: true + class Paginable::PlansController < ApplicationController + include Paginable + # /paginable/plans/privately_visible/:page def privately_visible - raise Pundit::NotAuthorizedError unless Paginable::PlanPolicy.new(current_user).privately_visible? - paginable_renderise(partial: 'privately_visible', scope: Plan.active(current_user)) + unless Paginable::PlanPolicy.new(current_user).privately_visible? + raise Pundit::NotAuthorizedError + end + paginable_renderise( + partial: "privately_visible", + scope: Plan.active(current_user) + ) end + # GET /paginable/plans/organisationally_or_publicly_visible/:page def organisationally_or_publicly_visible - raise Pundit::NotAuthorizedError unless Paginable::PlanPolicy.new(current_user).organisationally_or_publicly_visible? - paginable_renderise(partial: 'organisationally_or_publicly_visible', - scope: Plan.organisationally_or_publicly_visible(current_user)) + unless Paginable::PlanPolicy.new(current_user).organisationally_or_publicly_visible? + raise Pundit::NotAuthorizedError + end + paginable_renderise( + partial: "organisationally_or_publicly_visible", + scope: Plan.organisationally_or_publicly_visible(current_user) + ) end + # GET /paginable/plans/publicly_visible/:page def publicly_visible paginable_renderise( - partial: 'publicly_visible', + partial: "publicly_visible", scope: Plan.publicly_visible ) end + # GET /paginable/plans/org_admin/:page def org_admin - raise Pundit::NotAuthorizedError unless current_user.present? && current_user.can_org_admin? - paginable_renderise(partial: 'org_admin', - scope: current_user.org.plans) + unless current_user.present? && current_user.can_org_admin? + raise Pundit::NotAuthorizedError + end + paginable_renderise( + partial: "org_admin", + scope: current_user.org.plans + ) end -end \ No newline at end of file + +end diff --git a/app/controllers/paginable/templates_controller.rb b/app/controllers/paginable/templates_controller.rb index 19d9409..086b41a 100644 --- a/app/controllers/paginable/templates_controller.rb +++ b/app/controllers/paginable/templates_controller.rb @@ -1,38 +1,53 @@ +# frozen_string_literal: true + class Paginable::TemplatesController < ApplicationController + include Paginable - + + # TODO: Clean up this code for Rubocop + # rubocop:disable LineLength + # GET /paginable/templates/:page (AJAX) # ----------------------------------------------------- def index authorize Template templates = Template.latest_version.where(customization_of: nil) case params[:f] - when 'published' - template_ids = templates.select{|t| t.published? || t.draft? }.collect(&:family_id) + when "published" + template_ids = templates.select { |t| t.published? || t.draft? }.collect(&:family_id) templates = Template.latest_version(template_ids).where(customization_of: nil) - when 'unpublished' - template_ids = templates.select{|t| !t.published? && !t.draft? }.collect(&:family_id) + when "unpublished" + template_ids = templates.select { |t| !t.published? && !t.draft? }.collect(&:family_id) templates = Template.latest_version(template_ids).where(customization_of: nil) end - paginable_renderise partial: 'index', scope: templates.includes(:org), locals: { action: 'index' } + paginable_renderise( + partial: "index", + scope: templates.includes(:org), + locals: { action: "index" } + ) end - + # GET /paginable/templates/organisational/:page (AJAX) # ----------------------------------------------------- def organisational authorize Template - templates = Template.latest_version_per_org(current_user.org.id).where(customization_of: nil, org_id: current_user.org.id) + templates = Template.latest_version_per_org(current_user.org.id) + .where(customization_of: nil, org_id: current_user.org.id) case params[:f] - when 'published' - template_ids = templates.select{|t| t.published? || t.draft? }.collect(&:family_id) + when "published" + template_ids = templates.select { |t| t.published? || t.draft? }.collect(&:family_id) templates = Template.latest_version(template_ids) - when 'unpublished' - template_ids = templates.select{|t| !t.published? && !t.draft? }.collect(&:family_id) + when "unpublished" + template_ids = templates.select { |t| !t.published? && !t.draft? }.collect(&:family_id) templates = Template.latest_version(template_ids) end - paginable_renderise partial: 'organisational', scope: templates, locals: { action: 'organisational' } + paginable_renderise( + partial: "organisational", + scope: templates, + locals: { action: "organisational" } + ) end - + # GET /paginable/templates/customisable/:page (AJAX) # ----------------------------------------------------- def customisable @@ -40,26 +55,37 @@ customizations = Template.latest_customized_version_per_org(current_user.org.id) templates = Template.latest_customizable case params[:f] - when 'published' - customization_ids = customizations.select{|t| t.published? || t.draft? }.collect(&:customization_of) - templates = Template.latest_customizable.where(family_id: customization_ids) - when 'unpublished' - customization_ids = customizations.select{|t| !t.published? && !t.draft? }.collect(&:customization_of) - templates = Template.latest_customizable.where(family_id: customization_ids) - when 'not-customised' - templates = Template.latest_customizable.where.not(family_id: customizations.collect(&:customization_of)) + when "published" + customization_ids = customizations.select { |t| t.published? || t.draft? }.collect(&:customization_of) + templates = Template.latest_customizable.where(family_id: customization_ids) + when "unpublished" + customization_ids = customizations.select { |t| !t.published? && !t.draft? }.collect(&:customization_of) + templates = Template.latest_customizable.where(family_id: customization_ids) + when "not-customised" + templates = Template.latest_customizable.where.not(family_id: customizations.collect(&:customization_of)) end - paginable_renderise partial: 'customisable', scope: templates.joins(:org).includes(:org), locals: { action: 'customisable', customizations: customizations } + paginable_renderise( + partial: "customisable", + scope: templates.joins(:org).includes(:org), + locals: { action: "customisable", customizations: customizations } + ) end + # rubocop:enable LineLength + # GET /paginable/templates/publicly_visible/:page (AJAX) # ----------------------------------------------------- def publicly_visible - templates = Template.live(Template.families(Org.funder.pluck(:id)).pluck(:family_id)).publicly_visible.pluck(:id) << - Template.where(is_default: true).unarchived.published.pluck(:id) + templates = Template.live(Template.families(Org.funder.pluck(:id)).pluck(:family_id)) + .publicly_visible.pluck(:id) << + Template.where(is_default: true).unarchived.published.pluck(:id) paginable_renderise( - partial: 'publicly_visible', - scope: Template.joins(:org).includes(:org).where(id: templates.uniq.flatten).published) + partial: "publicly_visible", + scope: Template.joins(:org) + .includes(:org) + .where(id: templates.uniq.flatten) + .published + ) end # GET /paginable/templates/:id/history/:page (AJAX) @@ -70,8 +96,10 @@ @templates = Template.where(family_id: @template.family_id) @current = Template.current(@template.family_id) paginable_renderise( - partial: 'history', + partial: "history", scope: @templates, - locals: { current: @templates.maximum(:version) }) + locals: { current: @templates.maximum(:version) } + ) end + end diff --git a/app/controllers/paginable/themes_controller.rb b/app/controllers/paginable/themes_controller.rb index 76b9263..d4f3b3d 100644 --- a/app/controllers/paginable/themes_controller.rb +++ b/app/controllers/paginable/themes_controller.rb @@ -1,10 +1,13 @@ -module Paginable - class ThemesController < ApplicationController - include Paginable - # /paginable/themes/index/:page - def index - authorize(Theme) - paginable_renderise(partial: 'index', scope: Theme.all) - end +# frozen_string_literal: true + +class Paginable::ThemesController < ApplicationController + + include Paginable + + # /paginable/themes/index/:page + def index + authorize(Theme) + paginable_renderise(partial: "index", scope: Theme.all) end -end \ No newline at end of file + +end diff --git a/app/controllers/paginable/users_controller.rb b/app/controllers/paginable/users_controller.rb index d2a8110..02f2835 100644 --- a/app/controllers/paginable/users_controller.rb +++ b/app/controllers/paginable/users_controller.rb @@ -1,5 +1,9 @@ +# frozen_string_literal: true + class Paginable::UsersController < ApplicationController + include Paginable + # /paginable/users/index/:page def index authorize User @@ -8,6 +12,11 @@ else scope = current_user.org.users.includes(:roles) end - paginable_renderise(partial: 'index', scope: scope, view_all: !current_user.can_super_admin?) + paginable_renderise( + partial: "index", + scope: scope, + view_all: !current_user.can_super_admin? + ) end -end \ No newline at end of file + +end diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 9116fb1..c2ddaf6 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -1,19 +1,22 @@ +# frozen_string_literal: true + class PasswordsController < Devise::PasswordsController - - protected - - def after_resetting_password_path_for(resource) + + protected + + def after_resetting_password_path_for(resource) root_path end - + ## # Override Devise default behaviour by sending user to the home page # after the password reset email has been sent # - # @resource_name [String] The user's email address - # --------------------------------------------------------------------- + # resource_name - The user's email address + # + # Returns String def after_sending_reset_password_instructions_path_for(resource_name) root_path end -end \ No newline at end of file +end diff --git a/app/controllers/plan_exports_controller.rb b/app/controllers/plan_exports_controller.rb new file mode 100644 index 0000000..e960f0e --- /dev/null +++ b/app/controllers/plan_exports_controller.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +class PlanExportsController < ApplicationController + + after_action :verify_authorized + + def show + @plan = Plan.includes(:answers).find(params[:plan_id]) + + if privately_authorized? && export_params[:form].present? + @show_coversheet = export_params[:project_details].present? + @show_sections_questions = export_params[:question_headings].present? + @show_unanswered = export_params[:unanswered_questions].present? + @show_custom_sections = export_params[:custom_sections].present? + @public_plan = false + + elsif publicly_authorized? + skip_authorization + @show_coversheet = true + @show_sections_questions = true + @show_unanswered = true + @show_custom_sections = true + @public_plan = true + + else + raise Pundit::NotAuthorizedError + end + + @hash = @plan.as_pdf(@show_coversheet) + @formatting = export_params[:formatting] || @plan.settings(:export).formatting + if params.key?(:phase_id) + @selected_phase = @plan.phases.find(params[:phase_id]) + else + @selected_phase = @plan.phases.order("phases.updated_at DESC") + .detect { |p| p.visibility_allowed?(@plan) } + end + + respond_to do |format| + format.html { show_html } + format.csv { show_csv } + format.text { show_text } + format.docx { show_docx } + format.pdf { show_pdf } + end + end + + private + + def show_html + render layout: false + end + + def show_csv + send_data @plan.as_csv(@show_sections_questions, + @show_unanswered, + @selected_phase, + @show_custom_sections, + @show_coversheet), + filename: "#{file_name}.csv" + end + + def show_text + send_data render_to_string(partial: "shared/export/plan_txt"), + filename: "#{file_name}.txt" + end + + def show_docx + render docx: "#{file_name}.docx", + content: render_to_string(partial: "shared/export/plan") + end + + def show_pdf + render pdf: file_name, + margin: @formatting[:margin], + footer: { + center: _("Created using the %{application_name}. Last modified %{date}") % { + application_name: Rails.configuration.branding[:application][:name], + date: l(@plan.updated_at.to_date, formats: :short) + }, + font_size: 8, + spacing: (Integer(@formatting[:margin][:bottom]) / 2) - 4, + right: "[page] of [topage]" + } + end + + def file_name + @plan.title.gsub(/ /, "_") + end + + def publicly_authorized? + PublicPagePolicy.new(@plan, current_user).plan_organisationally_exportable? || + PublicPagePolicy.new(@plan).plan_export? + end + + def privately_authorized? + authorize @plan, :export? + end + + def export_params + params.fetch(:export, {}) + end + +end diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index 080f0d3..75af410 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -1,72 +1,89 @@ +# frozen_string_literal: true + class PlansController < ApplicationController + include ConditionalUserMailer - require 'pp' helper PaginableHelper helper SettingsTemplateHelper - include FeedbacksHelper after_action :verify_authorized, except: [:overview] def index authorize Plan @plans = Plan.active(current_user).page(1) - @organisationally_or_publicly_visible = Plan.organisationally_or_publicly_visible(current_user).page(1) + @organisationally_or_publicly_visible = + Plan.organisationally_or_publicly_visible(current_user).page(1) end # GET /plans/new - # ------------------------------------------------------------------------------------ def new @plan = Plan.new authorize @plan # Get all of the available funders and non-funder orgs - @funders = Org.funder.joins(:templates).where(templates: {published: true}).uniq.sort{|x,y| x.name <=> y.name } - @orgs = (Org.organisation + Org.institution + Org.managing_orgs).flatten.uniq.sort{|x,y| x.name <=> y.name } + @funders = Org.funder + .joins(:templates) + .where(templates: { published: true }).uniq.sort_by(&:name) + @orgs = (Org.organisation + Org.institution + Org.managing_orgs).flatten + .uniq.sort_by(&:name) # Get the current user's org @default_org = current_user.org if @orgs.include?(current_user.org) - flash[:notice] = "#{_('This is a')} #{_('test plan')}" if params[:test] + if params.key?(:test) + flash[:notice] = "#{_('This is a')} #{_('test plan')}" + end @is_test = params[:test] ||= false respond_to :html end # POST /plans - # ------------------------------------------------------------------- def create @plan = Plan.new authorize @plan - # We set these ids to -1 on the page to trick ariatiseForm into allowing the autocomplete to be blank if - # the no org/funder checkboxes are checked off - org_id = (plan_params[:org_id] == '-1' ? '' : plan_params[:org_id]) - funder_id = (plan_params[:funder_id] == '-1' ? '' : plan_params[:funder_id]) + # We set these ids to -1 on the page to trick ariatiseForm into allowing the + # autocomplete to be blank if the no org/funder checkboxes are checked off + org_id = (plan_params[:org_id] == "-1" ? "" : plan_params[:org_id]) + funder_id = (plan_params[:funder_id] == "-1" ? "" : plan_params[:funder_id]) - # If the template_id is blank then we need to look up the available templates and return JSON + # If the template_id is blank then we need to look up the available templates and + # return JSON if plan_params[:template_id].blank? # Something went wrong there should always be a template id respond_to do |format| - flash[:alert] = _('Unable to identify a suitable template for your plan.') + flash[:alert] = _("Unable to identify a suitable template for your plan.") format.html { redirect_to new_plan_path } end else # Otherwise create the plan - @plan.principal_investigator = current_user.surname.blank? ? nil : "#{current_user.firstname} #{current_user.surname}" + 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')) + 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 = (plan_params['visibility'].blank? ? Rails.application.config.default_plan_visibility : - plan_params[:visibility]) + @plan.visibility = if plan_params["visibility"].blank? + Rails.application.config.default_plan_visibility + else + plan_params[:visibility] + end @plan.template = Template.find(plan_params[:template_id]) if plan_params[:title].blank? - @plan.title = current_user.firstname.blank? ? _('My Plan') + '(' + @plan.template.title + ')' : - current_user.firstname + "'s" + _(" Plan") + @plan.title = if current_user.firstname.blank? + _("My Plan") + "(" + @plan.template.title + ")" + else + current_user.firstname + "'s" + _(" Plan") + end else @plan.title = plan_params[:title] end @@ -82,19 +99,24 @@ default = Template.default - msg = "#{success_message(_('plan'), _('created'))}
    " + msg = "#{success_message(@plan, _('created'))}
    " if !default.nil? && default == @plan.template # We used the generic/default template msg += " #{_('This plan is based on the default template.')}" elsif !@plan.template.customization_of.nil? + # 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]}" - + # rubocop:enable Metrics/LineLength else + # rubocop:disable Metrics/LineLength # We used the specified org's or funder's template + # rubocop:disable Metrics/LineLength msg += " #{_('This plan is based on the')} #{@plan.template.org.name}: '#{@plan.template.title}' template." + # rubocop:enable Metrics/LineLength end respond_to do |format| @@ -105,7 +127,7 @@ else # Something went wrong so report the issue to the user respond_to do |format| - flash[:alert] = failed_create_error(@plan, 'Plan') + flash[:alert] = failure_message(@plan, _("create")) format.html { redirect_to new_plan_path } end end @@ -114,40 +136,55 @@ # GET /plans/show def show - @plan = Plan.eager_load(params[:id]) + @plan = Plan.includes( + template: { phases: { sections: { questions: :answers } } }, + plans_guidance_groups: { guidance_group: :guidances } + ).find(params[:id]) authorize @plan - @visibility = @plan.visibility.present? ? @plan.visibility.to_s : Rails.application.config.default_plan_visibility + @visibility = if @plan.visibility.present? + @plan.visibility.to_s + else + Rails.application.config.default_plan_visibility + end @editing = (!params[:editing].nil? && @plan.administerable_by?(current_user.id)) # Get all Guidance Groups applicable for the plan and group them by org - @all_guidance_groups = @plan.get_guidance_group_options + @all_guidance_groups = @plan.guidance_group_options @all_ggs_grouped_by_org = @all_guidance_groups.sort.group_by(&:org) @selected_guidance_groups = @plan.guidance_groups - # Important ones come first on the page - we grab the user's org's GGs and "Organisation" org type GGs + # Important ones come first on the page - we grab the user's org's GGs and + # "Organisation" org type GGs @important_ggs = [] - @important_ggs << [current_user.org, @all_ggs_grouped_by_org[current_user.org]] if @all_ggs_grouped_by_org.include?(current_user.org) + if @all_ggs_grouped_by_org.include?(current_user.org) + @important_ggs << [current_user.org, @all_ggs_grouped_by_org[current_user.org]] + end @all_ggs_grouped_by_org.each do |org, ggs| if org.organisation? - @important_ggs << [org,ggs] + @important_ggs << [org, ggs] end # If this is one of the already selected guidance groups its important! if !(ggs & @selected_guidance_groups).empty? - @important_ggs << [org,ggs] unless @important_ggs.include?([org,ggs]) + @important_ggs << [org, ggs] unless @important_ggs.include?([org, ggs]) end end # Sort the rest by org name for the accordion - @important_ggs = @important_ggs.sort_by{|org,gg| (org.nil? ? '' : org.name)} - @all_ggs_grouped_by_org = @all_ggs_grouped_by_org.sort_by {|org,gg| (org.nil? ? '' : org.name)} - @selected_guidance_groups = @selected_guidance_groups.collect{|gg| gg.id} + @important_ggs = @important_ggs.sort_by { |org, gg| (org.nil? ? "" : org.name) } + @all_ggs_grouped_by_org = @all_ggs_grouped_by_org.sort_by do |org, gg| + (org.nil? ? "" : org.name) + end + @selected_guidance_groups = @selected_guidance_groups.ids - @based_on = (@plan.template.customization_of.nil? ? @plan.template : Template.where(family_id: @plan.template.customization_of).first) - + @based_on = if @plan.template.customization_of.nil? + @plan.template + else + Template.where(family_id: @plan.template.customization_of).first + end respond_to :html end @@ -155,25 +192,9 @@ def edit plan = Plan.find(params[:id]) authorize plan - plan, phase = Plan.load_for_phase(params[:id], params[:phase_id]) - - readonly = !plan.editable_by?(current_user.id) - - guidance_groups_ids = plan.guidance_groups.collect(&:id) - - guidance_groups = GuidanceGroup.where(published: true, id: guidance_groups_ids) - - # Since the answers have been pre-fetched through plan (see Plan.load_for_phase) - # we create a hash whose keys are question id and value is the answer associated - answers = plan.answers.reduce({}){ |m, a| m[a.question_id] = a; m } - - render('/phases/edit', locals: { - base_template_org: phase.template.base_org, - plan: plan, phase: phase, readonly: readonly, - question_guidance: plan.guidance_by_question_as_hash, - guidance_groups: guidance_groups, - answers: answers }) + guidance_groups = GuidanceGroup.where(published: true, id: plan.guidance_group_ids) + render_phases_edit(plan, phase, guidance_groups) end # PUT /plans/1 @@ -182,29 +203,47 @@ @plan = Plan.find(params[:id]) authorize @plan attrs = plan_params - + # rubocop:disable Metrics/BlockLength respond_to do |format| begin # Save the guidance group selections - guidance_group_ids = params[:guidance_group_ids].blank? ? [] : params[:guidance_group_ids].map(&:to_i).uniq + guidance_group_ids = if params[:guidance_group_ids].blank? + [] + else + 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) - format.html { redirect_to overview_plan_path(@plan), notice: success_message(_('plan'), _('saved')) } - format.json {render json: {code: 1, msg: success_message(_('plan'), _('saved'))}} + format.html do + redirect_to overview_plan_path(@plan), + notice: success_message(@plan, _("saved")) + end + format.json do + render json: { code: 1, msg: success_message(@plan, _("saved")) } + end else - flash[:alert] = failed_update_error(@plan, _('plan')) - format.html { render action: "edit" } - format.json {render json: {code: 0, msg: flash[:alert]}} + format.html do + # TODO: Should do a `render :show` here instead but show defines too many + # instance variables in the controller + redirect_to "#{plan_path(@plan)}", alert: failure_message(@plan, _("save")) + end + format.json do + render json: { code: 0, msg: failure_message(@plan, _("save")) } + end end rescue Exception - flash[:alert] = failed_update_error(@plan, _('plan')) - format.html { render action: "edit" } - format.json {render json: {code: 0, msg: flash[:alert]}} + flash[:alert] = failure_message(@plan, _("save")) + format.html do + render_phases_edit(@plan, @plan.phases.first, @plan.guidance_groups) + end + format.json do + render json: { code: 0, msg: flash[:alert] } + end end end + # rubocop:enable Metrics/BlockLength end def share @@ -212,44 +251,38 @@ if @plan.present? authorize @plan # Get the roles where the user is not a reviewer - @plan_roles = @plan.roles.select{ |r| !r.reviewer? } + @plan_roles = @plan.roles.select { |r| !r.reviewer? } else redirect_to(plans_path) end end - def destroy @plan = Plan.find(params[:id]) authorize @plan if @plan.destroy respond_to do |format| - format.html { redirect_to plans_url, notice: success_message(_('plan'), _('deleted')) } + format.html do + redirect_to plans_url, + notice: success_message(@plan, _("deleted")) + end end else respond_to do |format| - flash[:alert] = failed_create_error(@plan, _('plan')) + flash[:alert] = failure_message(@plan, _("delete")) format.html { render action: "edit" } end end end - # GET /status/1.json - # only returns json, why is this here? - def status - @plan = Plan.find(params[:id]) - authorize @plan - respond_to do |format| - format.json { render json: @plan.status } - end - end - def answer @plan = Plan.find(params[:id]) authorize @plan if !params[:q_id].nil? respond_to do |format| - format.json { render json: @plan.answer(params[:q_id], false).to_json(:include => :options) } + format.json do + render json: @plan.answer(params[:q_id], false).to_json(include: :options) + end end else respond_to do |format| @@ -261,46 +294,11 @@ def download @plan = Plan.find(params[:id]) authorize @plan - @phase_options = @plan.phases.order(:number).pluck(:title,:id) + @phase_options = @plan.phases.order(:number).pluck(:title, :id) @export_settings = @plan.settings(:export) - render 'download' + render "download" end - - - def export - @plan = Plan.includes(:answers).find(params[:id]) - authorize @plan - - @show_coversheet = params[:export][:project_details].present? - @show_sections_questions = params[:export][:question_headings].present? - @show_unanswered = params[:export][:unanswered_questions].present? - @public_plan = false - - @hash = @plan.as_pdf(@show_coversheet) - @formatting = params[:export][:formatting] || @plan.settings(:export).formatting - file_name = @plan.title.gsub(/ /, "_") - - - respond_to do |format| - format.html { render layout: false } - format.csv { send_data @plan.as_csv(@show_sections_questions), filename: "#{file_name}.csv" } - format.text { send_data render_to_string(partial: 'shared/export/plan_txt'), filename: "#{file_name}.txt" } - format.docx { render docx: "#{file_name}.docx", content: render_to_string(partial: 'shared/export/plan') } - format.pdf do - render pdf: file_name, - margin: @formatting[:margin], - footer: { - center: _('Created using the %{application_name}. Last modified %{date}') % {application_name: Rails.configuration.branding[:application][:name], date: l(@plan.updated_at.to_date, formats: :short)}, - font_size: 8, - spacing: (Integer(@formatting[:margin][:bottom]) / 2) - 4, - right: '[page] of [topage]' - } - end - end - end - - def duplicate plan = Plan.find(params[:id]) authorize plan @@ -308,9 +306,9 @@ respond_to do |format| if @plan.save @plan.assign_creator(current_user) - format.html { redirect_to @plan, notice: success_message(_('plan'), _('copied')) } + format.html { redirect_to @plan, notice: success_message(@plan, _("copied")) } else - format.html { redirect_to plans_path, alert: failed_create_error(@plan, 'Plan') } + format.html { redirect_to plans_path, alert: failure_message(@plan, _("copy")) } end end end @@ -323,20 +321,30 @@ if plan.visibility_allowed? plan.visibility = plan_params[:visibility] if plan.save - deliver_if(recipients: plan.owner_and_coowners, key: 'owners_and_coowners.visibility_changed') do |r| - UserMailer.plan_visibility(r,plan).deliver_now() + deliver_if(recipients: plan.owner_and_coowners, + key: "owners_and_coowners.visibility_changed") do |r| + UserMailer.plan_visibility(r, plan).deliver_now() end - render status: :ok, json: { msg: success_message(_('plan\'s visibility'), _('changed')) } + render status: :ok, + json: { msg: success_message(plan, _("updated")) } else - render status: :internal_server_error, json: { msg: _('Error raised while saving the visibility for plan id %{plan_id}') %{ :plan_id => params[:id]} } + render status: :internal_server_error, + json: { msg: failure_message(plan, _("update")) } end else + # rubocop:disable Metrics/LineLength render status: :forbidden, json: { - msg: _('Unable to change the plan\'s status since it is needed at least '\ - '%{percentage} percentage responded') %{ :percentage => Rails.application.config.default_plan_percentage_answered } } + msg: _("Unable to change the plan's status since it is needed at least %{percentage} percentage responded") % { + percentage: Rails.application.config.default_plan_percentage_answered + } + } + # rubocop:enable Metrics/LineLength end else - render status: :not_found, json: { msg: _('Unable to find plan id %{plan_id}') %{ :plan_id => params[:id]} } + render status: :not_found, + json: { msg: _("Unable to find plan id %{plan_id}") % { + plan_id: params[:id] } + } end end @@ -344,54 +352,51 @@ plan = Plan.find(params[:id]) authorize plan plan.visibility = (params[:is_test] === "1" ? :is_test : :privately_visible) + # rubocop:disable Metrics/LineLength if plan.save - render json: {code: 1, msg: (plan.is_test? ? _('Your project is now a test.') : _('Your project is no longer a test.') )} + render json: { + code: 1, + msg: (plan.is_test? ? _("Your project is now a test.") : _("Your project is no longer a test.")) + } else - render status: :bad_request, json: {code: 0, msg: _("Unable to change the plan's test status")} + render status: :bad_request, json: { + code: 0, msg: _("Unable to change the plan's test status") + } end - end - - def request_feedback - @plan = Plan.find(params[:id]) - authorize @plan - alert = _('Unable to submit your request for feedback at this time.') - - begin - if @plan.request_feedback(current_user) - redirect_to share_plan_path(@plan), - notice: _(request_feedback_flash_notice) - else - redirect_to share_plan_path(@plan), alert: alert - end - rescue Exception - redirect_to share_plan_path(@plan), alert: alert - end + # rubocop:enable Metrics/LineLength end def overview begin - plan = Plan.overview(params[:id]) + plan = Plan.includes(:phases, :sections, :questions, template: [ :org ]) + .find(params[:id]) + authorize plan render(:overview, locals: { plan: plan }) rescue ActiveRecord::RecordNotFound - flash[:alert] = _('There is no plan associated with id %{id}') %{ :id => params[:id] } + flash[:alert] = _("There is no plan associated with id %{id}") % { + id: params[:id] + } redirect_to(action: :index) end end private - 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, - :principal_investigator_email, :principal_investigator_identifier, - :data_contact, :data_contact_email, :data_contact_phone, :guidance_group_ids) - end + 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) + end # different versions of the same template have the same family_id # but different version numbers so for each set of templates with the # same family_id choose the highest version number. - def get_most_recent( templates ) + def get_most_recent(templates) groups = Hash.new templates.each do |t| k = t.family_id @@ -407,25 +412,6 @@ groups.values end - - def fixup_hash(plan) - rollup(plan, "notes", "answer_id", "answers") - rollup(plan, "answers", "question_id", "questions") - rollup(plan, "questions", "section_id", "sections") - rollup(plan, "sections", "phase_id", "phases") - - plan["template"]["phases"] = plan.delete("phases") - - ghash = {} - plan["guidance_groups"].map{|g| ghash[g["id"]] = g} - plan["plans_guidance_groups"].each do |pgg| - pgg["guidance_group"] = ghash[ pgg["guidance_group_id"] ] - end - - plan["template"]["org"] = Org.find(plan["template"]["org_id"]).serializable_hash() - end - - # find all object under src_plan_key # merge them into the items under obj_plan_key using # super_id = id @@ -439,7 +425,7 @@ if !id_to_obj.has_key?(id) id_to_obj[id] = Array.new end - id_to_obj[id] << o + id_to_obj[id] << o end plan[obj_plan_key].each do |o| @@ -451,14 +437,26 @@ plan.delete(src_plan_key) end - # Flash notice for successful feedback requests - # - # @return [String] - def request_feedback_flash_notice - # Use the generic feedback confirmation message unless the Org has - # specified one - text = current_user.org.feedback_email_msg || - feedback_confirmation_default_message - feedback_constant_to_text(text, current_user, @plan, current_user.org) + private + + # ============================ + # = Private instance methods = + # ============================ + + def render_phases_edit(plan, phase, guidance_groups) + readonly = !plan.editable_by?(current_user.id) + # Since the answers have been pre-fetched through plan (see Plan.load_for_phase) + # we create a hash whose keys are question id and value is the answer associated + answers = plan.answers.reduce({}) { |m, a| m[a.question_id] = a; m } + render("/phases/edit", locals: { + base_template_org: phase.template.base_org, + plan: plan, + phase: phase, + readonly: readonly, + guidance_groups: guidance_groups, + answers: answers, + guidance_presenter: GuidancePresenter.new(plan) + }) end + end diff --git a/app/controllers/public_pages_controller.rb b/app/controllers/public_pages_controller.rb index 021b7bf..17ab192 100644 --- a/app/controllers/public_pages_controller.rb +++ b/app/controllers/public_pages_controller.rb @@ -1,12 +1,18 @@ +# frozen_string_literal: true + class PublicPagesController < ApplicationController + after_action :verify_authorized, except: [:template_index, :plan_index] # GET template_index # ----------------------------------------------------- def template_index - templates = Template.live(Template.families(Org.funder.pluck(:id)).pluck(:family_id)).publicly_visible.pluck(:id) << - Template.where(is_default: true).unarchived.published.pluck(:id) - @templates = Template.includes(:org).where(id: templates.uniq.flatten).unarchived.published.order(title: :asc).page(1) + templates = Template.live(Template.families(Org.funder.pluck(:id)).pluck(:family_id)) + .publicly_visible.pluck(:id) << + Template.where(is_default: true).unarchived.published.pluck(:id) + @templates = Template.includes(:org) + .where(id: templates.uniq.flatten) + .unarchived.published.order(title: :asc).page(1) end # GET template_export/:id @@ -14,67 +20,54 @@ def template_export # only export live templates, id passed is family_id @template = Template.live(params[:id]) - # covers authorization for this action. Pundit dosent support passing objects into scoped policies - raise Pundit::NotAuthorizedError unless PublicPagePolicy.new( @template).template_export? + # covers authorization for this action. + # Pundit dosent support passing objects into scoped policies + unless PublicPagePolicy.new(@template).template_export? + raise Pundit::NotAuthorizedError + end skip_authorization # now with prefetching (if guidance is added, prefetch annottaions/guidance) - @template = Template.includes(:org, phases: {sections:{questions:[:question_options, :question_format, :annotations]}}).find(@template.id) + @template = Template.includes( + :org, + phases: { + sections: { + questions: [ + :question_options, + :question_format, + :annotations + ] + } + } + ).find(@template.id) @formatting = Settings::Template::DEFAULT_SETTINGS[:formatting] begin - file_name = @template.title.gsub(/[^a-zA-Z\d\s]/, '').gsub(/ /, "_") + file_name = @template.title.gsub(/[^a-zA-Z\d\s]/, "").gsub(/ /, "_") respond_to do |format| - format.docx { render docx: 'template_export', filename: "#{file_name}.docx" } + format.docx do + render docx: "template_export", filename: "#{file_name}.docx" + end + format.pdf do + # rubocop:disable LineLength render pdf: file_name, - margin: @formatting[:margin], - footer: { - center: _('Template created using the %{application_name} service. Last modified %{date}') % {application_name: Rails.configuration.branding[:application][:name], date: l(@template.updated_at.to_date, formats: :short)}, + margin: @formatting[:margin], + footer: { + center: _("Template created using the %{application_name} service. Last modified %{date}") % { + application_name: Rails.configuration.branding[:application][:name], + date: l(@template.updated_at.to_date, formats: :short) + }, font_size: 8, - spacing: (@formatting[:margin][:bottom] / 2) - 4, - right: '[page] of [topage]' + spacing: (@formatting[:margin][:bottom] / 2) - 4, + right: "[page] of [topage]" } + # rubocop:enable LineLength end end - rescue ActiveRecord::RecordInvalid => e # What scenario is this triggered in? it's common to our export pages - #send back to public_index page - redirect_to public_templates_path, alert: _('Unable to download the DMP Template at this time.') - end - - end - - # GET plan_export/:id - # ------------------------------------------------------------- - def plan_export - @plan = Plan.includes(:answers).find(params[:id]) - # covers authorization for this action. Pundit dosent support passing objects into scoped policies - raise Pundit::NotAuthorizedError unless PublicPagePolicy.new(@plan, current_user).plan_organisationally_exportable? || PublicPagePolicy.new(@plan).plan_export? - skip_authorization - - @show_coversheet = true - @show_sections_questions = true - @show_unanswered = true - @public_plan = true - - @hash = @plan.as_pdf(@show_coversheet) - @formatting = @plan.settings(:export).formatting - file_name = @plan.title.gsub(/ /, "_") - - respond_to do |format| - format.html - format.csv { send_data @exported_plan.as_csv(@sections, @unanswered_question, @question_headings), filename: "#{file_name}.csv" } - format.text { send_data @exported_plan.as_txt(@sections, @unanswered_question, @question_headings, @show_details), filename: "#{file_name}.txt" } - format.docx { render docx: 'export', filename: "#{file_name}.docx" } - format.pdf do - render pdf: file_name, - margin: @formatting[:margin], - footer: { - center: _('Created using the %{application_name} service. Last modified %{date}') % {application_name: Rails.configuration.branding[:application][:name], date: l(@plan.updated_at.to_date, formats: :short)}, - font_size: 8, - spacing: (@formatting[:margin][:bottom] / 2) - 4, - right: '[page] of [topage]' - } - end + rescue ActiveRecord::RecordInvalid => e + # What scenario is this triggered in? it's common to our export pages + redirect_to public_templates_path, + alert: _("Unable to download the DMP Template at this time.") end end @@ -82,6 +75,12 @@ # ------------------------------------------------------------------------------------ def plan_index @plans = Plan.publicly_visible.page(1) - render 'plan_index', locals: { query_params: { sort_field: 'plans.updated_at', sort_direction: 'desc' } } + render "plan_index", locals: { + query_params: { + sort_field: "plans.updated_at", + sort_direction: "desc" + } + } end + end diff --git a/app/controllers/question_formats_controller.rb b/app/controllers/question_formats_controller.rb index ced3624..29356bf 100644 --- a/app/controllers/question_formats_controller.rb +++ b/app/controllers/question_formats_controller.rb @@ -1,9 +1,12 @@ +# frozen_string_literal: true + class QuestionFormatsController < ApplicationController + # do we need authorizaton on this? it will only return the URL for the rda api # down the line we will add more methods for other external api's def rda_api_address render json: { - 'url' => QuestionFormat.rda_metadata.first.description + "url": QuestionFormat.rda_metadata.first.description }.to_json end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index aa0380d..81f6c69 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -1,38 +1,44 @@ -# app/controllers/registrations_controller.rb +# frozen_string_literal: true + class RegistrationsController < Devise::RegistrationsController def edit @user = current_user @prefs = @user.get_preferences(:email) @languages = Language.sorted_by_abbreviation - @orgs = Org.where(parent_id: nil).order("name") - @other_organisations = Org.where(parent_id: nil, is_other: true).pluck(:id) + @orgs = Org.order("name") + @other_organisations = Org.where(is_other: true).pluck(:id) @identifier_schemes = IdentifierScheme.where(active: true).order(:name) @default_org = current_user.org if !@prefs - flash[:alert] = 'No default preferences found (should be in branding.yml).' + flash[:alert] = "No default preferences found (should be in branding.yml)." end end # GET /resource def new - oauth = {provider: nil, uid: nil} + oauth = { provider: nil, uid: nil } IdentifierScheme.all.each do |scheme| - oauth = session["devise.#{scheme.name.downcase}_data"] unless session["devise.#{scheme.name.downcase}_data"].nil? + unless session["devise.#{scheme.name.downcase}_data"].nil? + oauth = session["devise.#{scheme.name.downcase}_data"] + end end @user = User.new unless oauth.nil? # The OAuth provider could not be determined or there was no unique UID! - if oauth['provider'].nil? || oauth['uid'].nil? - # flash[:alert] = _('We were unable to verify your account. Please use the following form to create a new account. You will be able to link your new account afterward.') - else + if !oauth["provider"].nil? && !oauth["uid"].nil? # Connect the new user with the identifier sent back by the OAuth provider - flash[:notice] = _('Please make a choice below. After linking your details to a %{application_name} account, you will be able to sign in directly with your institutional credentials.') % {application_name: Rails.configuration.branding[:application][:name]} - UserIdentifier.create(identifier_scheme: IdentifierScheme.find_by(name: oauth['provider'].downcase), - identifier: oauth['uid'], + # rubocop:disable LineLength + flash[:notice] = _("Please make a choice below. After linking your details to a %{application_name} account, you will be able to sign in directly with your institutional credentials.") % { + application_name: Rails.configuration.branding[:application][:name] + } + # rubocop:enable LineLength + scheme = IdentifierScheme.find_by(name: oauth["provider"].downcase) + UserIdentifier.create(identifier_scheme: scheme, + identifier: oauth["uid"], user: @user) end end @@ -40,80 +46,102 @@ # POST /resource def create - oauth = {provider: nil, uid: nil} + oauth = { provider: nil, uid: nil } IdentifierScheme.all.each do |scheme| - oauth = session["devise.#{scheme.name.downcase}_data"] unless session["devise.#{scheme.name.downcase}_data"].nil? + unless session["devise.#{scheme.name.downcase}_data"].nil? + oauth = session["devise.#{scheme.name.downcase}_data"] + end end - if !sign_up_params[:accept_terms] - redirect_to after_sign_up_error_path_for(resource), alert: _('You must accept the terms and conditions to register.') + if 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? - redirect_to after_sign_up_error_path_for(resource), alert: _('Please select an organisation from the list, or enter your organisation\'s name.') + # rubocop:disable LineLength + redirect_to after_sign_up_error_path_for(resource), + alert: _("Please select an organisation from the list, or enter your organisation's name.") + # rubocop:enable LineLength else - existing_user = User.where_case_insensitive('email', sign_up_params[:email]).first + existing_user = User.where_case_insensitive("email", sign_up_params[:email]).first if existing_user.present? if existing_user.invitation_token.present? && !existing_user.accept_terms? - existing_user.destroy # Destroys the existing user since the accept terms are nil/false. and they have an invitation - # Note any existing role for that user will be deleted too. Added to accommodate issue at: - # https://github.com/DMPRoadmap/roadmap/issues/322 when invited user creates an account outside the invite workflow + # Destroys the existing user since the accept terms are nil/false. and they + # have an invitation Note any existing role for that user will be deleted too. + # Added to accommodate issue at: https://github.com/DMPRoadmap/roadmap/issues/322 + # when invited user creates an account outside the invite workflow + existing_user.destroy + else - redirect_to after_sign_up_error_path_for(resource), alert: _('That email address is already registered.') + redirect_to after_sign_up_error_path_for(resource), + alert: _("That email address is already registered.") return end end - if params[:user][:org_id].blank? - other_org = Org.find_by(is_other: true) - if other_org.nil? - 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 - end - params[:user][:org_id] = other_org.id + + if params[:user][:org_id].blank? + other_org = Org.find_by(is_other: true) + if other_org.nil? + # rubocop:disable 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 LineLength end - build_resource(sign_up_params) - if resource.save - if resource.active_for_authentication? - set_flash_message :notice, :signed_up if is_navigational_format? - sign_up(resource_name, resource) - UserMailer.welcome_notification(current_user).deliver - unless oauth.nil? - # The OAuth provider could not be determined or there was no unique UID! - 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) - flash[:notice] = _('Welcome! You have signed up successfully with your institutional credentials. You will now be able to access your account with them.') - end + params[:user][:org_id] = other_org.id + end + + build_resource(sign_up_params) + if resource.save + if resource.active_for_authentication? + set_flash_message :notice, :signed_up if is_navigational_format? + sign_up(resource_name, resource) + UserMailer.welcome_notification(current_user).deliver_now + unless oauth.nil? + # The OAuth provider could not be determined or there was no unique UID! + 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) + # rubocop:disable 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 LineLength end end - respond_with resource, location: after_sign_up_path_for(resource) - else - set_flash_message :notice, :"signed_up_but_#{resource.inactive_message}" if is_navigational_format? + end + respond_with resource, location: after_sign_up_path_for(resource) + else + if is_navigational_format? + set_flash_message :notice, :"signed_up_but_#{resource.inactive_message}" respond_with resource, location: after_inactive_sign_up_path_for(resource) end - else - clean_up_passwords resource - redirect_to after_sign_up_error_path_for(resource), alert: _('Error processing registration. Please check that you have entered a valid email address and that your chosen password is at least 8 characters long.') end + else + clean_up_passwords resource + # rubocop:disable LineLength + redirect_to after_sign_up_error_path_for(resource), + alert: _("Unable to create your account.#{errors_for_display(resource)}") + # rubocop:enable LineLength + end end end def update if user_signed_in? then @prefs = @user.get_preferences(:email) - @orgs = Org.where(parent_id: nil).order("name") + @orgs = Org.order("name") @default_org = current_user.org - @other_organisations = Org.where(parent_id: nil, is_other: true).pluck(:id) + @other_organisations = Org.where(is_other: true).pluck(:id) @identifier_schemes = IdentifierScheme.where(active: true).order(:name) @languages = Language.sorted_by_abbreviation if params[:skip_personal_details] == "true" do_update_password(current_user, params) else - do_update(require_password=needs_password?(current_user, params)) + do_update(require_password = needs_password?(current_user, params)) end else - render(:file => File.join(Rails.root, 'public/403.html'), :status => 403, :layout => false) + render(file: File.join(Rails.root, "public/403.html"), status: 403, layout: false) end end @@ -128,86 +156,103 @@ def do_update(require_password = true, confirm = false) mandatory_params = true - message = _('Save Unsuccessful.') + ' ' # added to by below, overwritten otherwise + # added to by below, overwritten otherwise + message = _("Save Unsuccessful. ") # ensure that the required fields are present if params[:user][:email].blank? - message +=_('Please enter an email address.') + ' ' + message += _("Please enter an email address. ") mandatory_params &&= false end if params[:user][:firstname].blank? - message +=_('Please enter a First name.') + ' ' + message += _("Please enter a First name. ") mandatory_params &&= false end if params[:user][:surname].blank? - message +=_('Please enter a Last name.') + ' ' + message += _("Please enter a Last name. ") mandatory_params &&= false end if params[:user][:org_id].blank? && params[:user][:other_organisation].blank? - message += _('Please select an organisation from the list, or enter your organisation\'s name.') + # rubocop:disable LineLength + message += _("Please select an organisation from the list, or enter your organisation's name.") + # rubocop:enable LineLength mandatory_params &&= false end - if mandatory_params # has the user entered all the details - if require_password # user is changing email or password - if current_user.email != params[:user][:email] # if user is changing email - if params[:user][:password].blank? # password needs to be present - message = _('Please enter your password to change email address.') + # has the user entered all the details + if mandatory_params + # user is changing email or password + if require_password + # if user is changing email + if current_user.email != params[:user][:email] + # password needs to be present + if params[:user][:password].blank? + message = _("Please enter your password to change email address.") successfully_updated = false else successfully_updated = current_user.update_with_password(password_update) end - else # 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 + else + # 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) end - else # password not required + else + # password not required successfully_updated = current_user.update_without_password(update_params) end else successfully_updated = false end - #unlink shibboleth from user's details - if params[:unlink_flag] == 'true' then + # unlink shibboleth from user's details + if params[:unlink_flag] == "true" then current_user.update_attributes(shibboleth_id: "") end - #render the correct page + # render the correct page if successfully_updated if confirm - current_user.skip_confirmation! # will error out if confirmable is turned off in user model + # will error out if confirmable is turned off in user model + current_user.skip_confirmation! current_user.save! end session[:locale] = current_user.get_locale unless current_user.get_locale.nil? - set_gettext_locale #Method defined at controllers/application_controller.rb - set_flash_message :notice, success_message(_('profile'), _('saved')) - sign_in current_user, bypass: true # Sign in the user bypassing validation in case his password changed - redirect_to "#{edit_user_registration_path}\#personal-details", notice: success_message(_('profile'), _('saved')) + # Method defined at controllers/application_controller.rb + set_gettext_locale + set_flash_message :notice, success_message(current_user, _("saved")) + # Sign in the user bypassing validation in case his password changed + sign_in current_user, bypass: true + redirect_to "#{edit_user_registration_path}\#personal-details", + notice: success_message(current_user, _("saved")) else - flash[:alert] = message.blank? ? failed_update_error(current_user, _('profile')) : message + flash[:alert] = message.blank? ? failure_message(current_user, _("save")) : message render "edit" end end def do_update_password(current_user, params) if params[:user][:current_password].blank? - message = _('Please enter your current password') + message = _("Please enter your current password") elsif params[:user][:password_confirmation].blank? - message = _('Please enter a password confirmation') + message = _("Please enter a password confirmation") elsif params[:user][:password] != params[:user][:password_confirmation] - message = _('Password and comfirmation must match') + message = _("Password and comfirmation must match") else successfully_updated = current_user.update_with_password(password_update) end - #render the correct page + # render the correct page if successfully_updated session[:locale] = current_user.get_locale unless current_user.get_locale.nil? - set_gettext_locale #Method defined at controllers/application_controller.rb - set_flash_message :notice, success_message(_('password'), _('saved')) - sign_in current_user, bypass: true # TODO this method is deprecated - redirect_to "#{edit_user_registration_path}\#password-details", notice: success_message(_('password'), _('saved')) + # Method defined at controllers/application_controller.rbset_gettext_locale + set_flash_message :notice, success_message(current_user, _("saved")) + # TODO this method is deprecated + sign_in current_user, bypass: true + redirect_to "#{edit_user_registration_path}\#password-details", + notice: success_message(current_user, _("saved")) else - flash[:alert] = message.blank? ? failed_update_error(current_user, _('profile')) : message + flash[:alert] = message.blank? ? failure_message(current_user, _("save")) : message redirect_to "#{edit_user_registration_path}\#password-details" end end diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb index 913bc2a..79449d9 100644 --- a/app/controllers/roles_controller.rb +++ b/app/controllers/roles_controller.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class RolesController < ApplicationController + include ConditionalUserMailer respond_to :html after_action :verify_authorized @@ -9,41 +12,53 @@ authorize @role access_level = params[:role][:access_level].to_i - @role.set_access_level(access_level) - message = '' + @role.access_level = access_level + message = "" if params[:user].present? if @role.plan.owner.present? && @role.plan.owner.email == params[:user] - flash[:notice] = _('Cannot share plan with %{email} since that email matches with the owner of the plan.') % {email: params[:user]} + # rubocop:disable LineLength + flash[:notice] = _("Cannot share plan with %{email} since that email matches with the owner of the plan.") % { + email: params[:user] + } + # rubocop:enable LineLength else - user = User.where_case_insensitive('email',params[:user]).first + user = User.where_case_insensitive("email", params[:user]).first if Role.find_by(plan: @role.plan, user: user) # role already exists - flash[:notice] = _('Plan is already shared with %{email}.') % {email: params[:user]} + flash[:notice] = _("Plan is already shared with %{email}.") % { + email: params[:user] + } else if user.nil? registered = false User.invite!(email: params[:user]) - message = _('Invitation to %{email} issued successfully. \n') % {email: params[:user]} + message = _("Invitation to %{email} issued successfully.") % { + email: params[:user] + } user = User.find_by(email: params[:user]) end - message += _('Plan shared with %{email}.') % {email: user.email} + message += _("Plan shared with %{email}.") % { + email: user.email + } @role.user = user if @role.save if registered - deliver_if(recipients: user, key: 'users.added_as_coowner') do |r| + deliver_if(recipients: user, key: "users.added_as_coowner") do |r| UserMailer.sharing_notification(@role, r, inviter: current_user) .deliver_now end end flash[:notice] = message else - flash[:alert] = failed_create_error(@role, _('role')) + # rubocop:disable LineLength + flash[:alert] = _("You must provide a valid email address and select a permission level.") + # rubocop:enable LineLength end end end else - flash[:notice] = _('Please enter an email address') + flash[:alert] = _("Please enter an email address") end - redirect_to controller: 'plans', action: 'share', id: @role.plan.id + redirect_to controller: "plans", action: "share", id: @role.plan.id end @@ -51,14 +66,19 @@ @role = Role.find(params[:id]) authorize @role access_level = params[:role][:access_level].to_i - @role.set_access_level(access_level) + @role.access_level = access_level if @role.update_attributes(role_params) - deliver_if(recipients: @role.user, key: 'users.added_as_coowner') do |r| + deliver_if(recipients: @role.user, key: "users.added_as_coowner") do |r| UserMailer.permissions_change_notification(@role, current_user).deliver_now end - render json: {code: 1, msg: _("Successfully changed the permissions for #{@role.user.email}. They have been notified via email.")} + # rubocop:disable LineLength + render json: { + code: 1, + msg: _("Successfully changed the permissions for #{@role.user.email}. They have been notified via email.") + } + # rubocop:enable LineLength else - render json: {code: 0, msg: flash[:alert]} + render json: { code: 0, msg: flash[:alert] } end end @@ -68,14 +88,15 @@ user = @role.user plan = @role.plan @role.destroy - flash[:notice] = _('Access removed') - deliver_if(recipients: user, key: 'users.added_as_coowner') do |r| + flash[:notice] = _("Access removed") + deliver_if(recipients: user, key: "users.added_as_coowner") do |r| UserMailer.plan_access_removed(user, plan, current_user).deliver_now end - redirect_to controller: 'plans', action: 'share', id: @role.plan.id + redirect_to controller: "plans", action: "share", id: @role.plan.id end - # This function makes user's role on a plan inactive - i.e. "removes" this from their plans + # This function makes user's role on a plan inactive + # i.e. "removes" this from their plans def deactivate role = Role.find(params[:id]) authorize role @@ -86,9 +107,9 @@ role.plan.save end if role.save - flash[:notice] = _('Plan removed') + flash[:notice] = _("Plan removed") else - flash[:alert] = _('Unable to remove the plan') + flash[:alert] = _("Unable to remove the plan") end redirect_to(plans_path) end @@ -98,4 +119,5 @@ def role_params params.require(:role).permit(:plan_id) end -end \ No newline at end of file + +end diff --git a/app/controllers/session_locales_controller.rb b/app/controllers/session_locales_controller.rb new file mode 100644 index 0000000..d748b1e --- /dev/null +++ b/app/controllers/session_locales_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class SessionLocalesController < ApplicationController + + def update + session[:locale] = params[:locale] if available_locales.include?(param_locale) + redirect_to(:back) + end + + private + + def available_locales + LocaleSet.new(FastGettext.default_available_locales).for(:fast_gettext) + end + + def param_locale + LocaleFormatter.new(params[:locale], format: :fast_gettext).to_s + end + +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index dacd5a1..76b1228 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + class SessionsController < Devise::SessionsController - + def new redirect_to(root_path) end @@ -9,18 +11,28 @@ def create existing_user = User.find_by(email: params[:user][:email]) if !existing_user.nil? - + # Until ORCID login is supported - if !session['devise.shibboleth_data'].nil? - if u = UserIdentifier.create(identifier_scheme: IdentifierScheme.find_by(name: 'shibboleth'), - identifier: session['devise.shibboleth_data']['uid'], - user: existing_user) - success = _('Your account has been successfully linked to your institutional credentials. You will now be able to sign in with them.') + if !session["devise.shibboleth_data"].nil? + args = { + identifier_scheme: IdentifierScheme.find_by(name: "shibboleth"), + identifier: session["devise.shibboleth_data"]["uid"], + user: existing_user + } + if UserIdentifier.create(args) + # rubocop:disable LineLength + success = _("Your account has been successfully linked to your institutional credentials. You will now be able to sign in with them.") + # rubocop:enable LineLength end - existing_user.update_attributes(shibboleth_id: session['devise.shibboleth_data'][:uid]) + existing_user.update_attributes( + shibboleth_id: session["devise.shibboleth_data"][:uid] + ) end - session[:locale] = existing_user.get_locale unless existing_user.get_locale.nil? - set_gettext_locale #Method defined at controllers/application_controller.rb + unless existing_user.get_locale.nil? + session[:locale] = existing_user.get_locale + end + # Method defined at controllers/application_controller.rb + set_gettext_locale end super if success @@ -31,6 +43,8 @@ def destroy super session[:locale] = nil - set_gettext_locale #Method defined at controllers/application_controller.rb + # Method defined at controllers/application_controller.rb + set_gettext_locale end + end diff --git a/app/controllers/settings.rb b/app/controllers/settings.rb index a0342b3..de3ffd1 100644 --- a/app/controllers/settings.rb +++ b/app/controllers/settings.rb @@ -1,4 +1,9 @@ +# frozen_string_literal: true + module Settings + class SettingsController < ApplicationController + end + end diff --git a/app/controllers/settings/plans_controller.rb b/app/controllers/settings/plans_controller.rb index 3117367..b1c87ba 100644 --- a/app/controllers/settings/plans_controller.rb +++ b/app/controllers/settings/plans_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Settings class PlansController < SettingsController @@ -10,7 +12,7 @@ respond_to do |format| format.html format.partial - format.json{ render json: settings_json } + format.json { render json: settings_json } end end @@ -19,7 +21,7 @@ export_params = params[:export].try(:deep_symbolize_keys) settings = @plan.super_settings(:export).tap do |s| - if params[:commit] == 'Reset' + if params[:commit] == "Reset" s.formatting = nil s.fields = nil s.title = nil @@ -31,22 +33,24 @@ end if settings.save - flash[:notice] = _('Export settings updated successfully.') + flash[:notice] = _("Export settings updated successfully.") else - flash[:alert] = _('An error has occurred while saving/resetting your export settings.') + # rubocop:disable LineLength + flash[:alert] = _("An error has occurred while saving/resetting your export settings.") + # rubocop:enable LineLength end respond_to do |format| - @phase_options = @plan.phases.order(:number).pluck(:title,:id) + @phase_options = @plan.phases.order(:number).pluck(:title, :id) format.html { redirect_to(download_plan_path(@plan.id)) } # format.json { render json: settings_json } end end - private + private def get_settings @plan = Plan.find(params[:id]) - + @export_settings = plan.settings(:export) end @@ -59,4 +63,5 @@ end end + end diff --git a/app/controllers/splash_logs_controller.rb b/app/controllers/splash_logs_controller.rb index 4fce98d..0a19766 100644 --- a/app/controllers/splash_logs_controller.rb +++ b/app/controllers/splash_logs_controller.rb @@ -1,19 +1,23 @@ +# frozen_string_literal: true + class SplashLogsController < ApplicationController + respond_to :html ## - # POST /answers - def create - @splash_log = SplashLog.new() - @splash_log.destination = params[:destination] - if @splash_log.save - cookies[:dmp_splash_seen] = { - value: 'splash_dialog_seen', - expires: 3.hours.from_now, - } - redirect_to params[:destination] - else - redirect_to home_url - end - end -end \ No newline at end of file + # POST /answers + def create + @splash_log = SplashLog.new() + @splash_log.destination = params[:destination] + if @splash_log.save + cookies[:dmp_splash_seen] = { + value: "splash_dialog_seen", + expires: 3.hours.from_now, + } + redirect_to params[:destination] + else + redirect_to home_url + end + end + +end diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 82238e7..8e0f2b5 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -1,22 +1,29 @@ +# frozen_string_literal: true + class StaticPagesController < ApplicationController def about_us - dcc_news_feed_url = "http://www.dcc.ac.uk/news/dmponline-0/feed" - @dcc_news_feed = Feedjira::Feed.fetch_and_parse dcc_news_feed_url - respond_to do |format| - format.rss { redirect_to dcc_news_feed_url } - format.html - end + dcc_news_feed_url = "http://www.dcc.ac.uk/news/dmponline-0/feed" + @dcc_news_feed = Feedjira::Feed.fetch_and_parse dcc_news_feed_url + respond_to do |format| + format.rss { redirect_to dcc_news_feed_url } + format.html + end end def contact_us end def roadmap - end def privacy end + def termsuse + end + + def help + end + end diff --git a/app/controllers/super_admin/notifications_controller.rb b/app/controllers/super_admin/notifications_controller.rb index 575c786..3568ded 100644 --- a/app/controllers/super_admin/notifications_controller.rb +++ b/app/controllers/super_admin/notifications_controller.rb @@ -1,5 +1,9 @@ +# frozen_string_literal: true + module SuperAdmin + class NotificationsController < ApplicationController + before_action :set_notification, only: %i[show edit update destroy acknowledge] before_action :set_notifications, only: :index @@ -27,45 +31,41 @@ # POST /notifications.json def create authorize(Notification) - begin - n = Notification.new(notification_params) - n.notification_type = 'global' - n.save! - flash[:notice] = _('Notification created successfully') - rescue ActionController::ParameterMissing - flash[:alert] = _('Unable to save since notification parameter is missing') - rescue ActiveRecord::RecordInvalid => e - flash[:alert] = e.message + @notification = Notification.new(notification_params) + # Will eventually need to be removed if we introduce new notification types + @notification.notification_type = "global" + if @notification.save + flash.now[:notice] = success_message(@notification, _("created")) + render :edit + else + flash.now[:alert] = failure_message(@notification, _("create")) + render :new end - redirect_to action: :index end # PATCH/PUT /notifications/1 # PATCH/PUT /notifications/1.json def update authorize(Notification) - begin - @notification.update!(notification_params) - flash[:notice] = _('Notification updated successfully') - rescue ActionController::ParameterMissing - flash[:alert] = _('Unable to save since notification parameter is missing') - rescue ActiveRecord::RecordInvalid => e - flash[:alert] = e.message + if @notification.update(notification_params) + flash.now[:notice] = success_message(@notification, _("updated")) + else + flash.now[:alert] = failure_message(@notification, _("update")) end - redirect_to action: :index + render :edit end # DELETE /notifications/1 # DELETE /notifications/1.json def destroy authorize(Notification) - begin - @notification.destroy - flash[:notice] = _('Successfully destroyed your notification') - rescue ActiveRecord::RecordNotDestroyed - flash[:alert] = _('The theme with id %{id} could not be destroyed') % { id: params[:id] } + if @notification.destroy + msg = success_message(@notification, _("deleted")) + redirect_to super_admin_notifications_path, notice: msg + else + flash.now[:alert] = failure_message(@notification, _("delete")) + render :edit end - redirect_to action: :index end # GET /notifications/1/acknowledge @@ -80,7 +80,8 @@ def set_notification @notification = Notification.find(params[:id] || params[:notification_id]) rescue ActiveRecord::RecordNotFound - flash[:alert] = _('There is no notification associated with id %{id}') % { id: params[:id] } + flash[:alert] = _("There is no notification associated with id %{id}") % + { id: params[:id] } redirect_to action: :index end @@ -91,7 +92,10 @@ # Never trust parameters from the scary internet, only allow the white list through. def notification_params - params.require(:notification).permit(:title, :level, :body, :dismissable, :starts_at, :expires_at) + params.require(:notification).permit(:title, :level, :body, :dismissable, + :starts_at, :expires_at) end + end + end diff --git a/app/controllers/super_admin/org_swaps_controller.rb b/app/controllers/super_admin/org_swaps_controller.rb new file mode 100644 index 0000000..7ccfce5 --- /dev/null +++ b/app/controllers/super_admin/org_swaps_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class SuperAdmin::OrgSwapsController < ApplicationController + + 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 + # rubocop:disable Metrics/LineLength + if @org.present? + current_user.org = @org + 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 } + else + redirect_to :back, + alert: _("Unable to change your organisation affiliation at this time.") + end + else + redirect_to :back, alert: _("Unknown organisation.") + end + # rubocop:enable Metrics/LineLength + end + + private + + def org_swap_params + params.require(:user).permit(:org_id, :org_name) + end + +end diff --git a/app/controllers/super_admin/orgs_controller.rb b/app/controllers/super_admin/orgs_controller.rb index a208f67..c6fcd5b 100644 --- a/app/controllers/super_admin/orgs_controller.rb +++ b/app/controllers/super_admin/orgs_controller.rb @@ -1,83 +1,103 @@ +# frozen_string_literal: true + module SuperAdmin + class OrgsController < ApplicationController + after_action :verify_authorized def index authorize Org - render 'index', locals: { orgs: Org.includes(:templates, :users) } + render "index", locals: { orgs: Org.includes(:templates, :users) } 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.links = { "org": [] } + render "orgs/admin_edit", locals: { org: org, languages: Language.all.order("name"), + method: "POST", url: super_admin_orgs_path } end - + def create authorize Org org = Org.new(org_params) + org.language = Language.default org.logo = params[:logo] if params[:logo] if params[:org_links].present? - org.links = JSON.parse(params[:org_links]) + org.links = JSON.parse(params[:org_links]) else org.links = { org: [] } end - + begin org.funder = params[:funder].present? org.institution = params[:institution].present? - org.organisation = params[:organisation].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 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 + attrs: { domain: params[:shib_domain] }.to_json.to_s ) end end - + if org.save - redirect_to admin_edit_org_path(org.id), notice: success_message(_('organisation'), _('created')) + msg = success_message(org, _("created")) + redirect_to admin_edit_org_path(org.id), notice: msg else - flash[:alert] = failed_create_error(org, _('organisation')) - render 'orgs/admin_edit', locals: {org: org, languages: Language.all.order("name"), - method: 'POST', url: super_admin_orgs_path } + 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 + } end rescue Dragonfly::Job::Fetch::NotFound => dflye - redirect_to admin_edit_org_path(org), alert: _('There seems to be a problem with your logo. Please upload it again.') - render 'orgs/admin_edit', locals: {org: org, languages: Language.all.order("name"), - method: 'POST', url: super_admin_orgs_path } + failure = _("There seems to be a problem with your logo. Please upload it again.") + redirect_to admin_edit_org_path(org), alert: failure + render "orgs/admin_edit", locals: { + org: org, + languages: Language.all.order("name"), + method: "POST", + url: super_admin_orgs_path + } end end - + def destroy org = Org.includes(:users, :templates, :guidance_groups).find(params[:id]) authorize org - + # Only allow the delete if the org has no dependencies unless org.users.length > 0 || org.templates.length > 0 org.guidance_groups.delete_all if org.destroy! - redirect_to super_admin_orgs_path, notice: success_message(_('organisation'), _('removed')) + msg = success_message(org, _("removed")) + redirect_to super_admin_orgs_path, notice: msg else - redirect_to super_admin_orgs_path, alert: failed_destroy_error(org, _('organisation')) + failure = failure_message(org, _("remove")) + redirect_to super_admin_orgs_path, alert: failure end end end - + 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) - end - + + def org_params + params.require(:org).permit(:name, :abbreviation, :logo, :contact_email, + :contact_name, :remove_logo, :feedback_enabled, + :feedback_email_subject, :feedback_email_msg) + end + end -end \ No newline at end of file + +end diff --git a/app/controllers/super_admin/themes_controller.rb b/app/controllers/super_admin/themes_controller.rb index 75532fe..27cbc4a 100644 --- a/app/controllers/super_admin/themes_controller.rb +++ b/app/controllers/super_admin/themes_controller.rb @@ -1,5 +1,9 @@ +# frozen_string_literal: true + module SuperAdmin + class ThemesController < ApplicationController + helper PaginableHelper def index authorize(Theme) @@ -8,67 +12,56 @@ def new authorize(Theme) - render(:new_edit, locals: { theme: Theme.new, options: { url: super_admin_themes_path, method: :POST, title: _('New Theme') }}) + @theme = Theme.new end def create authorize(Theme) - begin - pparams = permitted_params - Theme.create!(pparams) - flash[:notice] = _('Theme created successfully') - rescue ActionController::ParameterMissing - flash[:alert] = _('Unable to save since theme parameter is missing') - rescue ActiveRecord::RecordInvalid => e - flash[:alert] = e.message + @theme = Theme.new(permitted_params) + if @theme.save + flash.now[:notice] = success_message(@theme, _("created")) + render :edit + else + flash.now[:alert] = failure_message(@theme, _("create")) + render :new end - redirect_to(action: :index) end def edit authorize(Theme) - begin - theme = Theme.find(params[:id]) - render(:new_edit, locals: { theme: theme, options: { url: super_admin_theme_path(theme), method: :PUT, title: _('Edit Theme') }}) - rescue ActiveRecord::RecordNotFound - flash[:alert] = _('There is no theme associated with id %{id}') % { :id => params[:id] } - redirect_to(action: :index) - end + @theme = Theme.find(params[:id]) end def update authorize(Theme) - begin - pparams = permitted_params - Theme.find(params[:id]).update_attributes!(pparams) - flash[:notice] = _('Theme updated successfully') - rescue ActiveRecord::RecordNotFound - flash[:alert] = _('There is no theme associated with id %{id}') % { :id => params[:id] } - rescue ActionController::ParameterMissing - flash[:alert] = _('Unable to save since theme parameter is missing') - rescue ActiveRecord::RecordInvalid => e - flash[:alert] = e.message + @theme = Theme.find(params[:id]) + if @theme.update_attributes(permitted_params) + flash.now[:notice] = success_message(@theme, _("updated")) + else + flash.now[:alert] = failure_message(@theme, _("update")) end - redirect_to(action: :index) + render :edit end def destroy authorize(Theme) - begin - Theme.find(params[:id]).destroy! - flash[:notice] = _('Successfully deleted your theme') - rescue ActiveRecord::RecordNotFound - flash[:alert] = _('There is no theme associated with id %{id}') % { :id => params[:id] } - rescue ActiveRecord::RecordNotDestroyed # Unlikely to happen since we don't have callback associated to destroy! but put for safety - flash[:alert] = _('The theme with id %{id} could not be destroyed') % { :id => params[:id] } + @theme = Theme.find(params[:id]) + if @theme.destroy + msg = success_message(@theme, _("deleted")) + redirect_to super_admin_themes_path, notice: msg + else + flash.now[:alert] = failure_message(@theme, _("delete")) + redner :edit end - redirect_to(action: :index) end + # Private instance methods private def permitted_params params.require(:theme).permit(:title, :description) end + end + end diff --git a/app/controllers/super_admin/users_controller.rb b/app/controllers/super_admin/users_controller.rb index c0640a3..ab5d69c 100644 --- a/app/controllers/super_admin/users_controller.rb +++ b/app/controllers/super_admin/users_controller.rb @@ -1,47 +1,42 @@ +# frozen_string_literal: true + module SuperAdmin + class UsersController < ApplicationController after_action :verify_authorized def edit - user = User.find(params[:id]) - if user.present? - authorize user - languages = Language.sorted_by_abbreviation - orgs = Org.where(parent_id: nil).order("name") - identifier_schemes = IdentifierScheme.where(active: true).order(:name) - - render 'super_admin/users/edit', - locals: { user: user, - languages: languages, - orgs: orgs, - identifier_schemes: identifier_schemes, - default_org: user.org } - else - redirect_to admin_index_users_path, alert: _('User not found.') - end + @user = User.find(params[:id]) + authorize @user + render "super_admin/users/edit", + locals: { user: @user, + languages: @languages, + orgs: @orgs, + identifier_schemes: @identifier_schemes, + default_org: @user.org } end - + def update - user = User.find(params[:id]) - if user.present? - authorize user - topic = _('%{username}\'s profile') % { username: user.name(false) } - if user.update_attributes(user_params) - redirect_to edit_super_admin_user_path(user), - notice: _('Successfully updated %{username}') % { username: topic } - else - redirect_to edit_super_admin_user_path(user), - alert: _('Unable to update %{username}') % { username: topic } - end + @user = User.find(params[:id]) + authorize @user + # 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) + flash.now[:notice] = success_message(@user, _("updated")) else - redirect_to edit_super_admin_user_path(user), alert: _('User not found.') + flash.now[:alert] = failure_message(@user, _("update")) end + render :edit end - + private - def user_params - params.require(:user).permit(:email, :firstname, :surname, :org_id, :language_id, :other_organisation) - end + def user_params + params.require(:user).permit(:email, :firstname, :surname, :org_id, + :language_id, :other_organisation) + end + end -end \ No newline at end of file + +end diff --git a/app/controllers/template_options_controller.rb b/app/controllers/template_options_controller.rb new file mode 100644 index 0000000..3132971 --- /dev/null +++ b/app/controllers/template_options_controller.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class TemplateOptionsController < ApplicationController + + 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]) + authorize Template.new, :template_options? + @templates = [] + + if org_id.present? || funder_id.present? + unless funder_id.blank? + # 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? + # 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 + # Only provide the customized version if its still up to date with the + # funder template! + if customization.present? && !customization.upgrade_customization? + customization + else + tmplt + end + end + end + end + + # If the no funder was specified OR the funder matches the org + if funder_id.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 + end + @templates = @templates.flatten.uniq + end + + # If no templates were available use the default template + if @templates.empty? + if Template.default.present? + customization = Template.published + .latest_customized_version(Template.default.family_id, + 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) + end + +end diff --git a/app/controllers/usage_controller.rb b/app/controllers/usage_controller.rb index 08b9299..cb186f7 100644 --- a/app/controllers/usage_controller.rb +++ b/app/controllers/usage_controller.rb @@ -1,7 +1,24 @@ +# frozen_string_literal: true + class UsageController < ApplicationController - # GET /usage + def index - raise Pundit::NotAuthorizedError unless current_user.present? && (current_user.can_org_admin? || current_user.can_super_admin?) - render('index', locals: { orgs: Org.all }) + check_authorized! + render("index", + locals: { + orgs: Org.all, + total_org_users: current_user.org.users.size, + total_org_plans: current_user.org.plans.size + } + ) end -end \ No newline at end of file + + private + + def check_authorized! + unless current_user.present? && + (current_user.can_org_admin? || current_user.can_super_admin?) + raise Pundit::NotAuthorizedError + end + end +end diff --git a/app/controllers/usage_downloads_controller.rb b/app/controllers/usage_downloads_controller.rb new file mode 100644 index 0000000..92320d3 --- /dev/null +++ b/app/controllers/usage_downloads_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class UsageDownloadsController < ApplicationController + + def index + check_authorized! + data = Org::TotalCountStatService.call + data_csvified = Csvable.from_array_of_hashes(data) + + send_data(data_csvified, filename: 'totals.csv') + end + + private + + def check_authorized! + unless current_user.present? && + (current_user.can_org_admin? || current_user.can_super_admin?) + raise Pundit::NotAuthorizedError + end + end +end diff --git a/app/controllers/user_identifiers_controller.rb b/app/controllers/user_identifiers_controller.rb index 8c451af..4437b90 100644 --- a/app/controllers/user_identifiers_controller.rb +++ b/app/controllers/user_identifiers_controller.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class UserIdentifiersController < ApplicationController + respond_to :html after_action :verify_authorized @@ -8,16 +11,20 @@ authorize UserIdentifier user = User.find(current_user.id) identifier = UserIdentifier.find(params[:id]) - + # If the requested identifier belongs to the current user remove it if user.user_identifiers.include?(identifier) identifier.destroy! - flash[:notice] = _('Successfully unlinked your account from %{is}.') % {is: identifier.identifier_scheme.description} + flash[:notice] = _("Successfully unlinked your account from %{is}.") % { + is: identifier.identifier_scheme.description + } else - flash[:alert] = _('Unable to unlink your account from %{is}.') % {is: identifier.identifier_scheme.description} + flash[:alert] = _("Unable to unlink your account from %{is}.") % { + is: identifier.identifier_scheme.description + } end - + redirect_to edit_user_registration_path end - + end diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb index 6734545..b3e841b 100644 --- a/app/controllers/users/invitations_controller.rb +++ b/app/controllers/users/invitations_controller.rb @@ -1,14 +1,22 @@ +# frozen_string_literal: true + class Users::InvitationsController < Devise::InvitationsController + protected - # Override require_no_authentication method defined at DeviseController (parent of Devise::InvitationsController) - # The following filter gets executed any time GET /users/invitation/accept?invitation_token=valid_token - # is requested. It replaces the default error message from devise (e.g. You are already signed in.) - # if the user is signed in already while trying to access to that URL - def require_no_authentication - super - if flash[:alert].present? - flash[:alert] = nil - flash[:notice] = _('You are already signed in as another user. Please log out to activate your invitation.') - end + # Override require_no_authentication method defined at DeviseController + # (parent of Devise::InvitationsController) The following filter gets + # executed any time GET /users/invitation/accept?invitation_token=valid_token + # is requested. It replaces the default error message from devise + # (e.g. You are already signed in.) if the user is signed in already while + # trying to access to that URL + def require_no_authentication + super + if flash[:alert].present? + flash[:alert] = nil + # rubocop:disable LineLength + flash[:notice] = _("You are already signed in as another user. Please log out to activate your invitation.") + # rubocop:enable LineLength end -end \ No newline at end of file + end + +end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 38719f9..5bb30a5 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController ## @@ -8,61 +10,75 @@ handle_omniauth(scheme) end end - - ## - # Processes callbacks from an omniauth provider and directs the user to + + # Processes callbacks from an omniauth provider and directs the user to # the appropriate page: # Not logged in and uid had no match ---> Sign Up page # Not logged in and uid had a match ---> Sign In and go to Home Page # Signed in and uid had no match --> Save the uid and go to the Profile Page # Signed in and uid had a match --> Go to the Home Page # - # @scheme [IdentifierScheme] The IdentifierScheme for the provider - # ------------------------------------------------------------- + # scheme - The IdentifierScheme for the provider + # def handle_omniauth(scheme) - user = User.from_omniauth(request.env["omniauth.auth"].nil? ? request.env : request.env["omniauth.auth"]) - + if request.env["omniauth.auth"].nil? + user = User.from_omniauth(request.env) + else + user = User.from_omniauth(request.env["omniauth.auth"]) + end + # If the user isn't logged in - if current_user.nil? + if current_user.nil? # If the uid didn't have a match in the system send them to register if user.nil? session["devise.#{scheme.name.downcase}_data"] = request.env["omniauth.auth"] redirect_to new_user_registration_url - + # Otherwise sign them in else # Until ORCID becomes supported as a login method - if scheme.name == 'shibboleth' - set_flash_message(:notice, :success, kind: scheme.description) if is_navigational_format? + if scheme.name == "shibboleth" + if is_navigational_format? + set_flash_message(:notice, :success, kind: scheme.description) + end sign_in_and_redirect user, event: :authentication else - flash[:notice] = _('Successfully signed in') + flash[:notice] = _("Successfully signed in") redirect_to new_user_registration_url end end - + # The user is already logged in and just registering the uid with us else # If the user could not be found by that uid then attach it to their record if user.nil? - if UserIdentifier.create(identifier_scheme: scheme, + if UserIdentifier.create(identifier_scheme: scheme, identifier: request.env["omniauth.auth"].uid, user: current_user) - - flash[:notice] = _('Your account has been successfully linked to %{scheme}.') % { scheme: scheme.description } + # rubocop:disable LineLength + flash[:notice] = _("Your account has been successfully linked to %{scheme}.") % { + scheme: scheme.description + } + # rubocop:enable LineLength else - flash[:alert] = _('Unable to link your account to %{scheme}.') % { scheme: scheme.description } + flash[:alert] = _("Unable to link your account to %{scheme}.") % { + scheme: scheme.description + } end - + else # 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 + identifier = UserIdentifier.where( + identifier: request.env["omniauth.auth"].uid + ).first if identifier.user.id != current_user.id - flash[:alert] = _("The current #{scheme.description} iD has been already linked to a user with email #{identifier.user.email}") + # 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 + + # Otherwise, the identifier was found and it matches the one already associated # with the current user so nothing else needs to be done end @@ -71,8 +87,8 @@ end end - # ------------------------------------------------------------- def failure redirect_to root_path end + end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index b35f965..08299de 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + class UsersController < ApplicationController + helper PaginableHelper helper PermsHelper include ConditionalUserMailer @@ -10,10 +13,20 @@ # Displays number of roles[was project_group], name, email, and last sign in def admin_index authorize User - if current_user.can_super_admin? - @users = User.page(1) - else - @users = current_user.org.users.page(1) + + respond_to do |format| + format.html do + if current_user.can_super_admin? + @users = User.page(1) + else + @users = current_user.org.users.page(1) + end + end + + format.csv do + send_data User.to_csv(current_user.org.users.order(:surname)), + filename: "users-accounts-#{Date.today}.csv" + end end end @@ -36,7 +49,7 @@ render json: { "user" => { "id" => user.id, - "html" => render_to_string(partial: 'users/admin_grant_permissions', + "html" => render_to_string(partial: "users/admin_grant_permissions", locals: { user: user, perms: perms }, formats: [:html]) } @@ -51,7 +64,7 @@ @user = User.find(params[:id]) authorize @user perms_ids = params[:perm_ids].blank? ? [] : params[:perm_ids].map(&:to_i) - perms = Perm.where( id: perms_ids) + perms = Perm.where(id: perms_ids) privileges_changed = false current_user.perms.each do |perm| if @user.perms.include? perm @@ -73,19 +86,20 @@ end end - if @user.save! + if @user.save if privileges_changed - deliver_if(recipients: @user, key: 'users.admin_privileges') do |r| + deliver_if(recipients: @user, key: "users.admin_privileges") do |r| UserMailer.admin_privileges(r).deliver_now end end render(json: { code: 1, - msg: success_message(_('permissions'), _('saved')), - current_privileges: render_to_string(partial: 'users/current_privileges', locals: { user: @user }, formats: [:html]) + msg: success_message(perms.first_or_initialize, _("saved")), + current_privileges: render_to_string(partial: "users/current_privileges", + locals: { user: @user }, formats: [:html]) }) else - render(json: { code: 0, msg: failed_update_error(@user, _('user')) }) + render(json: { code: 0, msg: failure_message(@user, _("updated")) }) end end @@ -103,29 +117,8 @@ pref.save # Include active tab in redirect path - redirect_to "#{edit_user_registration_path}\#notification-preferences", notice: success_message(_('preferences'), _('saved')) - end - - # PUT /users/:id/org_swap - # ----------------------------------------------------- - def org_swap - # Allows the user to swap their org affiliation on the fly - authorize current_user - begin - org = Org.find(org_swap_params[:org_id]) - rescue ActiveRecord::RecordNotFound - redirect_to(request.referer, alert: _('Please select an organisation from the list')) and return - end - if org.present? - current_user.org = org - if current_user.save! - redirect_to request.referer, notice: _('Your organisation affiliation has been changed. You may now edit templates for %{org_name}.') % {org_name: current_user.org.name} - else - redirect_to request.referer, alert: _('Unable to change your organisation affiliation at this time.') - end - else - redirect_to request.referer, alert: _('Unknown organisation.') - end + redirect_to "#{edit_user_registration_path}\#notification-preferences", + notice: success_message(pref, _("saved")) end # PUT /users/:id/activate @@ -140,12 +133,18 @@ user.save! render json: { code: 1, - msg: _('Successfully %{action} %{username}\'s account.') % { action: user.active ? _('activated') : _('deactivated'), username: user.name(false) } + msg: _("Successfully %{action} %{username}'s account.") % { + action: user.active ? _("activated") : _("deactivated"), + username: user.name(false) + } } rescue Exception render json: { code: 0, - msg: _('Unable to %{action} %{username}') % { action: user.active ? _('activate') : _('deactivate'), username: user.name(false) } + msg: _("Unable to %{action} %{username}") % { + action: user.active ? _("activate") : _("deactivate"), + username: user.name(false) + } } end end @@ -160,15 +159,12 @@ end private - def org_swap_params - params.require(:user).permit(:org_id, :org_name) - end ## # html forms return our boolean values as strings, this converts them to true/false def booleanize_hash(node) - #leaf: convert to boolean and return - #hash: iterate over leaves + # leaf: convert to boolean and return + # hash: iterate over leaves unless node.is_a?(Hash) return node == "true" end diff --git a/app/helpers/annotations_helper.rb b/app/helpers/annotations_helper.rb new file mode 100644 index 0000000..16b1c10 --- /dev/null +++ b/app/helpers/annotations_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module AnnotationsHelper + + # rubocop:disable all + TOOLTIPS_FOR_TEXT = { + example_answer: _("You can add an example answer to help users respond. These will be presented above the answer box and can be copied/ pasted."), + guidance: _("Enter specific guidance to accompany this question. If you have guidance by themes too, this will be pulled in based on your selections below so it's best not to duplicate too much text.") + } + # rubocop:enable all + + def tooltip_for_annotation_text(annotation) + TOOLTIPS_FOR_TEXT[annotation.type.to_sym] + end + +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a2a6012..eb143c2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + module ApplicationHelper - + def resource_name :user end @@ -13,26 +15,22 @@ def devise_mapping @devise_mapping ||= Devise.mappings[:user] end - - # --------------------------------------------------------------------------- - def hash_to_js_json_variable(obj_name, hash) - "".html_safe - end - # Determines whether or not the URL path passed matches with the full path (including params) of the last URL requested. - # see http://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-fullpath for details - # --------------------------------------------------------------------------- - def isActivePage(path, exact_match = false) + # Determines whether or not the URL path passed matches with the full path (including + # params) of the last URL requested. See + # http://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-fullpath + # for details + def active_page?(path, exact_match = false) if exact_match - return request.fullpath == path + request.fullpath == path else - return request.fullpath.include?(path) + request.fullpath.include?(path) end end - def is_integer?(string) - return string.present? && string.match(/^(\d)+$/) - end + alias isActivePage active_page? + + deprecate :isActivePage, deprecator: Cleanup::Deprecators::PredicateDeprecator.new def fingerprinted_asset(name) Rails.env.production? ? "#{name}-#{ASSET_FINGERPRINT}" : name @@ -41,4 +39,11 @@ def title(page_title) content_for(:title) { page_title } end + + def unique_dom_id(record, prefix = nil) + klass = dom_class(record, prefix) + record_id = record_key_for_dom_id(record) || record.object_id + "#{klass}_#{record_id}" + end + end diff --git a/app/helpers/exports_helper.rb b/app/helpers/exports_helper.rb new file mode 100644 index 0000000..d60364b --- /dev/null +++ b/app/helpers/exports_helper.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module ExportsHelper + + PAGE_MARGINS = { + top: '5', + bottom: "10", + left: "12", + right: "12", + } + + def font_face + @formatting[:font_face].presence || 'Arial, Helvetica, Sans-Serif' + end + + def font_size + @formatting[:font_size].presence || '12' + end + + def margin_top + get_margin_value_for_side(:top) + end + + def margin_bottom + get_margin_value_for_side(:bottom) + end + + def margin_left + get_margin_value_for_side(:left) + end + + def margin_right + get_margin_value_for_side(:right) + end + + def plan_attribution(attribution) + attribution = Array(attribution) + prefix = attribution.many? ? _("Creators:") : _("Creator:") + "#{prefix} #{attribution.join(', ')}" + end + + private + + def get_margin_value_for_side(side) + side = side.to_sym + if @formatting.dig(:margin, side).is_a?(Integer) + @formatting[:margin][side] * 4 + else + @formatting.dig(:margin, side).presence || PAGE_MARGINS[side] + end + end +end \ No newline at end of file diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb new file mode 100644 index 0000000..81e6ac0 --- /dev/null +++ b/app/helpers/languages_helper.rb @@ -0,0 +1,7 @@ +module LanguagesHelper + + def languages + Rails.cache.fetch("languages", expires_in: 1.hour) { Language.sorted_by_abbreviation } + end + +end \ No newline at end of file diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 9ee9a66..34d20de 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -1,6 +1,8 @@ module NotificationsHelper - # Return FA html class depending on Notification level - # @return [String] Font Awesome HTML class + + # FA html class depending on Notification level + # + # Returns String def fa_classes(notification) case notification.level when 'warning' diff --git a/app/helpers/orgs_helper.rb b/app/helpers/orgs_helper.rb index f106fa1..a2be874 100644 --- a/app/helpers/orgs_helper.rb +++ b/app/helpers/orgs_helper.rb @@ -1,12 +1,14 @@ +# frozen_string_literal: true + module OrgsHelper - # frozen_string_literal: true DEFAULT_EMAIL = '%{organisation_email}' # Tooltip string for Org feedback form. # - # @param org [Org] The current Org we're updating feedback form for. - # @return [String] The tooltip message + # org - The current Org we're updating feedback form for. + # + # Returns String def tooltip_for_org_feedback_form(org) email = org.contact_email.presence || DEFAULT_EMAIL _("SAMPLE MESSAGE: A data librarian from %{org_name} will respond to your request within 48 diff --git a/app/helpers/plans_helper.rb b/app/helpers/plans_helper.rb index 1b9cb6d..9e949f5 100644 --- a/app/helpers/plans_helper.rb +++ b/app/helpers/plans_helper.rb @@ -1,27 +1,10 @@ module PlansHelper - # Shows whether the user has default, template-default or custom settings - # for the given plan. - # -------------------------------------------------------- - def plan_settings_indicator(plan) - plan_settings = plan.super_settings(:export) - template_settings = plan.template.try(:settings, :export) - - key = if plan_settings.try(:value?) - plan_settings.formatting == template_settings.formatting ? "template_formatting" : "custom_formatting" - elsif template_settings.try(:value?) - "template_formatting" - else - "default_formatting" - end - - content_tag(:small, t("helpers.settings.plans.#{key}")) - end # display the role of the user for a given plan def display_role(role) if role.creator? access = _('Owner') - + else case role.access_level when 3 @@ -48,7 +31,7 @@ return "#{_('Private')}" # Test Plans end end - + def visibility_tooltip(val) case val when 'organisationally_visible' @@ -59,4 +42,16 @@ return _('Private: restricted to me and people I invite.') end end + + def download_plan_page_title(plan, phase, hash) + # If there is more than one phase show the plan title and phase title + return hash[:phases].many? ? "#{plan.title} - #{phase[:title]}" : plan.title + end + + def display_section?(customization, section, show_custom_sections) + display = !customization + display ||= customization && !section[:modifiable] + display ||= customization && section[:modifiable] && show_custom_sections + return display + end end diff --git a/app/helpers/sections_helper.rb b/app/helpers/sections_helper.rb new file mode 100644 index 0000000..e3bd8e9 --- /dev/null +++ b/app/helpers/sections_helper.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module SectionsHelper + + # HREF attribute value for headers in the section partials. If the section + # is modifiable, returns the section path, otherwise the edit section path. + # + # section - The section to return a URL for + # phase - The phase that section belongs + # template - The template that phase belongs to + # + # Returns String + def header_path_for_section(section, phase, template) + if section.modifiable? + edit_org_admin_template_phase_section_path(template_id: template.id, + phase_id: phase.id, + id: section.id) + else + org_admin_template_phase_section_path(template_id: template.id, + phase_id: phase.id, + id: section.id) + end + end + + def draggable_for_section?(section) + section.template.latest? && section.modifiable? + end + +end diff --git a/app/helpers/template_helper.rb b/app/helpers/template_helper.rb index 42ab240..3905e98 100644 --- a/app/helpers/template_helper.rb +++ b/app/helpers/template_helper.rb @@ -1,8 +1,36 @@ +# frozen_string_literal: true + module TemplateHelper - def links_to_a_elements(links, separator = ', ') + + def template_details_path(template) + if template_modifiable?(template) + edit_org_admin_template_path(template) + else + if template.persisted? + org_admin_template_path(template) + else + org_admin_templates_path + end + end + end + + # Is this Template modifiable? + # + # template - A Template object + # + # Returns Boolean + def template_modifiable?(template) + template.latest? && + template.customization_of.blank? && + template.id.present? && + template.org_id = current_user.org.id + end + + def links_to_a_elements(links, separator = ", ") a = links.map do |l| "#{l['text']}" end a.join(separator) end -end \ No newline at end of file + +end diff --git a/app/helpers/version_helper.rb b/app/helpers/version_helper.rb new file mode 100644 index 0000000..4cfc561 --- /dev/null +++ b/app/helpers/version_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Helpers for displaying the current code version in the views. +module VersionHelper + + # The current release version for HEAD + VERSION = `git rev-parse HEAD`.strip + + # The current release version for HEAD + # + # Returns String + def version + VERSION + end + +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index d459b3e..525773b 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -108,8 +108,8 @@ end end - # @param commenter - User who wrote the comment - # @param plan - Plan for which the comment is associated to + # commenter - User who wrote the comment + # plan - Plan for which the comment is associated to def new_comment(commenter, plan) if commenter.is_a?(User) && plan.is_a?(Plan) owner = plan.owner diff --git a/app/models/annotation.rb b/app/models/annotation.rb index ccc1121..51e0d51 100644 --- a/app/models/annotation.rb +++ b/app/models/annotation.rb @@ -1,39 +1,88 @@ +# == Schema Information +# +# Table name: annotations +# +# id :integer not null, primary key +# text :text +# type :integer default(0), not null +# created_at :datetime +# updated_at :datetime +# org_id :integer +# question_id :integer +# versionable_id :string(36) +# +# Indexes +# +# index_annotations_on_question_id (question_id) +# index_annotations_on_versionable_id (versionable_id) +# +# Foreign Keys +# +# fk_rails_... (org_id => orgs.id) +# fk_rails_... (question_id => questions.id) +# + class Annotation < ActiveRecord::Base - enum type: [ :example_answer, :guidance] + include ValidationMessages + include VersionableModel + ## - # Associations + # I liked type as the name for the enum so overriding inheritance column + self.inheritance_column = nil + + enum type: [ :example_answer, :guidance] + + # ================ + # = Associations = + # ================ + belongs_to :org belongs_to :question has_one :section, through: :question has_one :phase, through: :question has_one :template, through: :question - ## - # I liked type as the name for the enum so overriding inheritance column - self.inheritance_column = nil + # =============== + # = Validations = + # =============== - validates :question, :org, presence: {message: _("can't be blank")} + validates :text, presence: { message: PRESENCE_MESSAGE } - ## - # returns the text from the annotation + validates :org, presence: { message: PRESENCE_MESSAGE } + + validates :question, presence: { message: PRESENCE_MESSAGE } + + validates :type, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { message: UNIQUENESS_MESSAGE, + scope: [:question_id, :org_id] } + + + # ================= + # = Class Methods = + # ================= + + # Deep copy the given annotation and all it's associations # - # @return [String] the text from the annotation - def to_s - "#{text}" - end - - - ## - # deep copy the given annotation and all it's associations + # annotation - To be deep copied # - # @params [Annotation] annotation to be deep copied - # @return [Annotation] the saved, copied annotation + # Returns Annotation def self.deep_copy(annotation) annotation_copy = annotation.dup annotation_copy.save! return annotation_copy end + # =========================== + # = Public instance methods = + # =========================== + + # The text from the annotation + # + # Returns String + def to_s + "#{text}" + end + def deep_copy(**options) copy = self.dup copy.question_id = options.fetch(:question_id, nil) diff --git a/app/models/answer.rb b/app/models/answer.rb index 4e8a3e5..aaba327 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -1,134 +1,167 @@ -class Answer < ActiveRecord::Base - - after_save do |answer| - if answer.plan_id.present? - plan = answer.plan - complete = plan.no_questions_matches_no_answers? - if plan.complete != complete - plan.complete = complete - plan.save! - else - plan.touch # Force updated_at changes if nothing changed since save only saves if changes were made to the record - end - end - end - - ## - # Associations - belongs_to :question - belongs_to :user - belongs_to :plan - has_many :notes, dependent: :destroy - has_and_belongs_to_many :question_options, join_table: "answers_question_options" - - has_many :notes - - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :text, :plan_id, :lock_version, :question_id, :user_id, :question_option_ids, - :question, :user, :plan, :question_options, :notes, :note_ids, :id, - :as => [:default, :admin] - - ## - # Validations -# validates :user, :plan, :question, presence: true -# -# # Make sure there is only one answer per question! -# validates :question, uniqueness: {scope: [:plan], -# message: I18n.t('helpers.answer.only_one_per_question')} -# -# # The answer MUST have a text value if the question is NOT option based or a question_option if -# # it is option based. -# validates :text, presence: true, if: Proc.new{|a| -# (a.question.nil? ? false : !a.question.question_format.option_based?) -# } -# validates :question_options, presence: true, if: Proc.new{|a| -# (a.question.nil? ? false : a.question.question_format.option_based?) -# } -# -# # Make sure the plan and question are associated with the same template! -# validates :plan, :question, answer_for_correct_template: true - - ## - # deep copy the given answer - # - # @params [Answer] question_option to be deep copied - # @return [Answer] the saved, copied answer - def self.deep_copy(answer) - answer_copy = answer.dup - answer.question_options.each do |opt| - answer_copy.question_options << opt - end - answer_copy.save! - return answer_copy - end - - # This method helps to decide if an answer option (:radiobuttons, :checkbox, etc ) in form views should be checked or not - # Returns true if the given option_id is present in question_options, otherwise returns false - def has_question_option(option_id) - self.question_option_ids.include?(option_id) - end - - # Returns true if the answer is valid and false otherwise. If the answer's question is option_based, it is checked if exist - # any question_option selected. For non option_based (e.g. textarea or textfield), it is checked the presence of text - def is_valid? - if self.question.present? - if self.question.question_format.option_based? - return !self.question_options.empty? - else # (e.g. textarea or textfield question formats) - return self.text.present? - end - end - return false - end - # Returns answer notes whose archived is blank sorted by updated_at in descending order - def non_archived_notes - return notes.select{ |n| n.archived.blank? }.sort!{ |x,y| y.updated_at <=> x.updated_at } - end - - ## - # Returns True if answer text is blank, false otherwise - # specificly we want to remove empty hml tags and check - # - # @return [Boolean] is the answer's text blank - def is_blank? - if self.text.present? - return self.text.gsub(/<\/?p>/, '').gsub(//, '').chomp.blank? - end - # no text so blank - return true - end - - ## - # Returns the parsed JSON hash for the current answer object - # Generates a new hash if none exists for rda_questions - # - # @return [Hash] the parsed hash of the answer. - # Should have keys 'standards', 'text' - # 'standards' is a list of : pairs - # 'text' is the text from the comments box - def answer_hash - default = {'standards' => {}, 'text' => ''} - begin - h = self.text.nil? ? default : JSON.parse(self.text) - rescue JSON::ParserError => e - h = default - end - return h - end - - ## - # Given a hash of standards and a comment value, this updates answer - # text for rda_questions - # - # @param [standards] a hash of standards - # @param [text] option comment text - # nothing returned, but the status of the text field of the answer is changed - def update_answer_hash(standards={},text="") - h = {} - h['standards'] = standards - h['text'] = text - self.text = h.to_json - end -end +# frozen_string_literal: true + +# == Schema Information +# +# Table name: answers +# +# id :integer not null, primary key +# lock_version :integer default(0) +# text :text +# created_at :datetime +# updated_at :datetime +# plan_id :integer +# question_id :integer +# user_id :integer +# +# Indexes +# +# index_answers_on_plan_id (plan_id) +# index_answers_on_question_id (question_id) +# +# Foreign Keys +# +# fk_rails_... (plan_id => plans.id) +# fk_rails_... (question_id => questions.id) +# fk_rails_... (user_id => users.id) +# + +class Answer < ActiveRecord::Base + + include ValidationMessages + + + # ================ + # = Associations = + # ================ + + belongs_to :question + + belongs_to :user + + belongs_to :plan + + has_many :notes, dependent: :destroy + + has_and_belongs_to_many :question_options, join_table: "answers_question_options" + + has_many :notes + + + # =============== + # = Validations = + # =============== + + validates :plan, presence: { message: PRESENCE_MESSAGE } + + validates :user, presence: { message: PRESENCE_MESSAGE } + + validates :question, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { message: UNIQUENESS_MESSAGE, + scope: :plan_id } + + # ============= + # = Callbacks = + # ============= + + after_save :set_plan_complete + + + ## + # deep copy the given answer + # + # answer - question_option to be deep copied + # + # Returns Answer + def self.deep_copy(answer) + answer_copy = answer.dup + answer.question_options.each do |opt| + answer_copy.question_options << opt + end + answer_copy + end + + # This method helps to decide if an answer option (:radiobuttons, :checkbox, etc ) in + # form views should be checked or not + # + # Returns Boolean + def has_question_option(option_id) + question_option_ids.include?(option_id) + end + + # If the answer's question is option_based, it is checked if exist any question_option + # selected. For non option_based (e.g. textarea or textfield), it is checked the + # presence of text + # + # Returns Boolean + def is_valid? + if question.present? + if question.question_format.option_based? + return question_options.any? + else # (e.g. textarea or textfield question formats) + return text.present? + end + end + false + end + + # Answer notes whose archived is blank sorted by updated_at in descending order + # + # Returns Array + def non_archived_notes + notes.select { |n| n.archived.blank? }.sort! { |x, y| x.created_at <=> y.created_at } + end + + # Returns True if answer text is blank, false otherwise specificly we want to remove + # empty hml tags and check. + # + # Returns Boolean + def is_blank? + if text.present? + return text.gsub(/<\/?p>/, "").gsub(/<br\s?\/?>/, "").chomp.blank? + end + # no text so blank + true + end + + # The parsed JSON hash for the current answer object. Generates a new hash if none + # exists for rda_questions. + # + # Returns Hash + def answer_hash + default = { "standards" => {}, "text" => "" } + begin + h = text.nil? ? default : JSON.parse(text) + rescue JSON::ParserError => e + h = default + end + h + end + + ## + # Given a hash of standards and a comment value, this updates answer text for + # rda_questions + # + # standards - A Hash of standards + # text - A String with option comment text + # + # Returns String + def update_answer_hash(standards = {}, text = "") + h = {} + h["standards"] = standards + h["text"] = text + self.text = h.to_json + end + + def set_plan_complete + return unless plan_id? + complete = plan.no_questions_matches_no_answers? + if plan.complete != complete + plan.update!(complete: complete) + else + # Force updated_at changes if nothing changed since save only saves if changes + # were made to the record + plan.touch + end + end + +end diff --git a/app/models/concerns/acts_as_sortable.rb b/app/models/concerns/acts_as_sortable.rb new file mode 100644 index 0000000..e294ceb --- /dev/null +++ b/app/models/concerns/acts_as_sortable.rb @@ -0,0 +1,45 @@ +module ActsAsSortable + extend ActiveSupport::Concern + + module ClassMethods + + def update_numbers!(*ids, parent:) + # Ensure only records belonging to this parent are included. + ids = ids.map(&:to_i) & parent.public_send("#{model_name.singular}_ids") + return if ids.empty? + case connection.adapter_name + when "PostgreSQL" then update_numbers_postgresql!(ids) + when "Mysql2" then update_numbers_mysql2!(ids) + else + update_numbers_sequentially!(ids) + end + end + + private + + def update_numbers_postgresql!(ids) + # Build an Array with each ID and its relative position in the Array + values = ids.each_with_index.map { |id, i| "(#{id}, #{i + 1})" }.join(",") + # Run a single UPDATE query for all records. + query = <<~SQL + UPDATE #{table_name} \ + SET number = svals.number \ + FROM (VALUES #{sanitize_sql(values)}) AS svals(id, number) \ + WHERE svals.id = #{table_name}.id; + SQL + connection.execute(query) + end + + def update_numbers_mysql2!(ids) + ids_string = ids.map { |id| "'#{id}'" }.join(",") + update_all(%Q{ number = FIELD(id, #{sanitize_sql(ids_string)}) + WHERE id IN (#{sanitize_sql(ids_string)}) }) + end + + def update_numbers_sequentially!(ids) + ids.each_with_index.map do |id, number| + find(id).update_attribute(:number, number + 1) + end + end + end +end diff --git a/app/models/concerns/exportable_plan.rb b/app/models/concerns/exportable_plan.rb index f4cec41..cdc999e 100644 --- a/app/models/concerns/exportable_plan.rb +++ b/app/models/concerns/exportable_plan.rb @@ -1,122 +1,192 @@ +# frozen_string_literal: true + module ExportablePlan - extend ActiveSupport::Concern - included do - - def as_pdf(coversheet = false) - prepare(coversheet) - end - - def as_csv(headings = true, unanswered = true) - hash = prepare(false) - - CSV.generate do |csv| - hdrs = (hash[:phases].length > 1 ? [_('Phase')] : []) - if headings - hdrs << [_('Section'),_('Question'),_('Answer')] - else - hdrs << [_('Answer')] - end - - csv << hdrs.flatten - hash[:phases].each do |phase| - phase[:sections].each do |section| - section[:questions].each do |question| - answer = self.answer(question[:id], false) - answer_text = answer.present? ? answer.text : (unanswered ? _('Not Answered') : '') - flds = (hash[:phases].length > 1 ? [phase[:title]] : []) - if headings - if question[:text].is_a? String - question_text = question[:text] - else - question_text = (question[:text].length > 1 ? question[:text].join(', ') : question[:text][0]) - end - flds << [ section[:title], sanitize_text(question_text), sanitize_text(answer_text) ] - else - flds << [ sanitize_text(answer_text) ] - end - - csv << flds.flatten - end - end - end - end - end - - private - def prepare(coversheet = false) - hash = coversheet ? prepare_coversheet : {} - template = Template.includes(phases: { sections: {questions: :question_format } }). - joins(phases: { sections: { questions: :question_format } }). - where(id: self.template_id).order('sections.number', 'questions.number').first - - hash[:title] = self.title - hash[:answers] = self.answers - - # add the relevant questions/answers - phases = [] - template.phases.each do |phase| - phs = { title: phase.title, number: phase.number, sections: [] } - phase.sections.each do |section| - sctn = { title: section.title, number: section.number, questions: [] } - section.questions.each do |question| - txt = question.text - sctn[:questions] << { id: question.id, text: txt, format: question.question_format } - end - phs[:sections] << sctn - end - phases << phs - end - hash[:phases] = phases - - record_plan_export(:pdf) - - hash - end - - def prepare_coversheet - hash = {} - # name of owner and any co-owners - attribution = self.owner.present? ? [self.owner.name(false)] : [] - self.roles.administrator.not_creator.each do |role| - attribution << role.user.name(false) - end - hash[:attribution] = attribution - - # Org name of plan owner's org - hash[:affiliation] = self.owner.present? ? self.owner.org.name : '' - - # set the funder name - hash[:funder] = self.funder_name.present? ? self.funder_name : (self.template.org.present? ? self.template.org.name : '') - - # set the template name and customizer name if applicable - hash[:template] = self.template.title - customizer = "" - cust_questions = self.questions.where(modifiable: true).pluck(:id) - # if the template is customized, and has custom answered questions - if self.template.customization_of.present? && Answer.where(plan_id: self.id, question_id: cust_questions).present? - customizer = _(" Customised By: ") + self.template.org.name - end - hash[:customizer] = customizer - hash - end - - def record_plan_export(format) - exported_plan = ExportedPlan.new.tap do |ep| - ep.plan = self - ep.phase_id = self.phases.first.id - ep.format = format - plan_settings = self.settings(:export) - - Settings::Template::DEFAULT_SETTINGS.each do |key, value| - ep.settings(:export).send("#{key}=", plan_settings.send(key)) - end - end - exported_plan.save - end - - def sanitize_text(text) - if (!text.nil?) then ActionView::Base.full_sanitizer.sanitize(text.gsub(/ /i,"")) end - end + def as_pdf(coversheet = false) + prepare(coversheet) end + + def as_csv(headings = true, + unanswered = true, + selected_phase = nil, + 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) + end + + hdrs = (hash[:phases].many? ? [_("Phase")] : []) + if headings + hdrs << [_("Section"), _("Question"), _("Answer")] + else + hdrs << [_("Answer")] + end + + customization = hash[:customization] + + csv << hdrs.flatten + hash[:phases].each do |phase| + if selected_phase.nil? || phase[:title] == selected_phase.title + phase[:sections].each do |section| + show_section = !customization + show_section ||= customization && !section[:modifiable] + show_section ||= customization && section[:modifiable] && show_custom_sections + + if show_section + show_section_for_csv(csv, phase, section, headings, unanswered, hash) + end + end + end + end + end + end + + private + + def prepare(coversheet = false) + hash = coversheet ? prepare_coversheet : {} + template = Template.includes(phases: { sections: { questions: :question_format } }) + .joins(phases: { sections: { questions: :question_format } }) + .where(id: self.template_id) + .order("sections.number", "questions.number").first + hash[:customization] = template.customization_of.present? + hash[:title] = self.title + hash[:answers] = self.answers + + # add the relevant questions/answers + phases = [] + template.phases.each do |phase| + phs = { title: phase.title, number: phase.number, sections: [] } + phase.sections.each do |section| + sctn = { title: section.title, + number: section.number, + questions: [], + modifiable: section.modifiable } + section.questions.each do |question| + txt = question.text + sctn[:questions] << { + id: question.id, + text: txt, + format: question.question_format + } + end + phs[:sections] << sctn + end + phases << phs + end + hash[:phases] = phases + + record_plan_export(:pdf) + + hash + end + + def prepare_coversheet + hash = {} + # name of owner and any co-owners + attribution = self.owner.present? ? [self.owner.name(false)] : [] + self.roles.administrator.not_creator.each do |role| + attribution << role.user.name(false) + end + hash[:attribution] = attribution + + # Org name of plan owner's org + hash[:affiliation] = self.owner.present? ? self.owner.org.name : "" + + # set the funder name + hash[:funder] = self.funder_name.present? ? + self.funder_name : (self.template.org.present? ? + self.template.org.name : "") + + # set the template name and customizer name if applicable + hash[:template] = self.template.title + customizer = "" + cust_questions = self.questions.where(modifiable: true).pluck(:id) + # if the template is customized, and has custom answered questions + if self.template.customization_of.present? && + Answer.where(plan_id: self.id, question_id: cust_questions).present? + customizer = _(" Customised By: ") + self.template.org.name + end + hash[:customizer] = customizer + hash + end + + def prepare_coversheet_for_csv(csv, headings, hash) + csv << [ hash[:attribution].many? ? + _("Creators: ") : + _("Creator:"), _(hash[:attribution].join(", ")) ] + csv << [ "Affiliation: ", _(hash[:affiliation]) ] + if hash[:funder].present? + csv << [ _("Template: "), _(hash[:funder]) ] + else + csv << [ _("Template: "), _(hash[:template] + hash[:customizer]) ] + end + if self.grant_number.present? + csv << [ _("Grant number: "), _(self.grant_number) ] + end + if self.description.present? + csv << [ _("Project abstract: "), _(Nokogiri::HTML(self.description).text) ] + end + csv << [ _("Last modified: "), _(self.updated_at.to_date.strftime("%d-%m-%Y")) ] + csv << [ _("Copyright information:"), + _("The above plan creator(s) have agreed that others may use as + much of the text of this plan as they would like in their own plans, + and customise it as necessary. You do not need to credit the creator(s) + as the source of the language used, but using any of the plan's text + does not imply that the creator(s) endorse, or have any relationship to, + your project or proposal") ] + csv << [] + csv << [] + end + + def show_section_for_csv(csv, phase, section, headings, unanswered, hash) + section[:questions].each do |question| + answer = self.answer(question[:id], false) + if answer.present? || (answer.blank? && unanswered) + answer_text = answer.present? ? answer.text : + (unanswered ? _("Not Answered") : "") + if answer.present? && answer.question_options.any? + answer_text = answer.question_options.pluck(:text).join(", ") + end + end + flds = (hash[:phases].many? ? [phase[:title]] : []) + if headings + if question[:text].is_a? String + question_text = question[:text] + else + question_text = (question[:text].many? ? + question[:text].join(", ") : + question[:text][0]) + end + flds << [ section[:title], sanitize_text(question_text), + sanitize_text(answer_text) + ] + else + flds << [ sanitize_text(answer_text) ] + end + csv << flds.flatten + end + end + + def record_plan_export(format) + exported_plan = ExportedPlan.new.tap do |ep| + ep.plan = self + ep.phase_id = self.phases.first.id + ep.format = format + plan_settings = self.settings(:export) + + Settings::Template::DEFAULT_SETTINGS.each do |key, value| + ep.settings(:export).send("#{key}=", plan_settings.send(key)) + end + end + exported_plan.save + end + + def sanitize_text(text) + ActionView::Base.full_sanitizer.sanitize(text.to_s.gsub(/ /i, "")) + end + end diff --git a/app/models/concerns/json_link_validator.rb b/app/models/concerns/json_link_validator.rb index 8315a7d..739174c 100644 --- a/app/models/concerns/json_link_validator.rb +++ b/app/models/concerns/json_link_validator.rb @@ -1,32 +1,15 @@ module JSONLinkValidator - extend ActiveSupport::Concern - included do - # Parses a stringified JSON according to validate_links (e.g. [{ link: String, text: String}, ...]) - # param {String} the stringified JSON value - # Returns an Array of hashes after decoding/validating the stringified JSON passed, otherwise nil - def parse_links(value) - return nil unless value.is_a?(String) - begin - parsed_value = JSON.parse(value) - return valid_links?(parsed_value) ? parsed_value : nil - rescue JSON::ParserError - nil + # Validates whether or not the value passed is conforming to + # [{ link: String, text: String}, ...] + def valid_links?(value) + if value.is_a?(Array) + r = value.all? do |o| + o.is_a?(Hash) && o.key?('link') && o.key?('text') && + o['link'].is_a?(String) && o['text'].is_a?(String) end + return r end - # Validates whether or not the value passed is conforming to [{ link: String, text: String}, ...] - def valid_links?(value) - if value.is_a?(Array) - r = value.all? do |o| - o.is_a?(Hash) && - o.has_key?('link') && - o.has_key?('text') && - o['link'].is_a?(String) && - o['text'].is_a?(String) - end - return r - end - false - end + false end end \ No newline at end of file diff --git a/app/models/concerns/validation_messages.rb b/app/models/concerns/validation_messages.rb new file mode 100644 index 0000000..f0f711f --- /dev/null +++ b/app/models/concerns/validation_messages.rb @@ -0,0 +1,10 @@ +module ValidationMessages + # frozen_string_literal: true + + PRESENCE_MESSAGE = _("can't be blank") + + UNIQUENESS_MESSAGE = _("must be unique") + + INCLUSION_MESSAGE = _("isn't a valid value") + +end diff --git a/app/models/concerns/validation_values.rb b/app/models/concerns/validation_values.rb new file mode 100644 index 0000000..dff74c3 --- /dev/null +++ b/app/models/concerns/validation_values.rb @@ -0,0 +1,3 @@ +module ValidationValues + BOOLEAN_VALUES = [true, false].freeze +end \ No newline at end of file diff --git a/app/models/concerns/versionable_model.rb b/app/models/concerns/versionable_model.rb new file mode 100644 index 0000000..154dc1d --- /dev/null +++ b/app/models/concerns/versionable_model.rb @@ -0,0 +1,19 @@ +module VersionableModel + + extend ActiveSupport::Concern + + included do + + attr_readonly :versionable_id + + before_validation :set_versionable_id, unless: :versionable_id? + + end + + private + + def set_versionable_id + self.versionable_id = SecureRandom.uuid + end + +end \ No newline at end of file diff --git a/app/models/exported_plan.rb b/app/models/exported_plan.rb index c1825e2..24a854a 100644 --- a/app/models/exported_plan.rb +++ b/app/models/exported_plan.rb @@ -1,23 +1,29 @@ +# == Schema Information +# +# Table name: exported_plans +# +# id :integer not null, primary key +# format :string +# created_at :datetime not null +# updated_at :datetime not null +# phase_id :integer +# plan_id :integer +# user_id :integer +# + class ExportedPlan < ActiveRecord::Base + include ValidationMessages include GlobalHelpers include SettingsTemplateHelper -# TODO: REMOVE AND HANDLE ATTRIBUTE SECURITY IN THE CONTROLLER! - attr_accessible :plan_id, :user_id, :format, :user, :plan, :as => [:default, :admin] #associations between tables belongs_to :plan belongs_to :user - VALID_FORMATS = ['csv', 'html', 'pdf', 'text', 'docx'] + validates :plan, presence: { message: PRESENCE_MESSAGE } - validates :format, inclusion: { - in: VALID_FORMATS, - message: -> (object, data) do - _('%{value} is not a valid format') % { :value => data[:value] } - end - } - validates :plan, :format, presence: {message: _("can't be blank")} + validates :format, presence: { message: PRESENCE_MESSAGE } # Store settings with the exported plan so it can be recreated later # if necessary (otherwise the settings associated with the plan at a diff --git a/app/models/guidance.rb b/app/models/guidance.rb index 08a5cb7..f0ae5da 100644 --- a/app/models/guidance.rb +++ b/app/models/guidance.rb @@ -1,3 +1,27 @@ +# Guidance provides information from organisations to Users, helping them when +# answering questions. (e.g. "Here's how to think about your data +# protection responsibilities...") +# +# == Schema Information +# +# Table name: guidances +# +# id :integer not null, primary key +# published :boolean +# text :text +# created_at :datetime not null +# updated_at :datetime not null +# guidance_group_id :integer +# +# Indexes +# +# index_guidances_on_guidance_group_id (guidance_group_id) +# +# Foreign Keys +# +# fk_rails_... (guidance_group_id => guidance_groups.id) +# + # [+Project:+] DMPRoadmap # [+Description:+] # This class keeps the information organisations enter to support users when answering questions. @@ -5,27 +29,32 @@ # [+Created:+] 07/07/2014 # [+Copyright:+] Digital Curation Centre and California Digital Library - - class Guidance < ActiveRecord::Base include GlobalHelpers + include ValidationMessages + include ValidationValues - ## - # Associations + # ================ + # = Associations = + # ================ + belongs_to :guidance_group + has_and_belongs_to_many :themes, join_table: "themes_in_guidance" -# depricated, but required for migration "single_group_for_guidance" - #has_and_belongs_to_many :guidance_groups, join_table: "guidance_in_group" - # EVALUATE CLASS AND INSTANCE METHODS BELOW - # - # What do they do? do they do it efficiently, and do we need them? + # =============== + # = Validations = + # =============== + validates :text, presence: { message: PRESENCE_MESSAGE } + validates :guidance_group, presence: { message: PRESENCE_MESSAGE } + validates :published, inclusion: { message: INCLUSION_MESSAGE, + in: BOOLEAN_VALUES} - validates :text, presence: {message: _("can't be blank")} + validates :themes, presence: { message: PRESENCE_MESSAGE }, if: :published? # Retrieves every guidance associated to an org scope :by_org, -> (org) { @@ -34,48 +63,26 @@ scope :search, -> (term) { search_pattern = "%#{term}%" - joins(:guidance_group).where("guidances.text LIKE ? OR guidance_groups.name LIKE ?", search_pattern, search_pattern) + joins(:guidance_group) + .where("guidances.text LIKE ? OR guidance_groups.name LIKE ?", + search_pattern, + search_pattern) } - ## - # Determine if a guidance is in a group which belongs to a specified organisation - # - # @param org_id [Integer] the integer id for an organisation - # @return [Boolean] true if this guidance is in a group belonging to the specified organisation, false otherwise - def in_group_belonging_to?(org_id) - unless guidance_group.nil? - if guidance_group.org.id == org_id - return true - end - end - return false - end - ## - # returns all templates belgonging to a specified guidance group - # - # @param guidance_group [Integer] the integer id for an guidance_group - # @return [Array<Templates>] list of templates - def get_guidance_group_templates? (guidance_group) - # DISCUSS - here we have yet another way of finding a specific or group of - # an object. Would it make sense to standardise the project by only using - # either finders or where, or alteast the same syntax within the where statement. - # Also why is this a ? method... it dosent return a boolean - # Additionally, shouldnt this be a function of guidance group, not guidance? - # and finally, it should be a self.method, as it dosent care about the guidance it's acting on - templates = guidancegroups.where("guidance_group_id (?)", guidance_group.id).template - return templates - end + # ================= + # = Class methods = + # ================= - ## # 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 a funder organisation # owned by an organisation, of which the user is a member # - # @param id [Integer] the integer id for a guidance - # @param user [User] a user object - # @return [Boolean] true if the specified user can view the specified guidance, false otherwise + # id - The Integer id for a guidance + # user - A User object + # + # Returns Boolean def self.can_view?(user, id) guidance = Guidance.find_by(id: id) viewable = false @@ -86,7 +93,8 @@ if guidance.guidance_group.org == user.org viewable = true end - # guidance groups are viewable if they are owned by the Managing Curation Center + # guidance groups are viewable if they are owned by the Managing + # Curation Center if Org.managing_orgs.include?(guidance.guidance_group.org) viewable = true end @@ -101,26 +109,47 @@ return viewable end - ## # 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 a funder organisation # owned by an organisation, of which the user is a member # - # @param user [User] a user object - # @return [Array<Guidance>] a list of all "viewable" guidances to a user + # user - A User object + # + # Returns Array def self.all_viewable(user) - managing_groups = Org.includes(guidance_groups: :guidances).managing_orgs.collect{|o| o.guidance_groups} + managing_groups = Org.includes(guidance_groups: :guidances) + .managing_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} + funder_groups = Org.includes(guidance_groups: :guidances) + .funder.collect{|org| org.guidance_groups} # find all groups owned by any of the user's organisations organisation_groups = user.org.guidance_groups # find all guidances belonging to any of the viewable groups - all_viewable_groups = (managing_groups + funder_groups + organisation_groups).flatten - all_viewable_guidances = all_viewable_groups.collect{|group| group.guidances} + all_viewable_groups = (managing_groups + + funder_groups + + organisation_groups).flatten + all_viewable_guidances = all_viewable_groups.collect do |group| + group.guidances + end # pass the list of viewable guidances to the view return all_viewable_guidances.flatten end + + # Determine if a guidance is in a group which belongs to a specified + # organisation + # + # org_id - The Integer id for an organisation + # + # Returns Boolean + def in_group_belonging_to?(org_id) + unless guidance_group.nil? + if guidance_group.org.id == org_id + return true + end + end + return false + end end diff --git a/app/models/guidance_group.rb b/app/models/guidance_group.rb index b898ded..3a63430 100644 --- a/app/models/guidance_group.rb +++ b/app/models/guidance_group.rb @@ -1,21 +1,56 @@ +# Set of Guidances that pertain to a certain category of Users (e.g. Maths +# department, vs Biology department) +# +# == Schema Information +# +# Table name: guidance_groups +# +# id :integer not null, primary key +# name :string +# optional_subset :boolean default(FALSE), not null +# published :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# +# Indexes +# +# index_guidance_groups_on_org_id (org_id) +# +# Foreign Keys +# +# fk_rails_... (org_id => orgs.id) +# + class GuidanceGroup < ActiveRecord::Base include GlobalHelpers - ## - # Associations + include ValidationValues + include ValidationMessages + + # ================ + # = Associations = + # ================ + belongs_to :org + has_many :guidances, dependent: :destroy + has_and_belongs_to_many :plans, join_table: :plans_guidance_groups - # depricated but needed for migration "single_group_for_guidance" - # has_and_belongs_to_many :guidances, join_table: "guidance_in_group" + # =============== + # = Validations = + # =============== - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :org_id, :name, :optional_subset, :published, :org, :guidances, - :as => [:default, :admin] + validates :name, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { message: UNIQUENESS_MESSAGE, scope: :org_id } - validates :name, :org, presence: {message: _("can't be blank")} + validates :org, presence: { message: PRESENCE_MESSAGE } + + validates :optional_subset, inclusion: { in: BOOLEAN_VALUES, + message: INCLUSION_MESSAGE } + + validates :published, inclusion: { in: BOOLEAN_VALUES, + message: INCLUSION_MESSAGE } # EVALUATE CLASS AND INSTANCE METHODS BELOW @@ -23,100 +58,71 @@ # What do they do? do they do it efficiently, and do we need them? # Retrieves every guidance group associated to an org - scope :by_org, -> (org) { - where(org_id: org.id) - } - scope :search, -> (term) { + scope :by_org, ->(org) { where(org_id: org.id) } + + scope :search, lambda { |term| search_pattern = "%#{term}%" where("name LIKE ?", search_pattern) } - ## - # Converts the current guidance group to a string containing the display name. - # If it's organisation has no other guidance groups, then the name is simply - # the name of the parent organisation, otherwise it returns the name of the - # organisation followed by the name of the guidance group. - # - # @return [String] the display name for the guidance group - def display_name - if org.guidance_groups.count > 1 - return "#{org.name}: #{name}" - else - return org.name - end - end + scope :published, -> { where(published: true) } - ## - # Returns the list of all guidance groups not coming from the given organisations - # - # @param excluded_orgs [Array<Organisation>] a list of organisations to exclude in the result - # @return [Array<GuidanceGroup>] a list of guidance groups - def self.guidance_groups_excluding(excluded_orgs) - excluded_org_ids = Array.new + # ================= + # = Class methods = + # ================= - if excluded_orgs.is_a?(Array) - excluded_orgs.each do |org| - excluded_org_ids << org.id - end - else - excluded_org_ids << excluded_orgs - end - - return_orgs = GuidanceGroup.where("org_id NOT IN (?)", excluded_org_ids) - return return_orgs - end - - ## - # Returns whether or not a given user can view a given guidance group + # 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 # a funder organisation # an organisation, of which the user is a member # - # @param id [Integer] the integer id for a guidance group - # @param user [User] a user object - # @return [Boolean] true if the specified user can view the specified guidance group, false otherwise + # id - The integer id for a guidance group + # user - A User object + # + # Returns Boolean def self.can_view?(user, guidance_group) viewable = false # groups are viewable if they are owned by any of the user's organisations - if guidance_group.org == user.org - viewable = true - end + 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| - if guidance_group.org.id == managing_group.id - viewable = true - end + viewable = true if guidance_group.org.id == managing_group.id end # groups are viewable if they are owned by a funder - if guidance_group.org.funder? - viewable = true - end + viewable = true if guidance_group.org.funder? - return viewable + viewable end - ## - # Returns 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 - # a funder organisation - # an organisation, of which the user is a member - # - # @param user [User] a user object - # @return [Array<GuidanceGroup>] a list of all "viewable" guidance groups to a user + + # 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 + # a funder organisation + # an organisation, of which the user is a member + # + # user - A User object + # + # 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{|org| org.guidance_groups} + managing_org_groups = Org.includes(guidance_groups: [guidances: :themes]) + .managing_orgs.collect(&:guidance_groups) # find all groups owned by a Funder organisation - funder_groups = Org.includes(:guidance_groups).funder.collect{|org| org.guidance_groups} + funder_groups = Org.includes(:guidance_groups) + .funder + .collect(&:guidance_groups) organisation_groups = [user.org.guidance_groups] - # pass this organisation guidance groups to the view with respond_with @all_viewable_groups - all_viewable_groups = managing_org_groups + funder_groups + organisation_groups + # pass this organisation guidance groups to the view with respond_with + # all_viewable_groups + all_viewable_groups = managing_org_groups + + funder_groups + + organisation_groups all_viewable_groups = all_viewable_groups.flatten.uniq - return all_viewable_groups + all_viewable_groups end end diff --git a/app/models/identifier_scheme.rb b/app/models/identifier_scheme.rb index d07aafc..9243b11 100644 --- a/app/models/identifier_scheme.rb +++ b/app/models/identifier_scheme.rb @@ -1,6 +1,37 @@ +# == Schema Information +# +# Table name: identifier_schemes +# +# id :integer not null, primary key +# active :boolean +# description :string +# logo_url :text +# name :string +# user_landing_url :text +# created_at :datetime +# updated_at :datetime +# + class IdentifierScheme < ActiveRecord::Base + include ValidationMessages + include ValidationValues + + ## + # The maximum length for a name + NAME_MAXIMUM_LENGTH = 30 + has_many :user_identifiers has_many :users, through: :user_identifiers - - validates :name, uniqueness: {message: _("must be unique")}, presence: {message: _("can't be blank")} -end \ No newline at end of file + + # =============== + # = Validations = + # =============== + + validates :name, uniqueness: { message: UNIQUENESS_MESSAGE }, + presence: { message: PRESENCE_MESSAGE }, + length: { maximum: NAME_MAXIMUM_LENGTH } + + validates :active, inclusion: { message: INCLUSION_MESSAGE, + in: BOOLEAN_VALUES } + +end diff --git a/app/models/language.rb b/app/models/language.rb index 1d86727..c78e542 100644 --- a/app/models/language.rb +++ b/app/models/language.rb @@ -1,16 +1,90 @@ -class Language < ActiveRecord::Base - ## - # Associations - has_many :users - has_many :orgs - - ## - # Validations - # Cannot do FastGettext translations here because we constantize LANGUAGES in initializers/constants.rb - validates :abbreviation, presence: {message: "can't be blank"}, uniqueness: {message: "must be unique"} - - scope :sorted_by_abbreviation, -> { all.order(:abbreviation) } - scope :default, -> { where(default_language: true).first } - # Retrieves the id for a given abbreviation of a language - scope :id_for, -> (abbreviation) { where(abbreviation: abbreviation).pluck(:id).first } -end \ No newline at end of file +# frozen_string_literal: true + +# == Schema Information +# +# Table name: languages +# +# id :integer not null, primary key +# abbreviation :string +# default_language :boolean +# description :string +# name :string +# + +class Language < ActiveRecord::Base + + # frozen_string_literal: true + + include ValidationValues + + # ============= + # = Constants = + # ============= + + ABBREVIATION_MAXIMUM_LENGTH = 5 + + ABBREVIATION_FORMAT = /\A[a-z]{2}(\-[A-Z]{2})?\Z/ + + NAME_MAXIMUM_LENGTH = 20 + + # ================ + # = Associations = + # ================ + + has_many :users + + has_many :orgs + + + # =============== + # = Validations = + # =============== + + validates :name, presence: { message: "can't be blank" }, + length: { maximum: NAME_MAXIMUM_LENGTH } + + validates :abbreviation, presence: { message: "can't be blank" }, + uniqueness: { message: "must be unique" }, + length: { maximum: ABBREVIATION_MAXIMUM_LENGTH }, + format: { with: ABBREVIATION_FORMAT } + + validates :default_language, inclusion: { in: BOOLEAN_VALUES } + + # ============= + # = Callbacks = + # ============= + + before_validation :format_abbreviation, if: :abbreviation_changed? + + # ========== + # = Scopes = + # ========== + + scope :sorted_by_abbreviation, -> { all.order(:abbreviation) } + + # Retrieves the id for a given abbreviation of a language + scope :id_for, -> (abbreviation) { + where(abbreviation: abbreviation).pluck(:id).first + } + + # ======================== + # = Public class methods = + # ======================== + + def self.many? + Rails.cache.fetch([model_name, "many?"], expires_in: 1.hour) { all.many? } + end + + def self.default + where(default_language: true).first + end + + private + + def format_abbreviation + abbreviation.downcase! + return if abbreviation.blank? || abbreviation =~ /\A[a-z]{2}\Z/i + self.abbreviation = LocaleFormatter.new(abbreviation, format: :i18n).to_s + end + +end diff --git a/app/models/note.rb b/app/models/note.rb index 4341c2a..f63d5a6 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -1,14 +1,49 @@ +# == Schema Information +# +# Table name: notes +# +# id :integer not null, primary key +# archived :boolean default(FALSE), not null +# archived_by :integer +# text :text +# created_at :datetime +# updated_at :datetime +# answer_id :integer +# user_id :integer +# +# Indexes +# +# index_notes_on_answer_id (answer_id) +# +# Foreign Keys +# +# fk_rails_... (answer_id => answers.id) +# fk_rails_... (user_id => users.id) +# + class Note < ActiveRecord::Base - ## - # Associations + include ValidationMessages + include ValidationValues + + # ================ + # = Associations = + # ================ + belongs_to :answer + belongs_to :user - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :text, :user_id, :answer_id, :archived, :archived_by, - :answer, :user, :as => [:default, :admin] - - validates :text, :answer, :user, presence: {message: _("can't be blank")} + # =============== + # = Validations = + # =============== + + validates :text, presence: { message: PRESENCE_MESSAGE } + + validates :answer, presence: { message: PRESENCE_MESSAGE } + + validates :user, presence: { message: PRESENCE_MESSAGE } + + validates :archived, inclusion: { in: BOOLEAN_VALUES, + message: INCLUSION_MESSAGE } + end diff --git a/app/models/notification.rb b/app/models/notification.rb index c320f01..dd6a17d 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -1,11 +1,58 @@ +# == Schema Information +# +# Table name: notifications +# +# id :integer not null, primary key +# body :text +# dismissable :boolean +# expires_at :date +# level :integer +# notification_type :integer +# starts_at :date +# title :string +# created_at :datetime not null +# updated_at :datetime not null +# + class Notification < ActiveRecord::Base + include ValidationMessages + include ValidationValues + enum level: %i[info warning danger] enum notification_type: %i[global] - has_and_belongs_to_many :users, dependent: :destroy, join_table: 'notification_acknowledgements' + # ================ + # = Associations = + # ================ - validates :notification_type, :title, :level, :starts_at, :expires_at, :body, presence: true - validate :valid_dates + has_and_belongs_to_many :users, dependent: :destroy, + join_table: 'notification_acknowledgements' + + + # =============== + # = Validations = + # =============== + + validates :notification_type, presence: { message: PRESENCE_MESSAGE } + + validates :title, presence: { message: PRESENCE_MESSAGE } + + validates :level, presence: { message: PRESENCE_MESSAGE } + + validates :body, presence: { message: PRESENCE_MESSAGE } + + validates :dismissable, inclusion: { in: BOOLEAN_VALUES } + + validates :starts_at, presence: { message: PRESENCE_MESSAGE }, + after: { date: Date.today, on: :create } + + validates :expires_at, presence: { message: PRESENCE_MESSAGE }, + after: { date: Date.tomorrow, on: :create } + + + # ========== + # = Scopes = + # ========== scope :active, (lambda do where('starts_at <= :now and :now < expires_at', now: Time.now) @@ -22,19 +69,9 @@ # Has the Notification been acknowledged by the given user ? # If no user is given, currently logged in user (if any) is the default - # @return [Boolean] is the Notification acknowledged ? + # + # Returns Boolean def acknowledged?(user) - users.include?(user) if user.present? && dismissable? - end - - # Validate Notification dates - def valid_dates - return false if starts_at.blank? || expires_at.blank? - errors.add(:starts_at, _('Should be today or later')) if starts_at < Date.today - errors.add(:expires_at, _('Should be tomorrow or later')) if expires_at < Date.tomorrow - if starts_at > expires_at - errors.add(:starts_at, _('Should be before expiration date')) - errors.add(:expires_at, _('Should be after start date')) - end + dismissable? && user.present? && users.include?(user) end end diff --git a/app/models/org.rb b/app/models/org.rb index d095160..0b3e5e9 100644 --- a/app/models/org.rb +++ b/app/models/org.rb @@ -1,46 +1,111 @@ +# == Schema Information +# +# Table name: orgs +# +# id :integer not null, primary key +# abbreviation :string +# contact_email :string +# contact_name :string +# feedback_email_msg :text +# feedback_email_subject :string +# feedback_enabled :boolean default(FALSE) +# is_other :boolean default(FALSE), not null +# links :text +# logo_name :string +# logo_uid :string +# name :string +# org_type :integer default(0), not null +# sort_name :string +# target_url :string +# created_at :datetime not null +# updated_at :datetime not null +# language_id :integer +# region_id :integer +# +# Foreign Keys +# +# fk_rails_... (language_id => languages.id) +# fk_rails_... (region_id => regions.id) +# + class Org < ActiveRecord::Base + include ValidationMessages + include ValidationValues + include FeedbacksHelper include GlobalHelpers include FlagShihTzu extend Dragonfly::Model::Validations validates_with OrgLinksValidator + LOGO_FORMATS = %w[jpeg png gif jpg bmp].freeze + # Stores links as an JSON object: { org: [{"link":"www.example.com","text":"foo"}, ...] } # The links are validated against custom validator allocated at validators/template_links_validator.rb serialize :links, JSON - ## - # Associations -# belongs_to :organisation_type # depricated, but cannot be removed until migration run + + # ================ + # = Associations = + # ================ + belongs_to :language - has_many :guidance_groups + + belongs_to :region + + has_many :guidance_groups, dependent: :destroy + has_many :templates + has_many :users + has_many :annotations has_and_belongs_to_many :token_permission_types, join_table: "org_token_permissions", unique: true has_many :org_identifiers + has_many :identifier_schemes, through: :org_identifiers - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :abbreviation, :logo, :remove_logo, - :logo_file_name, :name, :links, - :organisation_type_id, :wayfless_entity, :parent_id, :sort_name, - :token_permission_type_ids, :language_id, :contact_email, :contact_name, - :language, :org_type, :region, :token_permission_types, - :guidance_group_ids, :is_other, :region_id, :logo_uid, :logo_name, - :feedback_enabled, :feedback_email_subject, :feedback_email_msg - ## - # Validators - validates :name, presence: {message: _("can't be blank")}, uniqueness: {message: _("must be unique")} + + # =============== + # = Validations = + # =============== + + validates :name, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { message: UNIQUENESS_MESSAGE } + + validates :abbreviation, presence: { message: PRESENCE_MESSAGE } + + validates :is_other, inclusion: { in: BOOLEAN_VALUES, + message: INCLUSION_MESSAGE } + + validates :language, presence: { message: PRESENCE_MESSAGE } + + validates :contact_name, presence: { message: PRESENCE_MESSAGE, if: :feedback_enabled } + + validates :contact_email, email: { allow_nil: true }, + presence: { message: PRESENCE_MESSAGE, if: :feedback_enabled } + + validates :org_type, presence: { message: PRESENCE_MESSAGE } + + validates :feedback_enabled, inclusion: { in: BOOLEAN_VALUES, + message: INCLUSION_MESSAGE } + + validates :feedback_email_subject, presence: { message: PRESENCE_MESSAGE, + if: :feedback_enabled } + + validates :feedback_email_msg, presence: { message: PRESENCE_MESSAGE, + if: :feedback_enabled } + + validates_property :format, of: :logo, in: LOGO_FORMATS, + message: _("must be one of the following formats: jpeg, jpg, png, gif, bmp") + + validates_size_of :logo, maximum: 500.kilobytes, message: _("can't be larger than 500KB") + # allow validations for logo upload dragonfly_accessor :logo do after_assign :resize_image end - validates_property :format, of: :logo, in: ['jpeg', 'png', 'gif','jpg','bmp'], message: _("must be one of the following formats: jpeg, jpg, png, gif, bmp") - validates_size_of :logo, maximum: 500.kilobytes, message: _("can't be larger than 500KB") ## # Define Bit Field values @@ -54,13 +119,17 @@ column: 'org_type' # Predefined queries for retrieving the managain organisation and funders - scope :managing_orgs, -> { where(abbreviation: Rails.configuration.branding[:organisation][:abbreviation]) } + scope :managing_orgs, -> do + where(abbreviation: Branding.fetch(:organisation, :abbreviation)) + end scope :search, -> (term) { search_pattern = "%#{term}%" where("orgs.name LIKE ? OR orgs.contact_email LIKE ?", search_pattern, search_pattern) } + before_validation :set_default_feedback_email_subject + before_validation :check_for_missing_logo_file after_create :create_guidance_group # EVALUATE CLASS AND INSTANCE METHODS BELOW @@ -68,7 +137,9 @@ # What do they do? do they do it efficiently, and do we need them? # Determines the locale set for the organisation - # @return String or nil + # + # Returns String + # Returns nil def get_locale if !self.language.nil? return self.language.abbreviation @@ -77,16 +148,14 @@ end end -# TODO: Should these be hardcoded? Also, an Org can currently be multiple org_types at one time. -# For example you can do: funder = true; project = true; school = true -# Calling type in the above scenario returns "Funder" which is a bit misleading -# Is FlagShihTzu's Bit flag the appropriate structure here or should we use an enum? -# Tests are setup currently to work with this issue. - ## - # returns the name of the type of the organisation as a string - # defaults to none if no org type present + # TODO: Should these be hardcoded? Also, an Org can currently be multiple org_types at + # one time. For example you can do: funder = true; project = true; school = true # - # @return [String] + # Calling type in the above scenario returns "Funder" which is a bit misleading + # Is FlagShihTzu's Bit flag the appropriate structure here or should we use an enum? + # Tests are setup currently to work with this issue. + # + # Returns String def org_type_to_s ret = [] ret << "Institution" if self.institution? @@ -103,17 +172,17 @@ end ## - # returns the name of the organisation + # The name of the organisation # - # @return [String] + # Returns String def to_s name end ## - # returns the abbreviation for the organisation if it exists, or the name if not + # The abbreviation for the organisation if it exists, or the name if not # - # @return [String] name or abbreviation of the organisation + # Returns String def short_name if abbreviation.nil? then return name @@ -123,20 +192,11 @@ end ## - # returns all published templates belonging to the organisation + # All published templates belonging to the organisation # - # @return [Array<Template>] published templates - def published_templates - return templates.where("published = ?", true) - end - - def check_api_credentials - if token_permission_types.count == 0 - users.each do |user| - user.api_token = "" - user.save! - end - end + # Returns ActiveRecord::Relation + def published_templates + return templates.where("published = ?", true) end def org_admins @@ -154,20 +214,52 @@ end private - ## - # checks size of logo and resizes if necessary - # - def resize_image - unless logo.nil? - if logo.height != 100 - self.logo = logo.thumb('x100') # resize height and maintain aspect ratio + + ## + # checks size of logo and resizes if necessary + # + def resize_image + unless logo.nil? + if logo.height != 100 + self.logo = logo.thumb('x100') # resize height and maintain aspect ratio + end + end + end + + # If the physical logo file is no longer on disk we do not want it to prevent the + # model from saving. This typically happens when you copy the database to another + # environment. The orgs.logo_uid stores the path to the physical logo file that is + # stored in the Dragonfly data store (default is: public/system/dragonfly/[env]/) + def check_for_missing_logo_file + if self.logo_uid.present? + data_store_path = Dragonfly.app.datastore.root_path + + if !File.exist?("#{data_store_path}#{self.logo_uid}") + # Attempt to locate the file by name. If it exists update the uid + logo = Dir.glob("#{data_store_path}/**/*#{self.logo_name}") + if !logo.empty? + self.logo_uid = logo.first.gsub(data_store_path, '') + else + # Otherwise the logo is missing so clear it to prevent save failures + self.logo = nil end end end + end - # creates a dfefault Guidance Group on create on the Org - def create_guidance_group - GuidanceGroup.create(name: self.abbreviation? ? self.abbreviation : self.name , org_id: self.id) + def set_default_feedback_email_subject + if self.feedback_enabled? && !self.feedback_email_subject.present? + self.feedback_email_subject = feedback_confirmation_default_subject end + end + # creates a dfefault Guidance Group on create on the Org + def create_guidance_group + GuidanceGroup.create!({ + name: abbreviation? ? self.abbreviation : self.name , + org: self, + optional_subset: false, + published: false, + }) + end end diff --git a/app/models/org_identifier.rb b/app/models/org_identifier.rb index db61fd6..be5faa8 100644 --- a/app/models/org_identifier.rb +++ b/app/models/org_identifier.rb @@ -1,13 +1,50 @@ +# == 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) +# + class OrgIdentifier < ActiveRecord::Base + include ValidationMessages + + # ================ + # = Associations = + # ================ + belongs_to :org belongs_to :identifier_scheme - + + # =============== + # = Validations = + # =============== + # Should only be able to have one identifier per scheme! - validates_uniqueness_of :identifier_scheme, scope: :org - - validates :identifier, :org, :identifier_scheme, presence: {message: _("can't be blank")} - + validates :identifier_scheme_id, uniqueness: { scope: :org_id, + message: UNIQUENESS_MESSAGE } + + validates :identifier, presence: { message: PRESENCE_MESSAGE } + + validates :org, presence: { message: PRESENCE_MESSAGE } + + validates :identifier_scheme, presence: { message: PRESENCE_MESSAGE } + + # =========================== + # = Public instance methods = + # =========================== + def attrs=(hash) write_attribute(:attrs, (hash.is_a?(Hash) ? hash.to_json.to_s : '{}')) end -end \ No newline at end of file +end diff --git a/app/models/perm.rb b/app/models/perm.rb index fcc429f..a2fdced 100644 --- a/app/models/perm.rb +++ b/app/models/perm.rb @@ -1,21 +1,63 @@ +# == Schema Information +# +# Table name: perms +# +# id :integer not null, primary key +# name :string +# created_at :datetime not null +# updated_at :datetime not null +# + class Perm < ActiveRecord::Base - ## - # Associations - has_and_belongs_to_many :users, :join_table => :users_perms + include ValidationMessages - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - #attr_accessible :name, :as => [:default, :admin] + # ================ + # = Associations = + # ================ - validates :name, presence: {message: _("can't be blank")}, uniqueness: {message: _("must be unique")} - - scope :add_orgs, -> {Perm.find_by(name: 'add_organisations')} - scope :change_affiliation, -> {Perm.find_by(name: 'change_org_affiliation')} - scope :grant_permissions, -> {Perm.find_by(name: 'grant_permissions')} - scope :modify_templates, -> {Perm.find_by(name: 'modify_templates')} - scope :modify_guidance, -> {Perm.find_by(name: 'modify_guidance')} - scope :use_api, -> {Perm.find_by(name: 'use_api')} - scope :change_org_details, -> {Perm.find_by(name: 'change_org_details')} - scope :grant_api, -> {Perm.find_by(name: 'grant_api_to_orgs')} + has_and_belongs_to_many :users, join_table: :users_perms + + # =============== + # = Validations = + # =============== + + validates :name, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { message: UNIQUENESS_MESSAGE } + + + # ================= + # = Class methods = + # ================= + + def self.add_orgs + Perm.find_by(name: 'add_organisations') + end + + def self.change_affiliation + Perm.find_by(name: 'change_org_affiliation') + end + + def self.grant_permissions + Perm.find_by(name: 'grant_permissions') + end + + def self.modify_templates + Perm.find_by(name: 'modify_templates') + end + + def self.modify_guidance + Perm.find_by(name: 'modify_guidance') + end + + def self.use_api + Perm.find_by(name: 'use_api') + end + + def self.change_org_details + Perm.find_by(name: 'change_org_details') + end + + def self.grant_api + Perm.find_by(name: 'grant_api_to_orgs') + end end diff --git a/app/models/phase.rb b/app/models/phase.rb index 2612f3e..84c216c 100644 --- a/app/models/phase.rb +++ b/app/models/phase.rb @@ -1,54 +1,108 @@ +# == Schema Information +# +# Table name: phases +# +# id :integer not null, primary key +# description :text +# modifiable :boolean +# number :integer +# title :string +# created_at :datetime +# updated_at :datetime +# template_id :integer +# versionable_id :string(36) +# +# Indexes +# +# index_phases_on_template_id (template_id) +# index_phases_on_versionable_id (versionable_id) +# +# Foreign Keys +# +# fk_rails_... (template_id => templates.id) +# + # [+Project:+] DMPRoadmap # [+Description:+] This model describes informmation about the phase of a plan, it's title, order of display and which template it belongs to. # # [+Created:+] 03/09/2014 # [+Copyright:+] Digital Curation Centre and University of California Curation Center class Phase < ActiveRecord::Base + include ValidationMessages + include ValidationValues + include ActsAsSortable + include VersionableModel + + ## # Sort order: Number ASC default_scope { order(number: :asc) } - ## - # Associations - belongs_to :template - has_many :sections, -> { order(:number => :asc) }, dependent: :destroy + # ================ + # = Associations = + # ================ + belongs_to :template, touch: true - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :description, :number, :title, :template_id, - :template, :sections, :modifiable, :as => [:default, :admin] + belongs_to :plan - validates :title, :number, :template, presence: {message: _("can't be blank")} + has_one :prefix_section, -> (phase) { + modifiable.where("number < ?", + phase.sections.not_modifiable.minimum(:number)) + }, class_name: "Section" + + has_many :sections, dependent: :destroy + + has_many :template_sections, -> { + not_modifiable + }, class_name: "Section" + + + has_many :suffix_sections, -> (phase) { + modifiable.where(<<~SQL, phase_id: phase.id, modifiable: false) + sections.number > (SELECT MAX(number) FROM sections + WHERE sections.modifiable = :modifiable + AND sections.phase_id = :phase_id) + SQL + }, class_name: "Section" + + + # =============== + # = Validations = + # =============== + + validates :title, presence: { message: PRESENCE_MESSAGE } + + validates :number, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { message: UNIQUENESS_MESSAGE, + scope: :template_id } + + validates :template, presence: { message: PRESENCE_MESSAGE } + + validates :modifiable, inclusion: { in: BOOLEAN_VALUES, + message: INCLUSION_MESSAGE } + + # ========== + # = Scopes = + # ========== scope :titles, -> (template_id) { Phase.where(template_id: template_id).select(:id, :title) } - -# TODO: Remove after implementing new template versioning logic - # Callbacks - after_save do |phase| - # Updates the template.updated_at attribute whenever a phase has been created/updated - phase.template.touch if template.present? - end def deep_copy(**options) copy = self.dup copy.modifiable = options.fetch(:modifiable, self.modifiable) copy.template_id = options.fetch(:template_id, nil) copy.save!(validate:false) if options.fetch(:save, false) - options[:phase_id] = id + options[:phase_id] = copy.id self.sections.each{ |section| copy.sections << section.deep_copy(options) } return copy end -# TODO: Move this to Plan model as `num_answered_questions(phase=nil)` + # TODO: Move this to Plan model as `num_answered_questions(phase=nil)` # Returns the number of answered question for the phase. def num_answered_questions(plan) - return 0 if plan.nil? - return sections.reduce(0) do |m, s| - m + s.num_answered_questions(plan) - end + plan&.num_answered_questions.to_i end # Returns the number of questions for a phase. Note, this method becomes useful @@ -58,6 +112,12 @@ self.sections.each do |s| n+= s.questions.size() end - return n + n 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 + end + end diff --git a/app/models/plan.rb b/app/models/plan.rb index a837d31..511682e 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -1,180 +1,272 @@ +# frozen_string_literal: true + +# The central model object within this domain. Represents a Data Management +# Plan for a research project. +# +# == Schema Information +# +# Table name: plans +# +# id :integer not null, primary key +# complete :boolean default(FALSE) +# data_contact :string +# data_contact_email :string +# data_contact_phone :string +# description :text +# feedback_requested :boolean default(FALSE) +# funder_name :string +# grant_number :string +# identifier :string +# principal_investigator :string +# principal_investigator_email :string +# principal_investigator_identifier :string +# principal_investigator_phone :string +# title :string +# visibility :integer default(3), not null +# created_at :datetime +# updated_at :datetime +# template_id :integer +# +# Indexes +# +# index_plans_on_template_id (template_id) +# +# Foreign Keys +# +# fk_rails_... (template_id => templates.id) +# + class Plan < ActiveRecord::Base + include ConditionalUserMailer include ExportablePlan - before_validation :set_creation_defaults + include ValidationMessages + include ValidationValues - ## - # Associations + # ============= + # = Constants = + # ============= + + + # Returns visibility message given a Symbol type visibility passed, otherwise + # nil + VISIBILITY_MESSAGE = { + organisationally_visible: _("organisational"), + publicly_visible: _("public"), + is_test: _("test"), + privately_visible: _("private") + } + + # ============== + # = Attributes = + # ============== + + # public is a Ruby keyword so using publicly + enum visibility: %i[organisationally_visible publicly_visible + is_test privately_visible] + + + alias_attribute :name, :title + + + # ================ + # = Associations = + # ================ + belongs_to :template + has_many :phases, through: :template + has_many :sections, through: :phases + has_many :questions, through: :sections + has_many :themes, through: :questions + + has_many :guidances, through: :themes + + has_many :guidance_group_options, -> { uniq.published.reorder("id") }, + through: :guidances, + source: :guidance_group, + class_name: "GuidanceGroup" + has_many :answers, dependent: :destroy + has_many :notes, through: :answers + has_many :roles, dependent: :destroy + has_many :users, through: :roles + has_and_belongs_to_many :guidance_groups, join_table: :plans_guidance_groups - accepts_nested_attributes_for :template has_many :exported_plans has_many :roles -# COMMENTED OUT THE DIRECT CONNECTION HERE TO Users to prevent assignment of users without an access_level specified (currently defaults to creator) -# has_many :users, through: :roles + # ===================== + # = Nested Attributes = + # ===================== - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :locked, :project_id, :version_id, :version, :plan_sections, - :exported_plans, :project, :title, :template, :grant_number, - :identifier, :principal_investigator, :principal_investigator_identifier, - :description, :data_contact, :funder_name, :visibility, :exported_plans, - :roles, :users, :org, :data_contact_email, :data_contact_phone, :feedback_requested, - :principal_investigator_email, :as => [:default, :admin] + accepts_nested_attributes_for :template + accepts_nested_attributes_for :roles - # public is a Ruby keyword so using publicly - enum visibility: [:organisationally_visible, :publicly_visible, :is_test, :privately_visible] - #TODO: work out why this messes up plan creation : - # briley: Removed reliance on :users, its really on :roles (shouldn't have a plan without at least a creator right?) It should be ok like this though now -# validates :template, :title, presence: true + # =============== + # = Validations = + # =============== - ## - # Constants - A4_PAGE_HEIGHT = 297 #(in mm) - A4_PAGE_WIDTH = 210 #(in mm) - ROUNDING = 5 #round estimate up to nearest 5% - FONT_HEIGHT_CONVERSION_FACTOR = 0.35278 #convert font point size to mm - FONT_WIDTH_HEIGHT_RATIO = 0.4 #Assume glyph width averages 2/5 the height + validates :title, presence: { message: PRESENCE_MESSAGE } - # Scope queries - # Note that in ActiveRecord::Enum the mappings are exposed through a class method with the pluralized attribute name (e.g visibilities rather than visibility) - scope :publicly_visible, -> { includes(:template).where(:visibility => visibilities[:publicly_visible]) } + validates :template, presence: { message: PRESENCE_MESSAGE } - # Retrieves any plan in which the user has an active role and it is not a reviewer - scope :active, -> (user) { - includes([:template, :roles]).where({ "roles.active": true, "roles.user_id": user.id }).where(Role.not_reviewer_condition) + validates :feedback_requested, inclusion: { in: BOOLEAN_VALUES } + + validates :complete, inclusion: { in: BOOLEAN_VALUES } + + + # ============= + # = Callbacks = + # ============= + + before_validation :set_creation_defaults + + + # ========== + # = Scopes = + # ========== + + # Retrieves any plan in which the user has an active role and it is not a + # reviewer + scope :active, lambda { |user| + includes(%i[template roles]) + .where("roles.active": true, "roles.user_id": user.id) + .where(Role.not_reviewer_condition) } # Retrieves any plan organisationally or publicly visible for a given org id scope :organisationally_or_publicly_visible, -> (user) { - includes(:template, {roles: :user}) - .where({ - visibility: [visibilities[:organisationally_visible], visibilities[:publicly_visible]], - "roles.access": Role.access_values_for(:creator, :administrator, :editor, :commenter).min, - "users.org_id": user.org_id}) - .where(['NOT EXISTS (SELECT 1 FROM roles WHERE plan_id = plans.id AND user_id = ?)', user.id]) + vis_value = [visibilities[:organisationally_visible], visibilities[:publicly_visible]] + includes(:template, roles: :user) + .where( + visibility: vis_value, + roles: { + access: Role.access_values_for(:creator, :administrator, :editor, :commenter) + .min, + }, + users: { org_id: user.org_id }) + .where( + ["NOT EXISTS (SELECT 1 FROM roles WHERE plan_id = plans.id AND user_id = ?)", + user.id]) } - scope :search, -> (term) { + scope :search, lambda { |term| search_pattern = "%#{term}%" - joins(:template).where("plans.title LIKE ? OR templates.title LIKE ?", search_pattern, search_pattern) + joins(:template) + .where("plans.title LIKE ? OR templates.title LIKE ?", + search_pattern, search_pattern) } # Retrieves plan, template, org, phases, sections and questions - scope :overview, -> (id) { - Plan.includes(:phases, :sections, :questions, template: [ :org ]).find(id) + scope :overview, lambda { |id| + includes(:phases, :sections, :questions, template: [:org]).find(id) } + ## # Settings for the template - has_settings :export, class_name: 'Settings::Template' do |s| + has_settings :export, class_name: "Settings::Template" do |s| s.key :export, defaults: Settings::Template::DEFAULT_SETTINGS end - alias_method :super_settings, :settings + alias super_settings settings - ## - # Proxy through to the template settings (or defaults if this plan doesn't have - # an associated template) if there are no settings stored for this plan. - # `key` is required by rails-settings, so it's required here, too. - # - # @param key [Key] a key required by rails - # @return [Settings] settings for this plan's template - def settings(key) - self_settings = self.super_settings(key) - return self_settings if self_settings.value? - self.template.settings(key) unless self.template.nil? + # ================= + # = Class methods = + # ================= + + # Pre-fetched a plan phase together with its sections and questions + # associated. It also pre-fetches the answers and notes associated to the plan + def self.load_for_phase(plan_id, phase_id) + # Preserves the default order defined in the model relationships + plan = Plan.joins(template: { phases: { sections: :questions } }) + .preload(template: { phases: { sections: :questions } }) + .where(id: plan_id, phases: { id: phase_id }) + .merge(Plan.includes(answers: :notes)).first + phase = plan.template.phases.find { |p| p.id == phase_id.to_i } + + [plan, phase] end - ## - # returns the most recent answer to the given question id - # optionally can create an answer if none exists + # deep copy the given plan and all of it's associations # - # @param qid [Integer] the id for the question to find the answer for - # @param create_if_missing [Boolean] if true, will genereate a default answer to the question - # @return [Answer,nil] the most recent answer to the question, or a new question with default value, or nil + # plan - Plan to be deep copied + # + # Returns Plan + def self.deep_copy(plan) + plan_copy = plan.dup + plan_copy.title = "Copy of " + plan.title + plan_copy.save! + plan.answers.each do |answer| + answer_copy = Answer.deep_copy(answer) + answer_copy.plan_id = plan_copy.id + answer_copy.save! + end + plan.guidance_groups.each do |guidance_group| + plan_copy.guidance_groups << guidance_group if guidance_group.present? + end + plan_copy + end + + # =========================== + # = Public instance methods = + # =========================== + + ## + # Proxy through to the template settings (or defaults if this plan doesn't + # have an associated template) if there are no settings stored for this plan. + # + # TODO: Update this comment below. AFAIK `key` has nothing to do with Rails. + # key - Is required by rails-settings, so it's required here, too. + # + # Returns Hash + def settings(key) + self_settings = super_settings(key) + return self_settings if self_settings.value? + template&.settings(key) + end + + # The most recent answer to the given question id optionally can create an answer if + # none exists. + # + # qid - The id for the question to find the answer for + # create_if_missing - If true, will genereate a default answer + # to the question (defaults: true). + # + # Returns Answer + # Returns nil def answer(qid, create_if_missing = true) - answer = answers.where(:question_id => qid).order("created_at DESC").first + answer = answers.where(question_id: qid).order("created_at DESC").first question = Question.find(qid) - if answer.nil? && create_if_missing then - answer = Answer.new - answer.plan_id = id + if answer.nil? && create_if_missing + answer = Answer.new + answer.plan_id = id answer.question_id = qid - answer.text = question.default_value - default_options = Array.new + answer.text = question.default_value + default_options = [] question.question_options.each do |option| - if option.is_default - default_options << option - end + default_options << option if option.is_default end answer.question_options = default_options end - return answer + answer end -# TODO: This just retrieves all of the guidance associated with the themes within the template -# so why are we transferring it here to the plan? - ## - # returns all of the sections for this version of the plan, and for the project's organisation - # - # @return [Array<Section>,nil] either a list of sections, or nil if none were found - def set_possible_guidance_groups - # find all the themes in this plan - # and get the guidance groups they belong to - ggroups = [] - self.template.phases.each do |phase| - phase.sections.each do |section| - section.questions.each do |question| - question.themes.each do |theme| - theme.guidances.each do |guidance| - ggroups << guidance.guidance_group if guidance.guidance_group.published - # only show published guidance groups - end - end - end - end - end + alias get_guidance_group_options guidance_group_options - self.guidance_groups = ggroups.uniq - end - - ## - # returns all of the possible guidance groups for the plan (all options to - # be selected by the user to display) - # - # @return Array<Guidance> - def get_guidance_group_options - # find all the themes in this plan - # and get the guidance groups they belong to - ggroups = [] - Template.includes(phases: [sections: [questions: [themes: [guidances: [guidance_group: :org]]]]]).find(self.template_id).phases.each do |phase| - phase.sections.each do |section| - section.questions.each do |question| - question.themes.each do |theme| - theme.guidances.each do |guidance| - ggroups << guidance.guidance_group if guidance.guidance_group.published - # only show published guidance groups - end - end - end - end - end - return ggroups.uniq - end + deprecate :get_guidance_group_options, + deprecator: Cleanup::Deprecators::GetDeprecator.new ## # Sets up the plan for feedback: @@ -186,35 +278,35 @@ begin val = Role.access_values_for(:reviewer, :commenter).min self.feedback_requested = true - # Share the plan with each org admin as the reviewer role admins = user.org.org_admins admins.each do |admin| - self.roles << Role.new(user: admin, access: val) + roles << Role.new(user: admin, access: val) end - - if self.save! + if save! # Send an email to the org-admin contact if user.org.contact_email.present? - contact = User.new(email: user.org.contact_email, firstname: user.org.contact_name) + contact = User.new(email: user.org.contact_email, + firstname: user.org.contact_name) UserMailer.feedback_notification(contact, self, user).deliver_now end - true + return true else - false + puts "save was false" + + return false end rescue Exception => e Rails.logger.error e - false + return false end end end ## - # Finalizes the feedback for the plan: - # emails confirmation messages to owners - # sets flag on plans.feedback_requested to false - # removes org admins from the 'reviewer' Role for the Plan + # Finalizes the feedback for the plan: Emails confirmation messages to owners + # sets flag on plans.feedback_requested to false removes org admins from the + # 'reviewer' Role for the Plan. def complete_feedback(org_admin) Plan.transaction do begin @@ -222,693 +314,262 @@ # Remove the org admins reviewer role from the plan vals = Role.access_values_for(:reviewer) - self.roles.delete(Role.where(plan: self, access: vals)) + roles.delete(Role.where(plan: self, access: vals)) - if self.save! + if save! # Send an email confirmation to the owners and co-owners - owners = User.joins(:roles).where('roles.plan_id =? AND roles.access IN (?)', self.id, Role.access_values_for(:administrator)) - deliver_if(recipients: owners, key: 'users.feedback_provided') do |r| + owners = User.joins(:roles) + .where("roles.plan_id =? AND roles.access IN (?)", + id, Role.access_values_for(:administrator)) + + deliver_if(recipients: owners, key: "users.feedback_provided") do |r| UserMailer.feedback_complete(r, self, org_admin).deliver_now end true else false end - rescue Exception => e + rescue ArgumentError => e Rails.logger.error e false end end end - # Returns all of the plan's available guidance by question as a hash for use on the write plan page - # { - # QUESTION: { - # GUIDANCE_GROUP: { - # THEME: [GUIDANCE, GUIDANCE], - # THEME: [GUIDANCE] - # } - # } - # } - def guidance_by_question_as_hash - # Get all of the selected guidance groups for the plan - guidance_groups_ids = self.guidance_groups.collect(&:id) - guidance_groups = GuidanceGroup.joins(:org).where("guidance_groups.published = ? AND guidance_groups.id IN (?)", - true, guidance_groups_ids) - - # Gather all of the Themes used in the plan as a hash - # { - # QUESTION: [THEME, THEME], - # QUESTION: [THEME] - # } - question_themes = {} - themes_used = [] - self.questions.joins(:themes).pluck('questions.id', 'themes.title').each do |qt| - themes_used << qt[1] unless themes_used.include?(qt[1]) - question_themes[qt[0]] = [] unless question_themes[qt[0]].present? - question_themes[qt[0]] << qt[1] unless question_themes[qt[0]].include?(qt[1]) - end - - # Gather all of the Guidance available for the themes used in the plan as a hash - # { - # THEME: { - # GUIDANCE_GROUP: [GUIDANCE, GUIDANCE], - # GUIDANCE_GROUP: [GUIDANCE] - # } - # } - theme_guidance = {} - GuidanceGroup.includes(guidances: :themes).joins(:guidances). - where('guidance_groups.published = ? AND guidances.published = ? AND themes.title IN (?) AND guidance_groups.id IN (?)', true, true, themes_used, guidance_groups.collect(&:id)). - pluck('guidance_groups.name', 'themes.title', 'guidances.text').each do |tg| - - theme_guidance[tg[1]] = {} unless theme_guidance[tg[1]].present? - theme_guidance[tg[1]][tg[0]] = [] unless theme_guidance[tg[1]][tg[0]].present? - theme_guidance[tg[1]][tg[0]] << tg[2] unless theme_guidance[tg[1]][tg[0]].include?(tg[2]) - end - - # Generate a hash for the view that contains all of a question guidance - # { - # QUESTION: { - # GUIDANCE_GROUP: { - # THEME: [GUIDANCE, GUIDANCE], - # THEME: [GUIDANCE] - # } - # } - # } - question_guidance = {} - question_themes.keys.each do |question| - ggs = {} - # Gather all of the guidance groups applicable to the themes assigned to the question - groups = [] - question_themes[question].each do |theme| - groups << theme_guidance[theme].keys if theme_guidance[theme].present? - end - - # Loop through all of the applicable guidance groups and collect their themed guidance - groups.flatten.uniq.each do |guidance_group| - guidances_by_theme = {} - - # Collect all of the guidances for each theme used by the question - question_themes[question].each do |theme| - if theme_guidance[theme].present? && theme_guidance[theme][guidance_group].present? - guidances_by_theme[theme] = [] unless guidances_by_theme[theme].present? - guidances_by_theme[theme] = theme_guidance[theme][guidance_group] - end - end - - ggs[guidance_group] = guidances_by_theme unless ggs[guidance_group] - end - - question_guidance[question] = ggs - end - - question_guidance - end - ## # determines if the plan is editable by the specified user # - # @param user_id [Integer] the id for a user - # @return [Boolean] true if user can edit the plan + # user_id - The id for a user + # + # Returns Boolean def editable_by?(user_id) user_id = user_id.id if user_id.is_a?(User) - has_role(user_id, :editor) + role?(user_id, :editor) end ## # determines if the plan is readable by the specified user # - # @param user_id [Integer] the id for a user - # @return [Boolean] true if the user can read the plan + # user_id - The Integer id for a user + # + # Returns Boolean def readable_by?(user_id) - user = user_id.is_a?(User) ? user_id : User.find(user_id) - owner_orgs = self.owner_and_coowners.collect(&:org) + user = user_id.is_a?(User) ? user_id : User.find(user_id) + owner_orgs = owner_and_coowners.collect(&:org) + org_sys_permission = Branding.fetch(:service_configuration, :plans, + :org_admins_read_all) + sup_sys_permission = Branding.fetch(:service_configuration, :plans, + :super_admins_read_all) - # Super Admins can view plans read-only, Org Admins can view their Org's plans - # otherwise the user must have the commenter role - (user.can_super_admin? || - user.can_org_admin? && owner_orgs.include?(user.org) || - has_role(user.id, :commenter)) + # Super Admins can view plans read-only + return true if user.can_super_admin? && sup_sys_permission + + # Org Admins can view their Org's plans if system permission allows + return true if user.can_org_admin? && owner_orgs.include?(user.org) && + org_sys_permission + + # ...otherwise the user must have the commenter role. + return true if role?(user.id, :commenter) + + # Else + false end - ## - # determines if the plan is readable by the specified user + # determines if the plan is readable by the specified user. # - # @param user_id [Integer] the id for a user - # @return [Boolean] true if the user can read the plan + # user_id - The Integer id for a user + # + # Returns Boolean def commentable_by?(user_id) user_id = user_id.id if user_id.is_a?(User) - has_role(user_id, :commenter) + role?(user_id, :commenter) end - ## # determines if the plan is administerable by the specified user # - # @param user_id [Integer] the id for the user - # @return [Boolean] true if the user can administer the plan + # user_id - The Integer id for the user + # + # Returns Boolean def administerable_by?(user_id) user_id = user_id.id if user_id.is_a?(User) - has_role(user_id, :administrator) + role?(user_id, :administrator) end - ## - # determines if the plan is owned by the specified user - # - # @param user_id [Integer] the id for the user - # @return [Boolean] true if the user can administer the plan - def owned_by?(user_id) - user_id = user_id.id if user_id.is_a?(User) - has_role(user_id, :creator) - end - - ## # determines if the plan is reviewable by the specified user # - # @param user_id [Integer] the id for the user - # @return [Boolean] true if the user can administer the plan + # user_id - The Integer id for the user + # + # Returns Boolean def reviewable_by?(user_id) user_id = user_id.id if user_id.is_a?(User) - has_role(user_id, :reviewer) + role?(user_id, :reviewer) end - ## - # determines whether or not the specified user has any rol on the plan + # Assigns the passed user_id to the creater_role for the project gives the + # user rights to read, edit, administrate, and defines them as creator # - # @param user_id [Integer] the id for the user - # @return [Boolean] true if the user has any rol - def any_role?(user) - user_id = user.id if user.is_a?(User) - !self.roles.index{ |rol| rol.user_id == user_id }.nil? - end - - ## - # defines and returns the status of the plan - # status consists of a hash of the num_questions, num_answers, sections, questions, and spaced used. - # For each section, it contains the id's of each of the questions - # for each question, it contains the answer_id, answer_created_by, answer_text, answer_options_id, aand answered_by + # user_id - The Integer user to be given priveleges' id # - # @return [Status] - - def status - status = { - "num_questions" => 0, - "num_answers" => 0, - "sections" => {}, - "questions" => {}, - "space_used" => 0 # percentage of available space in pdf used - } - - space_used = height_of_text(self.title, 2, 2) - - section_ids = sections.map {|s| s.id} - - # we retrieve this is 2 joins: - # 1. sections and questions - # 2. questions and answers - # why? because Rails 4 doesn't have any sensible left outer join. - # when we change to RAILS 5 it is meant to have so this can be fixed then - - records = Section.joins(questions: :question_format) - .select('sections.id as sectionid, - sections.title as stitle, - questions.id as questionid, - questions.text as questiontext, - question_formats.title as qformat') - .where("sections.id in (?) ", section_ids) - .to_a - - # extract question ids to get answers - question_ids = records.map {|r| r.questionid}.uniq - status["num_questions"] = question_ids.count - - arecords = Question.joins(answers: :user) - .select('questions.id as questionid, - answers.id as answerid, - answers.plan_id as plan_id, - answers.text as answertext, - answers.updated_at as updated, - users.email as username') - .where("questions.id in (?) and answers.plan_id = ?",question_ids, self.id) - .to_a - - # we want answerids to extract options later - answer_ids = arecords.map {|r| r.answerid}.uniq - status["num_answers"] = answer_ids.count - - # create map from questionid to answer structure - qa_map = {} - arecords.each do |rec| - qa_map[rec.questionid] = { - plan: rec.plan_id, - id: rec.answerid, - text: rec.answertext, - updated: rec.updated, - user: rec.username - } - end - - - # build main status structure - records.each do |rec| - sid = rec.sectionid - stitle = rec.stitle - qid = rec.questionid - qtext = rec.questiontext - format = rec.qformat - - answer = nil - if qa_map.has_key?(qid) - answer = qa_map[qid] - end - - aid = answer.nil? ? nil : answer[:id] - atext = answer.nil? ? nil : answer[:text] - updated = answer.nil? ? nil : answer[:updated] - uname = answer.nil? ? nil : answer[:user] - - space_used += height_of_text(stitle, 1, 1) - - shash = status["sections"] - if !shash.has_key?(sid) - shash[sid] = {} - shash[sid]["num_questions"] = 0 - shash[sid]["num_answers"] = 0 - shash[sid]["questions"] = Array.new - end - - shash[sid]["questions"] << qid - shash[sid]["num_questions"] += 1 - - space_used += height_of_text(qtext) unless qtext == stitle - if atext.present? - space_used += height_of_text(atext) - else - space_used += height_of_text(_('Question not answered.')) - end - - if answer.present? then - shash[sid]["num_answers"] += 1 - end - - status["questions"][qid] = { - "format" => format, - "answer_id" => aid, - "answer_updated_at" => updated.to_i, - "answer_text" => atext, - "answered_by" => uname - } - - end - - records = Answer.joins(:question_options).select('answers.id as answerid, question_options.id as optid').where(id: answer_ids).to_a - opt_hash = {} - records.each do |rec| - aid = rec.answerid - optid = rec.optid - if !opt_hash.has_key?(aid) - opt_hash[aid] = Array.new - end - opt_hash[aid] << optid - end - - status["questions"].each_key do |questionid| - answerid = status["questions"][questionid]["answer_id"] - status["questions"][questionid]["answer_option_ids"] = opt_hash[answerid] - end - - status['space_used'] = estimate_space_used(space_used) - - return status - end - - - ## - # assigns the passed user_id to the creater_role for the project - # gives the user rights to read, edit, administrate, and defines them as creator - # - # @param user_id [Integer] the user to be given priveleges' id def assign_creator(user_id) user_id = user_id.id if user_id.is_a?(User) add_user(user_id, true, true, true) end - ## - # returns the funder id for the plan - # - # @return [Integer, nil] the id for the funder - def funder_id - if self.template.nil? then - return nil - end - return self.template.org - end - - ## - # returns the funder organisation for the project or nil if none is specified - # - # @return [Organisation, nil] the funder for project, or nil if none exists - def funder - template = self.template - if template.nil? then - return nil - end - - if template.customization_of - return template.customization_of.org - else - return template.org - end - end - - ## - # assigns the passed user_id as an editor for the project - # gives the user rights to read and edit - # - # @param user_id [Integer] the user to be given priveleges' id - def assign_editor(user_id) - add_user(user_id, true) - end - - ## - # assigns the passed user_id as a reader for the project - # gives the user rights to read - # - # @param user_id [Integer] the user to be given priveleges' id - def assign_reader(user_id) - add_user(user_id) - end - - ## - # assigns the passed user_id as an administrator for the project - # gives the user rights to read, adit, and administrate the project - # - # @param user_id [Integer] the user to be given priveleges' id - def assign_administrator(user_id) - add_user(user_id, true, true) - end - - ## # the datetime for the latest update of this plan # - # @return [DateTime] the time of latest update + # Returns DateTime def latest_update - latest_update = updated_at - phases.each do |phase| - if phase.updated_at > latest_update then - latest_update = phase.updated_at - end - end - return latest_update + (phases.pluck(:updated_at) + [updated_at]).max end - # Getters to match 'My plans' columns - - ## - # the title of the project - # - # @return [String] the title of the project - def name - self.title - end - - ## # the owner of the project # - # @return [User] the creater of the project + # Returns User + # Returns nil def owner vals = Role.access_values_for(:creator) - User.joins(:roles).where('roles.plan_id = ? AND roles.access IN (?)', self.id, vals).first + User.joins(:roles) + .where("roles.plan_id = ? AND roles.access IN (?)", id, vals).first end ## - # returns the shared roles of a plan, excluding the creator - def shared - role_values = Role.where(plan: self).where(Role.not_creator_condition).any? + # TODO: Rewrite this description + # + # Returns Boolean + def shared? + roles.where(Role.not_creator_condition).any? end - ## + alias shared shared? + + deprecate :shared, deprecator: Cleanup::Deprecators::PredicateDeprecator.new + # the owner and co-owners of the project # - # @return [Users] + # Returns ActiveRecord::Relation def owner_and_coowners - vals = Role.access_values_for(:creator).concat(Role.access_values_for(:administrator)) - User.joins(:roles).where("roles.plan_id = ? AND roles.access IN (?)", self.id, vals) + vals = Role.access_values_for(:creator) + .concat(Role.access_values_for(:administrator)) + User.joins(:roles) + .where(roles: { plan_id: id, access: vals }) end - ## - # the time the project was last updated, formatted as a date + # The number of answered questions from the entire plan # - # @return [Date] last update as a date - def last_edited - self.latest_update.to_date - end - - # Returns the number of answered questions from the entire plan + # Returns Integer def num_answered_questions - return Answer.where(id: answers.map(&:id)).includes({ question: :question_format }, :question_options).reduce(0) do |m, a| - if a.is_valid? - m+=1 - end - m - end + Answer.where(id: answers.map(&:id)) + .includes(:question_options, question: :question_format) + .to_a + .sum { |answer| answer.is_valid? ? 1 : 0 } end - # Returns a section given its id or nil if does not exist for the current plan - def get_section(section_id) - self.sections.find { |s| s.id == section_id } - end - - # Returns the number of questions for a plan. + # The number of questions for a plan. + # + # Returns Integer def num_questions - return sections.includes(:questions).joins(:questions).reduce(0){ |m, s| m + s.questions.length } - end - # the following two methods are for eager loading. One gets used for the plan/show - # page and the oter for the plan/edit. The difference is just that one pulls in more than - # the other. - # TODO: revisit this and work out for sure that maintaining the difference is worthwhile. - # it may not be. Also make sure nether is doing more thanit needs to. - # - def self.eager_load(id) - Plan.includes( - [{template: [ - {phases: {sections: {questions: :answers}}} - ]}, - {plans_guidance_groups: {guidance_group: :guidances}} - ]).find(id) - end - - # Pre-fetched a plan phase together with its sections and questions associated. It also pre-fetches the answers and notes associated to the plan - def self.load_for_phase(id, phase_id) - plan = Plan - .joins(template: { phases: { sections: :questions }}) - .preload(template: { phases: { sections: :questions }}) # Preserves the default order defined in the model relationships - .where("plans.id = :id AND phases.id = :phase_id", { id: id, phase_id: phase_id }) - .merge(Plan.includes(answers: :notes))[0] - phase = plan.template.phases.find {|p| p.id==phase_id.to_i } - - return plan, phase - end - - # deep copy the given plan and all of it's associations - # - # @params [Plan] plan to be deep copied - # @return [Plan] saved copied plan - def self.deep_copy(plan) - plan_copy = plan.dup - plan_copy.title = "Copy of " + plan.title - plan_copy.save! - plan.answers.each do |answer| - answer_copy = Answer.deep_copy(answer) - answer_copy.plan_id = plan_copy.id - answer_copy.save! - end - plan.guidance_groups.each do |guidance_group| - if guidance_group.present? - plan_copy.guidance_groups << GuidanceGroup.where(id: guidance_group.id).first - end - end - return plan_copy - end - - # Returns visibility message given a Symbol type visibility passed, otherwise nil - def self.visibility_message(type) - message = { - :organisationally_visible => _('organisational'), - :publicly_visible => _('public'), - :is_test => _('test'), - :privately_visible => _('private') - } - message[type] + questions.count end # Determines whether or not visibility changes are permitted according to the - # percentage of the plan answered in respect to a threshold defined at application.config + # percentage of the plan answered in respect to a threshold defined at + # application.config + # + # Returns Boolean def visibility_allowed? - value=(self.num_answered_questions().to_f/self.num_questions()*100).round(2) - !self.is_test? && value >= Rails.application.config.default_plan_percentage_answered + value = (num_answered_questions.to_f / num_questions * 100).round(2) + !is_test? && value >= Rails.application + .config + .default_plan_percentage_answered end # Determines whether or not a question (given its id) exists for the self plan + # + # Returns Boolean def question_exists?(question_id) - Plan.joins(:questions).exists?(id: self.id, "questions.id": question_id) + Plan.joins(:questions).exists?(id: id, "questions.id": question_id) end - # Checks whether or not the number of questions matches the number of valid answers + # Checks whether or not the number of questions matches the number of valid + # answers + # + # Returns Boolean def no_questions_matches_no_answers? num_questions = question_ids.length - pre_fetched_answers = Answer - .includes({ question: :question_format }, :question_options) - .where(id: answer_ids) + pre_fetched_answers = Answer.includes(:question_options, + question: :question_format) + .where(id: answer_ids) num_answers = pre_fetched_answers.reduce(0) do |m, a| - if a.is_valid? - m+=1 - end + m += 1 if a.is_valid? m end - return num_questions == num_answers + num_questions == num_answers end private # Returns whether or not the user has the specified role for the plan - def has_role(user_id, role_as_sym) - if user_id.is_a?(Integer) && role_as_sym.is_a?(Symbol) - vals = Role.access_values_for(role_as_sym) - self.roles.where(user_id: user_id, access: vals, active: true).first.present? - else - false - end + def role?(user_id, role_as_sym) + vals = Role.access_values_for(role_as_sym.to_sym) + roles.where(user_id: user_id, access: vals, active: true).any? end - ## - # adds a user to the project - # if no flags are specified, the user is given read privleges - # - # @param user_id [Integer] the user to be given privleges - # @param is_editor [Boolean] whether or not the user can edit the project - # @param is_administrator [Boolean] whether or not the user can administrate the project - # @param is_creator [Boolean] wheter or not the user created the project - # @return [Array<ProjectGroup>] - # - # TODO: change this to specifying uniqueness of user/plan association and handle - # that way - # - def add_user(user_id, is_editor = false, is_administrator = false, is_creator = false) - Role.where(plan_id: self.id, user_id: user_id).each do |r| - r.destroy - end + alias has_role role? - role = Role.new + deprecate :has_role, deprecator: Cleanup::Deprecators::PredicateDeprecator.new + + # Adds a user to the project if no flags are specified, the user is given read privleges + # TODO: change this to specifying uniqueness of user/plan association and + # handle that way. + # + # + # user_id - The Integer user ID to be given privleges + # is_editor - Whether or not the user can edit the project (defaults: false) + # is_administrator - Whether or not the user can administrate the project + # (defaults: false) + # is_creator - Wheter or not the user created the project (defaults: false) + # + # Returns Boolean + # + def add_user(user_id, is_editor = false, + is_administrator = false, + is_creator = false) + + Role.where(plan_id: id, user_id: user_id).find_each(&:destroy) + + role = Role.new role.user_id = user_id role.plan_id = id # if you get assigned a role you can comment - role.commenter= true + role.commenter = true # the rest of the roles are inclusing so creator => administrator => editor if is_creator - role.creator = true + role.creator = true role.administrator = true - role.editor = true + role.editor = true end if is_administrator role.administrator = true - role.editor = true + role.editor = true end - if is_editor - role.editor = true - end + role.editor = true if is_editor role.save - - # This is necessary because we're creating the associated record but not assigning it - # to roles. Auto-saving like this may be confusing when coding upstream in a controller, - # view or api. Should probably change this to: - # self.roles << role - # and then let the save be called manually via: - # plan.save! - #self.reload - end - - ## - # creates a plan for each phase in the template associated with this project - # unless the phase is unpublished, it creates a new plan, and a new version of the plan and adds them to the project's plans - # - # @return [Array<Plan>] - def create_plans - self.template.phases.each do |phase| - latest_published_version = phase.latest_published_version - unless latest_published_version.nil? - new_plan = Plan.new - new_plan.version = latest_published_version - plans << new_plan - end - end - end - - - - ## - # Based on the height of the text gathered so far and the available vertical - # space of the pdf, estimate a percentage of how much space has been used. - # This is highly dependent on the layout in the pdf. A more accurate approach - # would be to render the pdf and check how much space had been used, but that - # could be very slow. - # NOTE: This is only an estimate, rounded up to the nearest 5%; it is intended - # for guidance when editing plan data, not to be 100% accurate. - # - # @param used_height [Integer] an estimate of the height used so far - # @return [Integer] the estimate of space used of an A4 portrain - def estimate_space_used(used_height) - @formatting ||= self.settings(:export).formatting - - return 0 unless @formatting[:font_size] > 0 - - margin_height = @formatting[:margin][:top].to_i + @formatting[:margin][:bottom].to_i - page_height = A4_PAGE_HEIGHT - margin_height # 297mm for A4 portrait - available_height = page_height * self.template.settings(:export).max_pages - - percentage = (used_height / available_height) * 100 - (percentage / ROUNDING).ceil * ROUNDING # round up to nearest five - end - - ## - # Take a guess at the vertical height (in mm) of the given text based on the - # font-size and left/right margins stored in the plan's settings. - # This assumes a fixed-width for each glyph, which is obviously - # incorrect for the font-face choices available; the idea is that - # they'll hopefully average out to that in the long-run. - # Allows for hinting different font sizes (offset from base via font_size_inc) - # and vertical margins (i.e. for heading text) - # - # @param text [String] the text to estimate size of - # @param font_size_inc [Integer] the size of the font of the text, defaults to 0 - # @param vertical_margin [Integer] the top margin above the text, defaults to 0 - def height_of_text(text, font_size_inc = 0, vertical_margin = 0) - @formatting ||= self.settings(:export).formatting - @margin_width ||= @formatting[:margin][:left].to_i + @formatting[:margin][:right].to_i - @base_font_size ||= @formatting[:font_size] - - return 0 unless @base_font_size > 0 - - font_height = FONT_HEIGHT_CONVERSION_FACTOR * (@base_font_size + font_size_inc) - font_width = font_height * FONT_WIDTH_HEIGHT_RATIO # Assume glyph width averages at 2/5s the height - leading = font_height / 2 - - chars_in_line = (A4_PAGE_WIDTH - @margin_width) / font_width # 210mm for A4 portrait - num_lines = (text.length / chars_in_line).ceil - - (num_lines * font_height) + vertical_margin + leading end # Initialize the title for new templates - # -------------------------------------------------------- + # + # Returns nil + # Returns String def set_creation_defaults - # Only run this before_validation because rails fires this before save/create - if self.id.nil? - self.title = "My plan (#{self.template.title})" if self.title.nil? && !self.template.nil? - end + # 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 + end diff --git a/app/models/pref.rb b/app/models/pref.rb index 02e1a59..c3c4707 100644 --- a/app/models/pref.rb +++ b/app/models/pref.rb @@ -1,19 +1,38 @@ +# == Schema Information +# +# Table name: prefs +# +# id :integer not null, primary key +# settings :text +# user_id :integer +# + class Pref < ActiveRecord::Base + include ValidationMessages + ## # Serialize prefs to JSON # The settings object only stores deviations from the default serialize :settings, JSON - ## - # Associations + # ================ + # = Associations = + # ================ belongs_to :user - ## - # Returns the hash generated from default preferences + # =============== + # = Validations = + # =============== + + validates :user, presence: { message: PRESENCE_MESSAGE } + + validates :settings, presence: { message: PRESENCE_MESSAGE } + + # The default preferences # - # @return [JSON] preferences hash + # Returns Hash def self.default_settings - return Rails.configuration.branding[:preferences] + Branding.fetch(:preferences) end -end \ No newline at end of file +end diff --git a/app/models/question.rb b/app/models/question.rb index 8a5a833..6a1d94a 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -1,51 +1,109 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: questions +# +# id :integer not null, primary key +# default_value :text +# modifiable :boolean +# number :integer +# option_comment_display :boolean default(TRUE) +# text :text +# created_at :datetime +# updated_at :datetime +# question_format_id :integer +# section_id :integer +# versionable_id :string(36) +# +# Indexes +# +# index_questions_on_section_id (section_id) +# index_questions_on_versionable_id (versionable_id) +# +# Foreign Keys +# +# fk_rails_... (question_format_id => question_formats.id) +# fk_rails_... (section_id => sections.id) +# + class Question < ActiveRecord::Base + include ValidationMessages + include ActsAsSortable + include VersionableModel + + # ============== + # = Attributes = + # ============== + + alias_attribute :to_s, :text + + # include ## # Sort order: Number ASC default_scope { order(number: :asc) } - ## - # Associations - has_many :answers, :dependent => :destroy - has_many :question_options, :dependent => :destroy, :inverse_of => :question # inverse_of needed for nester forms - has_many :annotations, :dependent => :destroy + # ================ + # = Associations = + # ================ + + has_many :answers, dependent: :destroy + + # inverse_of needed for nested forms + has_many :question_options, dependent: :destroy, inverse_of: :question + + has_many :annotations, dependent: :destroy, inverse_of: :question + has_and_belongs_to_many :themes, join_table: "questions_themes" + belongs_to :section + belongs_to :question_format + has_one :phase, through: :section + has_one :template, through: :section - ## - # Nested Attributes + + # =============== + # = Validations = + # =============== + + validates :text, presence: { message: PRESENCE_MESSAGE } + + validates :section, presence: { message: PRESENCE_MESSAGE, on: :update } + + validates :question_format, presence: { message: PRESENCE_MESSAGE } + + validates :number, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { scope: :section_id, + message: UNIQUENESS_MESSAGE } + + + # ===================== + # = Nested Attributes = + # ===================== + # TODO: evaluate if we need this - accepts_nested_attributes_for :answers, :reject_if => lambda {|a| a[:text].blank? }, :allow_destroy => true - accepts_nested_attributes_for :question_options, :reject_if => lambda {|a| a[:text].blank? }, :allow_destroy => true - accepts_nested_attributes_for :annotations, :allow_destroy => true + accepts_nested_attributes_for :answers, reject_if: -> (a) { a[:text].blank? }, + allow_destroy: true - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :default_value, :dependency_id, :dependency_text, :guidance,:number, - :annotation, :text, :section_id, :question_format_id, - :question_options_attributes, :annotations_attributes, - :option_comment_display, :theme_ids, :section, :question_format, - :question_options, :annotations, :answers, :themes, - :modifiable, :option_comment_display, :as => [:default, :admin] + accepts_nested_attributes_for :question_options, allow_destroy: true, + reject_if: -> (a) { a[:text].blank? } - validates :text, :section, :number, presence: {message: _("can't be blank")} - - ## - # returns the text from the question - # - # @return [String] question's text - def to_s - "#{text}" - end + accepts_nested_attributes_for :annotations, allow_destroy: true, + reject_if: proc { |a| a[:text].blank? && a[:id].blank? } - def option_based? - format = self.question_format - return format.option_based - end + # ===================== + # = Delegated methods = + # ===================== + + delegate :option_based?, to: :question_format + + # =========================== + # = Public instance methods = + # =========================== def deep_copy(**options) copy = self.dup @@ -53,65 +111,86 @@ 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{ |question_option| copy.question_options << question_option.deep_copy(options) } - self.annotations.each{ |annotation| copy.annotations << annotation.deep_copy(options) } - self.themes.each{ |theme| copy.themes << theme } - return copy + self.question_options.each do |question_option| + copy.question_options << question_option.deep_copy(options) + end + self.annotations.each do |annotation| + copy.annotations << annotation.deep_copy(options) + end + self.themes.each { |theme| copy.themes << theme } + copy end - -# TODO: consider moving this to a view helper instead and use the built in scopes for guidance. May need to add -# a new one for 'thematic_guidance'. This method doesn't even make reference to this class and its returning -# a hash that is specific to a view - ## - # guidance for org + + # TODO: consider moving this to a view helper instead and use the built in + # scopes for guidance. May need to add a new one for 'thematic_guidance'. + # This method doesn't even make reference to this class and its returning + # a hash that is specific to a view guidance for org # - # @param org [Org] the org to find guidance for - # @return [Hash{String => String}] + # org - The Org to find guidance for + # + # Returns Hash def guidance_for_org(org) # pulls together guidance from various sources for question guidances = {} - theme_ids = themes.collect{|t| t.id} - if theme_ids.present? - GuidanceGroup.includes(guidances: :themes).where(org_id: org.id).each do |group| + if theme_ids.any? + GuidanceGroup.includes(guidances: :themes) + .where(org_id: org.id).each do |group| group.guidances.each do |g| g.themes.each do |theme| if theme_ids.include? theme.id - guidances["#{group.name} " + _('guidance on') + " #{theme.title}"] = g + guidances["#{group.name} " + _("guidance on") + " #{theme.title}"] = g end end end end end - return guidances + guidances end - ## # get example answer belonging to the currents user for this question # - # @param org_ids [Array<Integer>] the ids for the organisations - # @return [Array<Annotation>] the example answers for this question for the specified orgs - def get_example_answers(org_ids) - org_ids = [org_ids] unless org_ids.is_a?(Array) - self.annotations.where(org_id: org_ids, type: Annotation.types[:example_answer]).order(:created_at) - end + # org_ids - The ids for the organisations + # + # Returns ActiveRecord::Relation + def example_answers(org_ids) + annotations.where(org_id: Array(org_ids), + type: Annotation.types[:example_answer]) + .order(:created_at) + end - ## + alias get_example_answers example_answers + + deprecate :get_example_answers, + deprecator: Cleanup::Deprecators::GetDeprecator.new + # get guidance belonging to the current user's org for this question(need org # to distinguish customizations) # - # @param org_id [Integer] the id for the organisation - # @return [String] the annotation guidance for this question for the specified org - def get_guidance_annotation(org_id) - guidance = self.annotations.where(org_id: org_id).where(type: Annotation.types[:guidance]) - return guidance.first + # org_id - The id for the organisation + # + # Returns Annotation + def guidance_annotation(org_id) + annotations.where(org_id: org_id, type: Annotation.types[:guidance]).first end + alias get_guidance_annotation guidance_annotation + + deprecate :get_guidance_annotation, + deprecator: Cleanup::Deprecators::GetDeprecator.new + def annotations_per_org(org_id) - example_answer = annotations.find_by(org_id: org_id, type: Annotation.types[:example_answer]) - guidance = annotations.find_by(org_id: org_id, type: Annotation.types[:guidance]) - example_answer = annotations.build({ type: :example_answer, text: '', org_id: org_id }) unless example_answer.present? - guidance = annotations.build({ type: :guidance, text: '', org_id: org_id }) unless guidance.present? - return [example_answer, guidance] + example_answer = annotations.find_by(org_id: org_id, + type: Annotation.types[:example_answer]) + guidance = annotations.find_by(org_id: org_id, + type: Annotation.types[:guidance]) + unless example_answer.present? + example_answer = annotations.build(type: :example_answer, text: "", org_id: org_id) + end + unless guidance.present? + guidance = annotations.build(type: :guidance, text: "", org_id: org_id) + end + [example_answer, guidance] end + end diff --git a/app/models/question_format.rb b/app/models/question_format.rb index b6d1e36..45056dc 100644 --- a/app/models/question_format.rb +++ b/app/models/question_format.rb @@ -1,37 +1,61 @@ +# == Schema Information +# +# Table name: question_formats +# +# id :integer not null, primary key +# description :text +# formattype :integer default(0) +# option_based :boolean default(FALSE) +# title :string +# created_at :datetime not null +# updated_at :datetime not null +# + class QuestionFormat < ActiveRecord::Base + include ValidationMessages + include ValidationValues ## - # Associations + # + FORMAT_TYPES = %i[textarea textfield radiobuttons checkbox dropdown + multiselectbox date rda_metadata] + + + # ============== + # = Attributes = + # ============== + + enum formattype: FORMAT_TYPES + + alias_attribute :to_s, :title + + alias_attribute :option_based?, :option_based + + # ================ + # = Associations = + # ================ + has_many :questions - enum formattype: [ :textarea, :textfield, :radiobuttons, :checkbox, :dropdown, :multiselectbox, :date, :rda_metadata ] - attr_accessible :formattype - validates :title, presence: {message: _("can't be blank")}, uniqueness: {message: _("must be unique")} + # =============== + # = Validations = + # =============== - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :title, :description, :option_based, :questions, :as => [:default, :admin] + validates :title, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { message: UNIQUENESS_MESSAGE } + + validates :description, presence: { message: PRESENCE_MESSAGE } + + validates :option_based, inclusion: { in: BOOLEAN_VALUES } + + + # ================= + # = Class methods = + # ================= # Retrieves the id for a given formattype passed - scope :id_for, -> (formattype) { where(formattype: formattype).pluck(:id).first } - - ## - # Define Bit Field Values so we can test a format without doing string comps - # Column type - - # EVALUATE CLASS AND INSTANCE METHODS BELOW - # - # What do they do? do they do it efficiently, and do we need them? - - - ## - # gives the title of the question_format - # - # @return [String] title of the question_format - def to_s - "#{title}" + def self.id_for(formattype) + where(formattype: formattype).pluck(:id).first end - end diff --git a/app/models/question_option.rb b/app/models/question_option.rb index 3eeb7e5..31e1187 100644 --- a/app/models/question_option.rb +++ b/app/models/question_option.rb @@ -1,28 +1,64 @@ +# == Schema Information +# +# Table name: question_options +# +# id :integer not null, primary key +# is_default :boolean +# number :integer +# text :string +# created_at :datetime +# updated_at :datetime +# question_id :integer +# +# Indexes +# +# index_question_options_on_question_id (question_id) +# +# Foreign Keys +# +# fk_rails_... (question_id => questions.id) +# + class QuestionOption < ActiveRecord::Base - ## - # Associations + include ValidationMessages + include ValidationValues + + # ================ + # = Associations = + # ================ + belongs_to :question + has_and_belongs_to_many :answers, join_table: :answers_question_options - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :text, :question_id, :is_default, :number, :question, - :as => [:default, :admin] - validates :text, :question, :number, presence: {message: _("can't be blank")} + # =============== + # = Validations = + # =============== + + validates :text, presence: { message: PRESENCE_MESSAGE } + + validates :question, presence: { message: PRESENCE_MESSAGE } + + validates :number, presence: { message: PRESENCE_MESSAGE } + + validates :is_default, inclusion: { in: BOOLEAN_VALUES, + message: INCLUSION_MESSAGE } + + # ========== + # = Scopes = + # ========== scope :by_number, -> { order(:number) } - ## - # deep copy the given question_option and all it's associations - # - # @params [QuestionOption] question_option to be deep copied - # @return [QuestionOption] the saved, copied question_option - def self.deep_copy(question_option) - question_option_copy = question_option.dup - question_option_copy.save! - return question_option_copy - end + + + # =========================== + # = Public instance methods = + # =========================== + + # =========================== + # = Public instance methods = + # =========================== def deep_copy(**options) copy = self.dup diff --git a/app/models/region.rb b/app/models/region.rb index a7e366a..6dd483f 100644 --- a/app/models/region.rb +++ b/app/models/region.rb @@ -1,8 +1,35 @@ -class Region < ActiveRecord::Base - has_many :sub_regions, class_name: 'Region', foreign_key: 'super_region_id' - - belongs_to :super_region, class_name: 'Region' - - validates :name, presence: {message: _("can't be blank")}, uniqueness: {message: _("must be unique")} - validates :abbreviation, uniqueness: {message: _("must be unique")}, allow_nil: true -end \ No newline at end of file +# == Schema Information +# +# Table name: regions +# +# id :integer not null, primary key +# abbreviation :string +# description :string +# name :string +# super_region_id :integer +# + +class Region < ActiveRecord::Base + include ValidationMessages + + # ================ + # = Associations = + # ================ + + has_many :sub_regions, class_name: 'Region', foreign_key: 'super_region_id' + + belongs_to :super_region, class_name: 'Region' + + # =============== + # = Validations = + # =============== + + validates :name, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { message: UNIQUENESS_MESSAGE } + + validates :description, presence: true + + validates :abbreviation, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { message: UNIQUENESS_MESSAGE } + +end diff --git a/app/models/role.rb b/app/models/role.rb index b7cdf28..321aff3 100644 --- a/app/models/role.rb +++ b/app/models/role.rb @@ -1,11 +1,70 @@ -class Role < ActiveRecord::Base - after_initialize :set_defaults - include FlagShihTzu +# == Schema Information +# +# Table name: roles +# +# id :integer not null, primary key +# access :integer default(0), not null +# active :boolean default(TRUE) +# created_at :datetime +# updated_at :datetime +# plan_id :integer +# user_id :integer +# +# Indexes +# +# index_roles_on_plan_id (plan_id) +# index_roles_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (plan_id => plans.id) +# fk_rails_... (user_id => users.id) +# - ## - # Associationsrequire "role" - +class Role < ActiveRecord::Base + + include FlagShihTzu + include ValidationMessages + include ValidationValues + + # ============= + # = Constants = + # ============= + + # Returns a hash of hashes where each key represents an access level + # (e.g. see access_level method to understand the integers) + # + # This method becomes useful for generating template messages (e.g. + # permissions change notification mailer) + ACCESS_LEVEL_MESSAGES = { + 5 => { + type: _('reviewer'), + placeholder1: _('read the plan and provide feedback.'), + placeholder2: nil + }, + 3 => { + type: _('co-owner'), + placeholder1: _('write and edit the plan in a collaborative manner.'), + placeholder2: _('You can also grant rights to other collaborators.') + }, + 2 => { + type: _('editor'), + placeholder1: _('write and edit the plan in a collaborative manner.'), + placeholder2: nil, + }, + 1 => { + type: _('read-only'), + placeholder1: _('read the plan and leave comments.'), + placeholder2: nil, + } + } + + # ================ + # = Associations = + # ================ + belongs_to :user + belongs_to :plan ## @@ -18,18 +77,56 @@ 5 => :reviewer, # 16 column: 'access' - validates :user, :plan, :access, presence: {message: _("can't be blank")} - validates :access, numericality: {greater_than: 0, message: _("can't be less than zero")} + # =============== + # = Validations = + # =============== + + validates :user, presence: { message: PRESENCE_MESSAGE } + + validates :plan, presence: { message: PRESENCE_MESSAGE } + + validates :active, inclusion: { in: BOOLEAN_VALUES, + message: INCLUSION_MESSAGE } + + validates :access, presence: { message: PRESENCE_MESSAGE }, + numericality: { greater_than: 0, only_integer: true, + message: _("can't be less than zero") } + + # ============= + # = Callbacks = + # ============= + + # TODO: Push this down to the DB constraints + after_initialize :set_defaults ## - # return the access level for the current project group - # 5 if the user is a reviewer - # 3 if the user is an administrator - # 2 if the user is an editor - # 1 if the user can only read + # Roles with given FlagShihTzu access flags + # + # flags - One or more symbols that represent access flags + # + # Return ActiveRecord::Relation + scope :with_access_flags, -> (*flags) { + bad_flag = flags.detect { |flag| !flag.in?(flag_mapping['access'].keys) } + raise ArgumentError, "Unkown access flag '#{bad_flag}'" if bad_flag + access_values = flags.map { |flag| sql_in_for_flag(flag.to_sym, 'access') } + .flatten + .uniq + where(access: access_values) + } + + # =========================== + # = Public instance methods = + # =========================== + + + # The access level for the current project group: + # - 5 if the user is a reviewer + # - 3 if the user is an administrator + # - 2 if the user is an editor + # - 1 if the user can only read # used to facilliatte formtastic # - # @return [Integer] + # Returns Integer def access_level if self.reviewer? return 5 @@ -42,52 +139,17 @@ end end - # Sets access_level according to bit fields defined in the column access - # TODO refactor according to the hash defined above (e.g. 1 key is :creator, 2 key is :administrator, etc) - def set_access_level(access_level) - if access_level >= 1 - self.commenter = true - else - self.commenter = false - end - if access_level >= 2 - self.editor = true - else - self.editor = false - end - if access_level >= 3 - self.administrator = true - else - self.administrator = false - end + def access_level=(value) + self.commenter = value.to_i >= 1 + self.editor = value.to_i >= 2 + self.administrator = value.to_i >= 3 end - # Returns a hash of hashes where each key represents an access level (e.g. see access_level method to understand the integers) - # This method becomes useful for generating template messages (e.g. permissions change notification mailer) - def self.access_level_messages - { - 5 => { - :type => _('reviewer'), - :placeholder1 => _('read the plan and provide feedback.'), - :placeholder2 => nil - }, - 3 => { - :type => _('co-owner'), - :placeholder1 => _('write and edit the plan in a collaborative manner.'), - :placeholder2 => _('You can also grant rights to other collaborators.') - }, - 2 => { - :type => _('editor'), - :placeholder1 => _('write and edit the plan in a collaborative manner.'), - :placeholder2 => nil, - }, - 1 => { - :type => _('read-only'), - :placeholder1 => _('read the plan and leave comments.'), - :placeholder2 => nil, - } - } - end + alias :set_access_level :access_level= + + deprecate :set_access_level, + deprecator: Cleanup::Deprecators::SetDeprecator.new + def set_defaults self.active = true if self.new_record? @@ -128,4 +190,4 @@ # 28 - editor + commenter + reviewer # 29 - creator + editor + commenter + reviewer # 30 - administrator + editor + commenter + reviewer -# 31 - creator + administrator + editor + commenter + reviewer \ No newline at end of file +# 31 - creator + administrator + editor + commenter + reviewer diff --git a/app/models/scopes/template_scope.rb b/app/models/scopes/template_scope.rb deleted file mode 100644 index 1f5556f..0000000 --- a/app/models/scopes/template_scope.rb +++ /dev/null @@ -1,90 +0,0 @@ -require 'active_support/concern' - -module TemplateScope - extend ActiveSupport::Concern - - included do - scope :archived, -> { where(archived: true) } - scope :unarchived, -> { where(archived: false) } -# scope :default, -> { published(where(is_default: true).last) } - scope :default, -> { where(is_default: true, published: true).last } - scope :published, -> (family_id = nil) { - if family_id.present? - unarchived.where(published: true, family_id: family_id) - else - unarchived.where(published: true) - end - } - - # Retrieves the latest templates, i.e. those with maximum version associated. It can be filtered down - # if family_id is passed. NOTE, the template objects instantiated only contain version and family attributes - # populated. See Template::latest_version scope method for an adequate instantiation of template instances - scope :latest_version_per_family, -> (family_id = nil) { - chained_scope = unarchived.select("MAX(version) AS version", :family_id) - if family_id.present? - chained_scope = chained_scope.where(family_id: family_id) - end - chained_scope.group(:family_id) - } - scope :latest_customized_version_per_customised_of, -> (customization_of=nil, org_id = nil) { - chained_scope = select("MAX(version) AS version", :customization_of) - chained_scope = chained_scope.where(customization_of: customization_of) - if org_id.present? - chained_scope = chained_scope.where(org_id: org_id) - end - chained_scope.group(:customization_of) - } - # Retrieves the latest templates, i.e. those with maximum version associated. It can be filtered down - # if family_id is passed - scope :latest_version, -> (family_id = nil) { - unarchived.from(latest_version_per_family(family_id), :current) - .joins("INNER JOIN templates ON current.version = templates.version " + - "AND current.family_id = templates.family_id INNER JOIN orgs ON orgs.id = templates.org_id") - } - # Retrieves the latest customized versions, i.e. those with maximum version associated for a set - # of family_id and an org - scope :latest_customized_version, -> (family_id = nil, org_id = nil) { - unarchived.from(latest_customized_version_per_customised_of(family_id, org_id), :current) - .joins("INNER JOIN templates ON current.version = templates.version"\ - " AND current.customization_of = templates.customization_of INNER JOIN orgs ON orgs.id = templates.org_id") - .where(templates: { org_id: org_id }) - } - # Retrieves the latest templates, i.e. those with maximum version associated for a set of org_id passed - scope :latest_version_per_org, -> (org_id = nil) { - if org_id.respond_to?(:each) - family_ids = families(org_id).pluck(:family_id) - else - family_ids = families([org_id]).pluck(:family_id) - end - latest_version(family_ids) - } - # Retrieve all of the latest customizations for the specified org - scope :latest_customized_version_per_org, -> (org_id=nil) { - family_ids = families(org_id).pluck(:family_id) - latest_customized_version(family_ids, org_id) - } - # Retrieves templates with distinct family_id. It can be filtered down if org_id is passed - scope :families, -> (org_id=nil) { - if org_id.respond_to?(:each) - unarchived.where(org_id: org_id, customization_of: nil).distinct - else - unarchived.where(customization_of: nil).distinct - end - } - # Retrieves the latest version of each customizable funder template (and the default template) - scope :latest_customizable, -> { - family_ids = families(Org.funder.collect(&:id)).distinct.pluck(:family_id) << default.family_id - published(family_ids.flatten).where('visibility = ? OR is_default = ?', visibilities[:publicly_visible], true) - } - # Retrieves unarchived templates with public visibility - scope :publicly_visible, -> { unarchived.where(:visibility => visibilities[:publicly_visible]) } - # Retrieves unarchived templates with organisational visibility - scope :organisationally_visible, -> { unarchived.where(:visibility => visibilities[:organisationally_visible]) } - # Retrieves unarchived templates whose title or org.name includes the term passed - scope :search, -> (term) { - search_pattern = "%#{term}%" - unarchived.where("templates.title LIKE ? OR orgs.name LIKE ?", search_pattern, search_pattern) - } - end -end - diff --git a/app/models/section.rb b/app/models/section.rb index cf9282a..afa0599 100644 --- a/app/models/section.rb +++ b/app/models/section.rb @@ -1,26 +1,101 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: sections +# +# id :integer not null, primary key +# description :text +# modifiable :boolean +# number :integer +# title :string +# created_at :datetime +# updated_at :datetime +# phase_id :integer +# versionable_id :string(36) +# +# Indexes +# +# index_sections_on_phase_id (phase_id) +# index_sections_on_versionable_id (versionable_id) +# +# Foreign Keys +# +# fk_rails_... (phase_id => phases.id) +# + class Section < ActiveRecord::Base - ## - # Associations + + include ValidationMessages + include ValidationValues + include ActsAsSortable + include VersionableModel + + + # ================ + # = Associations = + # ================ + belongs_to :phase belongs_to :organisation - has_many :questions, :dependent => :destroy + has_many :questions, dependent: :destroy has_one :template, through: :phase - #Link the data - accepts_nested_attributes_for :questions, :reject_if => lambda {|a| a[:text].blank? }, :allow_destroy => true + # =============== + # = Validations = + # =============== - attr_accessible :phase_id, :description, :number, :title, :published, - :questions_attributes, :organisation, :phase, :modifiable, - :as => [:default, :admin] + validates :phase, presence: { message: PRESENCE_MESSAGE } - validates :phase, :title, :number, presence: {message: _("can't be blank")} + validates :title, presence: { message: PRESENCE_MESSAGE } - before_validation :set_defaults + # validates :description, presence: { message: PRESENCE_MESSAGE } - ## - # return the title of the section + validates :number, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { scope: :phase_id, + message: UNIQUENESS_MESSAGE } + + validates :modifiable, inclusion: { in: BOOLEAN_VALUES, + message: INCLUSION_MESSAGE } + + # ============= + # = Callbacks = + # ============= + + # TODO: Move this down to DB constraints + before_validation :set_modifiable + + before_validation :set_number, if: :phase_id_changed? + + # ===================== + # = Nested Attributes = + # ===================== + + accepts_nested_attributes_for :questions, + reject_if: -> (a) { a[:text].blank? }, + allow_destroy: true + + # ========== + # = Scopes = + # ========== + + # The sections for this Phase that have been added by the admin # - # @return [String] the title of the section + # Returns ActiveRecord::Relation + scope :modifiable, -> { where(modifiable: true) } + + # The sections for this Phase that were part of the original Template + # + # Returns ActiveRecord::Relation + scope :not_modifiable, -> { where(modifiable: false) } + + # =========================== + # = Public instance methods = + # =========================== + + # The title of the Section + # + # Returns String def to_s "#{title}" end @@ -28,13 +103,10 @@ # Returns the number of answered questions for a given plan def num_answered_questions(plan) return 0 if plan.nil? - questions_hash = questions.reduce({}){ |m, q| m[q.id] = q; m } - return plan.answers.includes({ question: :question_format }, :question_options).reduce(0) do |m, a| - if questions_hash[a.question_id].present? && a.is_valid? - m+= 1 - end - m - end + plan.answers.includes({ question: :question_format }, :question_options) + .where(question_id: question_ids) + .to_a + .count(&:is_valid?) end def deep_copy(**options) @@ -42,13 +114,29 @@ copy.modifiable = options.fetch(:modifiable, self.modifiable) copy.phase_id = options.fetch(:phase_id, nil) copy.save!(validate: false) if options.fetch(:save, false) - options[:section_id] = id - self.questions.map{ |question| copy.questions << question.deep_copy(options) } - return copy + options[:section_id] = copy.id + self.questions.map { |question| copy.questions << question.deep_copy(options) } + copy + end + + # Can't be modified as it was duplicatd over from another Phase. + def unmodifiable? + !modifiable? end private - def set_defaults - self.modifiable = true if modifiable.nil? - end + + # ============================ + # = Private instance methods = + # ============================ + + def set_modifiable + self.modifiable = true if modifiable.nil? + end + + def set_number + return if phase.nil? + self.number = phase.sections.where.not(id: id).maximum(:number).to_i + 1 + end + end diff --git a/app/models/section_sorter.rb b/app/models/section_sorter.rb new file mode 100644 index 0000000..dddfb1f --- /dev/null +++ b/app/models/section_sorter.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# Private: Takes a list of Sections and sorts them in the correct display order based on +# the number, modifiable, and id attributes. +# +# Examples: +# +# SectionSorter.new(*@phase.sections).sort! # => Array of sorted Sections +# +# +class SectionSorter + + ## + # Access the array of Sections + # + # Returns Array + attr_accessor :sections + + ## + # Initialize a new SectionSorter + # + # sections - A set of Section records + # + def initialize(*sections) + @sections = sections + end + + # Re-order {#sections} into the correct order. + # + # Returns Array of Sections + def sort! + if all_sections_unmodifiable? + sort_as_homogenous_group + elsif all_sections_modifiable? + sort_as_homogenous_group + else + # If there are duplicates in the #1 position + if duplicate_number_values.include?(1) + + mod_1 = sections.select { |section| section.modifiable? && section.number == 1 } + + # There should only be, if any, one prefixed modifiable Section + prefix = mod_1.shift + + # In the off-chance that there is more than one prefix Section, stick them + # after the unmodifiable block + erratic = mod_1 + + # Collect the unmodifiable Section ids in the order the should be displayed + unmodifiable = sections + .select { |section| section.unmodifiable? } + .sort_by { |section| [section.number, section.id] } + + # Then any additional Sections that come after the main block... + modifiable = sections + .select { |section| section.modifiable? && section.number > 1 } + .sort_by { |section| [section.number, section.id] } + + # Create one Array with all of the ids in the correct order. + self.sections = [prefix] + unmodifiable + erratic + modifiable + else + prefix = sections.detect { |s| s.modifiable? && s.number == 1 } + remaining_sections = sections - [prefix] + unmodifiable = remaining_sections.select(&:unmodifiable?) + .sort_by { |s| [s.number, s.id] } + modifiable = remaining_sections.select(&:modifiable?) + .sort_by { |s| [s.number, s.id] } + + self.sections = [prefix] + unmodifiable + modifiable + end + sections.uniq.compact + end + end + + private + + def modifiable_values + @modifiable_values ||= sections.map(&:modifiable?).uniq + end + + def number_values_with_count + @number_values_with_count ||= begin + hash = Hash.new { |hash, key| hash[key] = 0 } + sections.map(&:number).each { |number| hash[number] += 1 } + hash + end + end + + def duplicate_number_values + @duplicate_number_values ||= number_values_with_count.select do |number, count| + count > 1 + end.keys + end + + def all_sections_unmodifiable? + modifiable_values == [false] + end + + def all_sections_modifiable? + modifiable_values == [true] + end + + def sort_as_homogenous_group + sections.sort_by { |section| [section.number, section.id] } + end + +end diff --git a/app/models/settings/template.rb b/app/models/settings/template.rb index d27957f..79a8941 100644 --- a/app/models/settings/template.rb +++ b/app/models/settings/template.rb @@ -1,8 +1,19 @@ +# == Schema Information +# +# Table name: settings +# +# id :integer not null, primary key +# target_type :string not null +# value :text +# var :string not null +# created_at :datetime not null +# updated_at :datetime not null +# target_id :integer not null +# + module Settings class Template < RailsSettings::SettingObject - #attr_accessible :var, :target, :target_id, :target_type - VALID_FONT_FACES = [ '"Times New Roman", Times, Serif', 'Arial, Helvetica, Sans-Serif' @@ -13,7 +24,9 @@ VALID_ADMIN_FIELDS = ['project_name', 'project_identifier', 'grant_title', 'principal_investigator', 'project_data_contact', 'project_description', 'funder', 'institution', 'orcid'] - + + VALID_FORMATS = ['csv', 'html', 'pdf', 'text', 'docx'] + DEFAULT_SETTINGS = { formatting: { margin: { diff --git a/app/models/splash_log.rb b/app/models/splash_log.rb deleted file mode 100644 index 43dcc4d..0000000 --- a/app/models/splash_log.rb +++ /dev/null @@ -1,3 +0,0 @@ -class SplashLog < ActiveRecord::Base - #attr_accessible :destination -end diff --git a/app/models/stat.rb b/app/models/stat.rb new file mode 100644 index 0000000..acc61db --- /dev/null +++ b/app/models/stat.rb @@ -0,0 +1,25 @@ +# == Schema Information +# +# Table name: stats +# +# id :integer not null, primary key +# count :integer default(0) +# date :date not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# + +class Stat < ActiveRecord::Base + belongs_to :org + + class << self + def to_csv(stats) + data = stats.map do |stat| + { date: stat.date, count: stat.count } + end + Csvable.from_array_of_hashes(data) + end + end +end diff --git a/app/models/stat_created_plan.rb b/app/models/stat_created_plan.rb new file mode 100644 index 0000000..43a9f2a --- /dev/null +++ b/app/models/stat_created_plan.rb @@ -0,0 +1,22 @@ +# == Schema Information +# +# Table name: stats +# +# id :integer not null, primary key +# count :integer default(0) +# date :date not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# + +class StatCreatedPlan < Stat + extend OrgDateRangeable + + class << self + def to_csv(created_plans) + Stat.to_csv(created_plans) + end + end +end diff --git a/app/models/stat_joined_user.rb b/app/models/stat_joined_user.rb new file mode 100644 index 0000000..ec9820b --- /dev/null +++ b/app/models/stat_joined_user.rb @@ -0,0 +1,22 @@ +# == Schema Information +# +# Table name: stats +# +# id :integer not null, primary key +# count :integer default(0) +# date :date not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# + +class StatJoinedUser < Stat + extend OrgDateRangeable + + class << self + def to_csv(joined_users) + Stat.to_csv(joined_users) + end + end +end diff --git a/app/models/template.rb b/app/models/template.rb index d973c03..4553d90 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -1,128 +1,340 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: templates +# +# id :integer not null, primary key +# archived :boolean +# customization_of :integer +# description :text +# is_default :boolean +# links :text +# locale :string +# published :boolean +# title :string +# version :integer +# visibility :integer +# created_at :datetime +# updated_at :datetime +# family_id :integer +# org_id :integer +# +# Indexes +# +# index_templates_on_family_id (family_id) +# index_templates_on_family_id_and_version (family_id,version) UNIQUE +# index_templates_on_org_id (org_id) +# template_organisation_dmptemplate_index (org_id,family_id) +# +# Foreign Keys +# +# fk_rails_... (org_id => orgs.id) +# + class Template < ActiveRecord::Base + include GlobalHelpers - include ActiveModel::Validations - include TemplateScope + include ValidationMessages + include ValidationValues + validates_with TemplateLinksValidator - before_validation :set_defaults - after_update :reconcile_published, if: Proc.new { |template| template.published? } + # A standard template should be organisationally visible. Funder templates + # that are meant for external use will be publicly visible. This allows a + # funder to create 'funder' as well as organisational templates. The default + # template should also always be publicly_visible. + enum visibility: %i[organisationally_visible publicly_visible] - # Stores links as an JSON object: { funder: [{"link":"www.example.com","text":"foo"}, ...], sample_plan: [{"link":"www.example.com","text":"foo"}, ...]} - # The links is validated against custom validator allocated at validators/template_links_validator.rb + # Stores links as an JSON object: + # {funder: [{"link":"www.example.com","text":"foo"}, ...], + # sample_plan: [{"link":"www.example.com","text":"foo"}, ...]} + # + # The links is validated against custom validator allocated at + # validators/template_links_validator.rb serialize :links, JSON - - ## - # Associations + + # ================ + # = Associations = + # ================ + belongs_to :org + has_many :plans + has_many :phases, dependent: :destroy + has_many :sections, through: :phases + has_many :questions, through: :sections - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :id, :org_id, :description, :published, :title, :locale, :customization_of, - :is_default, :guidance_group_ids, :org, :plans, :phases, :family_id, - :archived, :version, :visibility, :published, :links, :as => [:default, :admin] + has_many :annotations, through: :questions - # A standard template should be organisationally visible. Funder templates that are - # meant for external use will be publicly visible. This allows a funder to create 'funder' as - # well as organisational templates. The default template should also always be publicly_visible - enum visibility: [:organisationally_visible, :publicly_visible] + # =============== + # = Validations = + # =============== + + validates :title, presence: { message: PRESENCE_MESSAGE } + + validates :org, presence: { message: PRESENCE_MESSAGE } + + validates :locale, presence: { message: PRESENCE_MESSAGE } + + validates :version, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { message: UNIQUENESS_MESSAGE, + scope: :family_id } + + validates :visibility, presence: { message: PRESENCE_MESSAGE } + + validates :family_id, presence: { message: PRESENCE_MESSAGE } + + + # ============= + # = Callbacks = + # ============= + + before_validation :set_defaults + + after_update :reconcile_published, if: -> (template) { template.published? } + + # ========== + # = Scopes = + # ========== + + scope :archived, -> { where(archived: true) } + + scope :unarchived, -> { where(archived: false) } + + scope :published, ->(family_id = nil) { + if family_id.present? + unarchived.where(published: true, family_id: family_id) + else + unarchived.where(published: true) + end + } + + # Retrieves the latest templates, i.e. those with maximum version associated. + # It can be filtered down if family_id is passed + scope :latest_version, ->(family_id = nil) { + unarchived.from(latest_version_per_family(family_id), :current) + .joins(<<~SQL) + INNER JOIN templates ON current.version = templates.version + AND current.family_id = templates.family_id + INNER JOIN orgs ON orgs.id = templates.org_id + SQL + } + + # Retrieves the latest customized versions, i.e. those with maximum version + # associated for a set of family_id and an org + scope :latest_customized_version, ->(family_id = nil, org_id = nil) { + unarchived + .from(latest_customized_version_per_customised_of(family_id, org_id), + :current) + .joins(<<~SQL) + INNER JOIN templates ON current.version = templates.version + AND current.customization_of = templates.customization_of + INNER JOIN orgs ON orgs.id = templates.org_id + SQL + .where(templates: { org_id: org_id }) + } + + # Retrieves the latest templates, i.e. those with maximum version associated + # for a set of org_id passed + scope :latest_version_per_org, ->(org_id = nil) { + family_ids = if org_id.respond_to?(:each) + families(org_id).pluck(:family_id) + else + families([org_id]).pluck(:family_id) + end + latest_version(family_ids) + } + + # Retrieve all of the latest customizations for the specified org + scope :latest_customized_version_per_org, lambda { |org_id = nil| + family_ids = families(org_id).pluck(:family_id) + latest_customized_version(family_ids, org_id) + } + + # Retrieves templates with distinct family_id. It can be filtered down if + # org_id is passed + scope :families, lambda { |org_id = nil| + if org_id.respond_to?(:each) + unarchived.where(org_id: org_id, customization_of: nil).distinct + else + unarchived.where(customization_of: nil).distinct + end + } + + # Retrieves the latest version of each customizable funder template (and the + # default template) + scope :latest_customizable, lambda { + funder_ids = Org.funder.pluck(:id) + family_ids = families(funder_ids).distinct + .pluck(:family_id) + [default.family_id] + published(family_ids.uniq) + .where("visibility = :visibility OR is_default = :is_default", + visibility: visibilities[:publicly_visible], is_default: true) + } + + # Retrieves unarchived templates with public visibility + scope :publicly_visible, lambda { + unarchived.where(visibility: visibilities[:publicly_visible]) + } + + # Retrieves unarchived templates with organisational visibility + scope :organisationally_visible, lambda { + unarchived.where(visibility: visibilities[:organisationally_visible]) + } + + # Retrieves unarchived templates whose title or org.name includes the term + # passed + scope :search, lambda { |term| + unarchived.joins(:org).where("templates.title LIKE :term OR orgs.name LIKE :term", + term: "%#{term}%") + } + # defines the export setting for a template object - has_settings :export, class_name: 'Settings::Template' do |s| + has_settings :export, class_name: "Settings::Template" do |s| s.key :export, defaults: Settings::Template::DEFAULT_SETTINGS end - validates :org, :title, presence: {message: _("can't be blank")} + validates :org, :title, presence: { message: _("can't be blank") } - # Class methods gets defined within this - class << self - def current(family_id) - unarchived.where(family_id: family_id).order(version: :desc).first - end - def live(family_id) - if family_id.respond_to?(:each) - unarchived.where(family_id: family_id, published: true) - else - unarchived.where(family_id: family_id, published: true).first - end - end - def find_or_generate_version!(template) - if template.latest? - if template.generate_version? - return template.generate_version! - end - return template - end - raise _('A historical template cannot be retrieved for being modified') + # ================= + # = Class Methods = + # ================= + + def self.default + where(is_default: true, published: true).last + end + + def self.current(family_id) + unarchived.where(family_id: family_id).order(version: :desc).first + end + + def self.live(family_id) + if family_id.respond_to?(:each) + unarchived.where(family_id: family_id, published: true) + else + unarchived.where(family_id: family_id, published: true).first end end + def self.find_or_generate_version!(template) + if template.latest? && template.generate_version? + template.generate_version! + elsif template.latest? && !template.generate_version? + template + else + raise _("A historical template cannot be retrieved for being modified") + end + end + + # Retrieves the latest templates, i.e. those with maximum version associated. + # It can be filtered down if family_id is passed. NOTE, the template objects + # instantiated only contain version and family attributes populated. See + # Template::latest_version scope method for an adequate instantiation of + # template instances. + def self.latest_version_per_family(family_id = nil) + chained_scope = unarchived.select("MAX(version) AS version", :family_id) + if family_id.present? + chained_scope = chained_scope.where(family_id: family_id) + end + chained_scope.group(:family_id) + end + + private_class_method :latest_version_per_family + + def self.latest_customized_version_per_customised_of(customization_of = nil, + org_id = nil) + chained_scope = select("MAX(version) AS version", :customization_of) + chained_scope = chained_scope.where(customization_of: customization_of) + chained_scope = chained_scope.where(org_id: org_id) if org_id.present? + chained_scope.group(:customization_of) + end + + private_class_method :latest_customized_version_per_customised_of + + + # =========================== + # = Public instance methods = + # =========================== + # Creates a copy of the current template - # raises ActiveRecord::RecordInvalid when save option is true and validations fails + # raises ActiveRecord::RecordInvalid when save option is true and validations + # fails. def deep_copy(attributes: {}, **options) - copy = self.dup + copy = dup if attributes.respond_to?(:each_pair) - attributes.each_pair{ |attribute, value| copy.send("#{attribute}=".to_sym, value) if copy.respond_to?("#{attribute}=".to_sym) } + attributes.each_pair do |attribute, value| + if copy.respond_to?("#{attribute}=".to_sym) + copy.send("#{attribute}=".to_sym, value) + end + end end copy.save! if options.fetch(:save, false) options[:template_id] = copy.id - self.phases.each{ |phase| copy.phases << phase.deep_copy(options) } - return copy + phases.each { |phase| copy.phases << phase.deep_copy(options) } + copy end # Retrieves the template's org or the org of the template this one is derived # from of it is a customization def base_org - if self.customization_of.present? - return Template.where(family_id: self.customization_of).first.org + if customization_of.present? + Template.where(family_id: customization_of).first.org else - return self.org + org end end - # Returns whether or not this is the latest version of the current template's family + # Is this the latest version of the current Template's family? + # + # Returns Boolean def latest? - return (self.id == Template.latest_version(self.family_id).pluck(:id).first) + id == Template.latest_version(family_id).pluck(:id).first end + # Determines whether or not a new version should be generated def generate_version? - return self.published - end - # Determines whether or not a customization for the customizing_org passed should be generated - def customize?(customizing_org) - if customizing_org.is_a?(Org) && (self.org.funder_only? || self.is_default) - return !Template.unarchived.where(customization_of: self.family_id, org: customizing_org).exists? - end - return false - end - # Determines whether or not a customized template should be upgraded - def upgrade_customization? - if self.customization_of.present? - funder_template = Template.published(self.customization_of).select(:created_at).first - if funder_template.present? - return funder_template.created_at > self.created_at - end - end - return false + published end - # Checks to see if the template family has a published version and if its not the current template + # Determines whether or not a customization for the customizing_org passed + # should be generated + def customize?(customizing_org) + if customizing_org.is_a?(Org) && (org.funder_only? || is_default) + return !Template.unarchived.where(customization_of: family_id, + org: customizing_org).exists? + end + false + end + + # Determines whether or not a customized template should be upgraded + def upgrade_customization? + return false unless customization_of? + funder_template = Template.published(customization_of).select(:created_at).first + return false unless funder_template.present? + funder_template.created_at > created_at + end + + # Checks to see if the template family has a published version and if its not + # the current template def draft? - return !self.published && Template.published(self.family_id).length > 0 + !published && !Template.published(family_id).empty? end def removable? - versions = Template.includes(:plans).where(family_id: self.family_id) - return versions.select{|version| version.plans.length > 0 }.empty? + versions = Template.includes(:plans).where(family_id: family_id) + versions.reject { |version| version.plans.empty? }.empty? end - # Returns a new unpublished copy of self with a new family_id, version = zero for the specified org + # Returns a new unpublished copy of self with a new family_id, version = zero + # for the specified org def generate_copy!(org) - raise _('generate_copy! requires an organisation target') unless org.is_a?(Org) # Assume customizing_org is persisted + # Assume customizing_org is persisted + raise _("generate_copy! requires an organisation target") unless org.is_a?(Org) template = deep_copy( attributes: { version: 0, @@ -130,123 +342,107 @@ family_id: new_family_id, org: org, is_default: false, - title: _('Copy of %{template}') % { template: self.title } - }, modifiable: true, save: true) - return template + title: format(_("Copy of %{template}"), template: title) + }, modifiable: true, save: true + ) + template end # Generates a new copy of self with an incremented version number def generate_version! - raise _('generate_version! requires a published template') unless published + raise _("generate_version! requires a published template") unless published template = deep_copy( attributes: { - version: self.version+1, + version: version + 1, published: false, - org: self.org - }, save: true) - return template + org: org + }, save: true + ) + template end # Generates a new copy of self for the specified customizing_org def customize!(customizing_org) - raise _('customize! requires an organisation target') unless customizing_org.is_a?(Org) # Assume customizing_org is persisted - raise _('customize! requires a template from a funder') if !self.org.funder_only? && !self.is_default # Assume self has org associated + # Assume customizing_org is persisted + unless customizing_org.is_a?(Org) + raise ArgumentError, _("customize! requires an organisation target") + end + + # Assume self has org associated + if !org.funder_only? && !is_default + raise ArgumentError, _("customize! requires a template from a funder") + end + customization = deep_copy( attributes: { version: 0, published: false, family_id: new_family_id, - customization_of: self.family_id, + customization_of: family_id, org: customizing_org, visibility: Template.visibilities[:organisationally_visible], is_default: false - }, modifiable: false, save: true) - return customization + }, modifiable: false, save: true + ) + customization end - - # Generates a new copy of self including latest changes from the funder this template is customized_of + + # Generates a new copy of self including latest changes from the funder this + # template is customized_of def upgrade_customization! - raise _('upgrade_customization! requires a customised template') unless customization_of.present? - funder_template = Template.published(self.customization_of).first - raise _('upgrade_customization! cannot be carried out since there is no published template of its current funder') unless funder_template.present? - source = deep_copy(attributes: { version: self.version+1, published: false }) # preserves modifiable flags from the self template copied - # Creates a new customisation for the published template whose family_id is self.customization_of - customization = funder_template.deep_copy( - attributes: { - version: source.version, - published: source.published, - family_id: source.family_id, - customization_of: source.customization_of, - org: source.org, - visibility: Template.visibilities[:organisationally_visible], - is_default: false - }, modifiable: false, save: true) - # Sorts the phases from the source template, i.e. self - sorted_phases = source.phases.sort{ |phase1,phase2| phase1.number <=> phase2.number } - # Merges modifiable sections or questions from source into customization template object - customization.phases.each do |customization_phase| - # Search for the phase in the source template whose number matches the customization_phase - candidate_phase = sorted_phases.bsearch{ |phase| customization_phase.number <=> phase.number } - if candidate_phase.present? # The funder could have added this new phase after the customisation took place - # Selects modifiable sections from the candidate_phase - modifiable_sections = candidate_phase.sections.select{ |section| section.modifiable } - # Attaches modifiable sections into the customization_phase - modifiable_sections.each{ |modifiable_section| customization_phase.sections << modifiable_section } - # Sorts the sections for the customization_phase - sorted_sections = customization_phase.sections.sort{ |section1, section2| section1.number <=> section2.number } - # Selects unmodifiable sections from the candidate_phase - unmodifiable_sections = candidate_phase.sections.select{ |section| !section.modifiable } - unmodifiable_sections.each do |unmodifiable_section| - # Search for modifiable questions within the unmodifiable_section from candidate_phase - modifiable_questions = unmodifiable_section.questions.select{ |question| question.modifiable } - customization_section = sorted_sections.bsearch{ |section| unmodifiable_section.number <=> section.number } - if customization_section.present? # The funder could have deleted the section - modifiable_questions.each{ |modifiable_question| customization_section.questions << modifiable_question; } - end - # Search for unmodifiable questions within the unmodifiable_section in case source template added annotations - unmodifiable_questions = unmodifiable_section.questions.select{ |question| !question.modifiable } - sorted_questions = customization_section.questions.sort{ |question1, question2| question1.number <=> question2.number } - unmodifiable_questions.each do |unmodifiable_question| - customization_question = sorted_questions.bsearch{ |question| unmodifiable_question.number <=> question.number } - if customization_question.present? # The funder could have deleted the question - annotations_added_by_customiser = unmodifiable_question.annotations.select{ |annotation| annotation.org_id == source.org_id } - annotations_added_by_customiser.each{ |annotation| customization_question.annotations << annotation } - end - end - end - end - end - # Appends the modifiable phases from source - source.phases.select{ |phase| phase.modifiable }.each{ |modifiable_phase| customization.phases << modifiable_phase } - return customization + Template::UpgradeCustomizationService.call(self) + end + + def publish + update(published: true) + end + + def publish! + update!(published: true) end private - # Generate a new random family identifier - def new_family_id - family_id = loop do - random = rand 2147483647 - break random unless Template.exists?(family_id: random) - end - family_id + + # ============================ + # = Private instance methods = + # ============================ + + # Generate a new random family identifier + def new_family_id + family_id = loop do + random = rand 2_147_483_647 + break random unless Template.exists?(family_id: random) end - - # Default values to set before running any validation - def set_defaults - self.published ||= false - self.archived ||= false - self.is_default ||= false - self.version ||= 0 - self.visibility = ((self.org.present? && self.org.funder_only?) || self.is_default?) ? Template.visibilities[:publicly_visible] : Template.visibilities[:organisationally_visible] unless self.id.present? - self.customization_of ||= nil - self.family_id ||= new_family_id - self.archived ||= false - self.links ||= { funder: [], sample_plan: [] } + family_id + end + + # Default values to set before running any validation + def set_defaults + self.published ||= false + self.archived ||= false + self.is_default ||= false + self.version ||= 0 + unless id? + self.visibility = if (org.present? && org.funder_only?) || is_default? + Template.visibilities[:publicly_visible] + else + Template.visibilities[:organisationally_visible] + end end - - # Only one version of a template should be published at a time, so if this one was published make sure other versions are not - def reconcile_published - # Unpublish all other versions of this template family - Template.where('family_id = ? AND published = ? AND id != ?', self.family_id, true, self.id).update_all(published: false) - end + self.customization_of ||= nil + self.family_id ||= new_family_id + self.archived ||= false + self.links ||= { funder: [], sample_plan: [] } + end + + # Only one version of a template should be published at a time, so if this + # one was published make sure other versions are not + def reconcile_published + # Unpublish all other versions of this template family + Template.published + .where(family_id: family_id) + .where.not(id: id) + .update_all(published: false) + end + end diff --git a/app/models/theme.rb b/app/models/theme.rb index 4f0738e..4e66430 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -1,30 +1,48 @@ -class Theme < ActiveRecord::Base +# == Schema Information +# +# Table name: themes +# +# id :integer not null, primary key +# description :text +# locale :string +# title :string +# created_at :datetime not null +# updated_at :datetime not null +# - ## - # Associations +class Theme < ActiveRecord::Base + include ValidationMessages + + # ================ + # = Associations = + # ================ + has_and_belongs_to_many :questions, join_table: "questions_themes" has_and_belongs_to_many :guidances, join_table: "themes_in_guidance" - ## - # Possibly needed for active_admin - # -relies on protected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :guidance_ids , :as => [:default, :admin] - attr_accessible :question_ids, :as => [:default, :admin] - attr_accessible :description, :title, :locale , :as => [:default, :admin] + # =============== + # = Validations = + # =============== + validates :title, presence: { message: PRESENCE_MESSAGE } - validates :title, presence: {message: _("can't be blank")} + # ========== + # = Scopes = + # ========== scope :search, -> (term) { search_pattern = "%#{term}%" where("title LIKE ? OR description LIKE ?", search_pattern, search_pattern) } - ## - # returns the title of the theme + + # =========================== + # = Public instance methods = + # =========================== + + # The title of the Theme # - # @return [String] title of the theme + # Returns String def to_s title end - end diff --git a/app/models/token_permission_type.rb b/app/models/token_permission_type.rb index 5770e6c..8dd979b 100644 --- a/app/models/token_permission_type.rb +++ b/app/models/token_permission_type.rb @@ -1,31 +1,56 @@ +# == Schema Information +# +# Table name: token_permission_types +# +# id :integer not null, primary key +# text_description :text +# token_type :string +# created_at :datetime +# updated_at :datetime +# + class TokenPermissionType < ActiveRecord::Base - ## - # Associations - #has_and_belongs_to_many :org_token_permissions, join_table: "org_token_permissions" -# has_and_belongs_to_many :organisations, join_table: 'org_token_permissions', unique: true - has_and_belongs_to_many :orgs, join_table: 'org_token_permissions', unique: true + include ValidationMessages + + # ============= + # = Constants = + # ============= ## - # Possibly needed for active_admin - # - relies on proetected_attributes gem as syntax depricated in rails 4.2 - attr_accessible :token_type, :text_description, :as => [:default, :admin] - - ## - # Validators - validates :token_type, presence: {message: _("can't be blank")}, uniqueness: {message: _("must be unique")} - - ## - # Constant Token Permission Types + # GUIDANCES = TokenPermissionType.where(token_type: 'guidances').first.freeze + + ## + # PLANS = TokenPermissionType.where(token_type: 'plans').first.freeze + + ## + # TEMPLATES = TokenPermissionType.where(token_type: 'templates').first.freeze + + ## + # STATISTICS = TokenPermissionType.where(token_type: 'statistics').first.freeze - ## - # returns the token_type of the token_permission_type + # ================ + # = Associations = + # ================ + + has_and_belongs_to_many :orgs, join_table: 'org_token_permissions', unique: true + + + # ============== + # = Validators = + # ============== + + validates :token_type, presence: { message: PRESENCE_MESSAGE }, + uniqueness: { message: UNIQUENESS_MESSAGE } + + + # The token_type of the token_permission_type # - # @return [String] token_type of the token_permission_type + # Returns String def to_s self.token_type end diff --git a/app/models/user.rb b/app/models/user.rb index 619c714..8e5963e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,55 @@ +# == Schema Information +# +# Table name: users +# +# id :integer not null, primary key +# accept_terms :boolean +# active :boolean default(TRUE) +# api_token :string +# confirmation_sent_at :datetime +# confirmation_token :string +# confirmed_at :datetime +# current_sign_in_at :datetime +# current_sign_in_ip :string +# email :string(80) default(""), not null +# encrypted_password :string default("") +# firstname :string +# invitation_accepted_at :datetime +# invitation_created_at :datetime +# invitation_sent_at :datetime +# invitation_token :string +# invited_by_type :string +# last_sign_in_at :datetime +# last_sign_in_ip :string +# other_organisation :string +# recovery_email :string +# remember_created_at :datetime +# reset_password_sent_at :datetime +# reset_password_token :string +# sign_in_count :integer default(0) +# surname :string +# created_at :datetime not null +# updated_at :datetime not null +# invited_by_id :integer +# language_id :integer +# org_id :integer +# +# Indexes +# +# index_users_on_email (email) UNIQUE +# index_users_on_org_id (org_id) +# +# Foreign Keys +# +# fk_rails_... (language_id => languages.id) +# fk_rails_... (org_id => orgs.id) +# + class User < ActiveRecord::Base include ConditionalUserMailer + include ValidationMessages + include ValidationValues + ## # Devise # Include default devise modules. Others available are: @@ -14,39 +64,54 @@ # User Notification Preferences serialize :prefs, Hash - ## - # Associations + # ================ + # = Associations = + # ================ + + has_and_belongs_to_many :perms, join_table: :users_perms + belongs_to :language + belongs_to :org + has_one :pref + has_many :answers + has_many :notes + has_many :exported_plans + has_many :roles, dependent: :destroy - has_many :plans, through: :roles do - def filter(query) - return self unless query.present? - t = self.arel_table - q = "%#{query}%" - conditions = t[:title].matches(q) - columns = %i( - grant_number identifier description principal_investigator data_contact - ) - columns = ['grant_number', 'identifier', 'description', 'principal_investigator', 'data_contact'] - columns.each {|col| conditions = conditions.or(t[col].matches(q)) } - self.where(conditions) - end - end + + 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' - validates :email, email: true, allow_nil: true, uniqueness: {message: _("must be unique")} + has_and_belongs_to_many :notifications, dependent: :destroy, + join_table: 'notification_acknowledgements' - ## - # Scopes + + # =============== + # = Validations = + # =============== + + validates :active, inclusion: { in: BOOLEAN_VALUES, message: INCLUSION_MESSAGE } + + validates :firstname, presence: { message: PRESENCE_MESSAGE } + + validates :surname, presence: { message: PRESENCE_MESSAGE } + + validates :org, presence: { message: PRESENCE_MESSAGE } + + # ========== + # = Scopes = + # ========== + default_scope { includes(:org, :perms) } # Retrieves all of the org_admins for the specified org @@ -57,8 +122,9 @@ 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 + # 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("concat_ws(' ', firstname, surname) LIKE ? OR email LIKE ?", search_pattern, search_pattern) else @@ -66,12 +132,40 @@ end } - after_update :when_org_changes + # ============= + # = Callbacks = + # ============= + + before_update :clear_other_organisation, if: :org_id_changed? + + after_update :delete_perms!, if: :org_id_changed?, unless: :can_change_org? + + after_update :remove_token!, if: :org_id_changed?, unless: :can_change_org? + + # ================= + # = Class methods = + # ================= ## + # 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 + end + + def self.to_csv(users) + User::AtCsv.new(users).to_csv + end + # =========================== + # = Public instance methods = + # =========================== + # This method uses Devise's built-in handling for inactive users + # + # Returns Boolean def active_for_authentication? - super && self.active? + super && active? end # EVALUATE CLASS AND INSTANCE METHODS BELOW @@ -79,7 +173,9 @@ # What do they do? do they do it efficiently, and do we need them? # Determines the locale set for the user or the organisation he/she belongs - # @return String or nil + # + # Returns String + # Returns nil def get_locale if !self.language.nil? return self.language.abbreviation @@ -90,12 +186,11 @@ end end - - ## - # gives either the name of the user, or the email if name unspecified + # Gives either the name of the user, or the email if name unspecified # - # @param user_email [Boolean] defaults to true, allows the use of email if there is no firstname or surname - # @return [String] the email or the firstname and surname of the user + # user_email - Use the email if there is no firstname or surname (defaults: true) + # + # Returns String def name(use_email = true) if (firstname.blank? && surname.blank?) || use_email then return email @@ -105,130 +200,102 @@ end end - ## - # returns all active plans for a user + # The user's identifier for the specified scheme name # - # @return [Plans] - def active_plans - self.plans.includes(:template).where("roles.active": true).where(Role.not_reviewer_condition) - end - - - ## - # Returns the user's identifier for the specified scheme name + # scheme - The identifier scheme name (e.g. ORCID) # - # @param the identifier scheme name (e.g. ORCID) - # @return [UserIdentifier] the user's identifier for that scheme + # Returns UserIdentifier def identifier_for(scheme) user_identifiers.where(identifier_scheme: scheme).first end - ## - # sets a new organisation for the user + # Checks if the user is a super admin. If the user has any privelege which requires + # them to see the super admin page then they are a super admin. # - # @param new_organisation [Organisation] the new organisation for the user - def organisation=(new_org) - org_id = new_org.id unless new_org.nil? - end - - ## - # checks if the user is a super admin - # if the user has any privelege which requires them to see the super admin page - # then they are a super admin - # - # @return [Boolean] true if the user is an admin + # Returns Boolean def can_super_admin? return self.can_add_orgs? || self.can_grant_api_to_orgs? || self.can_change_org? end - ## - # checks if the user is an organisation admin - # if the user has any privlege which requires them to see the org-admin pages - # then they are an org admin + # Checks if the user is an organisation admin if the user has any privlege which + # requires them to see the org-admin pages then they are an org admin. # - # @return [Boolean] true if the user is an organisation admin + # Returns Boolean def can_org_admin? return self.can_grant_permissions? || self.can_modify_guidance? || self.can_modify_templates? || self.can_modify_org_details? end - ## - # checks if the user can add new organisations + # Can the User add new organisations? # - # @return [Boolean] true if the user can add new organisations + # Returns Boolean def can_add_orgs? perms.include? Perm.add_orgs end - ## - # checks if the user can change their organisation affiliations + # Can the User change their organisation affiliations? # - # @return [Boolean] true if the user can change their organisation affiliations + # Returns Boolean def can_change_org? perms.include? Perm.change_affiliation end - ## - # checks if the user can grant their permissions to others + # Can the User can grant their permissions to others? # - # @return [Boolean] true if the user can grant their permissions to others + # Returns Boolean def can_grant_permissions? perms.include? Perm.grant_permissions end - ## - # checks if the user can modify organisation templates + # Can the User modify organisation templates? # - # @return [Boolean] true if the user can modify organisation templates + # Returns Boolean def can_modify_templates? self.perms.include? Perm.modify_templates end - ## - # checks if the user can modify organisation guidance + # Can the User modify organisation guidance? # - # @return [Boolean] true if the user can modify organistion guidance + # Returns Boolean def can_modify_guidance? perms.include? Perm.modify_guidance end - ## - # checks if the user can use the api + # Can the User use the API? # - # @return [Boolean] true if the user can use the api + # Returns Boolean def can_use_api? perms.include? Perm.use_api end - ## - # checks if the user can modify their org's details + # Can the User modify their org's details? # - # @return [Boolean] true if the user can modify the org's details + # Returns Boolean def can_modify_org_details? perms.include? Perm.change_org_details end - ## - # checks if the user can grant the api to organisations + # Can the User grant the api to organisations? # - # @return [Boolean] true if the user can grant api permissions to organisations + # Returns Boolean def can_grant_api_to_orgs? perms.include? Perm.grant_api end - ## - # removes the api_token from the user - # modifies the user model + # Removes the api_token from the user + # + # Returns nil + # Returns Boolean def remove_token! - unless api_token.blank? - update_column(:api_token, "") unless new_record? - end + return if new_record? + update_column(:api_token, nil) end - ## - # generates a new token for the user unless the user already has a token. - # modifies the user's model. + # Generates a new token for the user unless the user already has a token. + # + # Returns nil + # Returns Boolean def keep_or_generate_token! if api_token.nil? || api_token.empty? self.api_token = loop do @@ -239,29 +306,14 @@ end end - ## - # Load the user based on the scheme and id provided by the Omniauth call - # -------------------------------------------------------------- - def self.from_omniauth(auth) - scheme = IdentifierScheme.find_by(name: auth.provider.downcase) - - if scheme.nil? - throw Exception.new('Unknown OAuth provider: ' + auth.provider) - else - joins(:user_identifiers).where('user_identifiers.identifier': auth.uid, - 'user_identifiers.identifier_scheme_id': scheme.id).first - end - end - - ## - # Return the user's preferences for a given base key + # The User's preferences for a given base key # - # @return [JSON] with symbols as keys + # Returns Hash def get_preferences(key) defaults = Pref.default_settings[key.to_sym] || Pref.default_settings[key.to_s] - if self.pref.present? - existing = self.pref.settings[key.to_s].deep_symbolize_keys + if pref.present? + existing = pref.settings[key.to_s].deep_symbolize_keys # Check for new preferences defaults.keys.each do |grp| @@ -278,34 +330,47 @@ end end - ## # Override devise_invitable email title - # -------------------------------------------------------------- def deliver_invitation(options = {}) super(options.merge(subject: _('A Data Management Plan in %{application_name} has been shared with you') % {application_name: Rails.configuration.branding[:application][:name]})) end - ## + # Case insensitive search over User model - # @param field [string] The name of the field being queried - # @param val [string] The string to search for, case insensitive. val is duck typed to check whether or not downcase method exist - # @return [ActiveRecord::Relation] The result of the search + # + # field - The name of the field being queried + # val - The String to search for, case insensitive. val is duck typed to check + # whether or not downcase method exist. + # + # Returns ActiveRecord::Relation + # Raises ArgumentError def self.where_case_insensitive(field, val) - User.where("lower(#{field}) = ?", val.respond_to?(:downcase) ? val.downcase : val.to_s) + unless columns.map(&:name).include?(field.to_s) + raise ArgumentError, "Field #{field} is not present on users table" + end + User.where("LOWER(#{field}) = :value", value: val.to_s.downcase) end - # Acknoledge a Notification - # @param notification Notification to acknowledge + # Acknowledge a Notification + # + # notification - Notification to acknowledge + # + # Returns ActiveRecord::Associations::CollectionProxy + # Returns nil def acknowledge(notification) notifications << notification if notification.dismissable? end private - def when_org_changes - if org_id != org_id_was - unless can_change_org? - perms.delete_all - remove_token! - end - end + + # ============================ + # = Private instance methods = + # ============================ + + def delete_perms! + perms.destroy_all + end + + def clear_other_organisation + self.other_organisation = nil end end diff --git a/app/models/user/at_csv.rb b/app/models/user/at_csv.rb new file mode 100644 index 0000000..35a4689 --- /dev/null +++ b/app/models/user/at_csv.rb @@ -0,0 +1,35 @@ +class User + class AtCsv + + HEADERS = ['Name', 'E-Mail', 'Created Date', 'Last Activity', 'Plans', 'Current Privileges', 'Active'] + + def initialize(users) + @users = users + end + + def to_csv + CSV.generate(headers: true) do |csv| + csv << HEADERS + @users.each do |user| + name = "#{user.firstname} #{user.surname}" + email = user.email + created = I18n.l user.created_at.to_date, format: :csv + last_activity = I18n.l user.updated_at.to_date, format: :csv + plans = user.plans.size + active = user.active ? 'Yes' : 'No' + + if user.can_super_admin? + current_privileges = 'Super Admin' + elsif user.can_org_admin? + current_privileges = 'Organisational Admin' + else + current_privileges = '' + end + + csv << [ name, email, created, last_activity, plans, current_privileges, active ] + end + end + end + + end +end \ No newline at end of file diff --git a/app/models/user_identifier.rb b/app/models/user_identifier.rb index 840e394..5a1f5ad 100644 --- a/app/models/user_identifier.rb +++ b/app/models/user_identifier.rb @@ -1,9 +1,42 @@ +# == 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) +# + class UserIdentifier < ActiveRecord::Base + include ValidationMessages + + # ================ + # = Associations = + # ================ + belongs_to :user belongs_to :identifier_scheme - - # Should only be able to have one identifier per scheme! - validates_uniqueness_of :identifier_scheme, scope: :user - - validates :identifier, :user, :identifier_scheme, presence: {message: _("can't be blank")} -end \ No newline at end of file + + # =============== + # = Validations = + # =============== + + validates :user, presence: true + + validates :identifier_scheme, presence: { message: PRESENCE_MESSAGE } + + validates :identifier, presence: { message: PRESENCE_MESSAGE } + +end diff --git a/app/policies/guidance_policy.rb b/app/policies/guidance_policy.rb index 09cf419..491bf1a 100644 --- a/app/policies/guidance_policy.rb +++ b/app/policies/guidance_policy.rb @@ -46,20 +46,4 @@ def admin_unpublish? user.can_modify_guidance? end - - def update_phases? - user.can_modify_guidance? - end - - def update_versions? - user.can_modify_guidance? - end - - def update_sections? - user.can_modify_guidance? - end - - def update_questions? - user.can_modify_guidance? - end end \ No newline at end of file diff --git a/app/policies/phase_policy.rb b/app/policies/phase_policy.rb index 6972fd6..834d68e 100644 --- a/app/policies/phase_policy.rb +++ b/app/policies/phase_policy.rb @@ -37,4 +37,7 @@ user.can_modify_templates? && (phase.template.org_id == user.org_id) end -end \ No newline at end of file + def sort? + user.can_modify_templates? && (phase.template.org_id == user.org_id) + end +end diff --git a/app/policies/plan_policy.rb b/app/policies/plan_policy.rb index 44948ec..09dee32 100644 --- a/app/policies/plan_policy.rb +++ b/app/policies/plan_policy.rb @@ -3,7 +3,7 @@ attr_reader :plan def initialize(user, plan) - raise Pundit::NotAuthorizedError, _("must be logged in") unless user + raise Pundit::NotAuthorizedError, _("must be logged in") unless user raise Pundit::NotAuthorizedError, _("are not authorized to view that plan") unless plan || plan.publicly_visible? @user = user @plan = plan @@ -14,7 +14,7 @@ end def share? - @plan.editable_by?(@user.id) + @plan.editable_by?(@user.id) end def export? @@ -72,8 +72,4 @@ def update_guidances_list? @plan.editable_by?(@user.id) end - - def phase_status? - @plan.readable_by?(@user.id) - end end diff --git a/app/presenters/guidance_presenter.rb b/app/presenters/guidance_presenter.rb new file mode 100644 index 0000000..1cc90a7 --- /dev/null +++ b/app/presenters/guidance_presenter.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +class GuidancePresenter + + attr_accessor :plan + attr_accessor :guidance_groups + + def initialize(plan) + @plan = plan + @guidance_groups = plan.guidance_groups.where(published: true) + end + + def any?(org: nil, question: nil) + if org.nil? + if question.present? + # check each annotation/guidance group for a response to this question + # Would be nice not to have to crawl the entire list each time we want to know + # this + anno = orgs.reduce(false) do |found, o| + found || guidance_annotations?(org: o, question: question) + end + if !anno + return orgs.reduce(anno) do |found, o| + found || guidance_groups_by_theme?(org: o, question: question) + end + else + return anno + end + else # question.nil? + return hashified_annotations? || hashified_guidance_groups? + end + end + guidance_annotations?(org: org, question: question) || + guidance_groups_by_theme?(org: org, question: question) + end + + # filters through the orgs with annotations and guidance groups to create a + # set of tabs with display names and any guidance/annotations to show + # + # question - The question to which guidance pretains + # + # Returns an array of tab hashes. These + def tablist(question) + # start with orgs + # filter into hash with annotation_presence, main_group presence, and + display_tabs = [] + orgs.each do |org| + annotations = guidance_annotations(org: org, question: question) + groups = guidance_groups_by_theme(org: org, question: question) + main_groups = groups.select { |group| group.optional_subset == false } + subsets = groups.reject { |group| group.optional_subset == false } + if annotations.present? || main_groups.present? # annotations and main group + # Tab with org.abbreviation + display_tabs << { name: org.abbreviation, groups: main_groups, + annotations: annotations } + end + if subsets.present? + subsets.each_pair do |group, theme| + display_tabs << { name: group.name.truncate(15), groups: { group => theme } } + end + end + end + display_tabs + end + + private + + # Returns an Array of orgs according to the guidance related to plan + # Note the Array is sorted in the following order: + # First funder org (if the template from the plan is a customization of another) + # Second template owner's org + # The orgs from every guidance group selected for this plan + def orgs + return @orgs if defined?(@orgs) + @orgs = [] + orgs_from_annotations.each { |org| @orgs << org unless org_found(@orgs, org) } + orgs_from_guidance_groups.each { |org| @orgs << org unless org_found(@orgs, org) } + @orgs + end + + def org_found(orgs, org) + orgs.find do |lookup_org| + lookup_org.id == org.id + end.present? + end + + # Returns true if exists any guidance_group applicable to the org and question passed + def guidance_groups_by_theme?(org: nil, question: nil) + return false unless question.respond_to?(:themes) + return false unless hashified_guidance_groups.has_key?(org) + result = guidance_groups_by_theme(org: org, question: question) + .detect do |gg, theme_hash| + if theme_hash.present? + theme_hash.detect { |theme, guidances| guidances.present? } + else + false + end + end + result.present? + end + + # Returns true if exists any annotation applicable to the org and question passed + def guidance_annotations?(org: nil, question: nil) + return false unless question.respond_to?(:id) + return false unless hashified_annotations.has_key?(org) + hashified_annotations[org].find do |annotation| + (annotation.question_id == question.id) && (annotation.type == "guidance") + end.present? + end + + # Returns a hash of guidance groups for an org and question passed with the following + # structure: + # { guidance_group: { theme: [guidance, ...], ... }, ... } + def guidance_groups_by_theme(org: nil, question: nil) + raise ArgumentError unless question.respond_to?(:themes) + question = Question.includes(:themes).find(question.id) + return {} unless hashified_guidance_groups.has_key?(org) + hashified_guidance_groups[org].each_key.reduce({}) do |acc, gg| + filtered_gg = hashified_guidance_groups[org][gg].each_key.reduce({}) do |acc, theme| + if question.themes.include?(theme) + acc[theme] = hashified_guidance_groups[org][gg][theme] + end + acc + end + acc[gg] = filtered_gg if filtered_gg.present? + acc + end + end + + # Returns a collection of annotations (type guidance) for an org and question passed + def guidance_annotations(org: nil, question: nil) + raise ArgumentError unless question.respond_to?(:id) + return [] unless hashified_annotations.has_key?(org) + hashified_annotations[org].select do |annotation| + (annotation.question_id == question.id) && (annotation.type == "guidance") + end + end + + def orgs_from_guidance_groups + return @orgs_from_guidance_groups if defined?(@orgs_from_guidance_groups) + @orgs_from_guidance_groups = Org.joins(:guidance_groups) + .where(guidance_groups: { id: guidance_groups.ids }) + .distinct("orgs.id") + @orgs_from_guidance_groups + end + + def orgs_from_annotations + return @orgs_from_annotations if defined?(@orgs_from_annotations) + @orgs_from_annotations = [] + if plan.template.customization_of.present? + family_id = plan.template.customization_of + @orgs_from_annotations << Template.find_by(family_id: family_id).org + end + @orgs_from_annotations << plan.template.org + @orgs_from_annotations + end + + def hashified_guidance_groups + @hashified_guidance_groups ||= hashify_guidance_groups + end + + def hashified_guidance_groups? + result = hashified_guidance_groups.detect do |org, gg_hash| + if gg_hash.present? + gg_hash.detect do |gg, theme_hash| + if theme_hash.present? + theme_hash.detect { |theme, guidances| guidances.present? } + else + false + end + end + else + false + end + end + result.present? + end + + def hashified_annotations + @hashified_annotations ||= hashify_annotations + end + + def hashified_annotations? + hashified_annotations.detect { |org, annotations| annotations.present? }.present? + end + + # Hashifies guidance groups for a plan according to the distinct orgs into the + # following structure: + # { org: { guidance_group: { theme: [guidance, ...], ... }, ... }, ... } + def hashify_guidance_groups + hashified_guidances = hashify_guidances + orgs_from_guidance_groups.reduce({}) do |acc, org| + org_guidance_groups = hashified_guidances.each_key.select do |gg| + gg.org_id == org.id + end + acc[org] = org_guidance_groups.reduce({}) do |acc, gg| + acc[gg] = hashified_guidances[gg] + acc + end + acc + end + end + + # Hashifies guidances from a collection of guidance_groups passed into the following + # structure: + # { guidance_group: { theme: [guidance, ...], ... }, ... } + def hashify_guidances + guidance_groups.reduce({}) do |acc, gg| + themes = Theme.includes(:guidances) + .joins(:guidances) + .merge(Guidance.where(guidance_group_id: gg.id, published: true)) + acc[gg] = themes.reduce({}) do |acc, theme| + acc[theme] = theme.guidances + acc + end + acc + end + end + + def hashify_annotations + orgs_from_annotations.reduce({}) do |acc, org| + annotations = Annotation.where(org_id: org.id, + question_id: plan.template.question_ids) + acc[org] = annotations.select { |annotation| annotation.org_id = org.id } + acc + end + end + +end diff --git a/app/scrubbers/table_free_scrubber.rb b/app/scrubbers/table_free_scrubber.rb new file mode 100644 index 0000000..1c01d1e --- /dev/null +++ b/app/scrubbers/table_free_scrubber.rb @@ -0,0 +1,12 @@ +class TableFreeScrubber < Rails::Html::PermitScrubber + + TABLE_TAGS = %w[table thead tbody tr td th tfoot caption] + + ALLOWED_TAGS = Rails.application.config.action_view.sanitized_allowed_tags - TABLE_TAGS + + def initialize + super + self.tags = ALLOWED_TAGS + end + +end diff --git a/app/services/org/create_created_plan_service.rb b/app/services/org/create_created_plan_service.rb new file mode 100644 index 0000000..842382a --- /dev/null +++ b/app/services/org/create_created_plan_service.rb @@ -0,0 +1,30 @@ +class Org + class CreateCreatedPlanService + class << self + def call(org = nil) + orgs = org.nil? ? Org.all : [org] + + orgs.each do |org| + OrgDateRangeable.split_months_from_creation(org) do |start_date, end_date| + create_count_for_date(start_date: start_date, end_date: end_date, org: org) + end + end + end + + private + + def count_plans(start_date: , end_date: , org:) + users = User.where('users.org_id = ?', org.id) + plans = Plan.where('plans.created_at >= ? AND plans.created_at <= ?', start_date, end_date) + creator_admon = Role.with_access_flags(:creator, :administrator) + + Role.joins([:plan, :user]).merge(creator_admon).merge(users).merge(plans).select(:plan_id).distinct.count + end + + def create_count_for_date(start_date:, end_date:, org:) + count = count_plans(start_date: start_date, end_date: end_date, org: org) + StatCreatedPlan.create(date: end_date.to_date, count: count, org_id: org.id) + end + end + end +end diff --git a/app/services/org/create_joined_user_service.rb b/app/services/org/create_joined_user_service.rb new file mode 100644 index 0000000..02dbbb2 --- /dev/null +++ b/app/services/org/create_joined_user_service.rb @@ -0,0 +1,25 @@ +class Org + class CreateJoinedUserService + class << self + def call(org = nil) + orgs = org.nil? ? ::Org.all : [org] + orgs.each do |org| + OrgDateRangeable.split_months_from_creation(org) do |start_date, end_date| + create_count_for_date(start_date: start_date, end_date: end_date, org: org) + end + end + end + + private + + def count_users(start_date: , end_date: , org_id: ) + User.where('created_at >= ? AND created_at <= ? AND org_id = ?', start_date, end_date, org_id).count + end + + def create_count_for_date(start_date:, end_date:, org:) + count = count_users(start_date: start_date, end_date: end_date, org_id: org.id) + ::StatJoinedUser.create(date: end_date.to_date, count: count, org_id: org.id) + end + 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 new file mode 100644 index 0000000..3f42100 --- /dev/null +++ b/app/services/org/create_last_month_created_plan_service.rb @@ -0,0 +1,32 @@ +class Org + class CreateLastMonthCreatedPlanService + class << self + def call(org = nil) + orgs = org.nil? ? ::Org.all : [org] + + orgs.each do |org| + months = OrgDateRangeable.split_months_from_creation(org) + last = months.last + if last.present? + create_count_for_date(start_date: last[:start_date], end_date: last[:end_date], org: org) + end + end + end + + private + + def count_plans(start_date: , end_date: , org:) + users = User.where('users.org_id = ?', org.id) + plans = Plan.where('plans.created_at >= ? AND plans.created_at <= ?', start_date, end_date) + creator_admon = Role.with_access_flags(:creator, :administrator) + + Role.joins([:plan, :user]).merge(creator_admon).merge(users).merge(plans).select(:plan_id).distinct.count + end + + def create_count_for_date(start_date:, end_date:, org:) + count = count_plans(start_date: start_date, end_date: end_date, org: org) + ::StatCreatedPlan.create(date: end_date.to_date, count: count, org_id: org.id) + end + end + end +end diff --git a/app/services/org/create_last_month_joined_user_service.rb b/app/services/org/create_last_month_joined_user_service.rb new file mode 100644 index 0000000..58817d2 --- /dev/null +++ b/app/services/org/create_last_month_joined_user_service.rb @@ -0,0 +1,27 @@ +class Org + class CreateLastMonthJoinedUserService + class << self + def call(org = nil) + orgs = org.nil? ? ::Org.all : [org] + orgs.each do |org| + months = OrgDateRangeable.split_months_from_creation(org) + last = months.last + if last.present? + create_count_for_date(start_date: last[:start_date], end_date: last[:end_date], org: org) + end + end + end + + private + + def count_users(start_date: , end_date: , org_id: ) + User.where('created_at >= ? AND created_at <= ? AND org_id = ?', start_date, end_date, org_id).count + end + + def create_count_for_date(start_date:, end_date:, org:) + count = count_users(start_date: start_date, end_date: end_date, org_id: org.id) + ::StatJoinedUser.create(date: end_date.to_date, count: count, org_id: org.id) + end + end + end +end diff --git a/app/services/org/total_count_created_plan_service.rb b/app/services/org/total_count_created_plan_service.rb new file mode 100644 index 0000000..41d8a4e --- /dev/null +++ b/app/services/org/total_count_created_plan_service.rb @@ -0,0 +1,28 @@ +class Org + class TotalCountCreatedPlanService + class << self + def call(org = nil) + return for_orgs unless org.present? + for_org(org) + end + + private + + def for_orgs + result = ::StatCreatedPlan.includes(:org).select(:"orgs.name", :count).group(:"orgs.name").sum(:count) + result.each_pair.map do |pair| + build_model(org_name: pair[0], count: pair[1].to_i) + end + end + + def for_org(org) + result = ::StatCreatedPlan.where(org: org).sum(:count) + build_model(org_name: org.name, count: result) + end + + def build_model(org_name: , count: ) + { org_name: org_name, count: count } + end + end + end +end diff --git a/app/services/org/total_count_joined_user_service.rb b/app/services/org/total_count_joined_user_service.rb new file mode 100644 index 0000000..0f63d41 --- /dev/null +++ b/app/services/org/total_count_joined_user_service.rb @@ -0,0 +1,28 @@ +class Org + class TotalCountJoinedUserService + class << self + def call(org = nil) + return for_orgs unless org.present? + for_org(org) + end + + private + + def for_orgs + result = ::StatJoinedUser.includes(:org).select(:"orgs.name", :count).group(:"orgs.name").sum(:count) + result.each_pair.map do |pair| + build_model(org_name: pair[0], count: pair[1].to_i) + end + end + + def for_org(org) + result = ::StatJoinedUser.where(org: org).sum(:count) + build_model(org_name: org.name, count: result) + end + + def build_model(org_name: , count: ) + { org_name: org_name, count: count } + end + end + end +end diff --git a/app/services/org/total_count_stat_service.rb b/app/services/org/total_count_stat_service.rb new file mode 100644 index 0000000..064a466 --- /dev/null +++ b/app/services/org/total_count_stat_service.rb @@ -0,0 +1,50 @@ +class Org + class TotalCountStatService + class << self + def call + total = build_from_joined_user + build_from_created_plan(total) + total.values + end + + private + + def build_model(org_name:, total_users: 0, total_plans: 0) + { + org_name: org_name, + total_users: total_users, + total_plans: total_plans + } + end + + def reducer_body(acc, count, key_target) + org_name = count[:org_name] + count = count[:count] + + if acc[org_name].present? + acc[org_name][key_target] = count + else + args = { org_name: org_name } + args[key_target] = count + acc[org_name] = build_model(args) + end + + acc + end + + def build_from_joined_user(total = {}) + 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 + created_plan_count.reduce(total) do |acc, count| + reducer_body(acc, count, :total_plans) + end + end + end + end +end diff --git a/app/services/template/upgrade_customization_service.rb b/app/services/template/upgrade_customization_service.rb new file mode 100644 index 0000000..95faddc --- /dev/null +++ b/app/services/template/upgrade_customization_service.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +class Template + + # Service object to upgrade a customization Template with new changes from the original + # funder Template. Remember: {target_template} is a customization of funder Template. + # + # - Duplicate the init template (Duplication called {#customized_template}) + # + # - Create a new customisation of funder template (Customization called + # {#target_template}) + # + # - Take each phase on the {#target_template} and iterate to find if there's a + # corresponding one in {#customized_template} + # - Test for each of corresponding phase in source + # - Copy over each of the modifiable sections from source to the target + # - Re-number the sections if necessary to keep the display order (number) the same + # - Copy each of the questions and annotations exactly + # - For each unmodifiable section, copy over any modifiable questions from target + # + # - Copy each of the modifiable sections from the {#customized_template} to the + # {#target_template} + # + class UpgradeCustomizationService + + # Exception raised when the Template is not a customization. + class NotACustomizationError < StandardError + end + + # Exception raised when no published funder Template can be found. + class NoFunderTemplateError < StandardError + end + + + ## + # The Template we're upgrading + # + # Returns {Template} + attr_reader :init_template + + # Initialize a new instance and run the script + # + # template - The Template we're upgrading + # + # Returns {Template} + def self.call(template) + new(template).call + end + + private_class_method :new + + # Initialize a new record + # + # template - The Template we're upgrading. Sets the value for {#init_template} + # + def initialize(template) + @init_template = template + end + + # Run the script + # + # Returns {Template} + def call + Template.transaction do + if init_template.customization_of.blank? + raise NotACustomizationError, + _("upgrade_customization! requires a customised template") + end + if funder_template.nil? + # rubocop:disable Metrics/LineLength + raise NoFunderTemplateError, + _("upgrade cannot be carried out since there is no published template of its current funder") + # rubocop:enable Metrics/LineLength + end + + # Merges modifiable sections or questions from source into target_template object + target_template.phases.map do |funder_phase| + # Search for the phase in the source template whose versionable_id matches the + # customization_phase + # + # a) If the Org's template ({#customized_template}) has the Phase... + if customized_phase = find_matching_record_in_collection( + record: funder_phase, + collection: customized_template.phases) + + # b) If the Org's template ({#customized_template}) doesn't have this Phase. + # This is not a problem, since {#customization_template} should have this + # Phase copied over from {#template_phase}. + else + next + end + copy_modifiable_sections_for_phase(customized_phase, funder_phase) + sort_sections_within_phase(funder_phase) + end + copy_custom_annotations_for_questions + end + target_template + end + + private + + # The funder Template for this {#template} + # + # Returns Template + def funder_template + @funder_template ||= Template.published(init_template.customization_of).first + end + + # A copy of the Template we're currently upgrading. Preserves modifiable flags from + # the self template copied + # + # + # Returns {Template} + def customized_template + @customized_template ||= init_template.deep_copy(attributes: { + version: init_template.version + 1, + published: false + }) + end + + # Creates a new customisation for the published template whose family_id {#template} + # is a customization of + # + # Returns {Template} + def target_template + @target_template ||= funder_template.deep_copy( + attributes: { + version: customized_template.version, + published: customized_template.published, + family_id: customized_template.family_id, + customization_of: customized_template.customization_of, + org: customized_template.org, + visibility: Template.visibilities[:organisationally_visible], + is_default: false + }, modifiable: false, save: true + ) + end + + # Find an item within collection that has the same versionable_id as record + # + # record - The record we're searching for a match of + # collection - The collection of records we're searching in + # + # Returns Positionable + # + # Returns nil + def find_matching_record_in_collection(record:, collection:) + collection.detect { |item| item.versionable_id == record.versionable_id } + end + + # Attach modifiable sections into the customization phase + # + # source_phase - A Phase to copy sections for. + # target_phase - A Phase to copy Sections to. + # + # Returns Array of Sections + def copy_modifiable_sections_for_phase(source_phase, target_phase) + target_section_ids = target_phase.sections.pluck(:versionable_id) + source_phase.sections.select(&:modifiable?).each do |section| + if section.number.in?(target_phase.sections.pluck(:number)) + section.number = target_phase.sections.maximum(:number) + 1 + end + target_phase.sections.append(section) or + raise("Unable to add Section##{section.id} to Phase##{target_phase.id}") + end + end + + def copy_custom_annotations_for_questions + init_template.annotations.where(org: template_org).each do |custom_annotation| + target_question = target_template.questions.find_by( + versionable_id: custom_annotation.question.versionable_id + ) + target_question.annotations << custom_annotation + end + end + + def sort_sections_within_phase(phase) + phase.sections = SectionSorter.new(*phase.sections).sort! + end + + def template_org + init_template.org + end + + end + +end diff --git a/app/validators/after_validator.rb b/app/validators/after_validator.rb new file mode 100644 index 0000000..57fe579 --- /dev/null +++ b/app/validators/after_validator.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +class AfterValidator < ActiveModel::EachValidator + + DEFAULT_MESSAGE = _("must be after %{date}") + + def validate_each(record, attribute, value) + return if value.nil? + return if record.persisted? && options[:on].to_s == 'create' + return if record.new_record? && options[:on].to_s == 'update' + date = options[:date] + msg = options.fetch(:message, DEFAULT_MESSAGE % { date: options[:date] }) + record.errors.add(attribute, msg) if value.to_date < options[:date] + end +end diff --git a/app/views/answers/_new_edit.html.erb b/app/views/answers/_new_edit.html.erb index 063ce59..ca81af5 100644 --- a/app/views/answers/_new_edit.html.erb +++ b/app/views/answers/_new_edit.html.erb @@ -6,7 +6,7 @@ <% q_format = question.question_format %> <% if q_format.rda_metadata? %> <p> - <strong><%= raw question.text %></strong> + <strong><%= sanitize question.text %></strong> </p> <% answer_hash = answer.answer_hash %> <div class="rda_metadata"><button class="remove-standard" style="display:none;"></button> @@ -65,14 +65,14 @@ </fieldset> <!--Example Answer area --> <% if template.present? && template.org.present? %> - <% question.get_example_answers([base_template_org.id, template.org.id]).each do |annotation| %> + <% question.example_answers([base_template_org.id, template.org.id]).each do |annotation| %> <% if annotation.present? && annotation.org.present? && annotation.text.present? %> <div class="panel panel-default"> <span class="label label-default"> <%="#{annotation.org.abbreviation} "%> <%=_('example answer')%> </span> <div class="panel-body"> - <%= raw annotation.text %> + <%= sanitize annotation.text %> </div> </div> <% end %> diff --git a/app/views/contact_us/contacts/new.html.erb b/app/views/contact_us/contacts/new.html.erb index 5fd93e4..da11501 100644 --- a/app/views/contact_us/contacts/new.html.erb +++ b/app/views/contact_us/contacts/new.html.erb @@ -3,7 +3,7 @@ <div class="col-md-12"> <h1><%= _("Contact Us") %></h1> <p> - <%= raw _('%{application_name} is provided by the %{organisation_name}.<br /> You can find out more about us on our <a href="%{organisation_url}" target="_blank">website</a>. If you would like to contact us about %{application_name}, please fill out the form below.') % {organisation_name: Rails.configuration.branding[:organisation][:name], + <%= sanitize _('%{application_name} is provided by the %{organisation_name}.<br /> You can find out more about us on our <a href="%{organisation_url}" target="_blank">website <em class="sr-only">(new window)</em></a>. If you would like to contact us about %{application_name}, please fill out the form below.') % {organisation_name: Rails.configuration.branding[:organisation][:name], organisation_url: Rails.configuration.branding[:organisation][:url], application_name: Rails.configuration.branding[:application][:name]} %> </p> diff --git a/app/views/devise/invitations/edit.html.erb b/app/views/devise/invitations/edit.html.erb index 9698605..788278f 100644 --- a/app/views/devise/invitations/edit.html.erb +++ b/app/views/devise/invitations/edit.html.erb @@ -14,11 +14,11 @@ <div class="form-group"> <%= f.label(:password, _('New password'), class: 'control-label') %> - <%= f.password_field(:password, class: 'form-control', "aria-required": true, "data-validation": "password") %> + <%= f.password_field(:password, class: 'form-control', "aria-required": true) %> </div> <div class="form-group"> <%= f.label(:password_confirmation, _('Password confirmation'), class: 'control-label') %> - <%= f.password_field(:password_confirmation, class: 'form-control', "aria-required": true, "data-validation": "password") %> + <%= f.password_field(:password_confirmation, class: 'form-control', "aria-required": true) %> </div> <%= f.button(_('Create account'), class: "btn btn-default", type: "submit") %> diff --git a/app/views/devise/mailer/invitation_instructions.html.erb b/app/views/devise/mailer/invitation_instructions.html.erb index 5e11588..d03884b 100644 --- a/app/views/devise/mailer/invitation_instructions.html.erb +++ b/app/views/devise/mailer/invitation_instructions.html.erb @@ -13,7 +13,9 @@ <%= _('A colleague has invited you to contribute to their Data Management Plan in %{tool_name}') %{ :tool_name => tool_name } %> </p> <p> - <%= raw(_('%{click_here} to accept the invitation, (or copy %{link} into your browser). If you don\'t want to accept the invitation, please ignore this email.') %{ :click_here => link_to(_('Click here'), link), :link => link }) %> + <%= sanitize(_('%{click_here} to accept the invitation, (or copy %{link} into your browser). If you don\'t want to accept the invitation, please ignore this email.') % { + click_here: link_to(_('Click here'), link), link: link + }) %> </p> <p> <%= _('All the best') %> @@ -21,6 +23,11 @@ <%= _('The %{tool_name} team') %{:tool_name => tool_name} %> </p> <p> - <%= _('Please do not reply to this email.') %> <%= raw(_('If you have any questions or need help, please contact us at %{helpdesk_email} or visit %{contact_us}') %{ :helpdesk_email => mail_to(helpdesk_email, helpdesk_email, subject: email_subject), :contact_us_url => link_to(contact_us, contact_us) }) %> + <%= _('Please do not reply to this email.') %>  + <%= sanitize(_('If you have any questions or need help, please contact us at %{helpdesk_email} or visit %{contact_us}') % { + helpdesk_email: mail_to(helpdesk_email, helpdesk_email, + subject: email_subject), + contact_us_url: link_to(contact_us, contact_us) + }) %> </p> <% end %> \ No newline at end of file diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb index 76dde6e..bb99b42 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -19,6 +19,6 @@ <%= _('The %{tool_name} team') %{:tool_name => tool_name} %> </p> <p> - <%= _('Please do not reply to this email.') %> <%= raw(_('If you have any questions or need help, please contact us at %{helpdesk_email} or visit %{contact_us}') %{ :helpdesk_email => mail_to(helpdesk_email, helpdesk_email, subject: email_subject), :contact_us => link_to(contact_us, contact_us) }) %> + <%= _('Please do not reply to this email.') %> <%= sanitize(_('If you have any questions or need help, please contact us at %{helpdesk_email} or visit %{contact_us}') %{ :helpdesk_email => mail_to(helpdesk_email, helpdesk_email, subject: email_subject), :contact_us => link_to(contact_us, contact_us) }) %> </p> <% end %> diff --git a/app/views/devise/registrations/_personal_details.html.erb b/app/views/devise/registrations/_personal_details.html.erb index 19c1d10..29ae105 100644 --- a/app/views/devise/registrations/_personal_details.html.erb +++ b/app/views/devise/registrations/_personal_details.html.erb @@ -1,6 +1,6 @@ <%= 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| %> <p class="form-control-static"> - <%= raw _("Please note that your email address is used as your username. If you change this, remember to use your new email address on sign in.") %> + <%= sanitize _("Please note that your email address is used as your username. If you change this, remember to use your new email address on sign in.") %> </p> <p class="form-control-static"><%= _('You can edit any of the details below.') %></p> @@ -23,14 +23,14 @@ </div> <% org_admin = (current_user.can_org_admin? && !current_user.can_super_admin?) %> - <div class="form-group col-xs-8" id="org-controls" <%= raw "data-toggle=\"tooltip\" title=\"#{_('Changing your organisation will result in the loss of your administrative privileges.')}\"" if org_admin %>> + <div class="form-group col-xs-8" id="org-controls" <%= "data-toggle=\"tooltip\" title=\"#{_('Changing your organisation will result in the loss of your administrative privileges.')}\"" if org_admin %>> <%= render partial: "shared/my_org", locals: {f: f, default_org: @default_org, orgs: @orgs, allow_other_orgs: true, required: true} %> </div> <% if org_admin %> <input type="hidden" id="original_org" value="<%= @user.org_id %>"> <% end %> - <% if MANY_LANGUAGES %> + <% if Language.many? %> <div class="form-group col-xs-8"> <% lang_id = current_user.language.nil? ? Language.id_for(FastGettext.default_locale) : current_user.language.id %> <%= f.label(:language_id, _('Language'), class: 'control-label') %> diff --git a/app/views/guidance_groups/_guidance_group_form.html.erb b/app/views/guidance_groups/_guidance_group_form.html.erb index 730f2bb..4ae1345 100644 --- a/app/views/guidance_groups/_guidance_group_form.html.erb +++ b/app/views/guidance_groups/_guidance_group_form.html.erb @@ -4,11 +4,23 @@ </div> <fieldset> <div class="checkbox"> - <%= f.label :published, raw("#{f.check_box :published, 'data-toggle': 'tooltip', title: _('Check this box when you are ready for guidance associated with this group to appear on user\'s plans.')} #{_('Published')}") %> + <%= f.label :published do %> + <%= f.check_box :published, + data: { toggle: 'tooltip' }, + title: _('Check this box when you are ready for guidance associated with this group to appear on user\'s plans.') + %> + <%= _('Published') %> + <% end %> </div> <div class="checkbox"> - <%= f.label :optional_subset, raw("#{f.check_box :optional_subset, 'data-toggle': 'tooltip', title: _('If the guidance is only meant for a subset of users e.g. those in a specific college or institute, check this box. Users will be able to select to display this subset guidance when answering questions in the \'create plan\' wizard.')} #{_('Optional Subset (e.g. School/Department)')}") %> + <%= f.label :optional_subset do %> + <%= f.check_box :optional_subset, + data: { toggle: 'tooltip' }, + title: _('If the guidance is only meant for a subset of users e.g. those in a specific college or institute, check this box. Users will be able to select to display this subset guidance when answering questions in the \'create plan\' wizard.') + %> + <%= _('Optional Subset (e.g. School/Department)') %> + <% end %> </div> </fieldset> <%= f.submit _('Save'), class: 'btn btn-primary' %> \ No newline at end of file diff --git a/app/views/guidance_groups/_index_by_theme.html.erb b/app/views/guidance_groups/_index_by_theme.html.erb new file mode 100644 index 0000000..542856d --- /dev/null +++ b/app/views/guidance_groups/_index_by_theme.html.erb @@ -0,0 +1,56 @@ +<%# locals{ guidance_groups_by_theme } %> +<% parent_id = guidance_groups_by_theme.object_id %> +<div id="<%= parent_id %>" class="panel-group" role="tablist" aria-multiselectable="true"> + <div id="guidance-accordion-controls"> + <div class="accordion-controls" data-parent="<%= parent_id %>"> + <a href="#" data-toggle-direction="show"><%= _('expand all') %></a> + <span>|</span> + <a href="#" data-toggle-direction="hide"><%= _('collapse all') %></a> + </div> + </div> + <% guidance_groups_by_theme.each_pair do |guidance_group, theme_hash| %> + <% guidances_output = [] %> + <% theme_hash.each_pair do |theme, guidances| %> + <%# if guidances with this theme have not been output %> + <% if (guidances.map(&:id) - guidances_output).any? %> + <div class="panel panel-default"> + <div + class="heading-button" + role="button" + data-toggle="collapse" + href="<%= "##{guidances.object_id}" %>" + aria-expanded="false" + aria-controls="<%= "##{guidances.object_id}" %>"> + <div class="panel-heading" role="tab" id="<%= "panel-heading-#{guidances.object_id}" %>"> + <h2 class="panel-title"> + <%= theme.title %> + <i class="fa fa-plus pull-right" aria-hidden="true"></i> + </h2> + </div> + </div> + <div + id="<%= "#{guidances.object_id}" %>" + class="panel-collapse collapse" + role="tabpanel" + aria-labelledby="<%= "panel-heading-#{guidances.object_id}" %>"> + <div class="panel-body"> + <% multiple = false %> + <% guidances.each do |guidance| %> + <% if multiple %> + <hr> + <% end %> + <p> + <% unless guidances_output.include?(guidance.id) %> + <%= sanitize(guidance.text) %> + <% guidances_output << guidance.id %> + <% multiple = true %> + <% end %> + </p> + <% end %> + </div> + </div> + </div> + <% end %> + <% end %> + <% end %> +</div> diff --git a/app/views/guidance_groups/_show.html.erb b/app/views/guidance_groups/_show.html.erb index 4b21cea..27a374b 100644 --- a/app/views/guidance_groups/_show.html.erb +++ b/app/views/guidance_groups/_show.html.erb @@ -12,11 +12,11 @@ <% group.keys.each_with_index do |theme, i| %> <div class="panel panel-default"> <div class="heading-button" role="button" data-toggle="collapse" - data-parent="<%= question.id %>-guidance" + data-parent="<%= question.id %>-guidance" href="#collapse-guidance-<%= question.id %>-<%= guidance_accordion_id %>-<%= i %>" - aria-expanded="false" + aria-expanded="false" aria-controls="#collapse-guidance-<%= question.id %>-<%= guidance_accordion_id %>-<%= i %>"> - + <div class="panel-heading" role="tab" id="heading-<%= i %>"> <h2 class="panel-title"> <%= theme %> @@ -28,7 +28,7 @@ aria-labelledby="heading-guidance-<%= question.id %>-<%= guidance_accordion_id %>-<%= i %>"> <div class="panel-body"> <% group[theme].each do |guidance| %> - <%= raw guidance %> + <%= sanitize guidance %> <% end %> </div> </div> diff --git a/app/views/guidance_groups/admin_edit.html.erb b/app/views/guidance_groups/admin_edit.html.erb index 606af02..180c1eb 100644 --- a/app/views/guidance_groups/admin_edit.html.erb +++ b/app/views/guidance_groups/admin_edit.html.erb @@ -9,13 +9,13 @@ <div class="row"> <div class="col-md-12"> <%= form_for(@guidance_group, url: admin_update_guidance_group_path(@guidance_group), html: {method: :put, id: "admin_update_guidance_group_form" }) do |f| %> - + <div class="form-group col-xs-8"> <%= f.label _('Name'), for: :name, class: "control-label" %> <%= f.text_field :name, as: :string, class: "form-control", 'aria-required': true, 'data-toggle': 'tooltip', title: _('Add an appropriate name for your guidance group. This name will tell the end user where the guidance has come from. We suggest you use the organisation or department name e.g. "OU" or "Maths & Stats"') %> </div> - <div class='form-input col-xs-8'> + <div class='form-input col-xs-8'> <%= f.check_box :published, 'data-toggle': 'tooltip', title: _("Check this box when you are ready for guidance associated with this group to appear on user's plans.") %> <%= f.label _('Published'), for: :published, class: "control-label" %> </div> @@ -24,12 +24,9 @@ <%= f.check_box :optional_subset, 'data-toggle': 'tooltip', title: _("If the guidance is only meant for a subset of users e.g. those in a specific college or institute, check this box. Users will be able to select to display this subset guidance when answering questions in the 'create plan' wizard.") %> <%= f.label _('Optional Subset'), for: :optional_subset, class: "control-label" %><%= _(' (e.g. School/ Department) ') %> </div> - + <div class="form-group col-xs-8"> <%= f.submit _('Save'), class: "btn btn-primary" %> - <% if @guidance_group.published == false then %> - <%= f.submit _('Publish'), name: "save_publish", class: "btn btn-primary" %> - <% end %> <%= link_to _('Cancel'), admin_index_guidance_path, class: "btn btn-primary", role: 'button' %> </div> <% end %> diff --git a/app/views/guidance_groups/admin_new.html.erb b/app/views/guidance_groups/admin_new.html.erb index c20ab18..095cc41 100644 --- a/app/views/guidance_groups/admin_new.html.erb +++ b/app/views/guidance_groups/admin_new.html.erb @@ -14,11 +14,11 @@ <%= f.text_field :name, as: :string, class: "form-control", 'aria-required': true, 'data-toggle': 'tooltip', title: _('Add an appropriate name for your guidance group. This name will tell the end user where the guidance has come from. We suggest you use the organisation or department name e.g. "OU" or "Maths & Stats"') %> </div> <fieldset class='col-xs-12'> - <div class='form-input'> + <div class='form-input'> <%= f.check_box :published, 'data-toggle': 'tooltip', title: _("Check this box when you are ready for guidance associated with this group to appear on user's plans.") %> <%= f.label _('Published'), class: "control-label" %> </div> - + <div class='form-input'> <%= f.check_box :optional_subset, 'data-toggle': 'tooltip', title: _("If the guidance is only meant for a subset of users e.g. those in a specific college or institute, check this box. Users will be able to select to display this subset guidance when answering questions in the 'create plan' wizard.") %> <%= f.label _('Optional Subset'), class: "control-label" %><%= _(' (e.g. School/ Department) ') %> diff --git a/app/views/guidances/_guidance_display.html.erb b/app/views/guidances/_guidance_display.html.erb index b3bce3b..3454f45 100644 --- a/app/views/guidances/_guidance_display.html.erb +++ b/app/views/guidances/_guidance_display.html.erb @@ -2,7 +2,7 @@ <div class="question-guidance"> <div class="accordion" id="<%= question.id %>-guidance"> - <% if !question.get_guidance_annotation(current_user.org).nil? && question.get_guidance_annotation(current_user.org) != "" %> + <% if !question.guidance_annotation(current_user.org).nil? && question.guidance_annotation(current_user.org) != "" %> <div class="accordion-group"> <div class="accordion-heading"> <a class="accordion-guidance-link" data-toggle="collapse" data-parent="#<%= question.id %>-guidance" href="#collapse-guidance-<%= question.id%>"> @@ -12,7 +12,9 @@ <span class="plus-laranja"> </span></a> </div> <div id="collapse-guidance-<%= question.id%>" class="guidance-accordion-body collapse"> - <div class="accordion-inner"><%= raw question.get_guidance_annotation(current_user.org).text %></div> + <div class="accordion-inner"> + <%= sanitize question.guidance_annotation(current_user.org).text %> + </div> </div> </div> <% end %> @@ -27,7 +29,7 @@ <span class="plus-laranja"> </span></a> </div> <div id="collapse-guidance-<%= guidance.id%>-<%= question.id %>" class="guidance-accordion-body collapse"> - <div class="accordion-inner"><%= raw guidance.text %></div> + <div class="accordion-inner"><%= sanitize guidance.text %></div> </div> </div> <% end %> diff --git a/app/views/guidances/admin_index.html.erb b/app/views/guidances/admin_index.html.erb index 15c1f2c..d7fc08b 100644 --- a/app/views/guidances/admin_index.html.erb +++ b/app/views/guidances/admin_index.html.erb @@ -4,7 +4,7 @@ <h1><%= _('Guidance') %></h1> <p class="text-justify"> - <%= raw _("First create a guidance group. This could be organisation wide or a subset e.g. a particular College / School, Institute or department. When you create guidance you'll be asked to assign it to a guidance group.") %> + <%= sanitize _("First create a guidance group. This could be organisation wide or a subset e.g. a particular College / School, Institute or department. When you create guidance you'll be asked to assign it to a guidance group.") %> </p> </div> </div> @@ -12,7 +12,7 @@ <div class="row"> <div class="col-md-12"> <h2><%= _('Guidance group list') %></h2> - + <!-- List of guidance groups --> <%= paginable_renderise( partial: '/paginable/guidance_groups/index', @@ -40,7 +40,7 @@ action: 'index', scope: @guidances, query_params: { sort_field: 'guidances.text', sort_direction: :asc }) %> - + <div> <a href="<%= admin_new_guidance_path %>" class="btn btn-primary"><%= _('Create guidance') %></a> </div> diff --git a/app/views/guidances/new_edit.html.erb b/app/views/guidances/new_edit.html.erb index 28e3cf8..b4b1e1a 100644 --- a/app/views/guidances/new_edit.html.erb +++ b/app/views/guidances/new_edit.html.erb @@ -1,3 +1,13 @@ +<% +if @guidance.id.present? + url = admin_update_guidance_path(@guidance) + method = "PUT" +else + url = admin_create_guidance_path + method = "POST" +end +%> + <% title _('Guidance') %> <%# locals: { guidance, themes, guidance_groups, options } %> <div class="row"> @@ -8,21 +18,29 @@ </div> <div class="row"> <div class="col-xs-12"> - <%= form_for(guidance, url: options[:url], html: { method: options[:method] , id: 'new_edit_guidance'}) do |f| %> + <%= form_for(@guidance, url: url, html: { method: method , id: 'new_edit_guidance'}) do |f| %> <div class="form-group" data-toggle="tooltip" title="<%= _('Enter your guidance here. You can include links where needed.') %>"> <%= f.label :text, class: 'control-label' %> - <%= text_area_tag("guidance-text", guidance.text, class: "form-control", 'aria-required': true, rows: 10) %> + <%= text_area_tag("guidance-text", @guidance.text, class: "form-control", 'aria-required': true, rows: 10) %> </div> <%= render partial: 'org_admin/shared/theme_selector', - locals: { f: f, all_themes: themes, as_radio: false, required: true, + locals: { f: f, all_themes: Theme.all.order("title"), as_radio: false, required: true, + in_error: @guidance.errors[:themes].present?, popover_message: _('Select one or more themes that are relevant to this guidance. This will display your generic organisation-level guidance, or any Schools/Departments for which you create guidance groups, across all templates that have questions with the corresponding theme tags.') } %> <div class="form-group"> <%= f.label _('Guidance group'), for: :guidance_group_id, class: 'control-label' %> - <%= f.collection_select(:guidance_group_id, guidance_groups, + <%= f.collection_select(:guidance_group_id, + GuidanceGroup.where(org_id: current_user.org_id).order("name ASC"), :id, :name, {prompt: false}, {multiple: false, 'data-toggle': 'tooltip', title: _('Select which group this guidance relates to.'), class: 'form-control', 'aria-required': true})%> </div> <div class="checkbox"> - <%= f.label :published, raw("#{f.check_box :published, as: :check_boxes, 'data-toggle': 'tooltip', title: _("Check this box when you are ready for this guidance to appear on user's plans.")} #{_('Published?')}") %> + <%= f.label :published do %> + <%= f.check_box :published, + as: :check_boxes, + data: { toggle: 'tooltip' }, + title: _("Check this box when you are ready for this guidance to appear on user's plans.") %> + <%= _('Published?') %> + <% end %> </div> <div class="form-group clear-fix"> <%= f.submit _('Save'), name: "edit_guidance_submit", class: "btn btn-primary" %> diff --git a/app/views/home/_welcome.html.erb b/app/views/home/_welcome.html.erb index 7d5ce6a..8d7fb35 100644 --- a/app/views/home/_welcome.html.erb +++ b/app/views/home/_welcome.html.erb @@ -2,12 +2,18 @@ <div> <h1><%= _("Welcome to #{Rails.configuration.branding[:application][:name]}.")%></h1> <p> - <%= raw _('<p>%{application_name} has been developed by the <strong>%{organisation_name}</strong> to help you write data management plans.</p>') % {:application_name => Rails.configuration.branding[:application][:name], :organisation_name => Rails.configuration.branding[:organisation][:name]} %> - <label for="linklist"><%=_('Getting started:')%></label> - <ul id="linklist"> + <%= sanitize _('<p>%{application_name} has been developed by the <strong>%{organisation_name}</strong> to help you write data management plans.</p>') % { + application_name: Rails.configuration.branding[:application][:name], + organisation_name: Rails.configuration.branding[:organisation][:name]} + %> + + <label for="linklist"> + <%=_('Getting started:')%> + </label> + <ul id="linklist"> <% Rails.application.config.branding[:organisation][:welcome_links].each do |wlink| %> - <li><a href="<%= wlink[:url] %>"><%= wlink[:title] %></a></li> + <li><a href="<%= wlink[:url] %>"><%= wlink[:title] %></a></li> <% end %> - </ul> + </ul> </p> </div> diff --git a/app/views/layouts/_branding.html.erb b/app/views/layouts/_branding.html.erb index 896ebee..566d49c 100644 --- a/app/views/layouts/_branding.html.erb +++ b/app/views/layouts/_branding.html.erb @@ -34,6 +34,7 @@ <%= link_to link['link'], :target=>'_blank', :class => 'org-a' do %> <i class="fa fa-globe" aria-hidden="true"> </i> <%= link['text'].blank? ? link['link'] : link['text'] %> + <em class="sr-only"> (new window)</em> <% end; i+=1 %> <% end %> <% end %> @@ -61,49 +62,49 @@ </a> <ul class="dropdown-menu" aria-labelledby="admin-menu"> <% if current_user.can_org_admin? %> - <li <%= 'class=active' if isActivePage(org_admin_plans_path) %>> + <li <%= 'class=active' if active_page?(org_admin_plans_path) %>> <%= link_to _('Plans'), org_admin_plans_path %> </li> <% end %> <% if current_user.can_modify_templates? %> <% template_path = current_user.can_super_admin? ? org_admin_templates_path : organisational_org_admin_templates_path %> - <li <%= 'class=active' if isActivePage(template_path) %>> + <li <%= 'class=active' if active_page?(template_path) %>> <%= link_to _('Templates'), template_path %> </li> <% end %> <% if current_user.can_modify_guidance? %> - <li <%= 'class=active' if isActivePage(admin_index_guidance_path(current_user.org_id)) %>> + <li <%= 'class=active' if active_page?(admin_index_guidance_path(current_user.org_id)) %>> <%= link_to _('Guidance'), admin_index_guidance_path(current_user.org_id) %> </li> <% end %> <% if current_user.can_super_admin? %> - <li <%= 'class=active' if isActivePage(admin_edit_org_path(current_user.org_id)) %>> + <li <%= 'class=active' if active_page?(admin_edit_org_path(current_user.org_id)) %>> <%= link_to _('Organisations'), super_admin_orgs_path %> </li> <% else %> <% if current_user.can_modify_org_details? %> - <li <%= 'class=active' if isActivePage(admin_edit_org_path(current_user.org_id)) %>> + <li <%= 'class=active' if active_page?(admin_edit_org_path(current_user.org_id)) %>> <%= link_to _('Organisation details'), admin_edit_org_path(current_user.org_id) %> </li> <% end %> <% end %> <% if current_user.can_grant_permissions? %> - <li <%= 'class=active' if isActivePage(admin_index_users_path) %>> + <li <%= 'class=active' if active_page?(admin_index_users_path) %>> <%= link_to _('Users'), admin_index_users_path, class: 'main_nav_last_li' %> </li> <% end %> <% if current_user.can_super_admin? %> - <li <%= 'class=active' if isActivePage(super_admin_themes_path) %>> + <li <%= 'class=active' if active_page?(super_admin_themes_path) %>> <%= link_to(_('Themes'), super_admin_themes_path) %> </li> <% end %> <% if current_user.can_org_admin? || current_user.can_super_admin? %> - <li <%= 'class=active' if isActivePage(usage_index_path) %>> + <li <%= 'class=active' if active_page?(usage_index_path) %>> <%= link_to(_('Usage'), usage_index_path) %> </li> <% end %> <% if current_user.can_super_admin? %> - <li <%= 'class=active' if isActivePage(super_admin_notifications_path) %>> + <li <%= 'class=active' if active_page?(super_admin_notifications_path) %>> <%= link_to _('Notifications'), super_admin_notifications_path %> </li> <% end %> diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb index 2652985..507b7d9 100644 --- a/app/views/layouts/_footer.html.erb +++ b/app/views/layouts/_footer.html.erb @@ -18,11 +18,11 @@ </ul> <ul class="nav navbar-nav navbar-right"> <li> - <%= link_to( image_tag("uc3_logo_white.png", class: "img-responsive", alt: 'University of California Curation Center'), + <%= link_to( image_tag("uc3_logo_white.png", class: "img-responsive", alt: 'University of California Curation Center (opens in a new window)'), "http://www.cdlib.org/uc3/", title: "University of California Curation Center", target: '_blank') %> </li> <li> - <%= link_to( image_tag("dcc_logo_white.png", class: "img-responsive", alt: 'Digital Curation Centre'), + <%= link_to( image_tag("dcc_logo_white.png", class: "img-responsive", alt: 'Digital Curation Centre (opens in a new window)'), "http://www.dcc.ac.uk/", title: "Digital Curation Centre", target: '_blank') %> </li> </ul> diff --git a/app/views/layouts/_navigation.html.erb b/app/views/layouts/_navigation.html.erb index 3438aae..5dea3e2 100644 --- a/app/views/layouts/_navigation.html.erb +++ b/app/views/layouts/_navigation.html.erb @@ -19,10 +19,10 @@ <div class="collapse navbar-collapse" id="app-navbar-menu"> <ul class="nav navbar-nav"> <% if user_signed_in? %> - <li class="<%= ' active' if isActivePage(root_path) %>"> + <li class="<%= ' active' if active_page?(root_path) %>"> <%= link_to _('My Dashboard'), plans_path %> </li> - <li <%= 'class=active' if isActivePage(new_plan_path) %>> + <li <%= 'class=active' if active_page?(new_plan_path) %>> <%= link_to _('Create plans'), new_plan_path %> </li> <li class="dropdown" id="reference-dropdown"> @@ -32,27 +32,27 @@ <span class="caret"></span> </a> <ul class="dropdown-menu inverse-dropdown" aria-labelledby="reference-menu"> - <li <%= 'class=active' if isActivePage(public_plans_path) %>> + <li <%= 'class=active' if active_page?(public_plans_path) %>> <%= link_to _('Public DMPs'), public_plans_path %> </li> - <li <%= 'class=active' if isActivePage(public_templates_path) %>> + <li <%= 'class=active' if active_page?(public_templates_path) %>> <%= link_to _('DMP Templates'), public_templates_path %> </ul> </a> </li> <% else %> - <li <%= 'class=active' if isActivePage(root_path, true) || isActivePage(plans_path, true) %>> + <li <%= 'class=active' if active_page?(root_path, true) || active_page?(plans_path, true) %>> <%= link_to _('Home'), root_path %> </li> - <li <%= 'class=active' if isActivePage(public_plans_path, true) %>> + <li <%= 'class=active' if active_page?(public_plans_path, true) %>> <%= link_to _('Public DMPs'), public_plans_path %> </li> - <li <%= 'class=active' if isActivePage(public_templates_path, true) %>> + <li <%= 'class=active' if active_page?(public_templates_path, true) %>> <%= link_to _('DMP Templates'), public_templates_path %> </li> <% end %> <!-- help page --> - <li <%= 'class=active' if isActivePage(help_path, true) %>> + <li <%= 'class=active' if active_page?(help_path, true) %>> <%= link_to _('Help'), help_path %> </li> </ul> @@ -60,7 +60,7 @@ <%= render "layouts/signin_signout" %> </ul> - <% unless isActivePage(root_path, true) %> + <% unless active_page?(root_path, true) %> <%= render partial: 'shared/access_controls', layout: 'shared/modal', locals: { id: "header-signin", title: _('Sign in')} %> <% end %> </div><!-- /.navbar-collapse --> diff --git a/app/views/layouts/_notifications.html.erb b/app/views/layouts/_notifications.html.erb index b74a5c3..c6d6697 100644 --- a/app/views/layouts/_notifications.html.erb +++ b/app/views/layouts/_notifications.html.erb @@ -3,7 +3,7 @@ <div class="alert alert-<%= a.level %>"> <span class="fa <%= fa_classes(a) %>"></span> <span class="aria-only"><strong><%= "#{a.level.capitalize}:" %></strong></span> - <span><%= raw a.body %></span> + <span><%= sanitize a.body %></span> <% if a.dismissable? %> <button class="close" data-dismiss="alert" data-url="<%= user_acknowledge_notification_path(a) %>" data-remote="true" data-method="post" data-params="notification_id=<%= a.id %>" aria-label="Close"> <span class="fa fa-times-circle" aria-hidden="true"></span> diff --git a/app/views/layouts/_paginable.html.erb b/app/views/layouts/_paginable.html.erb index b29ebee..c7a7c72 100644 --- a/app/views/layouts/_paginable.html.erb +++ b/app/views/layouts/_paginable.html.erb @@ -1,6 +1,6 @@ -<% +<% # Custom layout to be included on any view that needs pagination - # locals: { scope, search_term } + # locals: { scope, search_term } %> <% total = paginable? ? scope.total_count : scope.length %> <div class="paginable"> @@ -30,22 +30,22 @@ <% if searchable? %> <ul class="list-inline"> <% if paginable? %> - <li><%= link_to(_('View all search results'), paginable_base_url_with_query_params(page: 'ALL'), { 'data-remote': true, class: 'paginable-action' }) %></li> + <li><%= link_to(_('View all search results'), paginable_base_url('ALL'), { 'data-remote': true, class: 'paginable-action' }) %></li> <% else %> - <%= link_to(_('View less search results'), paginable_base_url_with_query_params(page: 1), { 'data-remote': true, class: 'paginable-action' }) %> + <%= link_to(_('View less search results'), paginable_base_url(1), { 'data-remote': true, class: 'paginable-action' }) %> <% end %> - <li><%= link_to(_('Clear search results'), paginable_base_url_with_query_params(page: 1, search: nil), { 'data-remote': true, class: 'paginable-action' }) %></li> + <li><%= link_to(_('Clear search results'), paginable_base_url(1), { 'data-remote': true, class: 'paginable-action' }) %></li> </ul> <% else %> <% if paginable? %> - <%= link_to(_('View all'), paginable_base_url_with_query_params(page: 'ALL'), { 'data-remote': true, class: 'paginable-action' }) if @paginable_options[:view_all] %> + <%= link_to(_('View all'), paginable_base_url('ALL'), { 'data-remote': true, class: 'paginable-action' }) if @paginable_options[:view_all] %> <% else %> - <%= link_to(_('View less'), paginable_base_url_with_query_params(page: 1), { 'data-remote': true, class: 'paginable-action' }) %> + <%= link_to(_('View less'), paginable_base_url(1), { 'data-remote': true, class: 'paginable-action' }) %> <% end %> <% end %> <% else %> <% if searchable? %> - <%= link_to(_('Clear search results'), paginable_base_url_with_query_params(page: 1, search: nil), { 'data-remote': true, class: 'paginable-action' }) %> + <%= link_to(_('Clear search results'), paginable_base_url(1), { 'data-remote': true, class: 'paginable-action' }) %> <% end %> <% end %> </div> diff --git a/app/views/layouts/_signin_signout.html.erb b/app/views/layouts/_signin_signout.html.erb index 6bacdc2..a682584 100644 --- a/app/views/layouts/_signin_signout.html.erb +++ b/app/views/layouts/_signin_signout.html.erb @@ -1,5 +1,5 @@ <!-- language dropdown --> -<% if MANY_LANGUAGES %> +<% if Language.many? %> <li class="dropdown" id="change-language"> <a href="#" class="dropdown-toggle" role="button" id="language-menu" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <i class="fa fa-language" aria-hidden="true"> </i> @@ -7,7 +7,7 @@ <span class="caret"></span> </a> <ul class="dropdown-menu inverse-dropdown" aria-labelledby="language-menu"> - <% LANGUAGES.each do |l| %> + <% languages.each do |l| %> <li <%= 'class=active' if FastGettext.locale == l.abbreviation %>> <%= link_to l.name, locale_path(l.abbreviation), method: :patch %> </li> @@ -34,7 +34,7 @@ </ul> </li> <% else %> - <% if !isActivePage(root_path, true) %> + <% if !active_page?(root_path, true) %> <li> <a href="#header-signin" data-toggle="modal" data-target="#header-signin"> <i class="fa fa-sign-in" aria-hidden="true"> </i> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 9f43109..28a82dd 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,7 +1,8 @@ <!DOCTYPE html> <html lang="en"> <head> - <title><%= content_for?(:title) ? yield(:title) : _('%{application_name}') % { :application_name => Rails.configuration.branding[:application][:name] } %> + <!-- Version: <%= version %> --> + <title><%= content_for?(:title) ? yield(:title) : _('%{application_name}') % { :application_name => Rails.configuration.branding[:application][:name] } %> <%= favicon_link_tag "favicon.ico" %>
    @@ -57,12 +58,13 @@
    <%= (has_alert or has_notice) ? 'show' : 'hide' %>" role="<%= (has_notice ? 'status' : (has_alert ? 'alert' : '')) %>"> <%= has_alert ? _('Error:') : _('Notice:') %> - <%= raw (has_alert ? alert : notice) %> + <%= sanitize (has_alert ? alert : notice) %> + <%= yield :errors %>
    <%= render "layouts/notifications", notifications: Notification.active_per_user(current_user) %> <%= yield %> @@ -75,22 +77,13 @@ <% constants_json = { + HOST: (Rails.env.development? || Rails.env.test? ? 'localhost' : Socket.gethostname), PASSWORD_MIN_LENGTH: 8, PASSWORD_MAX_LENGTH: 128, MAX_NUMBER_ORG_URLS: 3, MAX_NUMBER_GUIDANCE_SELECTIONS: 6, - VALIDATION_MESSAGE_DEFAULT: _('Please enter a valid value.'), - VALIDATION_MESSAGE_EMAIL: _('You must enter a valid email address.'), - VALIDATION_MESSAGE_URL: _('You must enter a valid URL (e.g. https://organisation.org).'), - VALIDATION_MESSAGE_NUMBER: _('Please enter a valid number.'), - VALIDATION_MESSAGE_PASSWORD: _('The password must be between 8 and 128 characters.'), - VALIDATION_MESSAGE_PASSWORDS_MATCH: _('The passwords must match.'), - VALIDATION_MESSAGE_RADIO: _('Please choose one of the options.'), - VALIDATION_MESSAGE_CHECKBOX: _('Please check the box to continue.'), - VALIDATION_MESSAGE_MULTI_CHECKBOX: _('Please select at least one of the options to continue.'), - VALIDATION_MESSAGE_SELECT: _('Please select a value from the list.'), - VALIDATION_MESSAGE_TEXT: _('This field is required.'), + REQUIRED_FIELD_TEXT: _('This field is required.'), SHOW_PASSWORD_MESSAGE: _('Show password'), SHOW_SELECT_ORG_MESSAGE: _('Select an organisation from the list.'), diff --git a/app/views/notes/_list.html.erb b/app/views/notes/_list.html.erb index 1650112..2c55831 100644 --- a/app/views/notes/_list.html.erb +++ b/app/views/notes/_list.html.erb @@ -6,15 +6,11 @@
    • -
        -
      • <%= note.user.name %>
      • -
      • (<%= l note.updated_at, format: :short %>)
      • -
      + <%= render partial: "notes/show", locals: {note: note }, formats: [:html] %>
      + <% if plan.commentable_by?(current_user) %>
        -
      • <%= link_to(_('Show'), "#note_show#{note.id}", class: 'note_show_link') %>
      • - <% if plan.commentable_by?(current_user) %> <% if current_user.id == note.user_id %>
      • <%= link_to(_('Edit'), "#note_edit#{note.id}", class: 'note_edit_link') %>
      • <%= link_to(_('Remove'), "#note_archive#{note.id}", class: 'note_archive_link') %>
      • @@ -23,9 +19,9 @@
      • <%= link_to(_('Remove'), "#note_archive#{note.id}", class: 'note_archive_link') %>
      • <% end %> <% end %> - <% end %>
      + <% end %>
    diff --git a/app/views/notes/_show.html.erb b/app/views/notes/_show.html.erb index 614835e..e3d78c6 100644 --- a/app/views/notes/_show.html.erb +++ b/app/views/notes/_show.html.erb @@ -2,7 +2,7 @@ <% if !note.nil? %> <% user = User.find(note.user_id) %>
      -
    • <%= raw note.text %>
    • +
    • <%= sanitize note.text %>
    • <%= "#{user.name} at #{l(note.updated_at, format: :short)}" %> diff --git a/app/views/org_admin/annotations/_form.html.erb b/app/views/org_admin/annotations/_form.html.erb index 86f191e..da2089f 100644 --- a/app/views/org_admin/annotations/_form.html.erb +++ b/app/views/org_admin/annotations/_form.html.erb @@ -1,15 +1,13 @@ -<% - titles = { - example_answer: _('You can add an example answer to help users respond. These will be presented above the answer box and can be copied/ pasted.'), - guidance: _("Enter specific guidance to accompany this question. If you have guidance by themes too, this will be pulled in based on your selections below so it's best not to duplicate too much text.") - } -%> -
      - <%= f.label(:type, f.object.type.humanize, class: "control-label") %> -
      - <%= f.text_area(:text, class: 'question') %> +
      "> +
      + <%= f.label(:type, f.object.type.humanize, class: "control-label") %> +
      + <%= f.text_area(:text, class: 'question', + id: "question_annotations_attributes_#{unique_dom_id(f.object)}_text") %> +
      -
      -<%= f.hidden_field(:id) %> -<%= f.hidden_field(:org_id) %> -<%= f.hidden_field(:type) %> \ No newline at end of file + <%= f.hidden_field(:id) %> + <%= f.hidden_field(:org_id) %> + <%= f.hidden_field(:type) %> + <%= f.hidden_field(:_destroy) %> + diff --git a/app/views/org_admin/annotations/_show.html.erb b/app/views/org_admin/annotations/_show.html.erb index 8d52d14..584b1b3 100644 --- a/app/views/org_admin/annotations/_show.html.erb +++ b/app/views/org_admin/annotations/_show.html.erb @@ -1,14 +1,11 @@ <% if example_answer.present? || guidance.present? %> > <% if example_answer.present? %> - <% label = (for_plan && template.customization_of.present?) ? _('%{org} Example Answer') % { org: example_answer.org.short_name } : _('Example Answer') %> -
      <%= label %>
      -
      <%= raw example_answer.text %>
      +
      <%= _('%{org} Example Answer') % { org: example_answer.org.short_name } %>
      +
      <%= sanitize example_answer.text %>
      <% end %> <% if guidance.present? %> - <% label = (for_plan && template.customization_of.present?) ? _('%{org} Guidance') % { org: guidance.org.short_name } : _('Guidance') %> -
      <%= label %>
      -
      <%= raw guidance.text %>
      +
      <%= sanitize guidance.text %>
      <% end %> -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/org_admin/phases/_overview.html.erb b/app/views/org_admin/phases/_overview.html.erb index bbdf21d..fde9db7 100644 --- a/app/views/org_admin/phases/_overview.html.erb +++ b/app/views/org_admin/phases/_overview.html.erb @@ -1,72 +1,6 @@

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

      -<% phases.each do |phase| %> -
      -
      -
      -
      -

      - <%= phase.title %> -
      - <% if template.customization_of.present? && template.latest? %> - <%= link_to _('Customize phase'), org_admin_template_phase_path(template.id, phase.id), { class: "btn btn-default", role: 'button' } %> - <% elsif modifiable %> - <%= link_to _('Edit phase'), edit_org_admin_template_phase_path(template.id, phase.id), { class: "btn btn-default", role: 'button' } %> - <% else %> - <%= link_to _('Show phase'), org_admin_template_phase_path(template.id, phase.id), { class: "btn btn-default", role: 'button' } %> - <% end %> - <% if template.latest? && phase.modifiable %> - <%= link_to _('Delete phase'), org_admin_template_phase_path(template.id, phase.id), - data: { confirm: _("You are about to delete the '%{phase_title}' phase. This will remove all of the sections and questions listed below. Are you sure?") % { phase_title: phase.title }, - length: 20, omission: _('... (continued)') }, method: :delete, class: 'btn btn-default', role: 'button' %> - <% end %> -
      -

      -

      - <%= raw phase.description %> -

      -
      -
      -
      -
      -
      - <% if phase.sections.length > 0 %> -
      - - - - - - - - - <% phase.sections.each do |section| %> - - - - - <% end %> - -
      <%= _('Sections')%><%= _('Questions')%>

      <%= section.title %>

      - <% if section.questions.present? %> -
        - <% section.questions.each do |question| %> -
      • - <%= raw question.text %> - <% if question.question_options.length > 0 %> -
          - <% question.question_options.each do |option| %> -
        • <%= option.text %>
        • - <% end %> -
        - <% end %> -
      • - <% end %> -
      - <% end %> -
      -
      - <% end %> -
      -
      -<% end %> \ No newline at end of file +<%= render partial: "org_admin/phases/phase", + collection: phases, + locals: { template: template, + modifiable: modifiable } %> diff --git a/app/views/org_admin/phases/_phase.html.erb b/app/views/org_admin/phases/_phase.html.erb new file mode 100644 index 0000000..cb088e3 --- /dev/null +++ b/app/views/org_admin/phases/_phase.html.erb @@ -0,0 +1,67 @@ +
      +
      +
      +
      +

      + <%= phase.title %> +
      + <% if template.customization_of.present? && template.latest? %> + <%= link_to _('Customise phase'), org_admin_template_phase_path(template.id, phase.id), { class: "btn btn-default", role: 'button' } %> + <% elsif modifiable %> + <%= link_to _('Edit phase'), edit_org_admin_template_phase_path(template.id, phase.id), { class: "btn btn-default", role: 'button' } %> + <% else %> + <%= link_to _('Show phase'), org_admin_template_phase_path(template.id, phase.id), { class: "btn btn-default", role: 'button' } %> + <% end %> + <% if template.latest? && phase.modifiable %> + <%= link_to _('Delete phase'), org_admin_template_phase_path(template.id, phase.id), + data: { confirm: _("You are about to delete the '%{phase_title}' phase. This will remove all of the sections and questions listed below. Are you sure?") % { phase_title: phase.title }, + length: 20, omission: _('... (continued)') }, method: :delete, class: 'btn btn-default', role: 'button' %> + <% end %> +
      +

      +

      + <%= sanitize phase.description %> +

      +
      +
      + +
      + <% if phase.sections.any? %> +
      + + + + + + + + + <% phase.sections.each do |section| %> + + + + + <% end %> + +
      <%= _('Sections')%><%= _('Questions')%>

      <%= section.title %>

      + <% if section.questions.present? %> +
        + <% section.questions.each do |question| %> +
      • + <%= sanitize question.text %> + <% if question.question_options.length > 0 %> +
          + <% question.question_options.each do |option| %> +
        • <%= option.text %>
        • + <% end %> +
        + <% end %> +
      • + <% end %> +
      + <% end %> +
      +
      + <% end %> +
      +
      diff --git a/app/views/org_admin/phases/_show.html.erb b/app/views/org_admin/phases/_show.html.erb index e10597e..a9f7982 100644 --- a/app/views/org_admin/phases/_show.html.erb +++ b/app/views/org_admin/phases/_show.html.erb @@ -4,5 +4,5 @@
      <%= _('Order of display') %>
      <%= phase.number %>
      <%= _('Description') %>
      -
      <%= raw phase.description %>
      +
      <%= sanitize phase.description %>
      diff --git a/app/views/org_admin/phases/container.html.erb b/app/views/org_admin/phases/container.html.erb index fed9838..9ca24e9 100644 --- a/app/views/org_admin/phases/container.html.erb +++ b/app/views/org_admin/phases/container.html.erb @@ -10,7 +10,8 @@
      - <%= render partial: "/org_admin/templates/navigation", locals: local_assigns.merge({ modifiable: modifiable }) %> + <%= render partial: "/org_admin/templates/navigation", + locals: local_assigns.merge({ modifiable: modifiable }) %>
      @@ -20,29 +21,62 @@

      <%= _('Phase details')%>

      - <%= link_to(_('Preview'), preview_org_admin_template_phase_path(template, phase), { class: 'btn btn-default phase_preview_link', role: 'button' }) %> + <%= link_to(_('Preview'), + preview_org_admin_template_phase_path(template, phase), + class: 'btn btn-default phase_preview_link', role: 'button') %>
      - <%= render partial: partial_path, locals: local_assigns.merge({ modifiable: modifiable }) %> + <%= render partial: partial_path, + locals: local_assigns.merge({ modifiable: modifiable }) %>

      <%= _('Sections') %>

      - <% if phase.sections.length > 1 %> -
      - + +
      + +
      + <% if phase.sections.many? %> + + <% end %>
      - <% end %> + +
      +
      + <% if template.latest? && (modifiable || template.customization_of.present?) %> + + <%= _("Drag arrows to rearrange sections.") %> + <% unless phase.sections.all?(&:modifiable?) %> + <%= _("You may place them before or after the main template sections.") %> + <% end %> + <% else %> + <%= link_to _('Re-order sections'), + org_admin_template_phase_versions_path(phase.template, phase), + method: "post", + class: "btn btn-primary btn-sm" %> + <% end %> +
      +
      +
      +
      - <%= render partial: 'org_admin/sections/index', locals: local_assigns.merge({ modifiable: modifiable }) %> + <%= render partial: 'org_admin/sections/index', + locals: local_assigns.merge(modifiable: modifiable) %>
      diff --git a/app/views/org_admin/phases/preview.html.erb b/app/views/org_admin/phases/preview.html.erb index 051e4b4..00467e5 100644 --- a/app/views/org_admin/phases/preview.html.erb +++ b/app/views/org_admin/phases/preview.html.erb @@ -1,18 +1,31 @@ -<% title "#{template.title}" %> -<% modifiable = template.latest? && !template.customization_of.present? && template.id.present? && (template.org_id = current_user.org.id) %> +<% title "#{@template.title}" %> +<% modifiable = @template.latest? && !@template.customization_of.present? && @template.id.present? && (@template.org_id = current_user.org.id) %>
      -

      <%= template.title %>

      +

      <%= @template.title %>

        - <% if template.latest? %> - <% if template.customization_of.present? %> -
      • <%= link_to _('Back to customise phase'), org_admin_template_phase_path(template_id: template.id, id: phase.id), class: 'btn btn-primary' %>
      • + <% if @template.latest? %> + <% if @template.customization_of.present? %> +
      • + <%= link_to _('Back to customise phase'), + org_admin_template_phase_path(template_id: @template.id, + id: @phase.id), + class: 'btn btn-primary' %> +
      • <% else %> -
      • <%= link_to _('Back to edit phase'), edit_org_admin_template_phase_path(template_id: template.id, id: phase.id), class: 'btn btn-primary' %>
      • +
      • + <%= link_to _('Back to edit phase'), + edit_org_admin_template_phase_path(template_id: @template.id, + id: @phase.id), + class: 'btn btn-primary' %> +
      • <% end %> <% else %> -
      • <%= link_to _('Back to phase'), org_admin_template_phase_path(template_id: template.id, id: phase.id), class: 'btn btn-primary' %>
      • +
      • + <%= link_to _('Back to phase'), + org_admin_template_phase_path(template_id: @template.id, id: @phase.id), + class: 'btn btn-primary' %>
      • <% end %>
      @@ -31,19 +44,19 @@
      - <%= render partial: '/phases/edit_plan_answers', + <%= render partial: '/phases/edit_plan_answers', locals: { - plan: nil, - phase: phase, - readonly: true, - question_guidance: {}, - edit: false, - guidance_groups: [], - base_template_org: template.base_org } %> + plan: nil, + phase: @phase, + readonly: true, + edit: false, + guidance_groups: [], + base_template_org: @template.base_org, + guidance_presenter: @guidance_presenter } %>
      - +
      -
      \ No newline at end of file +
      diff --git a/app/views/org_admin/plans/index.html.erb b/app/views/org_admin/plans/index.html.erb index 0c89215..5519501 100644 --- a/app/views/org_admin/plans/index.html.erb +++ b/app/views/org_admin/plans/index.html.erb @@ -1,6 +1,6 @@ <% title "#{current_user.org.name} Plans" %>
      -
      +

      <%= _('%{org_name} Plans') % { org_name: current_user.org.name } %>

      @@ -35,7 +35,7 @@
      <% end %> <% if @plans.length > 0 %> - <%= link_to _('Download plans'), org_admin_download_plans_path(format: :csv), target: '_blank', class: 'btn btn-default pull-right' %> + <%= link_to sanitize(_('Download plans (new window)')), org_admin_download_plans_path(format: :csv), target: '_blank', class: 'btn btn-default pull-right' %> <%= paginable_renderise( partial: '/paginable/plans/org_admin', controller: 'paginable/plans', diff --git a/app/views/org_admin/questions/_form.html.erb b/app/views/org_admin/questions/_form.html.erb index ccf2757..33f8c19 100644 --- a/app/views/org_admin/questions/_form.html.erb +++ b/app/views/org_admin/questions/_form.html.erb @@ -1,79 +1,95 @@ -

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

      -<%= form_for(question, url: url, namespace: question.id.present? ? question.id : 'new_question', html: { method: method, class: 'question_form' }) do |f| %> - <% current_format = question.question_format.present? ? question.question_format : QuestionFormat.find_by(formattype: QuestionFormat.formattypes[:textarea]) %> +

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

      - -
      - <%= f.label(:number, _('Question Number'), class: "control-label") %> - <%= f.number_field(:number, in: 1..50, class: "form-control", 'aria-required': true) %> -
      - -
      - <%= f.label(:text, _('Question text'), class: "control-label") %> - <%= f.text_area(:text, class: "question", 'aria-required': true) %> -
      - -
      - <%= f.label(:question_format_id, _('Answer format'), class: "control-label") %> - <%= f.select :question_format_id, - options_from_collection_for_select(QuestionFormat.all.order("title"), - :id, - :title, - question.question_format_id), - {}, - class: "form-control question_format", - 'data-toggle': 'tooltip', - 'data-html': true, - title: _("You can choose from:
      • - text area (large box for paragraphs);
      • - text field (for a short answer);
      • - checkboxes where options are presented in a list and multiple values can be selected;
      • - radio buttons where options are presented in a list but only one can be selected;
      • - dropdown like this box - only one option can be selected;
      • - multiple select box allows users to select several options from a scrollable list, using the CTRL key;
      ") - %> -
      - -
      -
      - <%= render "/org_admin/question_options/option_fields", f: f, q: question %> - +<%= form_for(question, url: url, + namespace: question.id.present? ? question.id : 'new_question', + html: { method: method, class: 'question_form' }) do |f| %> + + <% current_format = question.question_format.present? ? question.question_format : QuestionFormat.find_by(formattype: QuestionFormat.formattypes[:textarea]) %> +
      + +
      + <%= f.label(:number, _('Question Number'), class: "control-label") %> + <%= f.number_field(:number, in: 1..50, class: "form-control", + aria: { required: true }) %>
      -
      - <% comment_disp = current_format.option_based? || current_format.rda_metadata? %> -
      - -
      + + +
      + <%= f.label(:text, _('Question text'), class: "control-label") %> + <%= f.text_area(:text, class: "question", 'aria-required': true) %> +
      + + +
      + <%= f.label(:question_format_id, _('Answer format'), class: "control-label") %> + <%= f.select :question_format_id, + options_from_collection_for_select(question_formats, + :id, + :title, + question.question_format_id), + {}, + class: "form-control question_format", + 'data-toggle': 'tooltip', + 'data-html': true, + title: _("You can choose from:
      • - text area (large box for paragraphs);
      • - text field (for a short answer);
      • - checkboxes where options are presented in a list and multiple values can be selected;
      • - radio buttons where options are presented in a list but only one can be selected;
      • - dropdown like this box - only one option can be selected;
      • - multiple select box allows users to select several options from a scrollable list, using the CTRL key;
      ") + %> +
      + + +
      +
      + <%= render "/org_admin/question_options/option_fields", f: f, q: question %> + +
      +
      + + <% comment_disp = current_format.option_based? || current_format.rda_metadata? %> +
      + +
      + -
      - <%= f.label(:default_value, _('Default answer'), class: "control-label") %> -
      - - <%= f.text_field(:default_value, class: 'form-control') %> - - - <%= text_area_tag('question[default_value]', question.default_value, id: "#{question.id.present? ? question.id : 'new'}_question_default_value_area", class: "form-control question") %> - +
      + <%= f.label(:default_value, _('Default answer'), class: "control-label") %> +
      + + <%= f.text_field(:default_value, class: 'form-control') %> + + + <%= text_area_tag('question[default_value]', question.default_value, id: "#{question.id.present? ? question.id : 'new'}_question_default_value_area", class: "form-control question") %> + +
      - <%# example_answer and guidance annotations as nested fields %> - <% question.annotations_per_org(current_user.org_id).each do |annotation| %> - <%= f.fields_for(:annotations, annotation) do |annotation_fields| %> - <%= render partial: 'org_admin/annotations/form', locals: { f: annotation_fields } %> - <% end %> - <% end %> - -
      - <%= render partial: 'org_admin/shared/theme_selector', - locals: { f: f, all_themes: Theme.all.order("title"), as_radio: false, - popover_message: _('Select one or more themes that are relevant to this question. This will allow similarly themed organisation-level guidance to appear alongside your question.') } %> -
      -
      -
      - <%= f.submit _('Save'), class: "btn btn-default", role:'button' %> - <% if question.id.present? && !question.section.phase.template.published? %> - <% 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 %> - <% else %> - <%= link_to _('Cancel'), '#', class: "btn btn-default cancel-new-question" %> +
      + <%# example_answer and guidance annotations as nested fields %> + <% question.annotations_per_org(current_user.org_id).each do |annotation| %> + <%= f.fields_for(:annotations, annotation) do |annotation_fields| %> + <%= render partial: 'org_admin/annotations/form', locals: { f: annotation_fields } %> <% end %> + <% end %> + +
      + <%= render partial: 'org_admin/shared/theme_selector', + locals: { f: f, all_themes: Theme.all.order("title"), as_radio: false, + popover_message: _('Select one or more themes that are relevant to this question. This will allow similarly themed organisation-level guidance to appear alongside your question.') } %> +
      +
      +
      + <%= f.submit _('Save'), class: "btn btn-default", role:'button' %> + <% if question.id.present? && !question.section.phase.template.published? %> + <% 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 %> + <% else %> + <%= link_to _('Cancel'), '#', class: "btn btn-default cancel-new-question" %> + <% end %> +
      <% end %> diff --git a/app/views/org_admin/questions/_show.html.erb b/app/views/org_admin/questions/_show.html.erb index 05adce1..2df3df2 100644 --- a/app/views/org_admin/questions/_show.html.erb +++ b/app/views/org_admin/questions/_show.html.erb @@ -13,7 +13,7 @@
      <%= _('Question number')%>
      <%= question.number %>
      <%= _('Question text')%>
      -
      <%= raw question.text %>
      +
      <%= sanitize question.text %>
      <% if question.option_based? %>
      <%= _('Question options') %>
      @@ -23,7 +23,7 @@ <% if q_format.textfield? || q_format.textarea? %> <% if !question.default_value.nil? %>
      <%= _('Default value')%>
      -
      <%= raw question.default_value %>
      +
      <%= sanitize question.default_value %>
      <% end %> <% end %> @@ -38,17 +38,17 @@ <% if !question.section.phase.template.org.funder? %> - <% example_answer = question.get_example_answers(template.base_org.id).first %> + <% example_answer = question.example_answers(template.base_org.id).first %> <% if example_answer.present? && example_answer.text.present? %>
      <%= _('example answer')%>
      -
      <%= raw example_answer.text %>
      +
      <%= sanitize example_answer.text %>
      <% end %> <% end %> - <% guidance = question.get_guidance_annotation(template.base_org.id) %> + <% guidance = question.guidance_annotation(template.base_org.id) %> <% if guidance.present? %>
      <%= _('Guidance')%>
      -
      <%= raw guidance.text %>
      +
      <%= sanitize guidance.text %>
      <% end %> <% themes_q = question.themes %> @@ -68,21 +68,26 @@
      <% question.annotations_per_org(current_user.org_id).each do |annotation| %>
      <%= annotation.type.humanize %>
      -
      <%= annotation.text.present? ? raw(annotation.text) : _('None provided') %>
      +
      <%= annotation.text.present? ? sanitize(annotation.text) : _('None provided') %>
      <% end %>
      <% else %>

      <%= _('Annotations') %>

      - <%= form_for(question, url: 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), namespace: question.id, html: { method: 'put', class: 'question_form' }) do |f| %> + <%= form_for(question, html: { method: 'put', class: 'question_form' }, + url: 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)) do |f| %> <%# example_answer and guidance annotations as nested fields %> <% question.annotations_per_org(current_user.org_id).each do |annotation| %> <%= f.fields_for(:annotations, annotation) do |annotation_fields| %> - <%= render partial: 'org_admin/annotations/form', locals: { f: annotation_fields } %> + <%= render partial: 'org_admin/annotations/form', + locals: { f: annotation_fields } %> <% end %> <% end %>
      - <%= f.submit _('Save'), class: "btn btn-default", role:'button' %> + <%= f.submit _('Save'), class: "btn btn-default", role: 'button' %>
      <% end %> @@ -101,7 +106,9 @@
      <% question.annotations_per_org(current_user.org_id).each do |annotation| %>
      <%= annotation.type.humanize %>
      -
      <%= annotation.text.present? ? raw(annotation.text) : _('None provided') %>
      +
      + <%= annotation.text.present? ? sanitize(annotation.text) : _('None provided') %> +
      <% end %>
      <% end %> diff --git a/app/views/org_admin/sections/_form.html.erb b/app/views/org_admin/sections/_form.html.erb index 9c207d1..951b2ee 100644 --- a/app/views/org_admin/sections/_form.html.erb +++ b/app/views/org_admin/sections/_form.html.erb @@ -4,15 +4,6 @@ <%= f.text_field(:title, { class: "form-control", placeholder: _('Enter a title for the section'), 'data-toggle': 'tooltip', title: _('Enter a title for the section'), 'aria-required': true} ) %>
      -
      -
      - <%= f.label(:number, _('Order of display'), class: "control-label") %> -
      -
      - <%= f.number_field(:number, in: 1..15, class: "form-control", 'aria-required': true, 'data-toggle': 'tooltip', title: _('This allows you to order sections.')) %> -
      -
      -
      "> <%= f.label(:description, _('Description'), class: "control-label") %> <%= f.text_area(:description, class: "section") %> diff --git a/app/views/org_admin/sections/_index.html.erb b/app/views/org_admin/sections/_index.html.erb index 041013a..ea28203 100644 --- a/app/views/org_admin/sections/_index.html.erb +++ b/app/views/org_admin/sections/_index.html.erb @@ -1,60 +1,61 @@
      -
      - <% sections.each do |section| %> -
      - <% href = (section.modifiable? ? edit_org_admin_template_phase_section_path(template_id: template.id, phase_id: phase.id, id: section.id) : org_admin_template_phase_section_path(template_id: template.id, phase_id: phase.id, id: section.id)) %> - -
      " - class="panel-collapse collapse<%= " in" if current_section.present? && section.id == current_section.id %>" - role="tabpanel" - aria-labelledby="<%= "headingSection#{section.id}" %>"> -
      - <%# This is AJAX loaded on demand unless section_id is specified in the URL %> - <% if current_section.present? && section.id == current_section.id %> - <% partial = "org_admin/sections/#{section.modifiable? ? 'edit' : 'show'}" %> - <%= render partial: "#{partial}", - locals: { - template: template, - phase: phase, - section: current_section } - %> - <% end %> -
      -
      -
      +
      + + <%= render partial: "org_admin/sections/section_group", + locals: { sections: Array(prefix_section), + phase: phase, + template: template, + current_section: current_section, + panel_id: "section-#{prefix_section.id}", + modifiable: true } if prefix_section %> + + <%# If we are working with a modifiable phase then allow the core sections to be reordered %> + <% if phase.modifiable? %> + <% sections.each do |s| %> + <%= render partial: "org_admin/sections/section_group", + locals: { sections: Array(s), + phase: phase, + template: template, + current_section: current_section, + panel_id: "section-#{s.id}", + modifiable: s.modifiable } %> + <% end %> + <% else %> + <%= render partial: "org_admin/sections/section_group", + locals: { sections: sections, + phase: phase, + template: template, + current_section: current_section, + panel_id: "baseline-sections", + modifiable: modifiable } %> <% end %> + + <% suffix_sections.each do |s| %> + <%= render partial: "org_admin/sections/section_group", + locals: { sections: Array(s), + phase: phase, + template: template, + panel_id: "section-#{s.id}", + current_section: current_section, + modifiable: true } %> + <% end %> +
      + <% if template.latest? && (modifiable || template.customization_of.present?) %>
      + +
      " + class="panel-collapse collapse<%= " in" if current_section.present? && section.id == current_section.id %>" + role="tabpanel" + aria-labelledby="<%= "headingSection#{section.id}" %>"> +
      + <%# This is AJAX loaded on demand unless section_id is specified in the URL %> + <% if current_section.present? && section.id == current_section.id %> + <% partial = "org_admin/sections/#{section.modifiable? ? 'edit' : 'show'}" %> + <%= render partial: "#{partial}", + locals: { + template: template, + phase: phase, + section: current_section } + %> + <% end %> +
      +
      +
      diff --git a/app/views/org_admin/sections/_section_group.html.erb b/app/views/org_admin/sections/_section_group.html.erb new file mode 100644 index 0000000..4623c01 --- /dev/null +++ b/app/views/org_admin/sections/_section_group.html.erb @@ -0,0 +1,15 @@ + +
      + <% sections.each do |section| %> + <%= render partial: "org_admin/sections/section", + object: section, + locals: { phase: phase, + template: template, + current_section: current_section, + data_parent: panel_id, + draggable: draggable_for_section?(section) } %> + <% end%> +
      diff --git a/app/views/org_admin/sections/_show.html.erb b/app/views/org_admin/sections/_show.html.erb index 1a002ef..5d7e69d 100644 --- a/app/views/org_admin/sections/_show.html.erb +++ b/app/views/org_admin/sections/_show.html.erb @@ -1,13 +1,13 @@

      - <%= raw section.description %> + <%= sanitize section.description %>

      - <%= render partial: 'org_admin/questions/index', + <%= render partial: 'org_admin/questions/index', locals: local_assigns.merge({ editing: false }) %>
      -
      \ No newline at end of file +
      diff --git a/app/views/org_admin/shared/_theme_selector.html.erb b/app/views/org_admin/shared/_theme_selector.html.erb index 13dd640..2ea3cca 100644 --- a/app/views/org_admin/shared/_theme_selector.html.erb +++ b/app/views/org_admin/shared/_theme_selector.html.erb @@ -1,45 +1,44 @@ <%# locals: all_themes, as_radio & popover_message %> <% as_radio ||= false %> <% required ||= false %> +<% in_error ||= false %>
      -
      - <% if all_themes.length > 0 %> - <% - cntr = 0 - nbr_of_cols = (all_themes.length.to_f / MAX_NUMBER_THEMES_PER_COLUMN.to_f).ceil - col_size = (12 / (nbr_of_cols > 4 ? 3 : nbr_of_cols)).round - %> -
      - - <%= _('Themes') %> - <%= render partial: 'shared/popover', - locals: { message: popover_message, placement: 'right' }%> - + <% if all_themes.length > 0 %> + <% + cntr = 0 + nbr_of_cols = (all_themes.length.to_f / MAX_NUMBER_THEMES_PER_COLUMN.to_f).ceil + col_size = (12 / (nbr_of_cols > 4 ? 3 : nbr_of_cols)).round + %> + > + + <%= _('Themes') %> + <%= render partial: 'shared/popover', + locals: { message: popover_message, placement: 'right' }%> + -
      - <% all_themes.each do |theme| %> - <% if cntr >= MAX_NUMBER_THEMES_PER_COLUMN %> -
      -
      - <% cntr = 0 %> - <% end %> -
      - <% namespace = f.object.class.name.downcase %> - <% id = f.object.id.present? ? f.object.id : 'new' %> - - value="<%= theme.id %>"<%= f.object.themes.include?(theme) ? ' checked="checked"' : '' %>> - <%= theme.title %> +
      + <% all_themes.each do |theme| %> + <% if cntr >= MAX_NUMBER_THEMES_PER_COLUMN %>
      - <% cntr += 1 %> +
      + <% cntr = 0 %> <% end %> -
      -
      - <% else %> -

      <%= _('No themes have been defined. Please contact your administrator for assistance.') %>

      - <% end %> -
      -
      \ No newline at end of file +
      + <% namespace = f.object.class.name.downcase %> + <% id = f.object.id.present? ? f.object.id : 'new' %> + + value="<%= theme.id %>"<%= f.object.themes.include?(theme) ? ' checked="checked"' : '' %>> + <%= theme.title %> +
      + <% cntr += 1 %> + <% end %> +
      + + <% else %> +

      <%= _('No themes have been defined. Please contact your administrator for assistance.') %>

      + <% end %> +
      diff --git a/app/views/org_admin/templates/_form.html.erb b/app/views/org_admin/templates/_form.html.erb index 81af474..146b11d 100644 --- a/app/views/org_admin/templates/_form.html.erb +++ b/app/views/org_admin/templates/_form.html.erb @@ -12,12 +12,16 @@
      <%= f.label _('Visibility'), class: 'control-label' %> - <%= render partial: 'shared/popover', + <%= render partial: 'shared/popover', locals: { message: _('Checking this box prevents the template from appearing in the public list of templates.'), placement: 'right' }%>
      - <%= f.label(:visibility, - raw("#{check_box_tag('template_visibility', '0', (template.visibility == 'organisationally_visible'))} #{_('for internal %{org_name} use only') % {org_name: template.org.name}}")) %> + <%= f.label(:visibility) do %> + <%= check_box_tag('template_visibility', '0', + (f.object.visibility == 'organisationally_visible')) %> + + <%= _('for internal %{org_name} use only') % { org_name: f.object.org.name } %> + <% end %>
      <% end %> @@ -25,9 +29,9 @@
      <%= label_tag(:status, _('Status'), class: "control-label") %>

      - <% if template.published? %> + <% if f.object.published? %> <%= _('Published') %> - <% elsif (template.version.present? && template.version <= 0) || !template.id.present? %> + <% elsif (f.object.version.present? && f.object.version <= 0) || !f.object.id.present? %> <%= _('Unpublished') %> <% else %> <%= _('Draft') %> @@ -35,41 +39,41 @@

      -<% if template.id.present? %> +<% if f.object.id.present? %>
      <%= label_tag(:created_at, _('Created at'), class: "control-label") %>

      - <%= l template.created_at.to_date, formats: :short %> + <%= l f.object.created_at.to_date, formats: :short %>

      <%= label_tag(:updated, _('Last updated'), class: "control-label") %>

      - <%= l template.updated_at.to_date, formats: :short %> + <%= l f.object.updated_at.to_date, formats: :short %>

      <% end %> -<% if template.org.funder? %> +<% if f.object.org.funder? %>
      <%= render(partial: '/shared/links', - locals: { + locals: { context: 'funder', title: _('Funder Links'), - links: template.links['funder'], + links: Hash(f.object.links).fetch('funder', []), max_number_links: MAX_NUMBER_LINKS_FUNDER, tooltip: _('Add links to funder websites that provide additional information about the requirements for this template') }) %>
      <%= render(partial: '/shared/links', - locals: { + locals: { context: 'sample_plan', title: _('Sample Plan Links'), - links: template.links['sample_plan'], + links: Hash(f.object.links).fetch('sample_plan', []), max_number_links: MAX_NUMBER_LINKS_SAMPLE_PLAN, tooltip: _('Add links to sample plans if provided by the funder.') }) %>
      - <%= hidden_field_tag('template-links', ActiveSupport::JSON.encode(template.links)) %> + <%= hidden_field_tag('template-links', ActiveSupport::JSON.encode(f.object.links)) %> <% end %>
      diff --git a/app/views/org_admin/templates/_navigation.html.erb b/app/views/org_admin/templates/_navigation.html.erb index 7ce1ba0..3c87adf 100644 --- a/app/views/org_admin/templates/_navigation.html.erb +++ b/app/views/org_admin/templates/_navigation.html.erb @@ -1,17 +1,16 @@ -<% details_path = modifiable ? edit_org_admin_template_path(template.id) : template.id.present? ? org_admin_template_path(template.id) : org_admin_templates_path %>
      -

      <%= raw _('Here you can view previously published versions of your template. These can no longer be modified.')%>

      +

      + <%= sanitize _('Here you can view previously published versions of your template. These can no longer be modified.')%> +

      @@ -18,7 +20,7 @@ partial: '/paginable/templates/history', controller: 'paginable/templates', action: 'history', - query_params: query_params, + query_params: query_params, scope: templates, locals: local_assigns ) %> <% else %> diff --git a/app/views/org_admin/templates/index.html.erb b/app/views/org_admin/templates/index.html.erb index ad18404..8cddce3 100644 --- a/app/views/org_admin/templates/index.html.erb +++ b/app/views/org_admin/templates/index.html.erb @@ -3,74 +3,110 @@

      <%= _('Templates') %>

      + <% if current_user.can_super_admin? %>
      -

      <%= _('If you would like to modify one of the templates below, you must first change your organisation affiliation.') %>

      +

      + <%= _('If you would like to modify one of the templates below, you must first change your organisation affiliation.') %> +

      - <%= form_for current_user, url: org_swap_user_path(current_user), namespace: 'superadmin', html: {method: :put, 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} %> + <%= form_for current_user, url: user_org_swaps_path(current_user), + 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 + } %> <%= f.submit _('Change affiliation'), class: 'btn btn-default' %> <% end %>
      <% end %> +

      <%= _('If you wish to add an organisational template for a Data Management Plan, use the \'create template\' button. You can create more than one template if desired e.g. one for researchers and one for PhD students. Your template will be presented to users within your organisation when no funder templates apply. If you want to add questions to funder templates use the \'customise template\' options below.') %>

      +
      - +
      -

      <%= title %>

      +

      <%= @title %>

      - <% filter_path = "/paginable/templates/#{action}/#{1}" %> - <% qry = query_params.collect{ |k,v| "#{k}=#{v}" }.join('&') %> + <% filter_path = "/paginable/templates/#{action_name}/#{1}" %> + <% qry = @query_params.collect{ |k,v| "#{k}=#{v}" }.join('&') %>
      <%= paginable_renderise( - partial: "paginable/templates/#{action}", + partial: "paginable/templates/#{action_name}", controller: 'paginable/templates', - action: action, - scope: templates, - query_params: query_params, - locals: local_assigns) %> + action: action_name, + scope: @templates, + query_params: @query_params, + locals: { customizations: @customizations }) %>
      diff --git a/app/views/org_admin/templates/new.html.erb b/app/views/org_admin/templates/new.html.erb new file mode 100644 index 0000000..3fc2046 --- /dev/null +++ b/app/views/org_admin/templates/new.html.erb @@ -0,0 +1,30 @@ +
      +
      +

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

      + <%= link_to _('View all templates'), + org_admin_templates_path, + class: 'btn btn-default pull-right' %> +
      +
      + +
      +
      + + <%= render partial: "org_admin/templates/navigation", + locals: { template: @template } %> + +
      +
      +
      +
      +
      + <%= form_for([:org_admin, @template]) do |f| %> + <%= render 'form', { f: f }%> + <% end %> +
      +
      +
      +
      +
      +
      +
      diff --git a/app/views/orgs/_feedback_form.html.erb b/app/views/orgs/_feedback_form.html.erb index c16752e..6e88cc8 100644 --- a/app/views/orgs/_feedback_form.html.erb +++ b/app/views/orgs/_feedback_form.html.erb @@ -3,8 +3,14 @@

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

      - <%= f.label :feedback_enabled, raw("#{f.radio_button(:feedback_enabled, true)} #{_('On')}") %> - <%= f.label :feedback_enabled, raw("#{f.radio_button(:feedback_enabled, false)} #{_('Off')}") %> + <%= f.label :feedback_enabled do %> + <%= f.radio_button(:feedback_enabled, true) %> + <%= _('On') %> + <% end %> + <%= f.label :feedback_enabled do %> + <%= f.radio_button(:feedback_enabled, false) %> + <%= _('Off') %> + <% end %>
      diff --git a/app/views/orgs/_profile_form.html.erb b/app/views/orgs/_profile_form.html.erb index 484d33a..e6c6df4 100644 --- a/app/views/orgs/_profile_form.html.erb +++ b/app/views/orgs/_profile_form.html.erb @@ -12,7 +12,7 @@
      -
      +
      <%= f.label :logo, _('Organization logo'), class: "control-label" %> @@ -20,7 +20,11 @@
      <%= image_tag org.logo.url, alt: "#{org.name} #{_('logo')}" %>
      - <%= f.label :remove_logo, raw("#{f.check_box :remove_logo, title: _("This will remove your organisation's logo")} #{_('Remove logo')}") %> + <%= f.label :remove_logo do %> + <%= f.check_box :remove_logo, + title: _("This will remove your organisation's logo") %> + <%= _('Remove logo') %> + <% end %> - <%= _('or') %> - <%= f.file_field :logo %>
      @@ -33,7 +37,7 @@
      <%= render(partial: '/shared/links', - locals: { + locals: { context: 'org', title: _('Organisation URLs'), links: (org.links.present? ? org.links['org'] : []), @@ -51,11 +55,11 @@
      <%= f.label :contact_email, _('Contact email'), class: "control-label" %> - <%= f.email_field :contact_email, class: "form-control", 'aria-required': true %> + <%= f.email_field :contact_email, class: "form-control", aria: { required: true } %>
      <%= f.label :contact_name, _('Link text'), class: "control-label" %> - <%= f.text_field :contact_name, class: "form-control" %> + <%= f.text_field :contact_name, class: "form-control", aria: { required: true } %>
      @@ -78,20 +82,32 @@ <%= text_field_tag :shib_domain, shib_domain, class: "form-control", placeholder: _('Example: my-org.org') %>
      - <% end %> + <% end %>
      <%= _('Organisation Types') %>
      - <%= f.label :funder, raw("#{check_box_tag :funder, 2, org.funder?, class: 'org_types'} #{_('Funder')}") %> + <%= f.label :funder do %> + <%= check_box_tag :funder, 2, org.funder?, class: 'org_types' %> + <%= _('Funder') %> + <% end %>
      - <%= f.label :institution, raw("#{check_box_tag :institution, 1, org.institution?, class: 'org_types'} #{_('Institution')}") %> + <%= f.label :institution do %> + <%= check_box_tag :institution, 1, org.institution?, class: 'org_types' %> + <%= _('Institution') %> + <% end %>
      - <%= f.label :organisation, raw("#{check_box_tag :organisation, 4, org.organisation?, class: 'org_types'} #{_('Organisation')}") %> + <%= f.label :organisation do %> + <%= check_box_tag :organisation, 4, org.organisation?, class: 'org_types' %> + <%= _('Organisation') %> + <% end %>
      - <%= f.hidden_field :org_type, 'data-validation': 'text', 'data-validation-error': _('You must select at least one organisation type') %> + <%= f.hidden_field :org_type, data: { + validation: 'text', + validation_error: _('You must select at least one organisation type') } + %>
      <% else %> @@ -101,7 +117,7 @@ <% end %>
      - +
      <%= f.button(_('Save'), id:"save_org_details_submit", class: "btn btn-primary", type: "submit") %> diff --git a/app/views/orgs/shibboleth_ds.html.erb b/app/views/orgs/shibboleth_ds.html.erb index 6ff81a3..7c80ef9 100644 --- a/app/views/orgs/shibboleth_ds.html.erb +++ b/app/views/orgs/shibboleth_ds.html.erb @@ -10,24 +10,23 @@ <%= form_for 'shibboleth_ds', url: shibboleth_ds_path, namespace: 'shib-ds', html: {id: 'shibboleth_ds'} do |f| %>
      <%= f.label(:org_name, _('Look up your organisation here'), class: "control-label") %> - + <% if @orgs.length <= 10 %> - <% else %> - <%= render partial: "shared/accessible_combobox", - locals: {name: "shib-ds[org_name]", - id: 'shib-ds_org_name', - default_selection: nil, - models: @orgs, - attribute: 'name', - required: true, - classes: ''} %> + <%= render partial: "shared/accessible_combobox", + locals: { name: "shib-ds[org_name]", + id: 'shib-ds_org_name', + default_selection: nil, + models: @orgs, + attribute: 'name', + required: true, + classes: '' } %> <% end %> - + <%= f.button(_('Go'), class: "btn btn-default", type: "submit") %> <% if @orgs.length > 10 %> @@ -40,13 +39,13 @@
      <% end %> - + - +
      - +

      <%= _('Organisation not in the list?') %> 

      \ No newline at end of file +
      diff --git a/app/views/paginable/plans/_organisationally_or_publicly_visible.html.erb b/app/views/paginable/plans/_organisationally_or_publicly_visible.html.erb index e5b3d7c..e4415ba 100644 --- a/app/views/paginable/plans/_organisationally_or_publicly_visible.html.erb +++ b/app/views/paginable/plans/_organisationally_or_publicly_visible.html.erb @@ -1,33 +1,45 @@ -<% if current_user.org_id.present? %> -
      -
      -
      - - - - - - - - - - - - <% scope.each do |plan| %> - - - - - - - - <% end %> - -
      <%= _('Project Title') %> <%= paginable_sort_link('plans.title') %><%= _('Template') %> <%= paginable_sort_link('templates.title') %><%= _('Owner') %><%= _('Updated') %> <%= paginable_sort_link('plans.updated_at') %><%= _('Download') %>
      <%= plan.title.length > 40 ? "#{plan.title[0..39]} ..." : plan.title %><%= plan.template.title %><%= plan.owner.present? ? plan.owner.name : _('Unknown') %><%= l(plan.updated_at.to_date, formats: :short) %> - <%= link_to _('PDF'), plan_export_path(plan, format: :pdf), target: '_blank' %> -
      -
      -
      -
      +<% if current_user.org_id? %> +
      +
      +
      + + + + + + + + + + + + <% scope.each do |plan| %> + + + + + + + + <% end %> + +
      + <%= _('Project Title') %>  + <%= paginable_sort_link('plans.title') %> + + <%= _('Template') %>  + <%= paginable_sort_link('templates.title') %> + <%= _('Owner') %> + <%= _('Updated') %>  + <%= paginable_sort_link('plans.updated_at') %> + <%= _('Download') %>
      + <%= truncate plan.title, length: 40 %> + <%= plan.template.title %><%= plan.owner.present? ? plan.owner.name : _('Unknown') %><%= l(plan.updated_at.to_date, formats: :short) %> + <%= link_to _('PDF'), plan_export_path(plan, format: :pdf), + target: '_blank' %> +
      +
      +
      +
      <% end %> diff --git a/app/views/paginable/plans/_privately_visible.html.erb b/app/views/paginable/plans/_privately_visible.html.erb index 4ba2026..50cbea1 100644 --- a/app/views/paginable/plans/_privately_visible.html.erb +++ b/app/views/paginable/plans/_privately_visible.html.erb @@ -16,7 +16,7 @@ <% scope.each do |plan| %> - + <%= link_to "#{plan.title.length > 60 ? "#{plan.title[0..59]} ..." : plan.title}", plan_path(plan) %> @@ -35,10 +35,10 @@ <% end %> - <%= plan.visibility === 'is_test' ? _('N/A') : raw(display_visibility(plan.visibility)) %> + <%= plan.visibility === 'is_test' ? _('N/A') : sanitize(display_visibility(plan.visibility)) %> - <% if plan.shared %> + <% if plan.shared? %> <%= _("Yes") %> <% else %> <%= _('No') %> @@ -55,7 +55,7 @@
      diff --git a/app/views/paginable/templates/_index.html.erb b/app/views/paginable/templates/_index.html.erb index cc43ab9..962f313 100644 --- a/app/views/paginable/templates/_index.html.erb +++ b/app/views/paginable/templates/_index.html.erb @@ -1,4 +1,3 @@ -<% # locals: templates %>
      @@ -10,31 +9,7 @@ - <% scope.each do |template| %> - - - - - - - <% end %> + <%= render(partial: "org_admin/templates/row", collection: scope, as: :template) %>
      - <%= "#{template.is_default? ? '* ' : ''}#{template.title}" %> - - <%= template.org.name %> - - <% if template.published? %> - <%= _('Published') %> - <% elsif template.draft? %> - <% tooltip = _('This template is published changes but has unpublished changes!') %> - <%= _('Published') %> <%= tooltip %> -    - <% else %> - <%= _('Unpublished') %> - <% end %> - - <% last_temp_updated = template.updated_at %> - <%= l last_temp_updated.to_date, formats: :short %> -
      \ No newline at end of file diff --git a/app/views/paginable/templates/_organisational.html.erb b/app/views/paginable/templates/_organisational.html.erb index 2a00e3f..1715906 100644 --- a/app/views/paginable/templates/_organisational.html.erb +++ b/app/views/paginable/templates/_organisational.html.erb @@ -4,26 +4,26 @@ <%= _('Template Name') %> <%= paginable_sort_link('templates.title') %> - <% if action == 'organisational' %> + <% if action_name == 'organisational' %> <%= _('Description') %> <%= paginable_sort_link('templates.description') %> <% else %> - <%= (action == 'customizable' ? _('Funder') : _('Organisation')) %> <%= paginable_sort_link('orgs.name') %> + <%= (action_name == 'customizable' ? _('Funder') : _('Organisation')) %> <%= paginable_sort_link('orgs.name') %> <% end %> <%= _('Status') %> <%= _('Edited Date') %> <%= paginable_sort_link('templates.updated_at') %> - <% if action != 'index' %> + <% if action_name != 'index' %>   <% end %> <% scope.each do |template| %> - + <%= template.title %> - <%= action == 'organisational' ? raw(template.description) : template.org.name %> + <%= action_name == 'organisational' ? sanitize(template.description) : template.org.name %> <%# Leaving this line here as a placeholder for determining how to notify user of changes now that dirty flag is removed %> @@ -44,7 +44,7 @@ <% last_temp_updated = template.updated_at %> <%= l last_temp_updated.to_date, formats: :short %> - <% if action != 'index' %> + <% if action_name != 'index' %> \ No newline at end of file +
      diff --git a/app/views/phases/_overview.html.erb b/app/views/phases/_overview.html.erb index f216c85..04a9b3b 100644 --- a/app/views/phases/_overview.html.erb +++ b/app/views/phases/_overview.html.erb @@ -1,28 +1,27 @@ <%# locals: { phase } %> -
      +

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

      - <%= raw(phase.description) %> + <%= sanitize(phase.description) %>

      -
      -
      +
        - <% phase.sections.each do |s| %> + <% phase.sections.order(:number).each do |s| %>
      • <%= s.title %>
          <% s.questions.each do |q| %> -
        • <%= raw(q.text) %>
        • +
        • <%= sanitize(q.text) %>
        • <% end %>
      • <% end %>
      -
      \ No newline at end of file +
      diff --git a/app/views/phases/edit.html.erb b/app/views/phases/edit.html.erb index 046de4a..a1223fd 100644 --- a/app/views/phases/edit.html.erb +++ b/app/views/phases/edit.html.erb @@ -1,13 +1,21 @@ -<%# locals: { plan, phase, readonly, question_guidance } %> <% title "#{plan.title} - Write plan" %>
      -

      <%= plan.title %>

      +

      <%= plan.title %>

      - <%= render partial: 'phases/edit_plan_answers', layout: 'plans/navigation', locals: local_assigns %> + <%= render partial: 'phases/edit_plan_answers', + layout: 'plans/navigation', + locals: { + plan: plan, + phase: phase, + answers: answers, + readonly: readonly, + base_template_org: base_template_org, + guidance_presenter: guidance_presenter, + } %>
      \ No newline at end of file diff --git a/app/views/plan_exports/show.erb b/app/views/plan_exports/show.erb new file mode 100644 index 0000000..62f818d --- /dev/null +++ b/app/views/plan_exports/show.erb @@ -0,0 +1,2 @@ + +<%= render partial: 'shared/export/plan', locals: local_assigns %> \ No newline at end of file diff --git a/app/views/plans/_download_form.html.erb b/app/views/plans/_download_form.html.erb index 296e2e0..7a4377e 100644 --- a/app/views/plans/_download_form.html.erb +++ b/app/views/plans/_download_form.html.erb @@ -1,6 +1,6 @@ -<%= form_tag( export_plan_path(@plan), method: :get, target: '_blank', id: 'download_form') do |f| %> +<%= form_tag(plan_export_path(@plan), method: :get, target: '_blank', id: 'download_form') do |f| %>

      <%= _("Download settings") %>

      - + <%= hidden_field_tag 'export[form]', true %> <% if @phase_options.length > 1 %>
      <%= label_tag(:phase_id, _("Select phase to download")) %> @@ -12,25 +12,37 @@
      <%= _("Optional plan components") %>
      - <%= label_tag 'export[project_details]', raw("#{check_box_tag 'export[project_details]', true, false} #{_('project details coversheet')}") %> + <%= label_tag 'export[project_details]' do %> + <%= check_box_tag 'export[project_details]', true, false %> + <%= _('project details coversheet') %> + <% end %>
      - <%= label_tag 'export[question_headings]', raw("#{check_box_tag 'export[question_headings]', true, true} #{_('question text and section headings')}") %> + <%= label_tag 'export[question_headings]' do %> + <%= check_box_tag 'export[question_headings]', true, true %> + <%= _('question text and section headings') %> + <% end %>
      - <%= label_tag 'export[unanswered_questions]', raw("#{check_box_tag 'export[unanswered_questions]', true, true} #{_('unanswered questions')}") %> + <%= label_tag 'export[unanswered_questions]' do %> + <%= check_box_tag 'export[unanswered_questions]', true, true %> + <%= _('unanswered questions') %> + <% end %>
      <% if @plan.template.customization_of.present? %>
      - <%= label_tag 'export[custom_sections]', raw("#{check_box_tag 'export[custom_sections]', true, false} #{_('supplementary section(s) not requested by funding organisation')}") %> + <%= label_tag 'export[custom_sections]' do %> + <%= check_box_tag 'export[custom_sections]', true, false %> + <%= _('supplementary section(s) not requested by funding organisation') %> + <% end %>
      <% end %>
      - +

      <%= _('Format') %>

      - <%= select_tag :format, options_for_select(ExportedPlan::VALID_FORMATS, :pdf), + <%= select_tag :format, options_for_select(Settings::Template::VALID_FORMATS, :pdf), class: 'form-control', "aria-labelledby": "format" %>
      @@ -48,58 +60,58 @@
      <%= label_tag "export[formatting][font_face]", _('Face'), class: 'control-label' %> - <%= select_tag "export[formatting][font_face]", - options_for_select(Settings::Template::VALID_FONT_FACES, - @export_settings.formatting[:font_face]), - class: 'form-control', + <%= select_tag "export[formatting][font_face]", + options_for_select(Settings::Template::VALID_FONT_FACES, + @export_settings.formatting[:font_face]), + class: 'form-control', "data-default": @plan.template.settings(:export).formatting[:font_face] %>
      <%= label_tag "export[formatting][font_size]", _('Size') + " (pt)", class: 'control-label' %> - <%= select_tag "export[formatting][font_size]", + <%= select_tag "export[formatting][font_size]", options_for_select(Settings::Template::VALID_FONT_SIZE_RANGE.to_a, @export_settings.formatting[:font_size]), - class: 'form-control', + class: 'form-control', "data-default": @plan.template.settings(:export).formatting[:font_size] %>
      - <%= label_tag "export[formatting][margin][top]", _('Top'), + <%= label_tag "export[formatting][margin][top]", _('Top'), class: 'control-label' %> - <%= select_tag "export[formatting][margin][top]", - options_for_select(Settings::Template::VALID_MARGIN_RANGE.to_a, - @export_settings.formatting[:margin][:top]), - class: 'form-control', + <%= select_tag "export[formatting][margin][top]", + options_for_select(Settings::Template::VALID_MARGIN_RANGE.to_a, + @export_settings.formatting[:margin][:top]), + class: 'form-control', "data-default": @plan.template.settings(:export).formatting[:margin][:top] %>
      - <%= label_tag "export[formatting][margin][bottom]", _('Bottom'), + <%= label_tag "export[formatting][margin][bottom]", _('Bottom'), class: 'control-label' %> - <%= select_tag "export[formatting][margin][bottom]", - options_for_select(Settings::Template::VALID_MARGIN_RANGE.to_a, - @export_settings.formatting[:margin][:bottom]), - class: 'form-control', + <%= select_tag "export[formatting][margin][bottom]", + options_for_select(Settings::Template::VALID_MARGIN_RANGE.to_a, + @export_settings.formatting[:margin][:bottom]), + class: 'form-control', "data-default": @plan.template.settings(:export).formatting[:margin][:bottom] %>
      - <%= label_tag "export[formatting][margin][left]", _('Left'), + <%= label_tag "export[formatting][margin][left]", _('Left'), class: 'control-label' %> - <%= select_tag "export[formatting][margin][left]", - options_for_select(Settings::Template::VALID_MARGIN_RANGE.to_a, - @export_settings.formatting[:margin][:left]), - class: 'form-control', + <%= select_tag "export[formatting][margin][left]", + options_for_select(Settings::Template::VALID_MARGIN_RANGE.to_a, + @export_settings.formatting[:margin][:left]), + class: 'form-control', "data-default": @plan.template.settings(:export).formatting[:margin][:left] %>
      - <%= label_tag "export[formatting][margin][right]", _('Right'), + <%= label_tag "export[formatting][margin][right]", _('Right'), class: 'control-label' %> - <%= select_tag "export[formatting][margin][right]", - options_for_select(Settings::Template::VALID_MARGIN_RANGE.to_a, - @export_settings.formatting[:margin][:right]), - class: 'form-control', + <%= select_tag "export[formatting][margin][right]", + options_for_select(Settings::Template::VALID_MARGIN_RANGE.to_a, + @export_settings.formatting[:margin][:right]), + class: 'form-control', "data-default": @plan.template.settings(:export).formatting[:margin][:rigth] %>
      - - <%= button_tag(_('Download Plan'), class: "btn btn-primary", type: "submit") %> + + <%= button_tag(sanitize(_('Download Plan (new window)')), class: "btn btn-primary", type: "submit") %> <% end %> diff --git a/app/views/plans/_edit_details.html.erb b/app/views/plans/_edit_details.html.erb index 98c30dd..06e9d3c 100644 --- a/app/views/plans/_edit_details.html.erb +++ b/app/views/plans/_edit_details.html.erb @@ -6,11 +6,14 @@ <%= f.label(:title, _('Project title'), class: 'control-label') %>
      - <%= f.text_field(:title, class: "form-control", "aria-required": true, 'data-toggle': 'tooltip', + <%= f.text_field(:title, class: "form-control", "aria-required": true, 'data-toggle': 'tooltip', title: _('If applying for funding, state the name exactly as in the grant proposal.')) %>
      <%= f.hidden_field :visibility %> - <%= f.label(:is_test, raw("#{check_box_tag(:is_test,1, @plan.is_test? , "aria-label": "is_test")} #{_('mock project for testing, practice, or educational purposes')}"), class: 'control-label') %> + <%= 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 %>
      @@ -30,7 +33,7 @@ <%= f.label(:grant_number, _('Grant number'), class: 'control-label') %>
      - <%= f.text_field(:grant_number, class: "form-control", "aria-required": false, 'data-toggle': 'tooltip', + <%= f.text_field(:grant_number, class: "form-control", "aria-required": false, 'data-toggle': 'tooltip', title: _('Grant reference number if applicable [POST-AWARD DMPs ONLY]')) %>
      @@ -50,7 +53,7 @@ <%= f.label(:identifier, _('ID'), class: 'control-label') %>
      - <%= f.text_field(:identifier, class: "form-control", "aria-required": false, 'data-toggle': "tooltip", + <%= f.text_field(:identifier, class: "form-control", "aria-required": false, 'data-toggle': "tooltip", title: _('A pertinent ID as determined by the funder and/or organisation.')) %>
      @@ -86,8 +89,7 @@ <%= f.email_field( :principal_investigator_email, class: "form-control", - "aria-required": false, - "data-validation": "email") %> + "aria-required": false) %>
      @@ -106,7 +108,10 @@ <%= _('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, raw("#{check_box_tag(:show_data_contact, checked, checked == 0)} #{_('Same as Principal Investigator')}"), class: 'control-label') %> + <%= label_tag(:show_data_contact, class: 'control-label') do %> + <%= check_box_tag(:show_data_contact, checked, checked == 0) %> + <%= _('Same as Principal Investigator') %> + <% end %>
      @@ -127,8 +132,7 @@ <%= f.email_field( :data_contact_email, class: "form-control", - "aria-required": false, - "data-validation": "email") %> + "aria-required": false) %>
      @@ -143,17 +147,17 @@
      - <%= f.button(_('Submit'), class: "btn btn-default", type: "submit") %> + <%= f.button(_('Save'), class: "btn btn-default", type: "submit") %>
      -

      <%= _('Plan Guidance Configuration') %>

      +

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

      <%= _('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", + <%= render partial: "guidance_choices", locals: {choices: @important_ggs, form: f, current_selections: @selected_guidance_groups} %>
      @@ -162,7 +166,7 @@ <%= link_to _('See the full list'), '#', 'data-toggle' => 'modal', 'data-target' => '#modal-full-guidances', class: 'modal-guidances-window' %>
      - <%= f.button(_('Submit'), class: "btn btn-default", type: "submit") %> + <%= f.button(_('Save'), class: "btn btn-default", type: "submit") %>
      diff --git a/app/views/plans/_navigation.html.erb b/app/views/plans/_navigation.html.erb index 1201abf..70d17fc 100644 --- a/app/views/plans/_navigation.html.erb +++ b/app/views/plans/_navigation.html.erb @@ -1,25 +1,25 @@ <% phases = Phase.titles(plan.template.id) %>