af83/chouette-core

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

Summary

Maintainability
F
3 days
Test Coverage
require 'geokit'
require 'geo_ruby'
module Chouette
  class StopArea < Chouette::ActiveRecord
    include StopAreaReferentialSupport

    has_metadata
    include ProjectionFields
    include ObjectidSupport
    include CustomFieldsSupport
    include CodeSupport
    include ReferentSupport
    include Documentable

    include PgSearch::Model

    extend Enumerize
    enumerize :area_type, in: Chouette::AreaType::ALL, default: Chouette::AreaType::COMMERCIAL.first, scope: true
    enumerize :status, in: %i[in_creation confirmed deactivated], default: :in_creation, scope: true
    enumerize :kind, in: %i(commercial non_commercial), default: :commercial
    enumerize :mobility_impaired_accessibility, in: %i(unknown yes no partial), default: :unknown
    enumerize :wheelchair_accessibility, in: %i(unknown yes no partial), default: :unknown
    enumerize :step_free_accessibility, in: %i(unknown yes no partial), default: :unknown
    enumerize :escalator_free_accessibility, in: %i(unknown yes no partial), default: :unknown
    enumerize :lift_free_accessibility, in: %i(unknown yes no partial), default: :unknown
    enumerize :audible_signals_availability, in: %i(unknown yes no partial), default: :unknown
    enumerize :visual_signs_availability, in: %i(unknown yes no partial), default: :unknown

    AVAILABLE_LOCALIZATIONS = %i(en_UK nl_NL de_DE fr_FR it_IT es_ES)

    # WARNING Only effective in the current Referential
    has_many :stop_points
    has_many :routes, through: :stop_points
    has_many :lines, -> { distinct }, through: :routes
    has_many :specific_vehicle_journey_at_stops, :class_name => 'Chouette::VehicleJourneyAtStop', :foreign_key => "stop_area_id"
    has_many :specific_vehicle_journeys, through: :specific_vehicle_journey_at_stops, class_name: 'Chouette::VehicleJourney', source: :vehicle_journey
    has_many :entrances, dependent: :delete_all
    has_many :macro_messages, as: :source, class_name: "::Macro::Message", foreign_key: :source_id

    has_many :stop_area_zones, class_name: 'Fare::StopAreaZone', dependent: :delete_all
    has_many :fare_zones, through: :stop_area_zones, source: :zone
    has_many :group_members, class_name: 'StopAreaGroup::Member', dependent: :destroy, inverse_of: :stop_area
    has_many :groups, through: :group_members, inverse_of: :stop_areas

    scope :light, ->{ select(:id, :name, :city_name, :zip_code, :time_zone, :registration_number, :kind, :area_type, :time_zone, :stop_area_referential_id, :objectid) }
    scope :with_time_zone, -> { where.not time_zone: nil }

    pg_search_scope :by_text,
                    against: { name: 'A', registration_number: 'B', objectid: 'B', city_name: 'C', zip_code: 'C', street_name: 'C' },
                    using: { tsearch: { normalization: 2, prefix: true } },
                    ignoring: :accents

    scope :without_compass_bearing, -> { where compass_bearing: nil }
    scope :with_compass_bearing, -> { where.not compass_bearing: nil }
    scope :without_address, -> { where country_code: nil,  street_name: nil, zip_code: nil, city_name: nil }
    scope :without_position, -> { where 'latitude is null or longitude is null' }
    scope :with_position, -> { where 'latitude is not null and longitude is not null' }

    # DEPRECATED Use without/with_positon
    scope :without_geometry, -> { without_position }
    scope :with_geometry, -> { with_position }

    # DEPRECATED. Use particulars relation
    def specific_stops
      particulars
    end

    acts_as_tree :foreign_key => 'parent_id', :order => "name"

    belongs_to_array_in_many :line_routing_constraint_zones, class_name: "LineRoutingConstraintZone", array_name: :stop_areas

    attribute :transport_mode, Chouette::TransportMode::Type.new

    attr_accessor :stop_area_type
    attr_accessor :children_ids
    attr_writer :coordinates

    after_update :journey_patterns_control_route_sections,
                if: Proc.new { |stop_area| ['boarding_position', 'quay'].include? stop_area.stop_area_type }

    validates_presence_of :name
    validates_presence_of :kind
    validates_presence_of :latitude, :if => :longitude
    validates_presence_of :longitude, :if => :latitude
    validates_numericality_of :latitude, :less_than_or_equal_to => 90, :greater_than_or_equal_to => -90, :allow_nil => true
    validates_numericality_of :longitude, :less_than_or_equal_to => 180, :greater_than_or_equal_to => -180, :allow_nil => true
    validates_numericality_of :compass_bearing, :less_than => 360, :greater_than_or_equal_to => 0, :allow_nil => true

    validates_format_of :coordinates, :with => %r{\A *-?(0?[0-9](\.[0-9]*)?|[0-8][0-9](\.[0-9]*)?|90(\.[0]*)?) *\, *-?(0?[0-9]?[0-9](\.[0-9]*)?|1[0-7][0-9](\.[0-9]*)?|180(\.[0]*)?) *\Z}, allow_nil: true, allow_blank: true

    validates_numericality_of :waiting_time, greater_than_or_equal_to: 0, only_integer: true, if: :waiting_time
    validates :time_zone, inclusion: { in: TZInfo::Timezone.all_identifiers }, allow_nil: true, allow_blank: true
    validate :parent_area_type_must_be_greater
    validate :parent_kind_must_be_the_same
    validate :area_type_of_right_kind
    validate :registration_number_is_set
    validates_absence_of :parent_id, message: I18n.t('stop_areas.errors.parent_id.must_be_absent'), if: Proc.new { |stop_area| stop_area.kind == 'non_commercial' }

    validates :registration_number, uniqueness: { scope: :stop_area_provider_id }, allow_blank: true

    before_validation do
      unless self.registration_number.present?
        self.registration_number = stop_area_referential&.generate_registration_number
      end
    end

    def self.nullable_attributes # rubocop:disable Metrics/MethodLength
      %i[
        accessibility_limitation_description
        city_name
        comment
        country_code
        long_lat_type
        nearest_topic_name
        postal_region
        public_code
        registration_number
        street_name
        time_zone
        url
        zip_code
      ]
    end

    def localized_names
      read_attribute(:localized_names) || {}
    end

    def parent_area_type_must_be_greater
      return unless self.parent && has_valid_area_type?

      parent_area_type = Chouette::AreaType.find(self.parent.area_type)
      if Chouette::AreaType.find(self.area_type) >= parent_area_type
        errors.add(:parent_id, I18n.t('stop_areas.errors.parent_area_type', area_type: parent_area_type.label))
      end
    end

    def parent_kind_must_be_the_same
      return unless self.parent

      unless kind == self.parent.kind
        errors.add(:parent_id, I18n.t('stop_areas.errors.parent_kind', kind: kind))
      end
    end

    def has_valid_area_type?
      kind.present? && area_type.present? && Chouette::AreaType.send(self.kind).map(&:to_s).include?(self.area_type)
    end

    def area_type_of_right_kind
      return unless self.kind
      unless has_valid_area_type?
        errors.add(:area_type, I18n.t('stop_areas.errors.incorrect_kind_area_type'))
      end
    end

    def registration_number_is_set
      return unless stop_area_referential&.registration_number_format.present?

      unless registration_number.present?
        errors.add(:registration_number, I18n.t('stop_areas.errors.registration_number.cannot_be_empty'))
      end

      unless stop_area_referential.validates_registration_number(registration_number)
        errors.add(:registration_number, I18n.t('stop_areas.errors.registration_number.invalid', mask: stop_area_referential.registration_number_format))
      end
    end

    before_save :coordinates_to_lat_lng

    def combine_lat_lng
      if self.latitude.nil? || self.longitude.nil?
        ""
      else
        self.latitude.to_s+","+self.longitude.to_s
      end
    end

    def coordinates
      @coordinates || combine_lat_lng
    end

    def coordinates_to_lat_lng
      return unless @coordinates

      if @coordinates.empty?
        self.latitude = self.longitude = nil
      else
        self.latitude, self.longitude = @coordinates.split(",").map(&:to_f)
      end
    end

    def full_name
      "#{name} #{zip_code} #{city_name} - #{local_id}"
    end

    def full_display_name
      [self.get_objectid.short_id, name, zip_code, city_name].compact.join(' - ')
    end

    def display_name
      full_display_name.truncate(50)
    end

    def local_id
      get_objectid.short_id
    end

    def children_in_depth
      return [] if self.children.empty?

      self.children + self.children.map do |child|
        child.children_in_depth
      end.flatten.compact
    end

    def possible_children
      case area_type
        when "BoardingPosition" then []
        when "Quay" then []
        when "CommercialStopPoint" then Chouette::StopArea.where(:area_type => ['Quay', 'BoardingPosition']) - [self]
        when "StopPlace" then Chouette::StopArea.where(:area_type => ['StopPlace', 'CommercialStopPoint']) - [self]
      end
    end

    def possible_parents
      case area_type
        when "BoardingPosition" then Chouette::StopArea.where(:area_type => "CommercialStopPoint")  - [self]
        when "Quay" then Chouette::StopArea.where(:area_type => "CommercialStopPoint") - [self]
        when "CommercialStopPoint" then Chouette::StopArea.where(:area_type => "StopPlace") - [self]
        when "StopPlace" then Chouette::StopArea.where(:area_type => "StopPlace") - [self]
      end
    end

    def geometry_presenter
      Chouette::Geometry::StopAreaPresenter.new self
    end

    def self.commercial
      where kind: "commercial"
    end

    def self.non_commercial
      where kind: "non_commercial"
    end

    def self.stop_place
      where :area_type => "StopPlace"
    end

    def self.physical
      where :area_type => [ "BoardingPosition", "Quay" ]
    end

    def quay?
      area_type&.to_sym == Chouette::AreaType::QUAY
    end

    def to_lat_lng
      Geokit::LatLng.new(latitude, longitude) if latitude and longitude
    end

    def candidate_transport_modes
      Chouette::TransportMode.modes
    end

    def closest_children
      return self.children if position.blank?

      parent_point = self.class.connection.quote("SRID=4326;POINT(#{longitude} #{latitude})")
      child_point   = "ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)"

      stop_area_referential.stop_areas.where(parent_id: self.id).order("distance").select('stop_areas.*', "ST_DistanceSphere(#{parent_point}, #{child_point}) as distance")
    end

    def closest_specific_stops
      return self.specific_stops if position.blank?

      parent_point = self.class.connection.quote("SRID=4326;POINT(#{longitude} #{latitude})")
      specific_point   = "ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)"

      stop_area_referential.stop_areas.where(referent_id: self.id).order("distance").select('stop_areas.*', "ST_DistanceSphere(#{parent_point}, #{specific_point}) as distance")
    end

    def geometry
      GeoRuby::SimpleFeatures::Point.from_lon_lat(longitude, latitude, 4326) if latitude and longitude
    end

    def geometry=(geometry)
      geometry = geometry.to_wgs84
      self.latitude, self.longitude, self.long_lat_type = geometry.lat, geometry.lng, "WGS84"
    end

    def position
      geometry
    end

    def position=(position)
      position = nil if String === position && position == ""
      position = Geokit::LatLng.normalize(position), 4326 if String === position
      if position
        self.latitude  = position.lat
        self.longitude = position.lng
      end
    end

    def default_position
      # for first StopArea ... the bounds is nil :(
      Chouette::StopArea.bounds ? Chouette::StopArea.bounds.center : nil # FIXME #821 stop_area_referential.envelope.center
    end

    def around(scope, distance)
      db   = "ST_GeomFromEWKB(ST_MakePoint(longitude, latitude, 4326))"
      from = "ST_GeomFromText('POINT(#{self.longitude} #{self.latitude})', 4326)"
      scope.where("ST_DWithin(#{db}, #{from}, ?, false)", distance)
    end

    def self.near(origin, distance = 0.3)
      origin = origin.to_lat_lng

      lat_degree_units = units_per_latitude_degree(:kms)
      lng_degree_units = units_per_longitude_degree(origin.lat, :kms)

      where "SQRT(POW(#{lat_degree_units}*(#{origin.lat}-latitude),2)+POW(#{lng_degree_units}*(#{origin.lng}-longitude),2)) <= #{distance}"
    end

    def self.bounds
      # Give something like :
      # [["113.5292500000000000", "22.1127580000000000", "113.5819330000000000", "22.2157050000000000"]]
      min_and_max = connection.select_rows("select min(longitude) as min_lon, min(latitude) as min_lat, max(longitude) as max_lon, max(latitude) as max_lat from #{table_name} where latitude is not null and longitude is not null").first
      return nil unless min_and_max

      # Ignore [nil, nil, nil, nil]
      min_and_max.compact!
      return nil unless min_and_max.size == 4

      min_and_max.collect! { |n| n.to_f }

      # We need something like :
      # [[113.5292500000000000, 22.1127580000000000], [113.5819330000000000, 22.2157050000000000]]
      coordinates = min_and_max.each_slice(2).to_a
      GeoRuby::SimpleFeatures::Envelope.from_coordinates coordinates
    end

    # DEPRECATED use StopArea#area_type
    def stop_area_type
      area_type ? area_type : " "
    end

    # DEPRECATED use StopArea#area_type
    def stop_area_type=(stop_area_type)
      self.area_type = (stop_area_type ? stop_area_type.camelcase : nil)
    end

    def children_ids=(children_ids)
      children = children_ids.split(',').uniq
      # remove unset children
      self.children.each do |child|
        if (! children.include? child.id)
          child.update_attribute :parent_id, nil
        end
      end
      # add new children
      Chouette::StopArea.find(children).each do |child|
        child.update_attribute :parent_id, self.id
      end
    end

    def self.default_geometry!
      count = 0
      where(nil).find_each do |stop_area|
        Chouette::StopArea.unscoped do
          count += 1 if stop_area.default_geometry!
        end
      end
      count
    end

    def default_geometry!
      new_geometry = default_geometry
      update_attribute :geometry, new_geometry if new_geometry
    end

    def default_geometry
      children_geometries = children.with_geometry.map(&:geometry).uniq
      GeoRuby::SimpleFeatures::Point.centroid children_geometries if children_geometries.present?
    end

    def children_at_base
      list = Array.new
      children_in_depth.each do |child|
        if child.area_type == 'Quay' || child.area_type == 'BoardingPosition'
          list << child
        end
      end
      list
    end

    def parents
      list = Array.new
      if !parent.nil?
        list << parent
        list += parent.parents
      end
      list
    end

    def duplicate
      sa = self.deep_clone :except => [:object_version, :parent_id, :registration_number]
      sa.uniq_objectid
      sa.name = I18n.t("activerecord.copy", :name => self.name)
      sa
    end

    def journey_patterns_control_route_sections
      if self.changed_attributes['latitude'] || self.changed_attributes['longitude']
        self.stop_points.each do |stop_point|
          stop_point.route.journey_patterns.completed.map{ |jp| jp.control! }
        end
      end
    end

    def self.ransackable_scopes(auth_object = nil)
      [:by_status]
    end

    def self.by_status(*statuses)
      return Chouette::StopArea.all if statuses.reject(&:blank?).length == 3 || statuses.reject(&:blank?).empty?

      status = {
        in_creation: statuses.include?('in_creation'),
        confirmed: statuses.include?('confirmed'),
        deactivated: statuses.include?('deactivated'),
      }

      query = []
      query << "deleted_at IS NOT NULL" if statuses.include?('deactivated')
      query << "(confirmed_at IS NULL AND deleted_at IS NULL)" if statuses.include?('in_creation')
      query << "(confirmed_at IS NOT NULL AND deleted_at IS NULL)" if statuses.include?('confirmed')

      Chouette::StopArea.where(query.join(' OR '))
    end

    def self.order_by_status(dir)
      states = ["confirmed_at #{dir}", "deleted_at #{dir}"]
      states.reverse! if dir == 'asc'
      order(*states)
    end

    def activated?
      !!(deleted_at.nil? && confirmed_at)
    end

    def deactivated?
      deleted_at.present?
    end

    def activate
      self.confirmed_at = Time.now
      self.deleted_at = nil
    end

    def deactivate
      self.deleted_at = Time.now
    end

    def activate!
      update_attribute :confirmed_at, Time.now
      update_attribute :deleted_at, nil
    end

    def deactivate!
      update_attribute :deleted_at, Time.now
    end

    def status
      return :deactivated if deleted_at
      return :confirmed if confirmed_at

      :in_creation
    end

    def status=(status)
      case status&.to_sym
      when :deactivated
        deactivate
      when :confirmed
        activate
      when :in_creation
        self.confirmed_at = self.deleted_at = nil
      end
    end

    def self.statuses
      %i{in_creation confirmed deactivated}
    end

    def time_zone_offset
      return 0 unless time_zone.present?
      ActiveSupport::TimeZone[time_zone]&.utc_offset
    end

    def full_time_zone_name
      return unless time_zone.present?
      return unless ActiveSupport::TimeZone[time_zone].present?
      ActiveSupport::TimeZone[time_zone].tzinfo.name
    end

    def country
      return unless country_code
      ISO3166::Country[country_code]
    end

    def country_name
      return unless country
      country.translations[I18n.locale.to_s] || country.name
    end

    def time_zone_formatted_offset
      return nil unless time_zone.present?
      ActiveSupport::TimeZone[time_zone]&.formatted_offset
    end

    def commercial?
      kind == "commercial"
    end

    def non_commercial?
      !commercial?
    end

    def connection_links
      Chouette::ConnectionLink.where('departure_id = :id or arrival_id = :id', id: self.id)
    end

    def self.union(relation1, relation2)
      union_query = "select id from ((#{relation1.select(:id).to_sql}) UNION (#{relation2.select(:id).to_sql})) stop_area_ids"
      where "stop_areas.id IN (#{union_query})"
    end

    # Find parents associated to the current Stop Areas
    #
    #   stop_areas.where(...).parent_stop_areas
    #
    # NB: Can be used with by_text scope
    def self.parent_stop_areas(scope: Chouette::StopArea)
      scope.where(id: select(:parent_id).distinct)
    end

    # NB: Can't be used with by_text scope because of distinct usage
    def self.all_parents(relation, ignore_mono_parent: false)
      stop_area_parents = joins('JOIN "public"."stop_areas" children on "public"."stop_areas"."id" = children.parent_id').where("children.id" => relation)

      if ignore_mono_parent
        stop_area_parents = stop_area_parents.group(:id).having('count(children.id) > 1')
      end

      stop_area_parents.distinct
    end

    def formatted_area_type
      area_type_label = I18n.t("area_types.label.#{area_type}")
      "<span class='small label label-info label-stoparea'>#{area_type_label}</span>"
    end

    def formatted_selection_details
      out = ""
      out << formatted_area_type if stop_area_referential.stops_selection_displayed_fields['formatted_area_type']
      extra = [name]
      %i(zip_code city_name postal_region country_name local_id).each do |f|
        extra << send(f) if stop_area_referential.stops_selection_displayed_fields[f.to_s]
      end
      out + extra.select(&:present?).join(' - ')
    end

    def address=(address)
      self.country_code = address.country_code
      self.street_name = address.house_number_and_street_name
      self.zip_code = address.post_code
      self.city_name = address.city_name
      self.postal_region = address.postal_region
    end

    class << self
      def compute_bearings(options={})
        query = <<~TEXT
          SELECT
            stop_area_id, AVG(bearing) AS avg_bearing
          FROM (
              SELECT DISTINCT
                stop_area_id,
                Degrees(
                  ST_Azimuth(
                    ST_LineInterpolatePoint(line, (projected_distance - shape_delta  + abs(projected_distance - shape_delta)) / 2),
                    ST_LineInterpolatePoint(line, ((projected_distance + shape_delta + 1) - abs(projected_distance + shape_delta - 1)) / 2))
                  ) AS bearing
              FROM (
                SELECT *,
                  10 / ST_LengthSpheroid(line, 'SPHEROID["WGS 84", 6378137, 298.257223563]') AS shape_delta,
                  ST_LineLocatePoint(line, point) AS projected_distance
                FROM (
                  SELECT DISTINCT
                    journey_patterns.id AS journey_pattern_id,
                    stop_areas.id AS stop_area_id,
                    shapes.id AS shape_id,
                    stop_areas.latitude AS latitude,
                    stop_areas.longitude AS longitude,
                    ST_SetSRID(ST_Point(stop_areas.longitude, stop_areas.latitude), 4326) AS point,
                    shapes.geometry AS line
                  FROM journey_patterns INNER JOIN public.shapes ON public.shapes.id = journey_patterns.shape_id
                  INNER JOIN journey_patterns_stop_points ON journey_patterns_stop_points.journey_pattern_id = journey_patterns.id
                  INNER JOIN stop_points ON stop_points.id = journey_patterns_stop_points.stop_point_id
                  INNER JOIN public.stop_areas ON public.stop_areas.id = stop_points.stop_area_id
                  WHERE stop_areas.id #{options[:stop_area]&.id.present? ? ["=", options[:stop_area].id].join : "IS NOT NULL"}
                  LIMIT #{options[:limit] || 1000}
                  OFFSET #{(options[:limit] || 1000) *  (options[:page] || 1) - (options[:limit] || 1000)}
                ) AS inputs
              ) AS azimuth_points
          ) AS raw_bearings
          GROUP BY stop_area_id
        TEXT
        ::ActiveRecord::Base.connection.execute(query)
      end

      def average_bearings(options={})
        options_ = options.dup
        options_[:limit] = 5000 unless options_[:limit]
        options_[:page] = 1 unless options_[:page]
        options_[:debug_mode] = false

        result = {}
        loop do
          avg_bearings = compute_bearings(options_)
          avg_bearings.each do |h|
            next unless h["avg_bearing"]
            result[h["stop_area_id"]] = result[h["stop_area_id"]].present? ? (result[h["stop_area_id"]] + h["avg_bearing"]) / 2.0 : h["avg_bearing"]
          end
          break if avg_bearings.count < options_[:limit]
          options_[:page] += 1
        end

        return result.each { |k,v| result[k] = v.round(1) }
      end
    end

    def accessibilities
      Accessibilities.new(
        mobility_impaired_accessibility: mobility_impaired_accessibility,
        wheelchair_accessibility: wheelchair_accessibility,
        step_free_accessibility: step_free_accessibility,
        escalator_free_accessibility: escalator_free_accessibility,
        lift_free_accessibility: lift_free_accessibility,
        audible_signals_availability: audible_signals_availability,
        visual_signs_availability: visual_signs_availability,
        accessibility_limitation_description: accessibility_limitation_description
      )
    end

    class Accessibilities
      def initialize(options={})
        options.each { |k, v| send "#{k}=", v }
      end
      attr_accessor :mobility_impaired_accessibility
      attr_accessor :wheelchair_accessibility
      attr_accessor :step_free_accessibility
      attr_accessor :escalator_free_accessibility
      attr_accessor :lift_free_accessibility
      attr_accessor :audible_signals_availability
      attr_accessor :visual_signs_availability
      attr_accessor :accessibility_limitation_description
    end

    def candidate_fare_zones
      stop_area_referential.fare_zones
    end
  end
end