MushroomObserver/mushroom-observer

View on GitHub
script/bulk_name_change

Summary

Maintainability
Test Coverage
#!/usr/bin/env ruby
# frozen_string_literal: true

#
#  USAGE::
#
#    script/bulk_name_change --user <user> --old <name> --new <name>
#
#  DESCRIPTION::
#
#    This is a prototype script for testing heuristics for an eventual bulk
#    name change tool on the website.
#
#    It makes sure the new name is proposed for all observations for which
#    the old name is the consensus name.  If not, the new name is proposed
#    on behalf of the given user.
#
#    If the user has already voted on the old name, then the new name is
#    given that vote.  Otherwise it gives it a vote at or just above the
#    current consensus vote.  Then it votes down the old name to As If!
#
#    It totally ignores synonymy and any other considerations at present.
#    It prints out a list of observations and the following:
#
#      obs_id
#      users_vote_for_old_name
#      users_vote_for_new_name
#      consensus_for_old_name
#      consensus_for_new_name
#      consensus_for_new_consensus_name
#      new_consensus_name
#
#    I may decide to prevent voting down the old name if it results in the
#    new consensus name being neither the old nor the new name.
#
################################################################################

require(File.expand_path("../config/boot.rb", __dir__))
require(File.expand_path("../config/environment.rb", __dir__))
require(File.expand_path("../app/extensions/extensions.rb", __dir__))
require("optparse")

def parse_options
  user_id     = nil
  old_name_id = nil
  new_name_id = nil

  OptionParser.new do |opt|
    opt.on("-u", "--user USER", "ID or login of user who is changing name.") \
      { |o| user_id = o }
    opt.on("-o", "--old NAME", "ID, text_name or search_name of old name.") \
      { |o| old_name_id = o }
    opt.on("-n", "--new NAME", "ID, text_name or search_name of new name.") \
      { |o| new_name_id = o }
    # rubocop disable:Style/RedundantLineContinuation
    # Explicit Continuation is needed for the lines ending with ")"
    opt.on("-c", "--comment SUMMARY/BODY", "Text of comment to add to each " \
           "observation that we propose the new name for.") \
      { |o| @comment = o }
    opt.on("-r", "--refs TEXT", "Text to add to references section of each " \
           "name proposal made.") \
      { |o| @refs = o }
  end.parse!
  # rubocop enable:Style/RedundantLineContinuation

  @user     = if user_id.to_s.match?(/\D/)
                User.where(login: user_id).first
              else
                User.safe_find(user_id)
              end
  @old_name = Name.safe_find(old_name_id) ||
              Name.find_by(search_name: old_name_id) ||
              Name.where(text_name: old_name_id).to_a
  @new_name = Name.safe_find(new_name_id) ||
              Name.find_by(search_name: new_name_id) ||
              Name.where(text_name: new_name_id).to_a

  if @old_name.is_a?(Array) && @old_name.count > 1
    raise("Multiple matches for #{old_name_id.inspect}: " \
          "#{@old_name.map { |name| name.search_name.inspect }.join(", ")}.\n")
  end
  if @new_name.is_a?(Array) && @new_name.count > 1
    raise("Multiple matches for #{new_name_id.inspect}: " \
          "#{@new_name.map { |name| name.search_name.inspect }.join(", ")}.\n")
  end

  @old_name = @old_name.first if @old_name.is_a?(Array)
  @new_name = @new_name.first if @new_name.is_a?(Array)

  raise("Couldn't find user #{user_id.inspect}.\n")         unless @user
  raise("Couldn't find old name #{old_name_id.inspect}.\n") unless @old_name
  raise("Couldn't find new name #{new_name_id.inspect}.\n") unless @new_name

  puts("User: #{@user.login.inspect}")
  puts("Old:  #{@old_name.search_name.inspect}")
  puts("New:  #{@new_name.search_name.inspect}")
  puts
end

def process_observation(obs)
  print_observation(obs)
  get_initial_state(obs)
  @any_changes = false
  print_initial_state
  add_comment           unless @new_naming
  propose_new_name      unless @new_naming
  vote_on_new_name      unless @new_vote
  # No operations should have been performed on @@olc_vote,
  # so floating point comparison should be valid.
  # rubocop:disable Lint/FloatComparison
  vote_against_old_name unless @old_vote == -3.0
  # rubocop:enable Lint/FloatComparison
  @consensus.calc_consensus    if @any_changes
  print_result
end

def get_initial_state(obs)
  @observation = obs
  @consensus   = Observation::NamingConsensus.new(obs)
  @cur_name    = obs.name
  @old_naming  = obs.namings.find { |n| n.name == @old_name }
  @new_naming  = obs.namings.find { |n| n.name == @new_name }
  @cur_naming  = obs.namings.find { |n| n.name == @cur_name }
  @old_score   = average_votes(@old_naming)
  @new_score   = average_votes(@new_naming)
  @cur_score   = obs.vote_cache
  @old_vote    = @consensus.users_vote(@old_naming, @user)
  @new_vote    = @consensus.users_vote(@new_naming, @user)
  @cur_vote    = @consensus.users_vote(@cur_naming, @user)
end

def print_observation(obs)
  name = obs.name.search_name
  name = "(old name)" if obs.name == @old_name
  name = "(new name)" if obs.name == @new_name
  puts("https://mushroomobserver.org/#{obs.id} -- #{name}")
end

def print_initial_state
  name = "(cur=#{@cur_score.inspect})"
  name = "cur=#{@cur_score.inspect}/#{@cur_vote.inspect}" if
    @cur_name != @old_name && @cur_name != @new_name
  puts("  avg/user: " \
       "old=#{@old_score.inspect}/#{@old_vote.inspect}, " \
       "new=#{@new_score.inspect}/#{@new_vote.inspect}, " +
       name)
end

def print_result
  name = @observation.name
  if name != @cur_name && name == @new_name
    puts("  SUCCESS -- changed to new name")
  elsif name != @cur_name && name == @old_name
    puts("  FAILED -- accidentally changed to old name!!")
  elsif name != @cur_name
    puts("  FAILED -- accidentally changed to #{name.search_name.inspect}")
  elsif name == @old_name
    puts("  FAILED -- consensus is stuck on old name")
  elsif name != @new_name
    puts("  FAILED -- consensus is stuck on #{name.search_name.inspect}")
  else
    puts("  SUCCESS -- stayed on new name")
  end
  puts
end

def propose_new_name
  return if @new_naming

  puts("  > proposing new name...")
  @new_naming = Naming.new(
    observation: @observation,
    name: @new_name,
    user: @user
  )
  @new_naming.set_reasons(2 => @refs) if @refs
  @new_naming.save
  @observation.reload
  @any_changes = true
end

def vote_on_new_name
  return unless @new_naming

  vote = @old_vote || @old_score.try(&:ceil) || 1
  puts("  > voting #{vote} on new name...")
  @consensus.change_vote(@new_naming, vote, @user)
  @any_changes = true
end

def vote_against_old_name
  return unless @old_naming

  puts("  > voting -3 on old name...")
  consensus = Observation::NamingConsensus.new(@observation)
  consensus.change_vote(@old_naming, -3, @user)
  @any_changes = true
end

def add_comment
  summary, body = @comment.split(%r{/}, 2)
  puts("  > adding comment #{summary.inspect}...")
  Comment.create!(
    target: @observation,
    user: @user,
    summary: summary,
    comment: body
  )
end

def average_votes(naming)
  return nil unless naming

  sum = num = 0.0
  naming.votes.each do |vote|
    weight = vote.user.contribution
    sum += vote.value * weight
    num += weight
  end
  num.positive? ? sum / num : nil
end

# def users_vote(naming)
#   return nil unless naming

#   naming.votes.each do |vote|
#     return vote.value if vote.user == @user
#   end
#   nil
# end

parse_options
$stdout.sync = true
Observation.where(name: @old_name).each do |obs|
  process_observation(obs)
end
exit(0)