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..dd4959a3 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; @@ -203,7 +207,7 @@ nav.navbar-dark { } } } - dd { + dd:not(.qc-body) { padding-left: 4rem; margin-left: 0.2rem; } @@ -215,3 +219,11 @@ nav.navbar-dark { } } +.workflow-panel { + .workflow-arrow { + position: absolute; + left: -1.1rem; + top: 0.25rem; + } +} + diff --git a/app/controllers/checks_controller.rb b/app/controllers/checks_controller.rb index e3072a3b..8992320b 100644 --- a/app/controllers/checks_controller.rb +++ b/app/controllers/checks_controller.rb @@ -1,6 +1,7 @@ class ChecksController < ApplicationController before_action(:authenticate_curator!) + # POST /checks/123 # POST /checks/123.json def update unless Name.exists?(params[:name_id].to_i) @@ -27,7 +28,12 @@ def update end if success - render(json: @check.to_json, status: :ok) + respond_to do |format| + format.html { redirect_to(@check.name) } + format.json do + render(json: @check.to_json, status: :ok) + end + end else render( json: @check.try(:errors) || { error: 'something went wrong' }, diff --git a/app/controllers/names_controller.rb b/app/controllers/names_controller.rb index b126fd71..ff5bf925 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 @@ -65,7 +67,7 @@ def index(opts = {}) Name.valid_status end - @names = + @names ||= case @sort when 'date' if opts[:status] == 15 @@ -81,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) @@ -104,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 }, status: Name.status_hash.keys) + 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 alerts' + @status = 'user' + @names = user.observing_names.reverse + index render(:index) end @@ -203,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 @@ -346,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 @@ -355,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' @@ -460,6 +415,26 @@ def new_correspondence redirect_to(@tutorial || @name) end + # GET /names/1/observe + def observe + @name.add_observer(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 @@ -510,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..7796fbe0 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 :alerts + 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 fe0f910b..87680502 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -14,6 +14,12 @@ def download_link(file, name = nil) end end + def time_ago_with_date(date) + content_tag(:u, class: 'hover-help', title: date.to_s) do + time_ago_in_words(date) + ' ago' + end + end + def pager(object) will_paginate( object, @@ -131,7 +137,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' @@ -165,6 +171,7 @@ def modal_button(id, opts = {}) opts[:data][:toggle] = 'modal' opts[:data][:target] = "##{id}" opts[:tag] ||= :span + opts.merge!(type: '', class: '', tag: :a, href: '#') if opts[:as_anchor] content_tag(opts.delete(:tag), opts) { yield } end @@ -187,24 +194,30 @@ 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" + opts[:class] += ' text-muted' if opts[:color] == 'light' + link_to(url, opts) do fa_icon(icon) + text end end + def display_obj(obj) + preferred_fields = %i[name_html name display_name accession citation] + field = preferred_fields.find { |i| obj.respond_to? i } + if field + obj.send(field) + elsif obj.respond_to? :id + '%s %i' % [obj.class, obj.id] + else + obj.to_s + end + end + def display_link(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) + 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 62336170..87886f02 100644 --- a/app/models/name.rb +++ b/app/models/name.rb @@ -20,6 +20,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, @@ -82,6 +84,8 @@ class Name < ApplicationRecord } ) + include HasObservers + include Name::Status include Name::QualityChecks include Name::Etymology include Name::Citations @@ -232,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 @@ -326,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 @@ -371,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 @@ -528,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 @@ -581,6 +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/quality_checks.rb b/app/models/name/quality_checks.rb index 79951a13..6a2dfca0 100644 --- a/app/models/name/quality_checks.rb +++ b/app/models/name/quality_checks.rb @@ -637,7 +637,11 @@ def fail end def check - name.check(type) if checklist + name.check(type) + end + + def bypassed? + !checklist && check&.pass? end def to_hash @@ -662,12 +666,17 @@ def initialize(name) @name = name @set_h = {} @checks_h = {} + @bypassed_h = {} end def add(type, opts = {}) qc = QcWarning.new(type, opts.merge(name: name)) @checks_h[qc.type] = qc if qc.checklist - @set_h[qc.type] = qc if !qc.checklist || (qc.check && !qc.check.pass?) + if (!qc.checklist && !qc.check) || (qc.check && qc.check.fail?) + @set_h[qc.type] = qc + elsif qc.bypassed? + @bypassed_h[qc.type] = qc + end end def set @@ -682,6 +691,10 @@ def checked_checks @checks_h.values.select(&:check) end + def bypassed + @bypassed_h.values + end + def resort! new_set_h = @set_h new_checks_h = @checks_h 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/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/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 2ea49069..100a8836 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -19,6 +19,10 @@ 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) + 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/checks/_check.html.erb b/app/views/checks/_check.html.erb new file mode 100644 index 00000000..1e742cdb --- /dev/null +++ b/app/views/checks/_check.html.erb @@ -0,0 +1,60 @@ +

+ <% + blocking = warn.fail == :error + icon_class = blocking ? 'text-danger' : 'text-warning' + %> + <%= fa_icon('exclamation-triangle', class: icon_class) %> + <%= warn.title %> +
+
+ <%= warn[:message] %> + + <% if warn.link_to %> + <% if warn.link_public || @name.can_edit?(current_user) %> + <%= link_to(warn.link_to, class: 'text-danger') do %> + [<%= warn.link_text %>] + <% end %> + <% end %> + <% end %> + + <% if warn[:rules] || warn[:recommendations] || warn[:rule_notes] %> +
+ § Contravenes SeqCode: + <% comma = false %> + <% %w[rule rule_note recommendation].each do |section| %> + <% warn[:"#{section}s"]&.each do |i| %> + <% text = name_of_seqcode_section(section, i) %> + <%= ' • ' if comma %> + <%= link_to_seqcode_excerpt(section, i, text) %> + <% comma = true %> + <% end %> + <% end %> +
+ <% end %> + + <% if warn.check %> +
+ <% if warn.check.pass? %> + Bypassed by <%= link_to_user(warn.check.user) %> + <%= time_ago_in_words(warn.check.updated_at) %> ago • + <%= link_to( + check_url(@name, kind: warn.type, do: :skip), + method: :post) do %> + <%= fa_icon('exclamation-circle') %> reinstate as failed check + <% end %> + <% else %> + Identified by <%= link_to_user(warn.check.user) %> + <%= time_ago_in_words(warn.check.updated_at) %> ago + <% end %> +
+ <% elsif current_curator? %> +
+ Curator action: + <%= link_to( + check_url(@name, kind: warn.type, do: :pass), + method: :post) do %> + <%= fa_icon('check-circle') %> Bypass check (mark as valid) + <% end %> +
+ <% end %> +
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 3e3826f3..31d80e06 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 alerts ' \ + 'change status') %> + <%= f.input( + :opt_message_email, label: 'Receive message notifications', + hint: 'Receive emails whenever new correspondence is recorded in ' \ + 'entries with active alerts') %> + <%= 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/genomes/_genome.html.erb b/app/views/genomes/_genome.html.erb index bd0006cf..eda1bbce 100644 --- a/app/views/genomes/_genome.html.erb +++ b/app/views/genomes/_genome.html.erb @@ -147,17 +147,36 @@ end <% end %> <% if @genome.source? %> -
<%= fa_icon('flask') %> Source
-
- + <% end %> + <% end %> +
<%= fa_icon('flask') %> Source
+
+ <% if @genome.source_links.count > 3 %> + <%= show_genome_sources(1) %> + <% id = modal('Data sources') do %> + <%= show_genome_sources %> + <% end %> + <%= modal_button(id) do %> + See all sources + <% end %> + <% else %> + <%= show_genome_sources %> + <% end %>
<% 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/_curator_checks.html.erb b/app/views/names/_curator_checks.html.erb index e6858190..98636c02 100644 --- a/app/views/names/_curator_checks.html.erb +++ b/app/views/names/_curator_checks.html.erb @@ -1,5 +1,5 @@ <% if current_curator? && !@name.validated? %> - <% if !@name.qc_warnings.checks.empty? %> + <% unless @name.qc_warnings.checks.empty? && @name.qc_warnings.bypass.empty? %>
<%= link_to('#curator-checklist', class: 'btn btn-primary btn-curator-checklist', @@ -76,11 +76,19 @@ <% end %> + + <% unless @name.qc_warnings.bypassed.empty? %> +

Bypassed checks

+ <% @name.qc_warnings.bypassed.each do |warn| %> + <% warn.link_to = nil %> + <%= render(partial: 'checks/check', locals: { warn: warn }) %> + <% end %> + <% end %>