BathHacked/energy-sparks

View on GitHub
app/models/school.rb

Summary

Maintainability
D
2 days
Test Coverage
A
92%
# == Schema Information
#
# Table name: schools
#
#  activation_date                                     :date
#  active                                              :boolean          default(TRUE)
#  address                                             :text
#  alternative_heating_air_source_heat_pump            :boolean          default(FALSE), not null
#  alternative_heating_air_source_heat_pump_notes      :text
#  alternative_heating_air_source_heat_pump_percent    :integer          default(0)
#  alternative_heating_biomass                         :boolean          default(FALSE), not null
#  alternative_heating_biomass_notes                   :text
#  alternative_heating_biomass_percent                 :integer          default(0)
#  alternative_heating_district_heating                :boolean          default(FALSE), not null
#  alternative_heating_district_heating_notes          :text
#  alternative_heating_district_heating_percent        :integer          default(0)
#  alternative_heating_ground_source_heat_pump         :boolean          default(FALSE), not null
#  alternative_heating_ground_source_heat_pump_notes   :text
#  alternative_heating_ground_source_heat_pump_percent :integer          default(0)
#  alternative_heating_lpg                             :boolean          default(FALSE), not null
#  alternative_heating_lpg_notes                       :text
#  alternative_heating_lpg_percent                     :integer          default(0)
#  alternative_heating_oil                             :boolean          default(FALSE), not null
#  alternative_heating_oil_notes                       :text
#  alternative_heating_oil_percent                     :integer          default(0)
#  bill_requested                                      :boolean          default(FALSE)
#  bill_requested_at                                   :datetime
#  calendar_id                                         :bigint(8)
#  chart_preference                                    :integer          default("default"), not null
#  cooks_dinners_for_other_schools                     :boolean          default(FALSE), not null
#  cooks_dinners_for_other_schools_count               :integer
#  cooks_dinners_onsite                                :boolean          default(FALSE), not null
#  country                                             :integer          default("england"), not null
#  created_at                                          :datetime         not null
#  dark_sky_area_id                                    :bigint(8)
#  data_enabled                                        :boolean          default(FALSE)
#  enable_targets_feature                              :boolean          default(TRUE)
#  floor_area                                          :decimal(, )
#  funder_id                                           :bigint(8)
#  funding_status                                      :integer          default("state_school"), not null
#  has_swimming_pool                                   :boolean          default(FALSE), not null
#  id                                                  :bigint(8)        not null, primary key
#  indicated_has_solar_panels                          :boolean          default(FALSE), not null
#  indicated_has_storage_heaters                       :boolean          default(FALSE)
#  latitude                                            :decimal(10, 6)
#  level                                               :integer          default(0)
#  local_authority_area_id                             :bigint(8)
#  longitude                                           :decimal(10, 6)
#  met_office_area_id                                  :bigint(8)
#  name                                                :string
#  number_of_pupils                                    :integer
#  percentage_free_school_meals                        :integer
#  postcode                                            :string
#  process_data                                        :boolean          default(FALSE)
#  public                                              :boolean          default(TRUE)
#  region                                              :integer
#  removal_date                                        :date
#  school_group_cluster_id                             :bigint(8)
#  school_group_id                                     :bigint(8)
#  school_type                                         :integer          not null
#  scoreboard_id                                       :bigint(8)
#  serves_dinners                                      :boolean          default(FALSE), not null
#  slug                                                :string
#  solar_pv_tuos_area_id                               :bigint(8)
#  temperature_area_id                                 :bigint(8)
#  template_calendar_id                                :integer
#  updated_at                                          :datetime         not null
#  urn                                                 :integer          not null
#  validation_cache_key                                :string           default("initial")
#  visible                                             :boolean          default(FALSE)
#  weather_station_id                                  :bigint(8)
#  website                                             :string
#
# Indexes
#
#  index_schools_on_calendar_id              (calendar_id)
#  index_schools_on_latitude_and_longitude   (latitude,longitude)
#  index_schools_on_local_authority_area_id  (local_authority_area_id)
#  index_schools_on_school_group_cluster_id  (school_group_cluster_id)
#  index_schools_on_school_group_id          (school_group_id)
#  index_schools_on_scoreboard_id            (scoreboard_id)
#  index_schools_on_urn                      (urn) UNIQUE
#
# Foreign Keys
#
#  fk_rails_...  (calendar_id => calendars.id) ON DELETE => restrict
#  fk_rails_...  (school_group_cluster_id => school_group_clusters.id) ON DELETE => nullify
#  fk_rails_...  (school_group_id => school_groups.id) ON DELETE => restrict
#  fk_rails_...  (scoreboard_id => scoreboards.id) ON DELETE => nullify
#

require 'securerandom'

class School < ApplicationRecord
  extend FriendlyId
  include EnergyTariffHolder
  include ParentMeterAttributeHolder

  class ProcessDataError < StandardError; end

  friendly_id :slug_candidates, use: [:finders, :slugged, :history]

  delegate :holiday_approaching?, :next_holiday, to: :calendar

  has_and_belongs_to_many :key_stages, join_table: :school_key_stages

  has_many :users
  has_many :meters,               inverse_of: :school
  has_many :cads,                 inverse_of: :school
  has_many :school_times,         inverse_of: :school
  has_many :activities,           inverse_of: :school
  has_many :activity_types, through: :activities

  has_many :contacts,             inverse_of: :school
  has_many :observations,         inverse_of: :school
  has_many :intervention_types, through: :observations

  has_many :transport_surveys,    inverse_of: :school
  has_many :consent_documents,    inverse_of: :school
  has_many :meter_attributes,     inverse_of: :school, class_name: 'SchoolMeterAttribute'
  has_many :consent_grants,       inverse_of: :school
  has_many :meter_reviews,        inverse_of: :school
  has_many :school_targets,       inverse_of: :school
  has_many :school_target_events, inverse_of: :school
  has_many :audits,               inverse_of: :school

  has_many :audit_activity_types, -> { distinct }, through: :audits, source: :activity_types
  has_many :audit_intervention_types, -> { distinct }, through: :audits, source: :intervention_types

  has_many :programmes,               inverse_of: :school
  has_many :programme_types, through: :programmes
  has_many :programme_activity_types, through: :programmes, source: :activity_types

  has_many :alerts,                                   inverse_of: :school
  has_many :content_generation_runs,                  inverse_of: :school
  has_many :alert_generation_runs,                    inverse_of: :school
  has_many :subscription_generation_runs,             inverse_of: :school
  has_many :benchmark_result_school_generation_runs,  inverse_of: :school
  has_many :analysis_pages, through: :content_generation_runs

  has_many :low_carbon_hub_installations, inverse_of: :school
  has_many :solar_edge_installations, inverse_of: :school
  has_many :rtone_variant_installations, inverse_of: :school

  has_many :equivalences

  has_many :locations

  has_one :dashboard_message, as: :messageable, dependent: :destroy
  has_many :issues, as: :issueable, dependent: :destroy

  has_many :estimated_annual_consumptions

  has_many :amr_data_feed_readings,       through: :meters
  has_many :amr_validated_readings,       through: :meters
  has_many :alert_subscription_events,    through: :contacts

  has_many :school_alert_type_exclusions, dependent: :destroy
  has_many :school_batch_runs

  has_many :advice_page_school_benchmarks

  belongs_to :calendar, optional: true
  belongs_to :template_calendar, optional: true, class_name: 'Calendar'
  belongs_to :school_group_cluster, optional: true

  belongs_to :solar_pv_tuos_area, optional: true
  belongs_to :dark_sky_area, optional: true
  belongs_to :weather_station, optional: true

  belongs_to :school_group, optional: true
  delegate :default_issues_admin_user, to: :school_group

  belongs_to :scoreboard, optional: true
  belongs_to :local_authority_area, optional: true

  belongs_to :funder, optional: true

  has_one :school_onboarding
  has_one :configuration, class_name: 'Schools::Configuration'

  has_and_belongs_to_many :cluster_users, class_name: 'User', join_table: :cluster_schools_users

  has_many :school_partners, -> { order(position: :asc) }
  has_many :partners, through: :school_partners
  accepts_nested_attributes_for :school_partners, reject_if: proc {|attributes| attributes['position'].blank?}

  enum school_type: [:primary, :secondary, :special, :infant, :junior, :middle, :mixed_primary_and_secondary]

  enum chart_preference: [:default, :carbon, :usage, :cost]
  enum country: [:england, :scotland, :wales]
  enum funding_status: [:state_school, :private_school]
  enum region: [:north_east, :north_west, :yorkshire_and_the_humber, :east_midlands, :west_midlands, :east_of_england, :london, :south_east, :south_west]

  # active flag is a soft-delete, those with a removal date are deleted, others
  # are archived, with chance of returning if we receive funding
  scope :active,              -> { where(active: true) }
  scope :inactive,            -> { where(active: false) }
  scope :deleted,             -> { inactive.where.not(removal_date: nil) }
  scope :archived,            -> { inactive.where(removal_date: nil) }
  scope :visible,             -> { active.where(visible: true) }
  scope :not_visible,         -> { active.where(visible: false) }
  scope :process_data,        -> { active.where(process_data: true) }
  scope :data_enabled,        -> { active.where(data_enabled: true) }
  scope :without_group,       -> { active.where(school_group_id: nil) }
  scope :without_scoreboard,  -> { active.where(scoreboard_id: nil) }
  scope :awaiting_activation, -> { active.where('visible = ? or data_enabled = ?', false, false) }
  scope :data_visible,        -> { data_enabled.visible }

  scope :with_config, -> { joins(:configuration) }
  scope :by_name,     -> { order(name: :asc) }

  scope :not_in_cluster, -> { where(school_group_cluster_id: nil) }

  scope :with_energy_tariffs, -> { joins("INNER JOIN energy_tariffs ON energy_tariffs.tariff_holder_id = schools.id AND tariff_holder_type = 'School'").group('schools.id').order('schools.name') }
  scope :with_community_use, -> { where(id: SchoolTime.community_use.select(:school_id))}

  # includes creating a target, recording activities and actions, having an audit, starting a programme, recording temperatures
  scope :with_recent_engagement, -> { where(id: Observation.engagement.recorded_since(AcademicYear.current.start_date).select(:school_id)) }

  # have recently run a transport survey
  scope :with_transport_survey, -> { where(id: TransportSurvey.recently_added(AcademicYear.current.start_date).select(:school_id))}

  # have recently started a programme that isn't the default programme
  scope :joined_programme, -> { where(id: Programme.recently_started_non_default(AcademicYear.current.start_date).select(:school_id))}

  # TODO: cluster users, not just those directly linked
  scope :with_recently_logged_in_users, -> { where(id: User.recently_logged_in(AcademicYear.current.start_date).select(:school_id))}

  # combination of other scopes to define an engaged school
  scope :engaged, -> { active.with_recent_engagement.or(with_recently_logged_in_users).or(with_transport_survey).or(joined_programme) }

  # schools that are not linked to a funder either directly or via their school group
  scope :unfunded, -> { joins(:school_group).where('schools.funder_id is null AND school_groups.funder_id is null')}

  validates_presence_of :urn, :name, :address, :postcode, :website, :school_type
  validates_uniqueness_of :urn
  validates :floor_area, :number_of_pupils, :cooks_dinners_for_other_schools_count, numericality: { greater_than: 0, allow_blank: true }
  validates :cooks_dinners_for_other_schools_count, presence: true, if: :cooks_dinners_for_other_schools?
  validates :country, inclusion: { in: countries }
  validates :funding_status, inclusion: { in: funding_statuses }

  validates :percentage_free_school_meals, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100, allow_blank: true }
  # simplified pattern from: https://stackoverflow.com/questions/164979/regex-for-matching-uk-postcodes
  # adjusted to use \A and \z
  validates :postcode, format: { with: /\A[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}\z/i }
  validate :valid_uk_postcode, if: ->(school) { school.postcode.present? && school.postcode_changed? }

  validates_associated :school_times, on: :school_time_update

  validates :alternative_heating_oil_percent,
            :alternative_heating_lpg_percent,
            :alternative_heating_biomass_percent,
            :alternative_heating_district_heating_percent,
            :alternative_heating_ground_source_heat_pump_percent,
            :alternative_heating_air_source_heat_pump_percent,
            numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100, allow_blank: true }

  validates :weather_station, presence: true

  accepts_nested_attributes_for :school_times, reject_if: proc {|attributes| attributes['day'].blank? }, allow_destroy: true

  auto_strip_attributes :name, :website, :postcode, squish: true

  before_validation :geocode, if: ->(school) { school.postcode.present? && school.postcode_changed? }

  geocoded_by :postcode do |school, results|
    if (geo = results.first)
      school.latitude = geo.data['latitude']
      school.longitude = geo.data['longitude']
      school.country = geo.data['country']&.downcase
    end
  end

  def deleted?
    not_active? and removal_date.present?
  end

  def archived?
    not_active? && removal_date.nil?
  end

  def not_active?
    !active
  end

  def minimum_reading_date
    return unless amr_validated_readings.present?

    # Ideally, we'd also use the minimum amr_data_feed_readings reading_date here, however, those reading dates are
    # currently stored as strings (and in an inconsistent date format as defined in the associated meter's amr data feed
    # config) so we instead use the minimum validated reading date minus 1 year.
    amr_validated_readings.minimum(:reading_date) - 1.year
  end

  def full_location_to_s
    return '' unless postcode.present?

    "#{postcode} (#{longitude}, #{latitude})"
  end

  def find_user_or_cluster_user_by_id(id)
    users.find_by_id(id) || cluster_users.find_by_id(id)
  end

  def latest_alert_run
    alert_generation_runs.order(created_at: :desc).first
  end

  def latest_alerts_without_exclusions
    if latest_alert_run
      latest_alert_run.alerts.without_exclusions
    else
      Alert.none
    end
  end

  def should_generate_new_friendly_id?
    slug.blank? || name_changed? || postcode_changed?
  end

  # Prevent the generated urls from becoming too long
  def normalize_friendly_id(string)
    super[0..59]
  end

  # Try building a slug based on the following fields in increasing order of specificity.
  def slug_candidates
    [
      :name,
      [:postcode, :name],
      [:urn, :name]
    ]
  end

  def current_academic_year
    academic_year_for(Time.zone.today)
  end

  def academic_year_for(date)
    return nil unless calendar.present?
    calendar.academic_year_for(date)
  end

  def activity_types_in_academic_year(date = Time.zone.now)
    activity_types.merge(activities.in_academic_year_for(self, date).by_date(:desc)).uniq # first occurance is kept when using uniq
  end

  def intervention_types_in_academic_year(date = Time.zone.now)
    intervention_types.merge(observations.in_academic_year_for(self, date).by_date(:desc)).uniq # first occurance is kept when using uniq
  end

  def suggested_programme_types
    ProgrammeType.active.with_school_activity_count(self)
      .merge(activities.in_academic_year(current_academic_year))
      .not_in(programme_types)
  end

  def national_calendar
    calendar.based_on.based_on
  end

  def area_name
    school_group.name if school_group
  end

  def active_meters
    meters.where(active: true)
  end

  def meters_with_readings(supply = Meter.meter_types.keys)
    active_meters.joins(:amr_data_feed_readings).where(meter_type: supply).where.not(amr_data_feed_readings: { meter_id: nil }).distinct
  end

  def meters_with_validated_readings(supply = Meter.meter_types.keys)
    active_meters.joins(:amr_validated_readings).where(meter_type: supply).where.not(amr_validated_readings: { meter_id: nil }).distinct
  end

  def fuel_types
    if configuration.dual_fuel
      :electric_and_gas
    elsif configuration.has_electricity
      :electric_only
    elsif configuration.has_gas
      :gas_only
    else
      :none
    end
  end

  def has_gas?
    configuration.has_gas
  end

  def has_electricity?
    configuration.has_electricity
  end

  def analysis?
    configuration && configuration.analysis_charts.present?
  end

  def fuel_types_for_analysis
    configuration.fuel_types_for_analysis
  end

  def has_solar_pv?
    configuration.has_solar_pv
  end

  def has_storage_heaters?
    configuration.has_storage_heaters
  end

  def has_live_data?
    cads.active.any?
  end

  def school_admin
    users.school_admin
  end

  def staff
    users.staff
  end

  def all_school_admins
    school_admin + cluster_users
  end

  def all_adult_school_users
    (all_school_admins + staff).uniq
  end

  def activation_users
    users = []
    if school_onboarding && school_onboarding.created_user.present?
      users << school_onboarding.created_user
    end
    # also email admin, staff and group users
    users += all_adult_school_users.to_a
    users.uniq
  end

  def latest_content
    content_generation_runs.order(created_at: :desc).first
  end

  def latest_dashboard_alerts
    if latest_content
      latest_content.dashboard_alerts
    else
      DashboardAlert.none
    end
  end

  def authenticate_pupil(pupil_password)
    users.pupil.to_a.find {|user| user.pupil_password.casecmp?(pupil_password) }
  end

  def filterable_meters
    if has_solar_pv? || has_storage_heaters?
      Meter.none
    else
      active_meters.real
    end
  end

  def latest_management_priorities
    if latest_content
      latest_content.management_priorities
    else
      ManagementPriority.none
    end
  end

  def latest_analysis_pages
    if latest_content
      latest_content.analysis_pages
    else
      AnalysisPage.none
    end
  end

  def latest_management_dashboard_tables
    if latest_content
      latest_content.management_dashboard_tables
    else
      ManagementDashboardTable.none
    end
  end

  def latest_find_out_mores
    if latest_content
      latest_content.find_out_mores
    else
      FindOutMore.none
    end
  end

  def has_target?
    school_targets.any?
  end

  def has_current_target?
    current_target.present?
  end

  def current_target
    school_targets.by_start_date.detect(&:current?)
  end

  def most_recent_target
    school_targets.by_start_date.first
  end

  def expired_target
    school_targets.by_start_date.expired.first
  end

  def previous_expired_target(current_expired)
    idx = school_targets.by_start_date.expired.index { |t| t == current_expired } || return
    school_targets.by_start_date.expired[idx + 1]
  end

  def has_expired_target_for_fuel_type?(fuel_type)
    has_expired_target? && expired_target.try(fuel_type).present?
  end

  def has_expired_target?
    expired_target.present?
  end

  def has_school_target_event?(event_name)
    school_target_events.where(event: event_name).any?
  end

  def has_school_onboarding_event?(event_name)
    school_onboarding && school_onboarding.has_event?(event_name)
  end

  def suggest_annual_estimate?
    estimated_annual_consumptions.any? || configuration.suggest_annual_estimate?
  end

  def school_target_attributes
    # use the current target if we have one, otherwise the most current target
    # based on start date. So if target as expired, then progress pages still work
    if has_current_target?
      current_target.meter_attributes_by_meter_type
    elsif most_recent_target.present?
      most_recent_target.meter_attributes_by_meter_type
    else
      {}
    end
  end

  def latest_annual_estimate
    estimated_annual_consumptions.order(created_at: :desc).first
  end

  def estimated_annual_consumption_meter_attributes
    latest_annual_estimate.nil? ? {} : latest_annual_estimate.meter_attributes_by_meter_type
  end

  def school_group_pseudo_meter_attributes
    school_group ? school_group.pseudo_meter_attributes : {}
  end

  def global_pseudo_meter_attributes
    GlobalMeterAttribute.pseudo
  end

  def all_pseudo_meter_attributes
    all_attributes = [school_group_pseudo_meter_attributes, pseudo_meter_attributes, school_target_attributes].inject(global_pseudo_meter_attributes) do |collection, pseudo_attributes|
      pseudo_attributes.each do |meter_type, attributes|
        collection[meter_type] ||= []
        collection[meter_type] = collection[meter_type] + attributes
      end
      collection
    end

    all_attributes[:aggregated_electricity] ||= []
    all_attributes[:aggregated_electricity] += all_energy_tariff_attributes(:electricity)

    all_attributes[:aggregated_gas] ||= []
    all_attributes[:aggregated_gas] += all_energy_tariff_attributes(:gas)

    all_attributes[:solar_pv_consumed_sub_meter] ||= []
    all_attributes[:solar_pv_consumed_sub_meter] += all_energy_tariff_attributes(:solar_pv)

    all_attributes[:solar_pv_exported_sub_meter] ||= []
    all_attributes[:solar_pv_exported_sub_meter] += all_energy_tariff_attributes(:exported_solar_pv)

    all_attributes
  end

  def pseudo_meter_attributes_to_analytics
    all_pseudo_meter_attributes.inject({}) do |collection, (meter_type, attributes)|
      collection[meter_type.to_sym] = MeterAttribute.to_analytics(attributes)
      collection
    end
  end

  def meter_attributes_to_analytics
    meters.order(:mpan_mprn).inject({}) do |collection, meter|
      collection[meter.mpan_mprn] = meter.meter_attributes_to_analytics
      collection
    end
  end

  def invalidate_cache_key
    update_attribute(:validation_cache_key, SecureRandom.uuid)
  end

  def process_data!
    raise ProcessDataError, "#{name} cannot process data as it has no meter readings" if meters_with_readings.empty?
    raise ProcessDataError, "#{name} cannot process data as it has no floor area" if floor_area.blank?
    raise ProcessDataError, "#{name} cannot process data as it has no pupil numbers" if number_of_pupils.blank?
    update!(process_data: true)
  end

  def update_school_partner_positions!(position_attributes)
    transaction do
      school_partners.destroy_all
      update!(school_partners_attributes: position_attributes)
    end
  end

  def all_partners
    all_partners = partners
    all_partners += school_group.partners if school_group.present?
    all_partners
  end

  def consent_up_to_date?
    consent_grants.any? && consent_grants.by_date.first.consent_statement.current
  end

  def school_times_to_analytics
    school_times.school_day.map(&:to_analytics)
  end

  def community_use_times_to_analytics
    school_times.community_use.map(&:to_analytics)
  end

  def self.status_counts
    { active: self.visible.count, data_visible: self.data_visible.count, invisible: self.not_visible.count, removed: self.inactive.count }
  end

  def email_locales
    country == 'wales' ? [:en, :cy] : [:en]
  end

  def subscription_frequency
    if holiday_approaching?
      [:weekly, :termly, :before_each_holiday]
    else
      [:weekly]
    end
  end

  def recent_usage
    Schools::ManagementTableService.new(self)&.management_data&.by_fuel_type_table || OpenStruct.new
  end

  def all_data_sources(meter_type)
    meters.active.where(meter_type: meter_type).data_source_known.joins(:data_source).order('data_sources.name ASC').distinct.pluck('data_sources.name')
  end

  def all_procurement_routes(meter_type)
    meters.active.where(meter_type: meter_type).procurement_route_known.joins(:procurement_route).order('procurement_routes.organisation_name ASC').distinct.pluck('procurement_routes.organisation_name')
  end

  def school_group_cluster_name
    school_group_cluster.try(:name) || I18n.t('common.labels.not_set')
  end

  def parent_tariff_holder
    school_group.present? ? school_group : SiteSettings.current
  end

  def energy_tariff_meter_attributes(meter_type = EnergyTariff.meter_types.keys, applies_to = :both)
    raise InvalidAppliesToError unless EnergyTariff.applies_tos.key?(applies_to.to_s)

    applies_to_keys = [:both, applies_to].uniq

    energy_tariffs.where(meter_type: meter_type).left_joins(:meters).where(meters: { id: nil }, applies_to: applies_to_keys).usable.map(&:meter_attribute)
  end

  def holds_tariffs_of_type?(meter_type)
    Meter::MAIN_METER_TYPES.include?(meter_type.to_sym) && meters.where(meter_type: meter_type).any?
  end

  private

  def valid_uk_postcode
    return unless latitude.blank? || longitude.blank? || country.blank?

    errors.add(:postcode, I18n.t('schools.school_details.geocode_not_found_message'))
  end
end