scottwillson/racing_on_rails

View on GitHub
app/models/calculations/v3/calculation.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

# Rules to create and calculate results for event based on another event's results.
# Older code uses the term "Competition.""
# Link source event(s) with caculated Event.
# Handle all ActiveRecord work. All calculation occurs in pure-Ruby Calculator and Models.
#
# Source results selected in two large steps:
#   1. SQL. Apply broad criteria like current year, series events
#   2. Ruby. Apply rules like "best of 6".
# The distinction between SQL and Ruby is somewhat arbritrary. Could load _all_
# results and do all selection and rejection in Ruby. For both performance and
# clarity, some results are filtered early by SQL. For example, no one expects
# criterium and track results to show in the Road BAR.
#
# Calculated series overall results have sometimes been added directly to the
# source series event: for example, the Cross Crusade overall. To reduce
# complexity, this is no longer allowed. The model is now:
# Cross Crusade series
#  * Alpenrose source event
#    * source results
#  * Barton Park source event
#    * source results
#  * Overall event
#    * calculated results
#
#  series -> calculation -> overall event (child of series)
class Calculations::V3::Calculation < ApplicationRecord
  GROUP_BY = %w[age category].freeze
  PLACE_BY = %w[fewest_points place points time].freeze

  include ActiveSupport::Benchmarkable
  include Calculations::V3::CalculationConcerns::Cache
  include Calculations::V3::CalculationConcerns::CalculatedResults
  include Calculations::V3::CalculationConcerns::Dates
  include Calculations::V3::CalculationConcerns::Races
  include Calculations::V3::CalculationConcerns::ResultSources
  include Calculations::V3::CalculationConcerns::RulesConcerns
  include Calculations::V3::CalculationConcerns::SaveResults
  include Calculations::V3::CalculationConcerns::SourceResults

  serialize :points_for_place
  serialize :source_event_keys, Array

  has_many :calculation_categories, class_name: "Calculations::V3::Category", dependent: :destroy, inverse_of: :calculation
  has_many :calculations_events, class_name: "Calculations::V3::Event", dependent: :destroy
  has_many :categories, through: :calculation_categories, class_name: "::Category"
  has_many :calculation_disciplines, class_name: "Calculations::V3::Discipline", dependent: :destroy
  # Discipline of calculated results' event
  belongs_to :discipline, class_name: "::Discipline"
  # Only count results in these disciplines
  has_many :disciplines, through: :calculation_disciplines
  belongs_to :event, class_name: "::Event", inverse_of: :calculation, optional: true
  has_many :events, through: :calculations_events, class_name: "::Event"
  belongs_to :source_event, class_name: "::Event", optional: true

  accepts_nested_attributes_for :calculation_categories, allow_destroy: true
  accepts_nested_attributes_for :calculations_events, allow_destroy: true

  before_save :set_name
  before_destroy :destroy_event
  after_save :expire_cache

  validate :event_is_not_source_event
  validate :maximum_events_negative, unless: :blank?
  validates :event, uniqueness: { allow_nil: true, case_sensitive: false }
  validates :key, uniqueness: { allow_nil: true, case_sensitive: false, scope: :year }
  validates :group_by, inclusion: { in: GROUP_BY }
  validates :place_by, inclusion: { in: PLACE_BY }

  attribute :discipline_id, :integer, default: -> { ::Discipline[RacingAssociation.current.default_discipline]&.id }
  attribute :event_notes, :text, default: -> { "" }

  def self.calculate!(year: RacingAssociation.current.effective_year)
    source_event_keys = Calculations::V3::Calculation.where(year: year).pluck(:source_event_keys).flatten.uniq
    Calculations::V3::Calculation.where(year: year).where.not(key: source_event_keys).each(&:calculate!)
  end

  def self.with_results(year = RacingAssociation.current.effective_year)
    event_ids = Result
                .where(year: year)
                .where("competition_result is true or team_competition_result is true")
                .pluck(:event_id)
                .uniq

    Calculations::V3::Calculation.where(event_id: event_ids).distinct
  end

  def self.latest(key)
    where(key: key).order(:year).last
  end

  def add_event!
    benchmark "add_event!.#{key}.calculate.calculations" do
      return if event

      if source_event
        event = create_event!(
          date: source_event.date,
          discipline: source_event.discipline,
          end_date: source_event.end_date,
          name: event_name,
          notes: event_notes
        )
        source_event.children << event
      else
        self.event = create_event!(
          date: Time.zone.local(year).beginning_of_year,
          discipline: discipline.name,
          end_date: Time.zone.local(year).end_of_year,
          name: event_name,
          notes: event_notes
        )
      end
    end
  end

  # Find all source results with coarse scope (year, source_events)
  # Map results and calculation rules to calculate models
  # model calculate
  # serialize to DB
  def calculate!(source_calculations: true)
    ActiveSupport::Notifications.instrument "calculate.calculations.#{name}.racing_on_rails" do
      clear_cache
      clear_source_results_cache
      calculate_source_calculations if source_calculations
      add_event!
      update_event_dates
      results = results_to_models(source_results)
      calculator = Calculations::V3::Calculator.new(
        calculations_events: model_calculations_events,
        logger: logger,
        rules: rules,
        source_events: model_source_events,
        source_results: results,
        year: year
      )
      event_categories = nil
      benchmark "calculate!.#{key}.calculator.calculate.calculations" do
        event_categories = calculator.calculate!
      end
      benchmark "save_results.#{key}.calculate.calculations" do
        save_results event_categories
      end
      event.touch
      GC.start
      expire_cache
    end

    true
  end

  def calculate_source_calculations
    benchmark "calculate_source_calculations.#{key}.calculate.calculations" do
      Calculations::V3::Calculation.where(key: source_event_keys, year: year).find_each(&:calculate!)
    end
  end

  def calculated?(event)
    event&.type != "SingleDayEvent"
  end

  def category_names
    categories.map(&:name)
  end

  def destroy_event
    event&.destroy_races
    event&.destroy
  end

  def event_name
    if source_event
      if team?
        "Team Competition"
      else
        "Overall"
      end
    else
      name
    end
  end

  def expire_cache
    ApplicationController.expire_cache
  end

  def group_event_keys
    group && Calculations::V3::Calculation.where(group: group, year: year).pluck(:key)
  end

  def maximum_events_negative
    # Dupe with Rules
    if !maximum_events.is_a?(Integer) || maximum_events.to_i.positive?
      errors.add(:maximum_events, "must be an integer < 1, but is #{maximum_events.class} #{maximum_events}")
    end
  end

  def set_name
    if name == "New Calculation" && source_event
      self.name = if team?
                    "#{source_event.name}: Team Competition"
                  else
                    "#{source_event.name}: Overall"
                  end
    end
  end

  def event_is_not_source_event
    return if event.blank?

    if event == source_event || events.include?(event) || events.map(&:parent).include?(event)
      errors.add(:event, "cannot be source event")
    end
  end

  def update_event_dates
    benchmark "update_event_dates.#{key}.calculate.calculations" do
      if source_event && source_event.dates != event.dates
        event.date = source_event.date
        event.end_date = source_event.end_date
        event.save!
      end
    end
  end

  def years
    Calculations::V3::Calculation.where(key: key).pluck(:year)
  end
end