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 %>
-
<% 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')) %>.
+