From e85e71a7cf3b19fb8872b35b740a8f3eae0bc7d2 Mon Sep 17 00:00:00 2001 From: "Luis M. Rodriguez-R" Date: Thu, 10 Aug 2023 14:24:45 +0200 Subject: [PATCH 01/14] Initial observers infrastructure Starts addressing #131, initially just for `Name`, still missing `Register` --- app/controllers/names_controller.rb | 22 +++++++++++++++++ app/helpers/application_helper.rb | 7 ++++-- app/models/name.rb | 6 +++++ app/models/observe_name.rb | 4 ++++ app/models/observe_register.rb | 4 ++++ app/models/user.rb | 2 ++ app/views/names/show.html.erb | 15 ++++++++++-- config/routes.rb | 2 ++ .../20230810114945_create_observe_names.rb | 10 ++++++++ ...20230810115026_create_observe_registers.rb | 10 ++++++++ db/schema.rb | 24 ++++++++++++++++++- test/fixtures/observe_names.yml | 9 +++++++ test/fixtures/observe_registers.yml | 9 +++++++ test/models/observe_name_test.rb | 7 ++++++ test/models/observe_register_test.rb | 7 ++++++ 15 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 app/models/observe_name.rb create mode 100644 app/models/observe_register.rb create mode 100644 db/migrate/20230810114945_create_observe_names.rb create mode 100644 db/migrate/20230810115026_create_observe_registers.rb create mode 100644 test/fixtures/observe_names.yml create mode 100644 test/fixtures/observe_registers.yml create mode 100644 test/models/observe_name_test.rb create mode 100644 test/models/observe_register_test.rb diff --git a/app/controllers/names_controller.rb b/app/controllers/names_controller.rb index 4c82f55b..4fa50360 100644 --- a/app/controllers/names_controller.rb +++ b/app/controllers/names_controller.rb @@ -8,6 +8,7 @@ class NamesController < ApplicationController edit_rank edit_notes edit_etymology edit_links edit_type autofill_etymology link_parent link_parent_commit return validate endorse claim unclaim new_correspondence + observe unobserve ] ) before_action( @@ -28,6 +29,7 @@ class NamesController < ApplicationController return validate endorse ] ) + before_action(:authenticate_user!, only: %i[observe unobserve]) # GET /autocomplete_names.json?q=Maco # GET /autocomplete_names.json?q=Allo&rank=genus @@ -459,6 +461,26 @@ def new_correspondence redirect_to(@tutorial || @name) end + # GET /names/1/observe + def observe + @name.observers << current_user + if params[:from] && RedirectSafely.safe?(params[:from]) + redirect_to(params[:from]) + else + redirect_back(fallback_location: @name) + end + end + + # GET /names/1/unobserve + def unobserve + @name.observers.delete(current_user) + if params[:from] && RedirectSafely.safe?(params[:from]) + redirect_to(params[:from]) + else + redirect_back(fallback_location: @name) + end + end + private # Use callbacks to share common setup or constraints between actions diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index cf6aa366..7c7eea91 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -187,8 +187,11 @@ def download_buttons(list) end end - def download_button(url, icon, text) - link_to(url, class: 'btn btn-light btn-sm text-muted') do + def download_button(url, icon, text, opts = {}) + opts[:color] ||= 'light' + opts[:class] ||= '' + opts[:class] += " btn btn-#{opts[:color]} btn-sm text-muted" + link_to(url, class: opts[:class]) do fa_icon(icon) + text end end diff --git a/app/models/name.rb b/app/models/name.rb index 46f9d8b1..c8945be9 100644 --- a/app/models/name.rb +++ b/app/models/name.rb @@ -18,6 +18,8 @@ class Name < ApplicationRecord :child_placements, class_name: 'Name', foreign_key: 'parent_id', dependent: :destroy ) + has_many(:observe_names, dependent: :destroy) + has_many(:observers, through: :observe_names, source: :user) belongs_to( :proposed_by, optional: true, @@ -571,6 +573,10 @@ def curators @curators ||= (check_users + reviewers).uniq end + def observing?(user) + observe_names.where(user: user).present? + end + # ============ --- TAXONOMY --- ============ def top_rank? diff --git a/app/models/observe_name.rb b/app/models/observe_name.rb new file mode 100644 index 00000000..04b61695 --- /dev/null +++ b/app/models/observe_name.rb @@ -0,0 +1,4 @@ +class ObserveName < ApplicationRecord + belongs_to :user + belongs_to :name +end diff --git a/app/models/observe_register.rb b/app/models/observe_register.rb new file mode 100644 index 00000000..5669cd8b --- /dev/null +++ b/app/models/observe_register.rb @@ -0,0 +1,4 @@ +class ObserveRegister < ApplicationRecord + belongs_to :user + belongs_to :register +end diff --git a/app/models/user.rb b/app/models/user.rb index 2ea49069..e3873d2b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,6 +19,8 @@ class User < ApplicationRecord has_many(:tutorials, dependent: :nullify) has_many(:checks, dependent: :nullify) has_many(:checked_names, -> { distinct }, through: :checks, source: :name) + has_many(:observe_names, dependent: :destroy) + has_many(:observing_names, through: :observe_names, source: :name) validates( :username, diff --git a/app/views/names/show.html.erb b/app/views/names/show.html.erb index 5f8b51cd..2f1d6895 100644 --- a/app/views/names/show.html.erb +++ b/app/views/names/show.html.erb @@ -1,9 +1,20 @@ <% provide(:title, @name.abbr_name_raw) %> +<% + observe = nil + if user_signed_in? + from = name_path(@name) + observe = @name.observing?(current_user) ? + [unobserve_name_url(@name, from: from), 'eye-slash', 'Unobserve'] : + [observe_name_url(@name, from: from), 'eye', 'Observe'] + observe += [{ class: 'ml-3' }] + end +%> <%= download_buttons([ [name_url(@name, format: :json), 'file-code', 'JSON'], - [name_url(@name, format: :pdf), 'file-pdf', 'PDF'] - ]) + [name_url(@name, format: :pdf), 'file-pdf', 'PDF'], + observe + ].compact) %>

<%= @name.name_html %> diff --git a/config/routes.rb b/config/routes.rb index d6178e5e..1b0c864d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -65,6 +65,8 @@ post 'names/:id/claim' => 'names#claim', as: :claim_name post 'names/:id/unclaim' => 'names#unclaim', as: :unclaim_name post 'names/:id/new_correspondence' => 'names#new_correspondence', as: :new_correspondence_name + get 'names/:id/observe' => 'names#observe', as: :observe_name + get 'names/:id/unobserve' => 'names#unobserve', as: :unobserve_name get 'placements/new/:name_id' => 'placements#new', as: :new_placement post 'placements' => 'placements#create', as: :create_placement diff --git a/db/migrate/20230810114945_create_observe_names.rb b/db/migrate/20230810114945_create_observe_names.rb new file mode 100644 index 00000000..4a25adf2 --- /dev/null +++ b/db/migrate/20230810114945_create_observe_names.rb @@ -0,0 +1,10 @@ +class CreateObserveNames < ActiveRecord::Migration[6.1] + def change + create_table :observe_names do |t| + t.references :user, null: false, foreign_key: true + t.references :name, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20230810115026_create_observe_registers.rb b/db/migrate/20230810115026_create_observe_registers.rb new file mode 100644 index 00000000..9aef1763 --- /dev/null +++ b/db/migrate/20230810115026_create_observe_registers.rb @@ -0,0 +1,10 @@ +class CreateObserveRegisters < ActiveRecord::Migration[6.1] + def change + create_table :observe_registers do |t| + t.references :user, null: false, foreign_key: true + t.references :register, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 92ebd3a3..578acdc7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2023_08_08_131521) do +ActiveRecord::Schema.define(version: 2023_08_10_115026) do create_table "action_text_rich_texts", force: :cascade do |t| t.string "name", null: false @@ -191,6 +191,24 @@ t.index ["tutorial_id"], name: "index_names_on_tutorial_id" end + create_table "observe_names", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "name_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["name_id"], name: "index_observe_names_on_name_id" + t.index ["user_id"], name: "index_observe_names_on_user_id" + end + + create_table "observe_registers", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "register_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["register_id"], name: "index_observe_registers_on_register_id" + t.index ["user_id"], name: "index_observe_registers_on_user_id" + end + create_table "placements", force: :cascade do |t| t.integer "name_id", null: false t.integer "parent_id" @@ -360,6 +378,10 @@ add_foreign_key "names", "genomes" add_foreign_key "names", "registers" add_foreign_key "names", "tutorials" + add_foreign_key "observe_names", "names" + add_foreign_key "observe_names", "users" + add_foreign_key "observe_registers", "registers" + add_foreign_key "observe_registers", "users" add_foreign_key "placements", "names" add_foreign_key "placements", "publications" add_foreign_key "register_correspondences", "registers" diff --git a/test/fixtures/observe_names.yml b/test/fixtures/observe_names.yml new file mode 100644 index 00000000..352151c3 --- /dev/null +++ b/test/fixtures/observe_names.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + name: one + +two: + user: two + name: two diff --git a/test/fixtures/observe_registers.yml b/test/fixtures/observe_registers.yml new file mode 100644 index 00000000..d3de03ec --- /dev/null +++ b/test/fixtures/observe_registers.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + user: one + register: one + +two: + user: two + register: two diff --git a/test/models/observe_name_test.rb b/test/models/observe_name_test.rb new file mode 100644 index 00000000..cc845985 --- /dev/null +++ b/test/models/observe_name_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ObserveNameTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/observe_register_test.rb b/test/models/observe_register_test.rb new file mode 100644 index 00000000..7d3f098f --- /dev/null +++ b/test/models/observe_register_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ObserveRegisterTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end From f2177d5d830a48948761c03aaf70c75e71824915 Mon Sep 17 00:00:00 2001 From: "Luis M. Rodriguez-R" Date: Tue, 2 Jan 2024 18:36:58 +0100 Subject: [PATCH 02/14] Full implementation of observers --- Gemfile | 3 +- Gemfile.lock | 8 +- app/assets/stylesheets/custom.scss | 4 + app/controllers/names_controller.rb | 119 +-- app/controllers/registers_controller.rb | 169 ++-- app/helpers/application_helper.rb | 29 +- app/mailers/admin_mailer.rb | 28 +- app/models/has_observers.rb | 71 ++ app/models/name.rb | 129 +-- app/models/name/external_resources.rb | 4 + app/models/name/status.rb | 133 +++ app/models/name_correspondence.rb | 7 + app/models/register.rb | 153 +-- app/models/register/status.rb | 263 +++++ app/models/register_correspondence.rb | 7 + app/models/user.rb | 2 + .../correspondence_email.html.erb | 22 + .../correspondence_email.text.erb | 20 + .../admin_mailer/name_status_email.html.erb | 12 +- .../admin_mailer/name_status_email.text.erb | 2 +- .../observer_status_email.html.erb | 20 + .../observer_status_email.text.erb | 20 + .../register_status_email.html.erb | 41 +- .../register_status_email.text.erb | 26 +- .../admin_mailer/user_status_email.html.erb | 8 +- .../admin_mailer/user_status_email.text.erb | 6 +- app/views/devise/registrations/edit.html.erb | 37 +- app/views/layouts/_footer.html.erb | 6 + app/views/layouts/admin_mailer.html.erb | 7 + app/views/layouts/admin_mailer.text.erb | 9 +- app/views/names/_alert_messages.html.erb | 12 - app/views/names/_correspondence.html.erb | 45 +- app/views/names/_metadata.html.erb | 11 + app/views/names/_name.html.erb | 35 +- app/views/names/_name.json.jbuilder | 2 +- app/views/names/_quality_checks.html.erb | 19 +- app/views/names/_title.html.erb | 49 + app/views/names/edit_type.html.erb | 2 +- app/views/names/show.html.erb | 23 +- app/views/registers/_correspondence.html.erb | 52 +- app/views/registers/_metadata.html.erb | 17 +- app/views/registers/_register.html.erb | 3 + app/views/registers/_title.html.erb | 66 ++ app/views/registers/index.html.erb | 2 +- app/views/registers/show.html.erb | 34 +- app/views/shared/_correspondence.html.erb | 52 + .../shared/_correspondence_history.html.erb | 9 + .../shared/_correspondence_message.html.erb | 15 + app/views/users/_contributor.html.erb | 18 + config/environments/development.rb | 3 + config/routes.rb | 7 +- db/development.sqlite3.bak | Bin 44535808 -> 53284864 bytes ...15204310_add_opt_message_email_to_users.rb | 5 + ...240102013502_add_unique_observers_index.rb | 6 + db/schema.rb | 5 +- lib/tasks/init_observers.rake | 17 + yarn.lock | 937 +++++++++--------- 57 files changed, 1732 insertions(+), 1079 deletions(-) create mode 100644 app/models/has_observers.rb create mode 100644 app/models/name/status.rb create mode 100644 app/models/register/status.rb create mode 100644 app/views/admin_mailer/correspondence_email.html.erb create mode 100644 app/views/admin_mailer/correspondence_email.text.erb create mode 100644 app/views/admin_mailer/observer_status_email.html.erb create mode 100644 app/views/admin_mailer/observer_status_email.text.erb create mode 100644 app/views/names/_title.html.erb create mode 100644 app/views/registers/_title.html.erb create mode 100644 app/views/shared/_correspondence.html.erb create mode 100644 app/views/shared/_correspondence_history.html.erb create mode 100644 app/views/shared/_correspondence_message.html.erb create mode 100644 db/migrate/20231215204310_add_opt_message_email_to_users.rb create mode 100644 db/migrate/20240102013502_add_unique_observers_index.rb create mode 100644 lib/tasks/init_observers.rake diff --git a/Gemfile b/Gemfile index 50c257eb..60705c82 100644 --- a/Gemfile +++ b/Gemfile @@ -70,7 +70,8 @@ gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] # Custom gems: gem 'serrano', '~> 1.0' gem 'devise' -gem 'bootstrap', '~> 4.3' +gem 'bootstrap', '~> 4.6' +#gem 'dartsass-sprockets' gem 'jquery-rails' gem 'will_paginate' gem 'will_paginate-bootstrap4' diff --git a/Gemfile.lock b/Gemfile.lock index 598f333b..cca22657 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -61,7 +61,7 @@ GEM minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - addressable (2.8.5) + addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) afm (0.2.2) autoprefixer-rails (10.4.16.0) @@ -155,10 +155,10 @@ GEM mini_mime (1.1.5) minitest (5.20.0) multi_json (1.15.0) - net-ftp (0.3.0) + net-ftp (0.3.3) net-protocol time - net-imap (0.4.7) + net-imap (0.4.8) date net-protocol net-pop (0.1.2) @@ -329,7 +329,7 @@ PLATFORMS x86_64-linux DEPENDENCIES - bootstrap (~> 4.3) + bootstrap (~> 4.6) byebug capybara (~> 2.13) coffee-rails (~> 5.0) diff --git a/app/assets/stylesheets/custom.scss b/app/assets/stylesheets/custom.scss index 13bdc0c5..b395940d 100644 --- a/app/assets/stylesheets/custom.scss +++ b/app/assets/stylesheets/custom.scss @@ -29,6 +29,10 @@ } } +input[type=checkbox] { + accent-color: theme-color-level(primary, 0); +} + #dashboard-actions, .btn { .fa, .fas, .far, .fal, .fad, .fab { min-width: 1.5em; diff --git a/app/controllers/names_controller.rb b/app/controllers/names_controller.rb index 8f2eedf8..afe3c44e 100644 --- a/app/controllers/names_controller.rb +++ b/app/controllers/names_controller.rb @@ -67,7 +67,7 @@ def index(opts = {}) Name.valid_status end - @names = + @names ||= case @sort when 'date' if opts[:status] == 15 @@ -83,7 +83,7 @@ def index(opts = {}) @sort = 'alphabetically' Name.order(name: :asc) end - @names = @names.where(status: opts[:status]) + @names = @names.where(status: opts[:status]) if opts[:status] @names = @names.where(opts[:where]) if opts[:where] @names = @names.paginate(page: params[:page], per_page: 30) @@ -106,9 +106,22 @@ def user_names if params[:user] && current_user.admin? user = User.find_by(username: params[:user]) end - @title = "Names by #{user.username}" + @title = "Names by #{user.username}" + @status = 'user' + index(where: { created_by: user }) + render(:index) + end + + # GET /observing-names + def observing_names + user = current_user + if params[:user] && current_user.admin? + user = User.find_by(username: params[:user]) + end + @title = 'Names with active notifications' @status = 'user' - index(where: { created_by: user }, status: Name.status_hash.keys) + @names = user.observing_names.reverse + index render(:index) end @@ -205,6 +218,7 @@ def create respond_to do |format| if @name.save + @name.add_observer(current_user) format.html { redirect_to @name, notice: 'Name was successfully created' } format.json { render :show, status: :created, location: @name } else @@ -348,6 +362,7 @@ def link_parent_commit end if ok && @name.update(par) + par[:parent].add_observer(current_user) flash[:notice] = 'Parent successfully updated' redirect_to(@name) else @@ -357,103 +372,41 @@ def link_parent_commit # POST /names/1/return def return - par = { status: 5 } - if !@name.after_submission? - flash[:alert] = 'Name status is incompatible with return' - elsif @name.update(par) - # Email notification - AdminMailer.with( - user: @name.created_by, - name: @name, - action: 'return' - ).name_status_email.deliver_later - flash[:notice] = 'Name returned to author' - else - flash[:alert] = 'An unexpected error occurred' - end - redirect_to(@name) + change_status(:return, 'Name returned to author', current_user) end # POST /names/1/validate def validate - if params[:code] == 'icnp' || params[:code] == 'icn' - par = { - status: params[:code] == 'icnp' ? 20 : 25, - validated_by: current_user, validated_at: Time.now - } - if @name.validated? - flash[:alert] = 'Name status is incompatible with validation' - elsif @name.update(par) - # Email notification - AdminMailer.with( - user: @name.created_by, - name: @name, - action: 'validate' - ).name_status_email.deliver_later - flash[:notice] = 'Name successfully validated' - else - flash[:alert] = 'An unexpected error occurred' - end - else - flash[:alert] = - 'Invalid procedure for nomenclatural code ' + params[:code] - end - redirect_to(@name) + change_status( + :validate, 'Name successfully validated', current_user, params[:code] + ) end # POST /names/1/endorse def endorse - par = { status: 12, endorsed_by: current_user, endorsed_at: Time.now } - if @name.after_endorsement? - flash[:alert] = 'Name status is incompatible with endorsement' - elsif @name.update(par) - # Email notification - AdminMailer.with( - user: @name.created_by, - name: @name, - action: 'endorse' - ).name_status_email.deliver_later - flash[:notice] = 'Name successfully endorsed' - else - flash[:alert] = 'An unexpected error occurred' - end - redirect_to(@name) + change_status(:endorse, 'Name successfully endorsed', current_user) end # POST /names/1/claim def claim - if !@name.can_claim?(current_user) - flash[:alert] = 'You cannot claim this name' - elsif @name.claim(current_user) - flash[:notice] = 'Name successfully claimed' - else - flash[:alert] = 'An unexpected error occurred' - end - redirect_to(@name) + change_status(:claim, 'Name successfully claimed', current_user) end # POST /names/1/unclaim def unclaim - par = { status: 0 } - if !@name.can_unclaim?(current_user) - flash[:alert] = 'You cannot unclaim this name' - elsif @name.unclaim(current_user) - flash[:notice] = 'Name successfully returned to the public pool' - else - flash[:alert] = 'An unexpected error occurred' - end - redirect_to(@name) + change_status(:unclaim, 'Name successfully claimed', current_user) end # POST /names/1/new_correspondence def new_correspondence @name_correspondence = NameCorrespondence.new( - params.require(:name_correspondence).permit(:message) + params.require(:name_correspondence).permit(:message, :notify) ) unless @name_correspondence.message.empty? @name_correspondence.user = current_user @name_correspondence.name = @name if @name_correspondence.save + @name.add_observer(current_user) flash[:notice] = 'Correspondence recorded' else flash[:alert] = 'An unexpected error occurred with the correspondence' @@ -464,7 +417,7 @@ def new_correspondence # GET /names/1/observe def observe - @name.observers << current_user + @name.add_observer(current_user) if params[:from] && RedirectSafely.safe?(params[:from]) redirect_to(params[:from]) else @@ -532,4 +485,18 @@ def etymology_pars Name.etymology_fields.map { |j| :"etymology_#{i}_#{j}" } end.flatten end + + def change_status(fun, success_msg, *extra_opts) + if @name.send(fun, *extra_opts) + flash[:notice] = success_msg + else + flash[:alert] = @name.status_alert + end + redirect_to(@name) + rescue ActiveRecord::RecordInvalid => inv + flash['alert'] = + 'An unexpected error occurred while updating the name: ' + + inv.record.errors.map { |e| "#{e.attribute} #{e.message}" }.to_sentence + redirect_to(inv.record) + end end diff --git a/app/controllers/registers_controller.rb b/app/controllers/registers_controller.rb index 611a367e..c9cafaf8 100644 --- a/app/controllers/registers_controller.rb +++ b/app/controllers/registers_controller.rb @@ -6,6 +6,7 @@ class RegistersController < ApplicationController submit return return_commit endorse notification notify validate publish new_correspondence internal_notes nomenclature_review genomics_review + observe unobserve ] ) before_action(:set_name, only: %i[new create]) @@ -31,6 +32,7 @@ class RegistersController < ApplicationController :authenticate_can_edit!, only: %i[edit update destroy submit notification notify new_correspondence] ) + before_action(:authenticate_user!, only: %i[observe unobserve]) # GET /registers or /registers.json def index(status = :validated) @@ -51,6 +53,16 @@ def index(status = :validated) else current_user.registers end + when :observing + authenticate_curator! && return + if params[:user] + authenticate_curator! && return + user = User.find_by(username: params[:user]) + @extra_title = "by #{user.display_name}" + user.observing_registers + else + current_user.observing_registers + end when :draft authenticate_curator! && return Register.where(validated: false, notified: false, submitted: false) @@ -100,6 +112,7 @@ def create if @register.can_edit?(current_user) && @register.save && (!@name || @name.add_to_register(@register, current_user)) && (!@tutorial || @tutorial.add_to_register(@register, current_user)) + @register.add_observer(current_user) flash[:notice] = 'Register was successfully created' if @tutorial flash[:notice] += '. Remember to submit register list for evaluation' @@ -138,19 +151,9 @@ def destroy # POST /registers/r:abcd/submit def submit - ActiveRecord::Base.transaction do - par = { status: 10, submitted_at: Time.now, submitted_by: current_user } - @register.names.each do |name| - if name.after_submission? - flash[:alert] = 'Some names in the list have already been submitted' - end - name.update!(par) - end - @register.update!(submitted: true, submitted_at: Time.now) - flash[:notice] = 'Register list successfully submitted for review' - end - - redirect_to @register + change_status( + :submit, 'Register list successfully submitted for review', current_user + ) end # GET /registers/r:abcd/return @@ -162,41 +165,17 @@ def return # POST /registers/r:abcd/return def return_commit - ActiveRecord::Base.transaction do - par = { status: 5 } - @register.names.each { |name| name.update!(par) unless name.validated? } - @register.update!( - submitted: false, notified: false, notes: params[:register][:notes] - ) - end - - # Notify submitter - AdminMailer.with( - user: @register.user, - register: @register, - action: 'return' - ).register_status_email.deliver_later - - redirect_to(@register) + change_status( + :return, 'Register list returned to authors', + current_user, params[:register][:notes] + ) end # POST /registers/r:abcd/endorse def endorse - ActiveRecord::Base.transaction do - par = { status: 12, endorsed_by: current_user, endorsed_at: Time.now } - @register.names.each do |name| - name.update!(par) unless name.after_endorsement? - end - end - - # Notify submitter - AdminMailer.with( - user: @register.user, - register: @register, - action: 'endorse' - ).register_status_email.deliver_later - - redirect_to(@register) + change_status( + :endorse, 'Register list has been endorsed', current_user + ) end # GET /registers/r:abc/notify @@ -207,44 +186,14 @@ def notification # POST /registers/r:abc/notify def notify - par = register_notify_params.merge(notified: true, notified_at: Time.now) - @register.title = par[:title] - @register.abstract = par[:abstract] - - all_ok = false - publication = Publication.by_doi(params[:doi]) - par[:publication] = publication - if publication.new_record? - @register.errors.add(:doi, publication.errors[:doi].join('; ')) - elsif !par[:publication_pdf] && !@register.publication_pdf.attached? - @register.errors.add(:publication_pdf, 'cannot be empty') - else - par[:publication] = publication - ActiveRecord::Base.transaction do - @register.names.each do |name| - unless name.after_endorsement? - flash[:warning] = 'Some names in the list have not been endorsed ' \ - 'yet and will require expert review, which could delay validation' - name.status = 10 - name.submitted_at = Time.now - name.submitted_by = current_user - end - - unless name.publications.include? publication - name.publications << publication - end - name.proposed_by ||= publication - name.save! - end - all_ok = @register.update(par) - end - end - - if all_ok - HeavyMethodJob.perform_later(:automated_validation, @register) + # Note that +notify+ handles errors differently, and is incompatible with + # the standard +change_status+ call used in all other status changes + if @register.notify(current_user, register_notify_params, params[:doi]) flash[:notice] = 'The list has been successfully submitted for validation' redirect_to(@register) else + @register.title = par[:title] + @register.abstract = par[:abstract] flash[:alert] = 'Please review the errors below' notification render(:notification) @@ -253,29 +202,14 @@ def notify # POST /registers/r:abc/validate def validate - success = true - - @register.validate!(current_user) - flash['notice'] = 'Successfully validated the register list' - - # Notify submitter - AdminMailer.with( - user: @register.user, - register: @register, - action: 'validate' - ).register_status_email.deliver_later - - redirect_to(@register) - rescue ActiveRecord::RecordInvalid => inv - flash['alert'] = - 'An unexpected error occurred while validating the list: ' + - inv.record.errors.map { |e| "#{e.attribute} #{e.message}" }.to_sentence - redirect_to(inv.record) + change_status( + :validate, 'Successfully validated the register list', current_user + ) end # POST /registers/r:abc/publish def publish - # TODO See Register#post_validation + # TODO See Register::Status#post_validation end # GET /registers/r:abc/table @@ -318,18 +252,39 @@ def cite # POST /registers/r:abc/new_correspondence def new_correspondence @register_correspondence = RegisterCorrespondence.new( - params.require(:register_correspondence).permit(:message) + params.require(:register_correspondence).permit(:message, :notify) ) unless @register_correspondence.message.empty? @register_correspondence.user = current_user @register_correspondence.register = @register if @register_correspondence.save + @register.add_observer(current_user) flash[:notice] = 'Correspondence recorded' else flash[:alert] = 'An unexpected error occurred with the correspondence' end end - redirect_to @register + redirect_to(@register) + end + + # GET /register/1/observe + def observe + @register.add_observer(current_user) + if params[:from] && RedirectSafely.safe?(params[:from]) + redirect_to(params[:from]) + else + redirect_back(fallback_location: @register) + end + end + + # GET /register/1/unobserve + def unobserve + @register.observers.delete(current_user) + if params[:from] && RedirectSafely.safe?(params[:from]) + redirect_to(params[:from]) + else + redirect_back(fallback_location: @register) + end end # POST /registers/r:abc/internal_notes @@ -408,4 +363,18 @@ def authenticate_can_edit! def ensure_valid! @register&.validated? end + + def change_status(fun, success_msg, *extra_opts) + if @register.send(fun, *extra_opts) + flash[:notice] = success_msg + else + flash[:alert] = @register.status_alert + end + redirect_to(@register) + rescue ActiveRecord::RecordInvalid => inv + flash['alert'] = + 'An unexpected error occurred while updating the list: ' + + inv.record.errors.map { |e| "#{e.attribute} #{e.message}" }.to_sentence + redirect_to(inv.record) + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b13b92dc..45adffcc 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -131,7 +131,7 @@ def adaptable_value(entry, name) def modal(title, opts = {}) @modals ||= [] - id = opts[:id] || "modal-#{@modals.size}" + id = opts[:id] || "modal-#{SecureRandom.uuid}" @modals << content_tag( :div, id: id, class: 'modal fade', tabindex: '-1', role: 'dialog' @@ -190,24 +190,27 @@ def download_buttons(list) def download_button(url, icon, text, opts = {}) opts[:color] ||= 'light' opts[:class] ||= '' - opts[:class] += " btn btn-#{opts[:color]} btn-sm text-muted" - link_to(url, class: opts[:class]) do + opts[:class] += " btn btn-#{opts[:color]} btn-sm" + opts[:class] += ' text-muted' if opts[:color] == 'light' + link_to(url, opts) do fa_icon(icon) + text end end - def display_link(obj) + def display_obj(obj) field = %i[name_html name accession citation].find { |i| obj.respond_to? i } - display = - if field - obj.send(field) - elsif obj.respond_to? :id - obj.class.to_s + ' ' + obj.id - else - obj.to_s - end - link_to(display, obj) + if field + obj.send(field) + elsif obj.respond_to? :id + obj.class.to_s + ' ' + obj.id + else + obj.to_s + end + end + + def display_link(obj) + link_to(display_obj(obj), obj) end end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index 3c8f3dd1..32b2f0c5 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -6,7 +6,7 @@ class AdminMailer < ApplicationMailer ## # Email notifying of a user status change def user_status_email - return unless @user.opt_regular_email + return unless @user.opt_regular_email? @params = params @action = @@ -27,21 +27,39 @@ def user_status_email ## # Email notifying of a name status change def name_status_email - return unless @user.opt_regular_email + return unless @user.opt_regular_email? - @name = params[:name] + @name = params[:name] || params[:object] mail(subject: 'New name status in SeqCode Registry') end ## # Email notifying of a register list status change def register_status_email - return unless @user.opt_regular_email + return unless @user.opt_regular_email? - @register = params[:register] + @register = params[:register] || params[:object] mail(subject: 'New register list status in SeqCode Registry') end + ## + # Email notifying observers of a new correspondence + def correspondence_email + return unless @user.opt_message_email? + + @object = params[:object] + mail(subject: 'New correspondence message in SeqCode Registry') + end + + ## + # Email notifying observers of a new status change + def observer_status_email + return unless @user.opt_regular_email? + + @object = params[:object] + mail(subject: 'New status update in SeqCode Registry') + end + ## # Periodic reminder for contributors sent by +ReminderMail.register_reminder+ def register_reminder_email diff --git a/app/models/has_observers.rb b/app/models/has_observers.rb new file mode 100644 index 00000000..a467c1c1 --- /dev/null +++ b/app/models/has_observers.rb @@ -0,0 +1,71 @@ +module HasObservers + + # ============ --- EMAIL NOTIFICATIONS --- ============ + + def notify_user(action, user) + am = AdminMailer.with( + user: user, + object: self, + action: action.to_s + ) + + case action.to_sym + when :correspondence + # For observers + am.correspondence_email.deliver_later + when :status + # For observers + am.observer_status_email.deliver_later + else + if is_a? Name + # All other name status changes + am.name_status_email.deliver_later + elsif is_a? Register + # All other register status changes + am.register_status_email.deliver_later + else + raise 'Unknown class for observer notifications' + end + end + end + + ## + # Notify observers of a change via email + # + # action: One of +:status+ or +:correspondence+ + # options: + # - exclude_creator: Exclude creator from the list of observers notified + # - exclude_users: Array of users to exclude from notified observers + def notify_observers(action, exclude_creator: false, exclude_users: []) + observers.each do |user| + next if exclude_users.include?(user) + next if exclude_creator && user == created_by + + notify_user(action, user) + end + end + + def notify_status_change(action, user) + notify_user(action, created_by) + notify_observers( + :status, exclude_creator: true, exclude_users: [user] + ) + true + end + + # ============ --- HELPER FUNCTIONS FOR STATUS UPDATES --- ============ + + def assert_status_with_alert(test, action) + return true if test + + @status_alert = 'Status is incompatible with ' + action + false + end + + def update_status_with_alert(par) + return true if update(par) + + @status_alert = 'An unexpected error occurred' + false + end +end diff --git a/app/models/name.rb b/app/models/name.rb index a30fbd9b..87886f02 100644 --- a/app/models/name.rb +++ b/app/models/name.rb @@ -84,6 +84,8 @@ class Name < ApplicationRecord } ) + include HasObservers + include Name::Status include Name::QualityChecks include Name::Etymology include Name::Citations @@ -234,7 +236,20 @@ def type_material_name(type) end end + # ============ --- STATUS --- ============ + + def status_hash + self.class.status_hash[status] + end + + Name.status_hash.each do |k, v| + define_method("#{v[:symbol]}?") do + status == k + end + end + # ============ --- NOMENCLATURE --- ============ + def sanitize(str) ActionView::Base.full_sanitizer.sanitize(str) end @@ -328,6 +343,10 @@ def formal_txt sanitize(formal_html.gsub(/̶[01];/, "'")) end + def display(html: true) + html ? name_html : name + end + def rank_suffix self.class.rank_suffixes[inferred_rank.to_s.to_sym] end @@ -373,66 +392,6 @@ def citations ].flatten.compact.uniq end - # ============ --- STATUS --- ============ - - def status_hash - self.class.status_hash[status] - end - - def status_name - status_hash[:name] - end - - def status_help - status_hash[:help].gsub(/\n/, ' ') - end - - def status_symbol - status_hash[:symbol] - end - - def validated? - status_hash[:valid] - end - - def public? - status_hash[:public] - end - - def after_claim? - status >= 5 - end - - def after_register? - register.present? || after_submission? - end - - def after_submission? - status >= 10 - end - - def after_endorsement? - status >= 12 - end - - def after_notification? - validated? || register.try(:notified?) - end - - def after_validation? - valid? - end - - def after_register_publication? - register.try(:published?) - end - - Name.status_hash.each do |k, v| - define_method("#{v[:symbol]}?") do - status == k - end - end - # ============ --- OUTLINKS --- ============ def ncbi_search_url @@ -530,41 +489,11 @@ def claimed?(user) draft? && user?(user) end - def claim(user) - raise('User cannot claim name') unless can_claim?(user) - par = { created_by: user, created_at: Time.now } - par[:status] = 5 if auto? - return false unless update(par) - - # Email notification - AdminMailer.with( - user: user, - name: self, - action: 'claim' - ).name_status_email.deliver_later - - true - end - def can_unclaim?(user) curator_or_owner = user.try(:curator?) || self.user?(user) curator_or_owner && draft? end - def unclaim(user) - raise('User cannot unclaim name') unless can_unclaim?(user) - return false unless update(status: 0) - - # Email notification - AdminMailer.with( - user: created_by, - name: self, - action: 'unclaim' - ).name_status_email.deliver_later - - true - end - def correspondence_by?(user) return false unless user @@ -583,10 +512,30 @@ def curators @curators ||= (check_users + reviewers).uniq end + def corresponding_users + correspondences.map(&:user).uniq + end + + def associated_users + ( + [created_by, validated_by, submitted_by, endorsed_by] + + corresponding_users + ).compact.uniq + end + def observing?(user) observe_names.where(user: user).present? end + ## + # Attempts to add an observer while silently ignoring it if the user + # already observes the name + def add_observer(user) + self.observers << user + rescue ActiveRecord::RecordNotUnique + true + end + # ============ --- TAXONOMY --- ============ def top_rank? diff --git a/app/models/name/external_resources.rb b/app/models/name/external_resources.rb index 52bf1f41..a199603d 100644 --- a/app/models/name/external_resources.rb +++ b/app/models/name/external_resources.rb @@ -13,6 +13,8 @@ def queued_for_external_resources ## # Queue name for +NameExternalResourcesJob+ def queue_for_external_resources + return if Rails.configuration.bypass_external_apis + unless queued_for_external_resources NameExternalResourcesJob.perform_later(self) update_column(:queued_external, DateTime.now) @@ -23,6 +25,8 @@ def queue_for_external_resources # Generate a request to the external +uri+, and return the reponse body # if successful or +nil+ otherwise (fails silently) def external_request(uri) + return if Rails.configuration.bypass_external_apis + require 'uri' require 'net/http' diff --git a/app/models/name/status.rb b/app/models/name/status.rb new file mode 100644 index 00000000..c47a4edb --- /dev/null +++ b/app/models/name/status.rb @@ -0,0 +1,133 @@ +module Name::Status + # ============ --- GENERIC STATUS --- ============ + + attr_accessor :status_alert + + def status_name + status_hash[:name] + end + + def status_help + status_hash[:help].gsub(/\n/, ' ') + end + + def status_symbol + status_hash[:symbol] + end + + def validated? + status_hash[:valid] + end + + def public? + status_hash[:public] + end + + def after_claim? + status >= 5 + end + + def after_register? + register.present? || after_submission? + end + + def after_submission? + status >= 10 + end + + def after_endorsement? + status >= 12 + end + + def after_notification? + validated? || register.try(:notified?) + end + + def after_validation? + valid? + end + + def after_register_publication? + register.try(:published?) + end + + # ============ --- CHANGE STATUS --- ============ + + ## + # Return the name back to the authors + # + # user: The user returning the name (the current user) + def return(user) + assert_status_with_alert(after_submission?, 'return') or return false + update_status_with_alert(status: 5) or return false + notify_status_change(:return, user) + end + + ## + # Validate the name + # + # user: The user validating the name (the current user) + # code: Once of 'icnp' or 'icn'. Validation under SeqCode can only + # be done through a register list + def validate(user, code) + if !%w[icnp icn].include?(code) + @status_alert = 'Invalid procedure for nomenclatural code ' + code + return false + end + + assert_status_with_alert(!validated?, 'validation') or return false + update_status_with_alert( + status: code == 'icnp' ? 20 : 25, + validated_by: user, validated_at: Time.now + ) or return false + notify_status_change(:validate, user) + end + + ## + # Endorse the name for future publication + # + # user: The user endorsing the name (the current user) + def endorse(user) + assert_status_with_alert(!after_endorsement?, 'endorsement') or return false + update_status_with_alert( + status: 12, endorsed_by: user, endorsed_at: Time.now + ) or return false + notify_status_change(:endorse, user) + end + + ## + # Claim the name + # + # user: The user claiming the name (usually the current user) + def claim(user) + if !can_claim?(user) + @status_alert = 'User cannot claim name' + return false + end + + par = { created_by: user, created_at: Time.now } + par[:status] = 5 if auto? + update_status_with_alert(par) or return false + + add_observer(user) + notify_user(:claim, user) + notify_observers(:status, exclude_users: [user]) + true + end + + ## + # Unclaim the name + # + # user: The user marking the name as unclaimed (the current user) + def unclaim(user) + if !can_unclaim?(user) + @status_alert = 'User cannot unclaim name' + return false + end + + update_status_with_alert(status: 0) or return false + notify_user(:unclaim, user) + notify_observers(:status, exclude_users: [user]) + true + end +end diff --git a/app/models/name_correspondence.rb b/app/models/name_correspondence.rb index 2f010b7f..1d097212 100644 --- a/app/models/name_correspondence.rb +++ b/app/models/name_correspondence.rb @@ -2,4 +2,11 @@ class NameCorrespondence < ApplicationRecord belongs_to(:name) belongs_to(:user) has_rich_text(:message) + attr_accessor :notify + after_create(:notify_observers) + + def notify_observers + return unless notify == '1' + name.notify_observers(:correspondence, exclude_users: [user]) + end end diff --git a/app/models/register.rb b/app/models/register.rb index 8fa695e3..c0872d3b 100644 --- a/app/models/register.rb +++ b/app/models/register.rb @@ -15,6 +15,9 @@ class Register < ApplicationRecord has_many(:checks, through: :names) has_many(:check_users, -> { distinct }, through: :checks, source: :user) alias :correspondences :register_correspondences + alias :created_by :user + has_many(:observe_registers, dependent: :destroy) + has_many(:observers, through: :observe_registers, source: :user) has_rich_text(:notes) has_rich_text(:abstract) has_rich_text(:submitter_authorship_explanation) @@ -27,6 +30,9 @@ class Register < ApplicationRecord validates(:title, presence: true, if: :validated?) validate(:title_different_from_effective_publication) + include HasObservers + include Register::Status + attr_accessor :modal_form_id def to_param @@ -70,25 +76,6 @@ def acc_url(protocol = false) "#{'https://' if protocol}seqco.de/#{accession}" end - def status_name - validated? ? 'validated' : - notified? ? 'notified' : - endorsed? ? 'endorsed' : - submitted? ? 'submitted' : 'draft' - end - - def before_notification? - !validated? && !notified? - end - - def endorsed? - submitted? && all_endorsed? - end - - def draft? - status_name == 'draft' - end - def names_by_rank names.sort do |a, b| Name.ranks.index(a.rank) <=> Name.ranks.index(b.rank) @@ -132,6 +119,10 @@ def can_view_publication?(user) user.curator? || user.id == user_id end + def display + 'Register List %s' % accession + end + def proposing_publications @proposing_publications ||= Publication.where(id: names.pluck(:proposed_by)) @@ -223,83 +214,6 @@ def citations ([publication] + sorted_names.map(&:citations).flatten).compact.uniq end - ## - # Automated checks to prepare for validation, adding relevant notes - # to the list - def automated_validation - # Trivial cases (not-yet-notified or already validated) - return false unless notified? - return true if validated? - - # Minimum requirements - success = true - unless publication && publication_pdf.attached? - add_note('Missing publication or PDF files') - success = false - end - - # Check that all names have been endorsed - unless names.all?(&:after_endorsement?) - add_note('Some names have not been endorsed yet') - success = false - end - - # Check if the list has a PDF that includes the accession - has_acc = false - bnames = Hash[names.map { |n| [n.base_name, false] }] - cnames = Hash[names.map { |n| [n.base_name, n.corrigendum_from] }] - [publication_pdf, supplementary_pdf].each do |as| - break if has_acc && bnames.values.all? - next unless as.attached? - - as.open do |file| - render = PDF::Reader.new(file.path) - render.pages.each do |page| - txt = page.text - has_acc = true if txt.index(accession) - bnames.each_key do |bn| - if txt.index(bn) || (cnames[bn] && txt.index(cnames[bn])) - bnames[bn] = true - end - end - break if has_acc && bnames.values.all? - end - end - end - - if has_acc - add_note('The effective publication includes the SeqCode accession') - else - add_note( - 'The effective publication does not include the accession ' \ - '(SeqCode, Rule 26, Note 2)' - ) - end - - if bnames.values.all? - add_note('The effective publication mentions all names in the list') - elsif bnames.values.any? - if bnames.values.count(&:!) > 5 - add_note( - "The effective publication mentions" \ - " #{bnames.values.count(&:itself)} out of" \ - " #{bnames.count} names in the list" - ) - else - add_note( - "The effective publication mentions some names in the list," \ - " but not: #{bnames.select { |_, v| !v }.keys.join(', ')}" - ) - end - else - add_note( - 'The effective publication does not mention any names in the list' - ) - end - - save - end - def add_note(note, title = 'Auto-check') self.notes.body = <<~TXT #{notes.body} @@ -308,29 +222,6 @@ def add_note(note, title = 'Auto-check') TXT end - def validate!(user) - ActiveRecord::Base.transaction do - par = { validated_by: user, validated_at: Time.now } - names.each { |name| name.update!(par.merge(status: 15)) } - update!(par.merge(notes: nil, validated: true)) - end - - HeavyMethodJob.perform_later(:post_validation, @register) - true - end - - ## - # Production tasks to be executed once a list is validated - def post_validation - # TODO Produce and attach the certificate in PDF - # TODO Distribute the certificate to mirrors - # TODO Notify submitter - end - - def all_endorsed? - names.all?(&:after_endorsement?) - end - def reviewer_ids @reviewer_ids ||= names.pluck(:validated_by, :endorsed_by, :nomenclature_reviewer) @@ -345,6 +236,30 @@ def curators @curators ||= (check_users + reviewers).uniq end + def observing?(user) + observe_registers.where(user: user).present? + end + + ## + # Attempts to add an observer while silently ignoring it if the user + # already observes the register + def add_observer(user) + self.observers << user + rescue ActiveRecord::RecordNotUnique + true + end + + def corresponding_users + correspondences.map(&:user).uniq + end + + def associated_users + ( + [user, validated_by, published_by] + + corresponding_users + ).compact.uniq + end + private def assign_accession diff --git a/app/models/register/status.rb b/app/models/register/status.rb new file mode 100644 index 00000000..4e147c81 --- /dev/null +++ b/app/models/register/status.rb @@ -0,0 +1,263 @@ +module Register::Status + # ============ --- GENERIC STATUS --- ============ + + attr_accessor :status_alert + + def status_name + validated? ? 'validated' : + notified? ? 'notified' : + endorsed? ? 'endorsed' : + submitted? ? 'submitted' : 'draft' + end + + def status_help_hash + { + draft: + 'This is a draft register list, currently in preparation', + submitted: + 'This register list is currently being evaluated by expert curators ' \ + 'or awaiting effective publication', + endorsed: + 'All the names in this register list have been endorsed by expert ' \ + 'curators, and the SeqCode is awaiting notification of publication', + notified: + 'The SeqCode has been notified of effective publication and the ' \ + 'request is currently being evaluated by expert curators', + validated: + 'All names in this register list have been validly published ' \ + 'with a registered effective publication' + } + end + + def status_help + status_help_hash[status_name.to_sym] + end + + def in_curation? + notified? || endorsed? || submitted? + end + + def before_notification? + !validated? && !notified? + end + + def endorsed? + submitted? && all_endorsed? + end + + def draft? + status_name == 'draft' + end + + def public? + can_view? nil + end + + def all_endorsed? + names.all?(&:after_endorsement?) + end + + # ============ --- CHANGE STATUS --- ============ + + ## + # Submit the list for evaluation + # + # user: The user submitting the list (the current user) + def submit(user) + assert_status_with_alert(draft?, 'submit') or return false + ActiveRecord::Base.transaction do + par = { status: 10, submitted_at: Time.now, submitted_by: user } + names.each do |name| + name.update!(par) + end + update_status_with_alert( + submitted: true, submitted_at: Time.now + ) or return false + end + notify_status_change(:submit, user) + end + + ## + # Return the register (and all associated names) to the authors + # + # user: The user returning the liat (the current user, a curator) + # notes: Notes to add to the list, overwritting any old notes + def return(user, notes) + assert_status_with_alert(in_curation?, 'return') or return false + ActiveRecord::Base.transaction do + names.each { |name| name.update!(status: 5) unless name.validated? } + update_status_with_alert( + submitted: false, notified: false, notes: notes + ) or return false + end + notify_status_change(:return, user) + end + + ## + # Endorse the register (and all associated names) + # + # user: The user endorsing the list (the current user, a curator) + def endorse(user) + ActiveRecord::Base.transaction do + par = { status: 12, endorsed_by: user, endorsed_at: Time.now } + names.each { |name| name.update!(par) unless name.after_endorsement? } + end + notify_status_change(:endorse, user) + end + + ## + # Notify the Registry of publication + # + # user: The user notifying the Registry (the current user) + # params: The notification parameters + # doi: DOI of the effective publication + def notify(user, params, doi) + publication = Publication.by_doi(doi) + params[:publication] = publication + + if publication.new_record? + errors.add(:doi, publication.errors[:doi].join('; ')) + return false + end + + if !params[:publication_pdf] && !publication_pdf.attached? + errors.add(:publication_pdf, 'cannot be empty') + return false + end + + ActiveRecord::Base.transaction do + names.each do |name| + unless name.after_endorsement? + name.status = 10 + name.submitted_at = Time.now + name.submitted_by = user + end + unless name.publications.include? publication + name.publications << publication + end + name.proposed_by = publication + name.save! + end + update_status_with_alert( + params.merge(notified: true, notified_at: Time.now) + ) or return false + end + + HeavyMethodJob.perform_later(:automated_validation, self) + notify_status_change(:notify, user) + end + + ## + # Validate the register list and all associated names + # + # user: The user validating the list (the current user, a curator) + def validate(user) + ActiveRecord::Base.transaction do + par = { validated_by: user, validated_at: Time.now } + names.each { |name| name.update!(par.merge(status: 15)) } + update!(par.merge(notes: nil, validated: true)) + end + + HeavyMethodJob.perform_later(:post_validation, @register) + notify_status_change(:validate, user) + end + + # ============ --- TASKS ASSOCIATED TO STATUS CHANGE --- ============ + + ## + # Production tasks to be executed once a list is validated + def post_validation + # TODO Produce and attach the certificate in PDF + # TODO Distribute the certificate to mirrors + # TODO Notify submitter + end + + ## + # Automated checks to prepare for validation, adding relevant notes + # to the list + def automated_validation + # Trivial cases (not-yet-notified or already validated) + return false unless notified? + return true if validated? + + # Minimum requirements + success = true + unless publication && publication_pdf.attached? + add_note('Missing publication or PDF files') + success = false + end + + # Check that all names have been endorsed + unless names.all?(&:after_endorsement?) + add_note('Some names have not been endorsed yet') + success = false + end + + success = check_pdf_files && success + save && success + end + + ## + # Check if the PDF file(s) include accession and all list names, and report + # results as register list notes + # + # Returns boolean, with true indicating all checks passed and false otherwise + # + # IMPORTANT: Notes are soft-registered, remember to +save+ to make them + # persistent + def check_pdf_files + has_acc = false + bnames = Hash[names.map { |n| [n.base_name, false] }] + cnames = Hash[names.map { |n| [n.base_name, n.corrigendum_from] }] + [publication_pdf, supplementary_pdf].each do |as| + break if has_acc && bnames.values.all? + next unless as.attached? + + as.open do |file| + render = PDF::Reader.new(file.path) + render.pages.each do |page| + txt = page.text + has_acc = true if txt.index(accession) + bnames.each_key do |bn| + if txt.index(bn) || (cnames[bn] && txt.index(cnames[bn])) + bnames[bn] = true + end + end + break if has_acc && bnames.values.all? + end + end + end + + if has_acc + add_note('The effective publication includes the SeqCode accession') + else + add_note( + 'The effective publication does not include the accession ' \ + '(SeqCode, Rule 26, Note 2)' + ) + end + + if bnames.values.all? + add_note('The effective publication mentions all names in the list') + elsif bnames.values.any? + if bnames.values.count(&:!) > 5 + add_note( + "The effective publication mentions" \ + " #{bnames.values.count(&:itself)} out of" \ + " #{bnames.count} names in the list" + ) + else + add_note( + "The effective publication mentions some names in the list," \ + " but not: #{bnames.select { |_, v| !v }.keys.join(', ')}" + ) + end + else + add_note( + 'The effective publication does not mention any names in the list' + ) + end + + has_acc && bnames.values.all? + end +end diff --git a/app/models/register_correspondence.rb b/app/models/register_correspondence.rb index 6a5c663c..fcaad450 100644 --- a/app/models/register_correspondence.rb +++ b/app/models/register_correspondence.rb @@ -2,4 +2,11 @@ class RegisterCorrespondence < ApplicationRecord belongs_to(:register) belongs_to(:user) has_rich_text(:message) + attr_accessor :notify + after_create(:notify_observers) + + def notify_observers + return unless notify == '1' + register.notify_observers(:correspondence, exclude_users: [user]) + end end diff --git a/app/models/user.rb b/app/models/user.rb index e3873d2b..100a8836 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -21,6 +21,8 @@ class User < ApplicationRecord has_many(:checked_names, -> { distinct }, through: :checks, source: :name) has_many(:observe_names, dependent: :destroy) has_many(:observing_names, through: :observe_names, source: :name) + has_many(:observe_registers, dependent: :destroy) + has_many(:observing_registers, through: :observe_registers, source: :register) validates( :username, diff --git a/app/views/admin_mailer/correspondence_email.html.erb b/app/views/admin_mailer/correspondence_email.html.erb new file mode 100644 index 00000000..fb57becd --- /dev/null +++ b/app/views/admin_mailer/correspondence_email.html.erb @@ -0,0 +1,22 @@ +

There is a new message in correspondence of +<%= link_to(@object.display, + polymorphic_url(@object, anchor: 'correspondence')) %>.

+
+ +

What does this mean?

+

+ You have active notifications for + <%= link_to(@object.display, + polymorphic_url(@object, anchor: 'correspondence')) %> + and somebody has posted a message in correspondence with curators. +

+
+ +

What to do next?

+

+ If you are actively interested in this entry, open the link above and see the + messages in "Correspondence with curators". Otherwise, + <%= link_to('click here to stop notifications for this entry', + [@object, :unobserve]) %>. +

+ diff --git a/app/views/admin_mailer/correspondence_email.text.erb b/app/views/admin_mailer/correspondence_email.text.erb new file mode 100644 index 00000000..7ac971bf --- /dev/null +++ b/app/views/admin_mailer/correspondence_email.text.erb @@ -0,0 +1,20 @@ +There is a new message in correspondence of <%= @object.display %> + +What does this mean? +==================== + +You have active notifications for <%= @object.display %> and somebody +has posted a message in correspondence with curators. + +To view the entry in the Registry, visit: +<%= polymorphic_url(@object, anchor: 'correspondence') %> + +What to do next? +================ + +If you are actively interested in this entry, open the link above and see the +messages in "Correspondence with curators". + +Otherwise, open the following link to stop notifications for this entry: +<%= polymorphic_url([@object, :unobserve]) %> + diff --git a/app/views/admin_mailer/name_status_email.html.erb b/app/views/admin_mailer/name_status_email.html.erb index c1f2aa9b..84fc253e 100644 --- a/app/views/admin_mailer/name_status_email.html.erb +++ b/app/views/admin_mailer/name_status_email.html.erb @@ -1,7 +1,8 @@ -<% case @action %> +<% case @action.to_s %> <% when 'endorse' %>

The name <%= link_to(@name.name_html, @name) %> has been endorsed.


+

What does this mean?

Our team of curators have marked this name and the associated metadata @@ -10,6 +11,7 @@ notified of publication.


+

What to do next?

If you are currently writing the manuscript that describes this taxon, @@ -26,12 +28,14 @@ <% when 'return' %>

The name <%= link_to(@name.name_html, @name) %> has been returned.


+

What does this mean?

Our team of curators have found some issues with the name and have returned it to you for further inspection.


+

What to do next?

Review the correspondance with the curators to determine the following @@ -45,11 +49,13 @@ <% when 'validate' %>

The name <%= link_to(@name.name_html, @name) %> has been validated.


+

What does this mean?

The name is now considered valid under the SeqCode.


+

What to do next?

You can cite the name without quotation marks now. The formal recommended @@ -64,12 +70,14 @@ <% when 'claim' %>

You have claimed the name <%= link_to(@name.name_html, @name) %>.


+

What does this mean?

The data and metadata associated to this name are no longer publicly available, and you are now able to edit all the associated metadata.


+

What to do next?

You can now complete all the validation requirements and submit the @@ -79,11 +87,13 @@ <% when 'unclaim' %>

You have unclaimed the name <%= @name.name_html %>.


+

What does this mean?

You are no longer authorized to modify this name's data and metadata.


+

What to do next?

If you were aware and agree with this, you don't need to take any further diff --git a/app/views/admin_mailer/name_status_email.text.erb b/app/views/admin_mailer/name_status_email.text.erb index 3fa6717e..e1f0c189 100644 --- a/app/views/admin_mailer/name_status_email.text.erb +++ b/app/views/admin_mailer/name_status_email.text.erb @@ -1,4 +1,4 @@ -<% case @action %> +<% case @action.to_s %> <% when 'endorse' %> The name <%= @name.name %> has been endorsed. diff --git a/app/views/admin_mailer/observer_status_email.html.erb b/app/views/admin_mailer/observer_status_email.html.erb new file mode 100644 index 00000000..565b2ea5 --- /dev/null +++ b/app/views/admin_mailer/observer_status_email.html.erb @@ -0,0 +1,20 @@ +

The entry <%= link_to(@object.display, + polymorphic_url(@object, anchor: 'correspondence')) %> has changed status. +

+
+ +

What does this mean?

+

+ You have active notifications for <%= link_to(@object.display, @object) %> + and this entry has changed status. +

+
+ +

What to do next?

+

+ If you are actively interested in this entry, open the link above and see the + new status. Otherwise, + <%= link_to('click here to stop notifications for this entry', + [@object, :unobserve]) %>. +

+ diff --git a/app/views/admin_mailer/observer_status_email.text.erb b/app/views/admin_mailer/observer_status_email.text.erb new file mode 100644 index 00000000..1a7bbf25 --- /dev/null +++ b/app/views/admin_mailer/observer_status_email.text.erb @@ -0,0 +1,20 @@ +The entry <%= @object.display %> has changed status. + +What does this mean? +==================== + +You have active notifications for <%= @object.display %> and this entry +has changed status. + +To view the entry in the Registry, visit: +<%= polymorphic_url(@object) %> + +What to do next? +================ + +If you are actively interested in this entry, open the link above and see the +new status. + +Otherwise, open the following link to stop notifications for this entry: +<%= polymorphic_url([@object, :unobserve]) %> + diff --git a/app/views/admin_mailer/register_status_email.html.erb b/app/views/admin_mailer/register_status_email.html.erb index b1419936..6be39ac5 100644 --- a/app/views/admin_mailer/register_status_email.html.erb +++ b/app/views/admin_mailer/register_status_email.html.erb @@ -1,5 +1,38 @@ <% name_s = @register.names.count == 1 ? 'name' : 'names' %> -<% case @action %> +<% case @action.to_s %> +<% when 'submit' %> +

+ You have successfully submitted the register list + <%= link_to(@register.acc_url, @register.acc_url(true)) %>. +

+
+ +

What does this mean?

+

+ Our team of curators have received your submission and will process it as + soon as possible. +

+
+ +

What to do next?

+

No actions are needed from your side at the moment.

+<% when 'notify' %> +

+ You have successfully notified the SeqCode Registry of effective publication + for the register list + <%= link_to(@register.acc_url, @register.acc_url(true)) %>. +

+
+ +

What does this mean?

+

+ Our team of curators have received your notification and will process it as + soon as possible. +

+
+ +

What to do next?

+

No actions are needed from your side at the moment.

<% when 'endorse' %>

The register list @@ -7,6 +40,7 @@ has been endorsed.


+

What does this mean?

Our team of curators have marked this list and all the included names @@ -15,6 +49,7 @@ been published and the Registry is notified of publication.


+

What to do next?

If you are currently writing the manuscript that describes these taxa, @@ -33,6 +68,7 @@ has been validated.


+

What does this mean?

All the names in this list are now considered valid under the SeqCode. @@ -40,6 +76,7 @@ <%= @register.priority_date %>.


+

What to do next?

You can now cite the registration list using the assigned DOI: @@ -61,12 +98,14 @@ has been returned to you.


+

What does this mean?

Our team of curators have identified at least one blocking issue with your list, and have returned it to you for inspection.


+

What to do next?

Please visit the <%= link_to('register list page', @register) %>, and diff --git a/app/views/admin_mailer/register_status_email.text.erb b/app/views/admin_mailer/register_status_email.text.erb index 6f8901ef..0dc22659 100644 --- a/app/views/admin_mailer/register_status_email.text.erb +++ b/app/views/admin_mailer/register_status_email.text.erb @@ -1,5 +1,29 @@ <% name_s = @register.names.count == 1 ? 'name' : 'names' %> -<% case @action %> +<% case @action.to_s %> +<% when 'submit' %> + You have successfully submitted the register list + <%= @register.acc_url %> + + What does this mean? + ==================== + Our team of curators have received your submission and will process it as + soon as possible. + + What to do next? + ================ + No actions are needed from your side at the moment. +<% when 'notify' %> + You have successfully notified the SeqCode Registry of effective publication + for the register list <%= @register.acc_url %> + + What does this mean? + ==================== + Our team of curators have received your notification and will process it as + soon as possible. + + What to do next? + ================ + No actions are needed from your side at the moment. <% when 'endorse' %> The register list <%= @register.acc_url %> has been endorsed. diff --git a/app/views/admin_mailer/user_status_email.html.erb b/app/views/admin_mailer/user_status_email.html.erb index ea02d705..54edccc7 100644 --- a/app/views/admin_mailer/user_status_email.html.erb +++ b/app/views/admin_mailer/user_status_email.html.erb @@ -1,13 +1,14 @@ -<% case @action[1] %> +<% case @action[1].to_s %> <% when 'endorse' %>

Your application as a <%= @action[0] %> of the SeqCode Registry has been endorsed.


+

What does this mean?

- <% case @action[0] %> + <% case @action[0].to_s %> <% when 'curator' %> You are now authorized to access and edit any pre-validation names, including changing their status (e.g., endorsing or returning to @@ -27,9 +28,10 @@ been denied.


+

What does this mean?

- <% case @action[0] %> + <% case @action[0].to_s %> <% when 'curator' %> You are not authorized to curate other submitter's records in the Registry, but you can still submit your own names as a contributor. diff --git a/app/views/admin_mailer/user_status_email.text.erb b/app/views/admin_mailer/user_status_email.text.erb index ae25a137..b967e1d7 100644 --- a/app/views/admin_mailer/user_status_email.text.erb +++ b/app/views/admin_mailer/user_status_email.text.erb @@ -1,11 +1,11 @@ -<% case @action[1] %> +<% case @action[1].to_s %> <% when 'endorse' %> Your application as a <%= @action[0] %> of the SeqCode Registry has been endorsed. What does this mean? ==================== - <% case @action[0] %> + <% case @action[0].to_s %> <% when 'curator' %> You are now authorized to access and edit any pre-validation names, including changing their status (e.g., endorsing or returning to submitters). We @@ -23,7 +23,7 @@ What does this mean? ==================== - <% case @action[0] %> + <% case @action[0].to_s %> <% when 'curator' %> You are not authorized to curate other submitter's records in the Registry, but you can still submit your own names as a contributor. diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 3e3826f3..323f2b30 100644 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -7,12 +7,16 @@ <%= f.error_notification %> -

+
<% if devise_mapping.confirmable? && resource.pending_reconfirmation? %>

Currently waiting confirmation for: <%= resource.unconfirmed_email %>

<% end %> +

-

Personal information

+

+ <%= fa_icon('id-badge', class: 'mr-1') %> + Personal information +

<%= f.input :email, required: true, autofocus: true %> <%= f.input :family, label: 'Family name', input_html: { autocomplete: 'lastname' } %> @@ -20,9 +24,12 @@ input_html: { autocomplete: 'firstname' } %> <%= f.input :affiliation, input_html: { autocomplete: 'affiliation' } %> -
+


-

Account information

+

+ <%= fa_icon('user-circle', class: 'mr-1') %> + Account information +

<%= f.input :username, disabled: true, input_html: { autocomplete: 'username' } %> <%= f.input :password, @@ -36,11 +43,24 @@ hint: 'we need your current password to confirm your changes', required: true, input_html: { autocomplete: 'current-password' } %> -
+


-

Email notifications

- <%= f.input(:opt_regular_email, label: 'Receive regular notifications') %> - <%= f.input(:opt_notification, label: 'Receive monthly reminders') %> +

+ <%= fa_icon('mail-bulk', class: 'mr-1') %> + Email notifications +

+ <%= f.input( + :opt_regular_email, label: 'Receive regular notifications', + hint: 'Receive emails whenever entries with active notifications ' \ + 'change status') %> + <%= f.input( + :opt_message_email, label: 'Receive message notifications', + hint: 'Receive emails whenever new correspondence is recorded in ' \ + 'entries with active notifications') %> + <%= f.input( + :opt_notification, label: 'Receive monthly reminders', + hint: 'Receive periodic reminders of pending submissions' + ) %>
@@ -50,3 +70,4 @@
<% end %> +



diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb index 3cd22c29..145734e2 100644 --- a/app/views/layouts/_footer.html.erb +++ b/app/views/layouts/_footer.html.erb @@ -1,3 +1,9 @@ <%= yield_modals %> + + diff --git a/app/views/layouts/admin_mailer.html.erb b/app/views/layouts/admin_mailer.html.erb index f112027e..a2eec537 100644 --- a/app/views/layouts/admin_mailer.html.erb +++ b/app/views/layouts/admin_mailer.html.erb @@ -16,5 +16,12 @@ Best wishes,
The SeqCode Registry Team

+
+
+ You are receiving this email from the SeqCode Registry because of your + current user configuration. + <%= link_to('Configure Email Notifications', + edit_user_registration_url(anchor: 'email-notifications')) %>. +
diff --git a/app/views/layouts/admin_mailer.text.erb b/app/views/layouts/admin_mailer.text.erb index a5390414..45ca5d37 100644 --- a/app/views/layouts/admin_mailer.text.erb +++ b/app/views/layouts/admin_mailer.text.erb @@ -2,6 +2,11 @@ Dear <%= @user.informal_name %>, <%= yield %> - Best wishes, - The SeqCode Registry Team +Best wishes, +The SeqCode Registry Team +-------------------------- + +You are receiving this email from the SeqCode Registry because of your current +user configuration. Configure Email Notifications at: +<%= edit_user_registration_url(anchor: 'email-notifications') %> diff --git a/app/views/names/_alert_messages.html.erb b/app/views/names/_alert_messages.html.erb index 7e5ff3bf..27fcf636 100644 --- a/app/views/names/_alert_messages.html.erb +++ b/app/views/names/_alert_messages.html.erb @@ -20,18 +20,6 @@ <% end %> <% elsif !@name.public? %> -
- This name is undergoing the process of validation, currently in the - state of <%= @name.status_name %>. The entry and all of its metadata - is currently not public, only visible to the submitter and the system - curators. - <% unless @name.created_at < 1.year.ago %> -
- This name has no standing or validity but it is reserved for up to a - year, so no homonyms can be proposed until - <%= (@name.created_at + 1.year).strftime('%b %-d / %Y') %> - <% end %> -
<% if @name.created_at < 1.year.ago %>
This name was registered over a year ago and it's no longer reserved diff --git a/app/views/names/_correspondence.html.erb b/app/views/names/_correspondence.html.erb index 7343ceb9..16209287 100644 --- a/app/views/names/_correspondence.html.erb +++ b/app/views/names/_correspondence.html.erb @@ -1,36 +1,9 @@ -<% if @name&.can_edit?(current_user) %> -
-

Correspondence with curators

- <% @name.correspondences.each do |correspondence| %> -
- <%= fa_icon('comment') %> - <%= link_to(correspondence.user) do %> - <%= correspondence.user.username %> - <% if correspondence.user.curator? %> - - curator - - <% end %> - <% end %> - <%= time_ago_in_words(correspondence.created_at) %> ago: -
-
<%= correspondence.message %>
- <% end %> -
New message
-
- <% par = { id: @name.id } %> - <% tutorial ||= nil %> - <% par[:tutorial] = tutorial if tutorial %> - <%= simple_form_for( - NameCorrespondence.new, - url: new_correspondence_name_url(par), - method: :post - ) do |f| %> - <%= f.input(:message, as: :rich_text_area, label: false) %> - <%= f.button(:submit, 'Send message') %> - » The messages will be in the permanent record, - visible only to submitter and curators - <% end %> -
-
-<% end %> +<%= + render( + partial: 'shared/correspondence', + locals: { + object: @name, tutorial: tutorial ||= nil + } + ) +%> + diff --git a/app/views/names/_metadata.html.erb b/app/views/names/_metadata.html.erb index 4fef6fb2..c3477341 100644 --- a/app/views/names/_metadata.html.erb +++ b/app/views/names/_metadata.html.erb @@ -98,6 +98,17 @@ <% end %> <% end %> + <% if @name.validated? %> +
  • + <% id = modal('Quality Checks', size: 'xl') do %> + <%= render(partial: 'quality_checks', + locals: { force_qc_view: true }) %> + <% end %> + <%= modal_button(id, type: '', class: '', tag: 'a', href: '#') do %> + Quality checks before valid publication + <% end %> +
  • + <% end %> diff --git a/app/views/names/_name.html.erb b/app/views/names/_name.html.erb index f4588602..4bf021a3 100644 --- a/app/views/names/_name.html.erb +++ b/app/views/names/_name.html.erb @@ -28,16 +28,31 @@ ><%= time_ago_in_words(name.created_at) %> ago <% end %> <% entry.footer do %> -