scottwillson/racing_on_rails

View on GitHub
app/models/competitions/competition.rb

Summary

Maintainability
D
2 days
Test Coverage
# frozen_string_literal: true

module Competitions
  # Results that derive their results from other Events. Se TYPES.
  # Year-long: BAR, Ironman, WSBA Rider Rankings, Oregon Cup.
  # Event-based: Cross Crusade, Mount Tabor Series.
  #
  # +point_schedule+: Array. How many points for each place?
  # +source_events+: Which events count toward Competition? Not all Competitions use this relationship.
  # Many dynamically choose their source events each time they calculate.
  # +competition_event_memberships+: Relationship between source_events and competition.
  #
  # +calculate!+ is the main method
  class Competition < Event
    include Competitions::Categories
    include Competitions::Dates
    include Competitions::Naming
    include Competitions::Points

    TYPES = %w[
      Competitions::AgeGradedBar
      Competitions::Bar
      Competitions::BlindDateAtTheDairyMonthlyStandings
      Competitions::BlindDateAtTheDairyOverall
      Competitions::BlindDateAtTheDairyTeamCompetition
      Competitions::Cat4WomensRaceSeries
      Competitions::Competition
      Competitions::CrossCrusadeCallups
      Competitions::CrossCrusadeOverall
      Competitions::CrossCrusadeTeamCompetition
      Competitions::DirtyCirclesOverall
      Competitions::GrandPrixBradRoss::Overall
      Competitions::GrandPrixBradRoss::TeamStandings
      Competitions::GrandPrixBradRoss::Team
      Competitions::Ironman
      Competitions::OregonCup
      Competitions::OregonJuniorCyclocrossSeries::Overall
      Competitions::OregonJuniorCyclocrossSeries::Team
      Competitions::OregonJuniorMountainBikeSeries::Overall
      Competitions::OregonTTCup
      Competitions::OregonWomensPrestigeSeries
      Competitions::OregonWomensPrestigeTeamSeries
      Competitions::OverallBar
      Competitions::PortlandShortTrackSeries::MonthlyStandings
      Competitions::PortlandShortTrackSeries::Overall
      Competitions::PortlandShortTrackSeries::TeamStandings
      Competitions::TaborOverall
      Competitions::TeamBar
      Competitions::WillametteValleyClassicsTour::Overall
      Competitions::OregonJuniorMountainBikeSeries::Team
      Competitions::PortlandShortTrackSeries::Team
      Competitions::PortlandTrophyCup
      Competitions::ThrillaOverall
    ].freeze

    UNLIMITED = Float::INFINITY

    after_create  :create_races
    after_save    :expire_cache

    has_many :competition_event_memberships
    has_many :source_events,
             through: :competition_event_memberships,
             source: :event,
             class_name: "::Event"

    def self.find_for_year(year = RacingAssociation.current.year)
      where("date between ? and ?", Time.zone.local(year).beginning_of_year.to_date, Time.zone.local(year).end_of_year.to_date).first
    end

    def self.find_for_year!(year = RacingAssociation.current.year)
      find_for_year(year) || raise(ActiveRecord::RecordNotFound)
    end

    def self.find_or_create_for_year(year = RacingAssociation.current.year)
      find_for_year(year) || create(date: Time.zone.local(year).beginning_of_year)
    end

    # Update results based on source event results.
    # (Calculate clashes with internal Rails method)
    def self.calculate!(year = Time.zone.today.year)
      ActiveSupport::Notifications.instrument "calculate.#{name}.competitions.racing_on_rails" do
        transaction do
          year = year.to_i if year.is_a?(String)
          competition = find_or_create_for_year(year)
          competition.set_date
          raise(ActiveRecord::ActiveRecordError, competition.errors.full_messages) unless competition.errors.empty?

          competition.delete_races
          competition.create_races
          competition.create_children
          # Could bulk load all Event and Races at this point, but hardly seems to matter
          competition.calculate_members_only_places
          competition.calculate!
        end
      end
      # Don't return the entire populated instance!
      true
    end

    def add_source_events
      parent.children.each do |source_event|
        source_events << source_event
      end
    end

    def create_races
      logger.debug "Competition#create_races #{id} #{name} #{date} races: #{race_category_names.size}"
      race_category_names.each do |name|
        category = Category.where(name: name).first || Category.create!(raw_name: name)
        unless races.exists?(category: category)
          if team?
            races.create! category: category, result_columns: %w[ place team_name points ]
          else
            races.create! category: category
          end
        end
      end
    end

    # Override in superclass for Competitions like OBRA OverallBAR
    def create_children
      true
    end

    # Rebuild results
    def calculate!
      before_calculate

      races_in_upgrade_order.each do |race|
        results = source_results(race)
        logger.debug("#{self.class.name}#calculate! race: #{race.name} source_results: #{results.count}") if logger.debug?
        results = add_upgrade_results(results, race)
        results = after_source_results(results, race)
        results = delete_non_calculation_attributes(results)
        results = add_field_size(results)
        results = map_team_member_to_boolean(results)

        calculated_results = Competitions::Calculations::Calculator.calculate(results, rules(race))

        race.destroy_duplicate_results!
        race.results.reload

        new_results, existing_results, obsolete_results = partition_results(calculated_results, race)
        logger.debug "Calculator source results:   #{results.size}"
        logger.debug "Calculator new_results:      #{new_results.size}"
        logger.debug "Calculator existing_results: #{existing_results.size}"
        logger.debug "Calculator obsolete_results: #{obsolete_results.size}"
        create_competition_results_for new_results, race
        update_competition_results_for existing_results, race
        delete_competition_results_for obsolete_results, race
      end

      after_calculate
      save!
    end

    # Callback
    def before_calculate; end

    # Callback
    def after_calculate
      # TODO: ensure subclasses call super
      self.updated_at = Time.zone.now
    end

    def races_in_upgrade_order
      if upgrades.present?
        upgrade_categories = upgrades.values.map { |categories| Array.wrap(categories) }.flatten.uniq
        categories_in_upgrade_order = upgrade_categories + (races.map(&:name) - upgrade_categories)
        categories_in_upgrade_order.map { |name| races.detect { |race| race.name == name } }.compact
      else
        races
      end
    end

    def source_results(race)
      Competition.benchmark("#{self.class.name} source_results", level: :debug) do
        Result.connection.select_all source_results_query(race)
      end
    end

    def source_results_query(race)
      query = Result
              .select(
                "distinct results.id as id",
                "1 as multiplier",
                "age",
                "categories.ability_begin as category_ability",
                "categories.ages_begin as category_ages_begin",
                "categories.ages_end as category_ages_end",
                "categories.equipment as category_equipment",
                "categories.gender as category_gender",
                "events.bar_points as event_bar_points",
                "events.date",
                "events.discipline",
                "events.type",
                "gender",
                "member_from",
                "member_to",
                "parents_events.bar_points as parent_bar_points",
                "parents_events_2.bar_points as parent_parent_bar_points",
                "people.gender as person_gender",
                "people.name as person_name",
                "points_factor",
                "races.bar_points as race_bar_points",
                "races.visible",
                "results.#{participant_id_attribute} as participant_id",
                "results.event_id",
                "place",
                "results.points_bonus_penalty as bonus_points",
                "results.points",
                "results.race_id",
                "results.race_name as category_name",
                "results.year",
                "team_member",
                "team_name"
              )
              .joins(:race, :event, :person)
              .joins("left outer join events parents_events on parents_events.id = events.parent_id")
              .joins("left outer join events parents_events_2 on parents_events_2.id = parents_events.parent_id")
              .joins("left outer join categories on categories.id = races.category_id")
              .joins("left outer join competition_event_memberships on results.event_id = competition_event_memberships.event_id and competition_event_memberships.competition_id = #{id}")
              .where("results.year" => year)

      query = query.where("races.visible" => true) if reject_invisible_results?

      query = if source_event_types.include?(Event)
                query.where("(events.type in (?) or events.type is NULL)", source_event_types)
              else
                query.where("events.type in (?)", source_event_types)
              end

      categories = categories_clause(race)
      query = query.merge(categories) if categories

      query
    end

    # Only consider results with categories that match +race+'s category
    def categories_clause(race)
      Category.where("races.category_id" => categories_for(race)) if categories?
    end

    def after_source_results(results, _race)
      results
    end

    # TODO: just do this in source_results with join
    def add_upgrade_results(results, race)
      if race.name.in?(upgrades.keys)
        upgrade_categories = Array.wrap(upgrades[race.name])
        upgrade_races = races.select { |r| r.name.in?(upgrade_categories) }
        results.to_a + Result.connection.select_all(Result
          .select(
            "distinct results.id as id",
            "1 as multiplier",
            "events.bar_points as event_bar_points",
            "events.date",
            "events.type",
            "member_from",
            "member_to",
            "parents_events.bar_points as parent_bar_points",
            "parents_events_2.bar_points as parent_parent_bar_points",
            "place",
            "points_factor",
            "races.bar_points as race_bar_points",
            "results.#{participant_id_attribute} as participant_id",
            "results.event_id",
            "results.points_bonus_penalty as bonus_points",
            "results.points",
            "results.race_id",
            "results.race_name as category_name",
            "results.year",
            "team_member",
            "team_name",
            "true as upgrade"
          )
          .joins(:race, :event, :person)
          .joins("left outer join events parents_events on parents_events.id = events.parent_id")
          .joins("left outer join events parents_events_2 on parents_events_2.id = parents_events.parent_id")
          .joins("left outer join competition_event_memberships on results.event_id = competition_event_memberships.event_id and competition_event_memberships.competition_id = #{id}")
          .where("results.race_id" => upgrade_races).
          # Only include upgrade results for people with category results
          where(
            "results.#{participant_id_attribute}" =>
            results.map { |r| r["participant_id"] }.uniq
          )).to_a
      else
        results
      end
    end

    def rules(race)
      {
        break_ties: break_ties?,
        completed_events: completed_events,
        dnf_points: dnf_points,
        double_points_for_last_event: double_points_for_last_event?,
        end_date: end_date,
        field_size_bonus: field_size_bonus?,
        maximum_events: maximum_events(race),
        maximum_upgrade_points: maximum_upgrade_points,
        members_only: members_only?,
        minimum_events: minimum_events,
        missing_result_penalty: missing_result_penalty,
        most_points_win: most_points_win?,
        place_bonus: place_bonus,
        points_schedule_from_field_size: points_schedule_from_field_size?,
        point_schedule: point_schedule,
        results_per_event: results_per_event,
        results_per_race: results_per_race,
        source_event_ids: source_event_ids(race),
        team: team?,
        use_source_result_points: use_source_result_points?,
        upgrade_points_multiplier: upgrade_points_multiplier
      }
    end

    # Some competitions are only open to RacingAssociation members, and non-members are dropped from the results.
    def calculate_members_only_places
      if place_members_only?
        Race
          .includes(:event)
          .where.not("events.type" => self.class.name.demodulize)
          .year(year)
          .where("events.updated_at > ? || races.updated_at > ?", 1.week.ago, 1.week.ago)
          .references(:events)
          .find_each(&:calculate_members_only_places!)
      end
    end

    def delete_non_calculation_attributes(results)
      non_calculation_attributes = %w[
        age
        category_ability
        category_ages_begin
        category_ages_end
        category_equipment
        category_gender
        discipline
        event_bar_points
        gender
        parent_bar_points
        parent_parent_bar_points
        person_gender
        person_name
        points_factor
        race_bar_points
        visible
      ]
      results.map do |result|
        result.except(*non_calculation_attributes)
      end
    end

    # Calculate field size. It's not stored in the DB, and can't be calculated
    # from source results. Eventually, *should* load all results and calculate
    # in Calculator.
    def add_field_size(results)
      results.each do |result|
        result["field_size"] = field_sizes[result["race_id"]]
      end
    end

    def field_sizes
      @field_sizes ||= ::Result.group(:race_id).count
    end

    def set_team_size_to_one(results)
      results.each { |r| r["team_size"] = 1 }
    end

    def completed_events
      source_events.count(&:any_results?) if source_events?
    end

    def map_team_member_to_boolean(results)
      if team?
        results.each do |result|
          result["team_member"] = result["team_member"] == 1
        end
      else
        results
      end
    end

    # Only delete obsolete races
    def delete_races
      obsolete_races = races.reject { |race| race.name.in?(race_category_names) }
      logger.debug "Competition#delete_races #{id} #{name} #{date} obsolete_races: #{obsolete_races.size}"
      if obsolete_races.any?
        race_ids = obsolete_races.map(&:id)
        Competitions::Score.where("competition_result_id in (select id from results where race_id in (#{race_ids.join(',')}))").delete_all
        Result.where("race_id in (?)", race_ids).delete_all
      end
      obsolete_races.each { |race| races.delete(race) }
    end

    def partition_results(calculated_results, race)
      participant_ids            = race.results.map(&participant_id_attribute)
      calculated_participant_ids = calculated_results.map(&:participant_id)

      new_participant_ids      = calculated_participant_ids - participant_ids
      existing_participant_ids = calculated_participant_ids & participant_ids
      obsolete_participant_ids = participant_ids - calculated_participant_ids

      [
        calculated_results.select { |r| r.participant_id.in?            new_participant_ids },
        calculated_results.select { |r| r.participant_id.in?            existing_participant_ids },
        race.results.select       { |r| r[participant_id_attribute].in? obsolete_participant_ids }
      ]
    end

    def participant_id_attribute
      if team?
        :team_id
      else
        :person_id
      end
    end

    def person_id_for_competition_result(result)
      if team?
        nil
      else
        result.participant_id
      end
    end

    def create_competition_results_for(results, race)
      Rails.logger.debug "create_competition_results_for #{race.name}"

      team_ids = team_ids_by_participant_id_hash(results)

      results.each do |result|
        competition_result = ::Result.create!(
          competition_result: true,
          event: self,
          person_id: person_id_for_competition_result(result),
          place: result.place,
          points: result.points,
          preliminary: result.preliminary,
          race: race,
          team_competition_result: team?,
          team_id: team_ids[result.participant_id]
        )

        result.scores.each do |score|
          create_score competition_result, score.source_result_id, score.points, score.notes
        end
      end

      true
    end

    def update_competition_results_for(results, race)
      Rails.logger.debug "update_competition_results_for #{race.name}"
      return true if results.empty?

      team_ids = team_ids_by_participant_id_hash(results)
      existing_results = race.results.where(participant_id_attribute => results.map(&:participant_id)).includes(:scores)

      results.each do |result|
        update_competition_result_for result, existing_results, team_ids
      end
    end

    def update_competition_result_for(result, existing_results, team_ids)
      existing_result = existing_results.detect { |r| r[participant_id_attribute] == result.participant_id }

      # Ensure true or false, not nil
      existing_result.preliminary   = result.preliminary ? true : false
      # to_s important. Otherwise, a change from 3 to "3" triggers a DB update.
      existing_result.place         = result.place.to_s
      existing_result.points        = result.points
      existing_result.team_id       = team_ids[result.participant_id]

      # TODO: Why do we need explicit dirty check?
      if existing_result.place_changed? || existing_result.team_id_changed? || existing_result.points_changed? || existing_result.preliminary_changed?
        existing_result.save!
      end

      update_scores_for result, existing_result
    end

    def update_scores_for(result, existing_result)
      existing_scores = existing_result.scores.map { |s| [s.source_result_id, s.points.to_f, s.notes] }
      new_scores = result.scores.map { |s| [s.source_result_id || existing_result.id, s.points.to_f, s.notes] }

      scores_to_create = new_scores - existing_scores
      scores_to_delete = existing_scores - new_scores

      # Delete first because new scores might have same key
      Score.where(competition_result_id: existing_result.id).where(source_result_id: scores_to_delete.map(&:first)).delete_all if scores_to_delete.present?

      scores_to_create.each do |score|
        create_score existing_result, score.first, score.second, score.last
      end
    end

    def delete_competition_results_for(results, race)
      Rails.logger.debug "delete_competition_results_for #{race.name}"
      if results.present?
        Score.where(competition_result_id: results).delete_all
        Result.where(id: results).delete_all
      end
    end

    # Competition results could know they need to lookup their team
    # Can move to Result?
    def team_ids_by_participant_id_hash(results)
      team_ids_by_participant_id_hash = {}
      results.map(&:participant_id).uniq.each do |participant_id|
        team_ids_by_participant_id_hash[participant_id] = participant_id
      end

      unless team?
        ::Person.select("id, team_id").where("id in (?)", results.map(&:participant_id).uniq).map do |person|
          team_ids_by_participant_id_hash[person.id] = person.team_id
        end
      end

      team_ids_by_participant_id_hash
    end

    # This is always the 'best' result
    def create_score(competition_result, source_result_id, points, notes)
      ::Competitions::Score.create!(
        source_result_id: source_result_id || competition_result.id,
        competition_result_id: competition_result.id,
        notes: notes,
        points: points
      )
    end

    def source_event_types
      [SingleDayEvent, Event]
    end

    def source_event_ids(_race)
      source_events.map(&:id) if source_events?
    end

    def minimum_events
      nil
    end

    def missing_result_penalty
      nil
    end

    def maximum_events(_race)
      nil
    end

    def maximum_upgrade_points
      Competition::UNLIMITED
    end

    def place_bonus
      nil
    end

    def points_schedule_from_field_size?
      false
    end

    def break_ties?
      true
    end

    def dnf_points
      0
    end

    # Events that combine/split races create invisible (visible: false) results
    # Other competitions shoudl ignroe those results.
    def reject_invisible_results?
      true
    end

    def results_per_event
      Competition::UNLIMITED
    end

    def results_per_race
      1
    end

    def use_source_result_points?
      false
    end

    def upgrade_points_multiplier
      0.50
    end

    # Team-based competition? False (default) implies it is person-based.
    def team?
      false
    end

    def default_ironman
      false
    end

    def upgrades
      {}
    end

    def competition?
      true
    end

    def expire_cache
      ApplicationController.expire_cache
    end
  end
end