scottwillson/racing_on_rails

View on GitHub
app/models/race.rb

Summary

Maintainability
D
1 day
Test Coverage
# frozen_string_literal: true

# A Race is essentionally a collection of Results labelled with a Category. Races must belong to a parent Event.
#
# Races only have some of their attributes populated. These attributes are listed in the +result_columns+ Array.
#
# People use say "category" where we use Race in code. Could rename this EventCategory.
#
# Race +result_columns+: populated columns displayed on results page. Usually Result attributes, but also creates
# virtual "custom" columns.
class Race < ApplicationRecord
  include Calculations::V3::Rejection
  include Comparable
  include Export::Races
  include RacingOnRails::PaperTrail::Versions
  include Sanctioned

  DEFAULT_RESULT_COLUMNS = %w[ place number last_name first_name team_name points time ].freeze
  RESULT_COLUMNS = %w[
    age age_group category_class category_name city date_of_birth first_name gender laps last_name license notes
    number place points points_bonus points_bonus_penalty points_from_place points_penalty points_total state
    team_name time time_bonus_penalty time_gap_to_leader time_gap_to_previous time_gap_to_winner time_total
  ].freeze

  validates :event, :category, presence: true
  validates :rejection_reason, inclusion: { in: ::Calculations::V3::REJECTION_REASONS, allow_blank: true }

  before_validation :find_associated_records

  before_save :symbolize_custom_columns
  before_save :normalize_result_columns

  belongs_to :category
  belongs_to :discipline, inverse_of: :races, optional: true
  belongs_to :event, inverse_of: :races
  belongs_to :split_from, class_name: "Race", optional: true
  has_one :promoter, through: :event
  has_many :results, dependent: :destroy

  serialize :result_columns, Array
  serialize :custom_columns, Array

  scope :include_results, -> { includes(:category, results: :team) }
  scope :year, ->(year) { where(date: Time.zone.local(year).beginning_of_year.to_date..Time.zone.local(year).end_of_year.to_date) }

  # Defaults to Event's BAR points
  def bar_points
    self[:bar_points] || event.bar_points
  end

  # 0..3
  def bar_points=(value)
    if value.nil? || value == event.try(:bar_points)
      self[:bar_points] = nil
    elsif value.to_i == value.to_f
      self[:bar_points] = value
    else
      raise ArgumentError, "BAR points must be an integer, but was: #{value}"
    end
  end

  def category_name=(name)
    self.category = if name.blank?
                      nil
                    else
                      Category.new(name: name)
                    end
    category.try :name
  end

  def category_name
    category&.name
  end

  def category_friendly_param
    category.try :friendly_param
  end

  def discipline=(value)
    unless value == event&.discipline
      super
    end
  end

  def discipline_id=(value)
    unless value == event&.discipline_id
      super
    end
  end

  def name
    category_name
  end

  # Combine with event name
  def full_name
    if name == event.full_name
      name
    elsif event.full_name[name]
      event.full_name
    else
      "#{event.full_name}: #{name}"
    end
  end

  # Range of dates_of_birth of people in this race
  def dates_of_birth
    raise(ArgumentError, "Need category to calculate dates of birth") unless category

    Date.new(date.year - category.ages.end, 1, 1)..Date.new(date.year - category.ages.begin, 12, 31)
  end

  def date
    event.try :date
  end

  def year
    event&.date && event.date.year
  end

  # Incorrectly doubles tandem and other team events' field sizes
  def field_size
    if self[:field_size].present? && self[:field_size] > 0
      self[:field_size]
    else
      results.size
    end
  end

  def sanctioned_by
    self[:sanctioned_by] || event.try(:sanctioned_by) || RacingAssociation.current.default_sanctioned_by
  end

  def present_columns
    columns = []
    results.each do |result|
      (RESULT_COLUMNS + result.custom_attributes.keys).each do |result_column|
        value = result.send(result_column)
        columns << result_column if value.present? && value != 0 && value != 0.0 && value != "0" && value != "0.0"
      end
    end
    columns.compact.map(&:to_s).uniq.sort
  end

  def result_columns
    return DEFAULT_RESULT_COLUMNS.dup if self[:result_columns].empty?

    super
  end

  def normalize_result_columns
    return if result_columns.empty? || result_columns == DEFAULT_RESULT_COLUMNS

    if result_columns.include?("name")
      name_index = result_columns.index("name")
      result_columns[name_index] = "first_name"
      result_columns.insert(name_index + 1, "last_name")
    end

    if result_columns&.include?("place") && result_columns.first != "place"
      result_columns.delete("place")
      result_columns.insert(0, "place")
    end
  end

  def symbolize_custom_columns
    custom_columns&.map! { |col| col.to_s.to_sym }
  end

  def update_split_from!
    save! if set_split_from
  end

  def set_split_from
    return false if results.blank?

    event.races.reject { |race| race == self }.each do |race|
      if category.include?(race.category) && results_in?(race)
        self.split_from = race
        return true
      end
    end

    false
  end

  def results_in?(other_race)
    people = results.sort.map(&:person_id)
    other_race_people = other_race.results.sort.map(&:person_id)

    people_not_in_other_race = people - other_race_people
    return false if people_not_in_other_race.present?

    people_in_other_race = people & other_race_people
    people == people_in_other_race
  end

  # Ensure child team and people are not duplicates of existing records
  # Tricky side effect -- external references to new association records
  # (category, bar_category, person, team) will not point to associated records
  # FIXME Handle people with only a number
  def find_associated_records
    if category && (category.new_record? || category.changed?)
      if category.name.blank?
        self.category = nil
      else
        existing_category = Category.find_by(name: category.name)
        self.category = existing_category if existing_category
      end
    end

    true
  end

  def has_result(row_hash)
    return true if row_hash["place"].present? && row_hash["place"] != "1" && row_hash["place"] != "0"
    if row_hash["person.first_name"].blank? &&
       row_hash["person.last_name"].blank? &&
       row_hash["person.road_number"].blank? &&
       row_hash["team.name"].blank?
      return false
    end

    true
  end

  # Sort results by laps, time
  def place_results_by_time
    _results = results.to_a.sort do |x, y|
      if x.laps && y.laps && x.laps != y.laps
        y.laps <=> x.laps
      elsif x.time
        if y.time
          x.time <=> y.time
        else
          1
        end
      else
        -1
      end
    end

    _results.each_with_index do |result, index|
      result.place = if index == 0
                       1
                     else
                       result.place = if _results[index - 1].compare_by_time(result, true) == 0
                                        _results[index - 1].place
                                      else
                                        index + 1
                                      end
                     end
      if result.place_changed?
        result.update_column(:place, result.place)
        result.update_column(:numeric_place, result.numeric_place)
      end
    end
  end

  def calculate_members_only_places!
    # count up from zero
    last_members_only_place = 0
    # assuming first result starting at zero+one (better than sorting results twice?)
    last_result_place = 0
    results.sort.each do |result|
      place_before = result.members_only_place.to_i
      result.members_only_place = ""
      next unless result.numeric_place?

      if result.member_result?
        # only increment if we have moved onto a new place
        last_members_only_place += 1 if result.numeric_place != last_members_only_place && result.numeric_place != last_result_place
        result.members_only_place = last_members_only_place.to_s
      end
      # Slight optimization. Most of the time, no point in saving a result that hasn't changed
      result.update(members_only_place: result.members_only_place) if place_before != result.members_only_place
      # store to know when switching to new placement (team result feature)
      last_result_place = result.numeric_place
    end
  end

  def create_result_before(result_id)
    return results.create(place: "1") if results.empty?

    if result_id
      _results = results.sort
      result = Result.find(result_id)
      start_index = _results.index(result)
      (start_index..._results.size).each do |index|
        _results[index].update! place: _results[index].next_place if _results[index].numeric_place?
      end

      results.create place: result.place
    else
      append_result
    end
  end

  def append_result
    results.create place: results.max.next_place
  end

  def destroy_result(result)
    _results = results.sort
    start_index = _results.index(result) + 1
    (start_index..._results.size).each do |index|
      if _results[index].numeric_place?
        _results[index].place = _results[index].numeric_place - 1
        _results[index].save!
      end
    end
    result.destroy
  end

  def destroy_duplicate_results!
    duplicate_results = []

    results.group_by(&:person_id).each do |_person_id, person_results|
      duplicate_results << person_results.drop(1) if person_results.size > 1
    end

    duplicate_results.flatten.uniq.each(&:destroy!)
  end

  def any_results?
    results.any?
  end

  # Helper method for as_json
  def sorted_results
    results.sort
  end

  def source_categories
    Category
      .joins("inner join races")
      .joins("inner join result_sources")
      .joins("inner join results as calculated_results")
      .joins("inner join results as source_results")
      .where("result_sources.calculated_result_id = calculated_results.id")
      .where("result_sources.source_result_id = source_results.id")
      .where("races.category_id = categories.id")
      .where("source_results.race_id = races.id")
      .where("calculated_results.race_id": id)
      .distinct
  end

  delegate :junior?, to: :category

  # By category name
  def <=>(other)
    return -1 if other.nil?

    if rejected?
      return 1
    elsif other.rejected?
      return -1
    end

    category_name <=> other.category_name
  end

  def hash
    if new_record?
      category.hash
    else
      id
    end
  end

  def==(other)
    return false unless other.is_a?(self.class)
    return other.id == id unless other.new_record? || new_record?

    category == other.category
  end

  def inspect_debug
    puts "  #{self}"
    results.reload.sort.each(&:inspect_debug)
  end

  def to_s
    "#<Race #{id} #{self[:event_id]} #{self[:category_id]} >"
  end
end