af83/chouette-core

View on GitHub
app/models/chouette/time_table.rb

Summary

Maintainability
F
3 days
Test Coverage
# frozen_string_literal: true

module Chouette
  class TimeTable < Referential::Model
    include ApplicationDaysSupport
    include ObjectidSupport
    include TimetableSupport
    has_metadata

    attr_accessor :skip_save_shortcuts

    def self.ransackable_attributes auth_object = nil
      column_names + _ransackers.keys
    end

    ransacker :unaccented_comment, formatter: ->(val){ val.parameterize } do
      Arel.sql('unaccent(time_tables.comment)')
    end

    has_and_belongs_to_many :vehicle_journeys, :class_name => 'Chouette::VehicleJourney'
    has_many :routes, -> { distinct }, through: :vehicle_journeys, :class_name => 'Chouette::Route'
    has_many :lines, -> { distinct }, through: :routes, :class_name => 'Chouette::Line'

    has_many :dates, -> {order(:date)}, inverse_of: :time_table, :validate => :true, :class_name => "Chouette::TimeTableDate", dependent: :destroy
    has_many :periods, -> {order(:period_start)}, inverse_of: :time_table, :validate => :true, :class_name => "Chouette::TimeTablePeriod", dependent: :destroy

    belongs_to :calendar
    belongs_to :created_from, class_name: 'Chouette::TimeTable'

    scope :overlapping, -> (period_range) do
      joins("
        LEFT JOIN time_table_periods ON time_tables.id = time_table_periods.time_table_id
        LEFT JOIN time_table_dates ON time_tables.id = time_table_dates.time_table_id
      ")
      .where("(time_table_periods.period_start <= :end AND time_table_periods.period_end >= :begin) OR (time_table_dates.date BETWEEN :begin AND :end)", {begin: period_range.begin, end: period_range.end})
    end

    scope :not_associated, -> {
      joins('LEFT JOIN "time_tables_vehicle_journeys" ON time_tables_vehicle_journeys.time_table_id = time_tables.id')
      .where("time_tables_vehicle_journeys.vehicle_journey_id is null")
    }

    scope :used, -> { joins(:vehicle_journeys).distinct }

    scope :empty, -> {
      left_joins(:periods, :dates).where(time_table_periods: {id: nil}, time_table_dates: {id: nil})
    }
    scope :without_periods, -> {
      left_joins(:periods).where(time_table_periods: {id: nil})
    }
    scope :non_empty, -> { where.not(id: empty) }

    scope :linked_to_lines, ->(lines) { joins(vehicle_journeys: :route).where('routes.line_id' => lines.map(&:id)) }
    scope :by_text, ->(text) { text.blank? ? all : where('unaccent(time_tables.comment) ILIKE :t or lower(time_tables.objectid) LIKE :t', t: "%#{text.downcase}%") }

    def self.scheduled_on(date)
      day_order = date.wday == 0 ? 8 : date.wday + 1
      query = <<~SQL
        (int_day_types >> :day_order & 1 = 1
          AND :date between period_start and period_end
          AND (
            time_table_dates.id is null OR
            (NOT (date = :date AND in_out = false))
          )
        ) OR (date = :date AND in_out = true)
      SQL

      left_joins(:periods, :dates).where(query, date: date, day_order: day_order)
    end

    def self.shared_by_several_lines?
      joins(:routes).group(:id).having("count(distinct(line_id)) > 1").exists?
    end

    after_save :save_shortcuts

    def local_id
      "local-#{self.referential.id}-#{self.id}"
    end

    def checksum_attributes(db_lookup = true)
      [].tap do |attrs|
        attrs << self.int_day_types
        dates = self.dates
        dates += TimeTableDate.where(time_table_id: self.id) if db_lookup && !new_record?
        attrs << dates.map(&:checksum).map(&:to_s).uniq.sort
        periods = self.periods
        periods += TimeTablePeriod.where(time_table_id: self.id) if db_lookup && !new_record?
        attrs << periods.map(&:checksum).map(&:to_s).uniq.sort
      end
    end

    has_checksum_children TimeTableDate
    has_checksum_children TimeTablePeriod

    def self.object_id_key
      "Timetable"
    end

    accepts_nested_attributes_for :dates, :allow_destroy => :true
    accepts_nested_attributes_for :periods, :allow_destroy => :true

    validates_presence_of :comment
    validates_associated :dates
    validates_associated :periods

    def self.applied_at_least_once_in(date_range)
      self.where(id: applied_at_least_once_in_ids(date_range))
    end

    def self.applied_at_least_once_in_ids(date_range)
      ids = Set.new
      date_range.each_slice(200) do |range|
        query =  <<-SQL
          WITH  dates AS (
            #{dates_subquery(range)}
          ), applicable_dates_subquery AS (
            #{applicable_dates_subquery}
          )
          #{self.select('DISTINCT(time_tables.id)').joins("INNER JOIN applicable_dates_subquery ON applicable_dates_subquery.time_table_id = time_tables.id").to_sql}
        SQL

        ids += ::ActiveRecord::Base.connection.execute(query).map{|r| r['id']}
      end
      ids.to_a
    end

    def self.dates_subquery(date_range)
      <<-SQL
      select CURRENT_DATE + i AS date
      from generate_series(#{(date_range.min - Time.now.to_date).to_i}, #{(date_range.max - Time.now.to_date).to_i}) i
      SQL
    end

    def self.applicable_dates_subquery
      <<-SQL
      SELECT dates.date, time_tables.id AS time_table_id
      FROM dates
        LEFT JOIN  \"#{Apartment::Tenant.current}\".time_tables ON 1=1
        LEFT JOIN  \"#{Apartment::Tenant.current}\"."time_table_dates" AS excluded_dates ON excluded_dates."time_table_id" = "time_tables"."id" AND excluded_dates.date = dates.date AND excluded_dates.in_out = false
        LEFT JOIN  \"#{Apartment::Tenant.current}\"."time_table_dates" AS included_dates ON included_dates."time_table_id" = "time_tables"."id" AND included_dates.date = dates.date AND included_dates.in_out = true
        LEFT JOIN  \"#{Apartment::Tenant.current}\"."time_table_periods" AS periods ON periods."time_table_id" = "time_tables"."id" AND periods.period_start <= dates.date AND periods.period_end >= dates.date
      WHERE
        (included_dates.id IS NOT NULL OR (periods.id IS NOT NULL AND (time_tables.int_day_types & POW(2, ((DATE_PART('dow', dates.date)::int+6)%7)+2)::int) > 0) AND excluded_dates.id IS NULL)
      GROUP BY dates.date, time_tables.id
      ORDER BY dates.date ASC
      SQL
    end

    # THIS WILL NEED SOME LATER OPTIM
    def self.clean!
      # Delete vehicle_journey time_table association
      ::ActiveRecord::Base.transaction do
        time_table_ids = pluck(:id)
        Chouette::TimeTablesVehicleJourney.where(time_table_id: time_table_ids).delete_all
        Chouette::TimeTableDate.joins(:time_table).where(time_tables: {id: time_table_ids}).delete_all
        Chouette::TimeTablePeriod.joins(:time_table).where(time_tables: {id: time_table_ids}).delete_all

        delete_all
      end
    end

    def continuous_dates
      in_days = self.dates.where(in_out: true).sort_by(&:date)
      chunk = {}
      group = nil
      in_days.each_with_index do |date, index|
        group ||= index
        group = (date.date == in_days[index - 1].date + 1.day) ? group : group + 1
        chunk[group] ||= []
        chunk[group] << date
      end
      # Remove less than 2 continuous day chunk
      chunk.values.delete_if {|dates| dates.count < 2}
    end

    def color
      _color = read_attribute(:color)
      _color.present? ? _color : nil
    end

    def convert_continuous_dates_to_periods
      chunks = self.continuous_dates

      transaction do
        chunks.each do |chunk|
          self.periods.create!(period_start: chunk.first.date, period_end: chunk.last.date)
          self.dates.delete(chunk)
        end
      end
    end

    def find_date_by_id id
      self.dates.find id
    end

    def destroy_date date
      date.destroy
    end

    def update_in_out date, in_out
      date.in_out = in_out
    end

    def find_period_by_id id
      self.periods.find(id)
    end

    def build_period
      self.periods.build
    end

    def delete_periods deleted_periods
      self.periods.delete(deleted_periods)
    end

    def self.state_permited_attributes item
      item.slice('comment', 'color').to_hash
    end

    def self.start_validity_period
      [Chouette::TimeTable.minimum(:start_date)].compact.min
    end
    def self.end_validity_period
      [Chouette::TimeTable.maximum(:end_date)].compact.max
    end

    def add_exclude_date(in_out, date)
      self.dates.create!({in_out: in_out, date: date})
    end

    def actualize
      self.dates.clear
      self.periods.clear
      from = self.calendar.convert_to_time_table
      self.dates   = from.dates
      self.periods = from.periods
      self.save
    end

    def save_shortcuts
      return if skip_save_shortcuts
      shortcuts_update
      return unless changes.key?(:start_date) || changes.key?(:end_date)

      self.update_columns start_date: start_date, end_date: end_date
    end

    # Update start_date/end_date of all TimeTables in the current scope
    def self.update_shortcuts
      current_scope = self.current_scope || all

      bouding_dates = current_scope.left_joins(:dates).left_joins(:periods).group('time_tables.id').select(
        'time_tables.id as id',
        'least(min(time_table_dates.date), min(time_table_periods.period_start)) as start_date',
        'greatest(max(time_table_dates.date), max(time_table_periods.period_end)) as end_date')

      update_query = <<-SQL
        WITH bounding_dates AS (#{bouding_dates.to_sql})
        UPDATE time_tables
        SET start_date = bounding_dates.start_date, end_date = bounding_dates.end_date
        FROM bounding_dates WHERE time_tables.id = bounding_dates.id
      SQL

      connection.execute update_query
    end

    # The period covered by the TimeTable (from the minimum to the maximum dates)
    def validity_period
      start_date..end_date if start_date && end_date
    end

    def shortcuts_update
      dates_array = bounding_dates

      if dates_array.empty?
        self.start_date=nil
        self.end_date=nil
      else
        self.start_date=dates_array.min
        self.end_date=dates_array.max
      end
    end

    def validity_out_from_on?(expected_date)
      return false unless self.end_date
      self.end_date <= expected_date
    end

    def validity_out_between?(starting_date, ending_date)
      return false unless self.start_date
      starting_date < self.end_date  &&
        self.end_date <= ending_date
    end
    def self.validity_out_from_on?(expected_date,limit=0)
      if limit==0
        Chouette::TimeTable.where("end_date <= ?", expected_date)
      else
        Chouette::TimeTable.where("end_date <= ?", expected_date).limit( limit)
      end
    end
    def self.validity_out_between?(start_date, end_date,limit=0)
      if limit==0
        Chouette::TimeTable.where( "? < end_date", start_date).where( "end_date <= ?", end_date)
      else
        Chouette::TimeTable.where( "? < end_date", start_date).where( "end_date <= ?", end_date).limit( limit)
      end
    end

    # Return days which intersects with the time table dates and periods
    def intersects(days)
      [].tap do |intersect_days|
        days.each do |day|
          intersect_days << day if include_day?(day)
        end
      end
    end

    def periods_max_date
      return nil if self.periods.empty?

      min_start = self.periods.map(&:period_start).compact.min
      max_end = self.periods.map(&:period_end).compact.max
      result = nil

      if max_end && min_start
        max_end.downto( min_start) do |date|
          if self.valid_days.include?(date.cwday) && !self.excluded_date?(date)
              result = date
              break
          end
        end
      end
      result
    end
    def periods_min_date
      return nil if self.periods.empty?

      min_start = self.periods.map(&:period_start).compact.min
      max_end = self.periods.map(&:period_end).compact.max
      result = nil

      if max_end && min_start
        min_start.upto(max_end) do |date|
          if self.valid_days.include?(date.cwday) && !self.excluded_date?(date)
              result = date
              break
          end
        end
      end
      result
    end
    def bounding_dates
      bounding_min = self.dates.select{|d| d.in_out}.map(&:date).compact.min
      bounding_max = self.dates.select{|d| d.in_out}.map(&:date).compact.max

      unless self.periods.empty?
        bounding_min = periods_min_date if periods_min_date &&
            (bounding_min.nil? || (periods_min_date < bounding_min))

        bounding_max = periods_max_date if periods_max_date &&
            (bounding_max.nil? || (bounding_max < periods_max_date))
      end
      [bounding_min, bounding_max].compact
    end

    def date_range
      bounding_min, bounding_max = bounding_dates
      if bounding_min && bounding_max
        Range.new(bounding_min, bounding_max)
      end
    end

    def effective_days_of_period(period,valid_days=self.valid_days)
      days = []
        period.period_start.upto(period.period_end) do |date|
          if valid_days.include?(date.cwday) && !self.excluded_date?(date)
              days << date
          end
        end
      days
    end

    def effective_days(valid_days=self.valid_days)
      days=self.effective_days_of_periods(valid_days)
      self.dates.each do |d|
        days |= [d.date] if d.in_out
      end
      days.sort
    end

    def effective_days_of_periods(valid_days=self.valid_days)
      days = []
      self.periods.each { |p| days |= self.effective_days_of_period(p,valid_days)}
      days.sort
    end

    def clone_periods
      periods = []
      self.periods.each { |p| periods << p.copy}
      periods.sort_by(&:period_start)
    end

    def included_days
      days = []
      self.dates.each do |d|
        days |= [d.date] if d.in_out
      end
      days.sort
    end

    def excluded_days
      days = []
      self.dates.each do |d|
        days |= [d.date] unless d.in_out
      end
      days.sort
    end

    def build_date in_out, date
      self.dates.build in_out: in_out, date: date
    end

    def saved_dates
      Hash[self.dates.collect{ |d| [d.id, d.date]}]
    end

    def all_dates
      dates
    end

    def full_display_name
      [get_objectid.short_id, comment].join(' - ')
    end

    def display_name
      full_display_name.truncate(40)
    end

    # produce a copy of periods without anyone overlapping or including another
    def optimize_overlapping_periods
      periods = self.clone_periods
      optimized = []
      i=0
      while i < periods.length
        p1 = periods[i]
        optimized << p1
        j= i+1
        while j < periods.length
          p2 = periods[j]
          if p1.contains? p2
            periods.delete p2
          elsif p1.overlap? p2
            p1.period_start = [p1.period_start,p2.period_start].min
            p1.period_end = [p1.period_end,p2.period_end].max
            periods.delete p2
          else
            j += 1
          end
        end
        i+= 1
      end
      optimized.sort { |a,b| a.period_start <=> b.period_start}
    end

    # add a peculiar day or switch it from excluded to included
    def add_included_day(d)
      if self.excluded_date?(d)
        self.dates.each do |date|
          if date.date === d
            date.in_out = true
          end
        end
      elsif !self.include_in_dates?(d)
        self.dates << Chouette::TimeTableDate.new(:date => d, :in_out => true)
      end
    end

    # merge effective days from another timetable
    def merge!(another_tt)
      transaction do
        days = [].tap do |array|
          array.push(*self.effective_days, *another_tt.effective_days)
          array.uniq!
        end

        self.dates.clear
        self.periods.clear

        days.each do |day|
          self.dates << Chouette::TimeTableDate.new(date: day, in_out: true)
        end
        self.save!
      end
      self.convert_continuous_dates_to_periods
    end

    def included_days_in_dates_and_periods
      in_day  = self.dates.select {|d| d.in_out }.map(&:date)
      out_day = self.dates.select {|d| !d.in_out }.map(&:date)

      in_periods = self.periods.map{|p| (p.period_start..p.period_end).to_a }.flatten
      days = in_periods + in_day
      days -= out_day
      days
    end

    # keep common dates with another_tt
    def intersect!(another_tt)
      transaction do
        days = [].tap do |array|
          array.push(*self.effective_days)
          array.delete_if {|day| !another_tt.effective_days.include?(day) }
          array.uniq!
        end

        self.dates.clear
        self.periods.clear

        days.sort.each do |d|
          self.dates << Chouette::TimeTableDate.new(:date => d, :in_out => true)
        end
        self.save!
      end
      self.convert_continuous_dates_to_periods
    end

    # remove common dates with another_tt
    def disjoin!(another_tt)
      transaction do
        days = [].tap do |array|
          array.push(*self.effective_days)
          array.delete_if {|day| another_tt.effective_days.include?(day) }
          array.uniq!
        end

        self.dates.clear
        self.periods.clear

        days.sort.each do |d|
          self.dates << Chouette::TimeTableDate.new(:date => d, :in_out => true)
        end
        self.save!
      end
      self.convert_continuous_dates_to_periods
    end

    def duplicate(tt_params = {})
      tt = self.deep_clone include: [:periods, :dates], except: [:object_version, :objectid]
      tt.created_from = self
      tt.comment      = tt_params[:comment].presence || I18n.t("activerecord.copy", :name => self.comment)
      tt
    end

    def intersect_periods!(mask_periods)
      apply to_timetable.limit(mask_periods).normalize!
    end

    def remove_periods!(removed_periods)
      deleted_dates = []
      dates.each do |date|
        if removed_periods.any? { |p| p.include? date.date }
           deleted_dates << date
        end
      end
      dates.delete deleted_dates

      deleted_periods = []
      periods.each do |period|
        modified_ranges = removed_periods.inject([period.range]) do |period_ranges, removed_period|
          period_ranges.map { |p| p.remove removed_period }.flatten
        end

        unless modified_ranges.empty?
          modified_ranges.each_with_index do |modified_range, index|
            if modified_range.min != modified_range.max
              new_period = index == 0 ? period : periods.build

              new_period.period_start, new_period.period_end =
                                       modified_range.min, modified_range.max
            else
              build_date_if_relevant modified_range.min
              deleted_periods << period if index == 0
            end
          end
        else
          deleted_periods << period
        end
      end
      periods.delete deleted_periods
    end

    def build_date_if_relevant date
      if applicable_date?(date) && !dates.any?{|d| d.date == date}
        dates.build in_out: true, date: date
      end
    end

    def empty?
      dates.empty? && periods.empty?
    end

    def to_timetable
      Cuckoo::Timetable.new.tap do |timetable|
        dates.each do |date|
          if date.in?
            timetable.included_dates << date.date
          else
            timetable.excluded_dates << date.date
          end
        end

        periods.each do |period|
          timetable.periods << period.to_timetable_period
        end
      end
    end

    def to_days_bit
      to_timetable.to_days_bit
    end

    def apply(timetable)
      Applier.new(self, timetable).apply
      self
    end

    class Applier

      attr_reader :time_table, :timetable

      def initialize(time_table, timetable)
        @time_table, @timetable = time_table, timetable
      end

      def apply
        apply_day_types
        apply_included_dates
        apply_excluded_dates
        apply_periods

        self
      end

      delegate :int_day_types=, :dates, :periods, to: :time_table

      def apply_day_types
        if (days_of_week = timetable.uniq_days_of_week)
          self.int_day_types = days_of_week.hash
        else
          raise ArgumentError
        end
      end

      def apply_included_dates
        included_dates = timetable.included_dates.to_a
        dates.select(&:in?).sort_by(&:date).each do |date|
          expected_date = included_dates.shift
          if expected_date
            date.date = expected_date
          else
            dates.delete date
          end
        end

        included_dates.each do |included_date|
          dates.build date: included_date, in_out: true
        end
      end

      def apply_excluded_dates
        excluded_dates = timetable.excluded_dates.to_a
        dates.select(&:out?).sort_by(&:date).each do |date|
          expected_date = excluded_dates.shift
          if expected_date
            date.date = expected_date
          else
            dates.delete date
          end
        end

        excluded_dates.each do |excluded_date|
          dates.build date: excluded_date, in_out: false
        end
      end

      def apply_periods
        expected_periods = timetable.periods.to_a
        periods.sort_by(&:period_start).each do |period|
          expected_period = expected_periods.shift
          if expected_period
            period.period_start = expected_period.first
            period.period_end = expected_period.last
          else
            periods.delete period
          end
        end

        expected_periods.each do |period|
          periods.build period_start: period.first, period_end: period.last
        end
      end

    end

  end
end