mysociety/alaveteli

View on GitHub
app/models/public_body.rb

Summary

Maintainability
F
5 days
Test Coverage
# == Schema Information
# Schema version: 20231011091031
#
# Table name: public_bodies
#
#  id                                     :integer          not null, primary key
#  version                                :integer          not null
#  last_edit_editor                       :string           not null
#  last_edit_comment                      :text
#  created_at                             :datetime         not null
#  updated_at                             :datetime         not null
#  home_page                              :text
#  api_key                                :string           not null
#  info_requests_count                    :integer          default(0), not null
#  info_requests_successful_count         :integer
#  info_requests_not_held_count           :integer
#  info_requests_overdue_count            :integer
#  info_requests_visible_classified_count :integer
#  info_requests_visible_count            :integer          default(0), not null
#  name                                   :text
#  short_name                             :text
#  request_email                          :text
#  url_name                               :text
#  first_letter                           :string
#  publication_scheme                     :text
#  disclosure_log                         :text
#

require 'csv'
require 'securerandom'
require 'set'
require 'confidence_intervals'

class PublicBody < ApplicationRecord
  include CalculatedHomePage
  include Categorisable
  include Taggable
  include Notable
  include Rails.application.routes.url_helpers
  include LinkToHelper

  class ImportCSVDryRun < StandardError; end

  admin_columns exclude: %i[name last_edit_editor]

  def self.admin_title
    'Authority'
  end

  attr_accessor :no_xapian_reindex

  # Default fields available for importing from CSV, in the format
  # [field_name, 'short description of field (basic html allowed)']
  cattr_accessor :csv_import_fields do
    [
      ['name', '(i18n)<strong>Existing records cannot be renamed</strong>'],
      ['short_name', '(i18n)'],
      ['request_email', '(i18n)'],
      ['publication_scheme', '(i18n)'],
      ['disclosure_log', '(i18n)'],
      ['home_page', ''],
      ['tag_string', '(tags separated by spaces)']
    ]
  end

  # Set to 0 to prevent application of the not_many_requests tag
  cattr_accessor :not_many_public_requests_size, default: 5

  has_many :info_requests,
           -> { order(created_at: :desc) },
           inverse_of: :public_body
  has_many :track_things,
           -> { order(created_at: :desc) },
           inverse_of: :public_body,
           dependent: :destroy
  has_many :censor_rules,
           -> { order(created_at: :desc) },
           inverse_of: :public_body,
           dependent: :destroy
  has_many :track_things_sent_emails,
           -> { order(created_at: :desc) },
           inverse_of: :public_body,
           dependent: :destroy
  has_many :public_body_change_requests,
           -> { order(created_at: :desc) },
           inverse_of: :public_body,
           dependent: :destroy
  has_many :draft_info_requests,
           -> { order(created_at: :desc) },
           inverse_of: :public_body

  has_and_belongs_to_many :info_request_batches,
                          inverse_of: :public_bodies
  has_and_belongs_to_many :draft_info_request_batches,
                          class_name: 'AlaveteliPro::DraftInfoRequestBatch',
                          inverse_of: :public_bodies

  validates_presence_of :name, message: N_("Name can't be blank")
  validates_presence_of :url_name, message: N_("URL name can't be blank")
  validates_presence_of :last_edit_editor,
                        message: N_("Last edit editor can't be blank")

  validates :request_email,
            not_nil: { message: N_("Request email can't be nil") }

  validates_uniqueness_of :short_name,
                          message: N_("Short name is already taken"),
                          allow_blank: true
  validates_uniqueness_of :url_name, message: N_("URL name is already taken")
  validates_uniqueness_of :name, message: N_("Name is already taken")

  validates :last_edit_editor,
            length: { maximum: 255,
                      too_long: N_("Last edit editor can't be longer than " \
                                   "255 characters") }

  validate :request_email_if_requestable

  before_save :set_api_key!, unless: :api_key

  after_save :update_auto_applied_tags

  after_update :reindex_requested_from, :invalidate_cached_pages,
               unless: :no_xapian_reindex

  # Every public body except for the internal admin one is visible
  scope :visible, -> { where("public_bodies.id <> #{ PublicBody.internal_admin_body.id }") }

  acts_as_versioned
  acts_as_xapian texts: [:name, :short_name, :notes_as_string],
                 values: [
                   # for sorting
                   [:created_at_numeric, 1, "created_at", :number]
                 ],
                 terms: [
                   [:name_for_search, 'N', 'name'],
                   [:variety, 'V', "variety"],
                   [:tag_array_for_search, 'U', "tag"]
                 ],
                 eager_load: [:translations]

  strip_attributes allow_empty: false, except: %i[request_email]
  strip_attributes allow_empty: true, only: %i[request_email]

  translates :name, :short_name, :request_email, :url_name, :first_letter,
             :publication_scheme, :disclosure_log

  # Cannot be grouped at top as it depends on the `translates` macro
  include Translatable

  # Cannot be grouped at top as it depends on the `translates` macro
  include PublicBodyDerivedFields

  # Cannot be grouped at top as it depends on the `translates` macro
  class Translation
    include PublicBodyDerivedFields
    strip_attributes allow_empty: false, except: %i[request_email]
    strip_attributes allow_empty: true, only: %i[request_email]
  end

  non_versioned_columns << 'created_at' << 'updated_at' << 'first_letter' << 'api_key'
  non_versioned_columns << 'info_requests_count' << 'info_requests_successful_count'
  non_versioned_columns << 'info_requests_count' << 'info_requests_visible_classified_count'
  non_versioned_columns << 'info_requests_not_held_count' << 'info_requests_overdue'
  non_versioned_columns << 'info_requests_overdue_count' << 'info_requests_visible_count'

  # Cannot be defined directly under `include` statements as this is opening
  # the PublicBody::Version class dynamically defined by  the
  # `acts_as_versioned` macro.
  #
  # TODO: acts_as_versioned accepts an extend parameter [1] so these methods
  # could be extracted to a module:
  #
  #    acts_as_versioned :extend => PublicBodyVersionExtensions
  #
  # This includes the module in both the parent class (PublicBody) and the
  # Version class (PublicBody::Version), so the behaviour is slightly
  # different to opening up PublicBody::Version.
  #
  # We could add an `extend_version_class` option pretty trivially by
  # following the pattern for the existing `extend` option.
  #
  # [1] https://github.com/technoweenie/acts_as_versioned/blob/63b1fc8529/lib/acts_as_versioned.rb#L98-L118
  class Version
    before_save :copy_translated_attributes

    def copy_translated_attributes
      public_body.attributes.each do |name, value|
        if public_body.translated?(name) &&
           !public_body.non_versioned_columns.include?(name)
          send("#{name}=", value)
        end
      end
    end

    def last_edit_comment_for_html_display
      text = last_edit_comment.strip
      text = CGI.escapeHTML(text)
      text = MySociety::Format.make_clickable(text)
      text.gsub(/\n/, '<br>')
    end

    def compare(previous = nil)
      if previous.nil?
        changes = []
      else
        v = self
        changes = self.class.content_columns.inject([]) { |memo, c|
          unless %w(version
                    last_edit_editor
                    last_edit_comment
                    created_at
                    updated_at).include?(c.name)
            from = previous.send(c.name)
            to = send(c.name)
            memo << { name: c.name.humanize,
                      from: from,
                      to: to } if from != to
          end
          memo
        }
      end
      if block_given?
        changes.each do |change|
          yield(change)
        end
      end
      changes
    end

    def editor
      User.find_by(url_name: last_edit_editor)
    end
  end

  # Public: Search for Public Bodies whose name, short_name, request_email or
  # tags contain the given query
  #
  # query  - String to query the searchable fields
  # locale - String to specify the language of the search query
  #          (default: AlaveteliLocalization.locale)
  #
  # Returns an ActiveRecord::Relation
  def self.search(query, locale = AlaveteliLocalization.locale)
    sql = <<-SQL
    (
      lower(public_body_translations.name) like lower('%'||?||'%')
      OR lower(public_body_translations.short_name) like lower('%'||?||'%')
      OR lower(public_body_translations.request_email) like lower('%'||?||'%' )
      OR lower(has_tag_string_tags.name) like lower('%'||?||'%' )
    )
    AND has_tag_string_tags.model_id = public_bodies.id
    AND has_tag_string_tags.model_type = 'PublicBody'
    AND (public_body_translations.locale = ?)
    SQL

    PublicBody.joins(:translations, :tags).
      where([sql, query, query, query, query, locale]).
      uniq
  end

  def self.with_domain(domain)
    return none unless domain

    with_translations(AlaveteliLocalization.locale).
      where("lower(public_body_translations.request_email) " \
            "like lower('%'||?||'%')", domain).
        merge(PublicBody::Translation.order(:name))
  end

  def set_api_key
    set_api_key! if api_key.nil?
  end

  def set_api_key!
    self.api_key = SecureRandom.base64(33)
  end

  def self.find_by_name(name)
    find_by(name: name)
  end

  def self.find_by_url_name(url_name)
    find_by(url_name: url_name)
  end

  # like find_by_url_name but also search historic url_name if none found
  def self.find_by_url_name_with_historic(name)
    # If many bodies are found (usually because the url_name is the same
    # across locales) return any of them.
    found = joins(:translations).
      where("public_body_translations.url_name = ?", name).
      readonly(false).
      first

    return found if found

    # If none found, then search the history of short names and find unique
    # public bodies in it
    old = PublicBody::Version.
      where(url_name: name).
      distinct.
      pluck(:public_body_id)

    # Maybe return the first one, so we show something relevant,
    # rather than throwing an error?
    if old.size > 1
      raise "Two bodies with the same historical URL name: #{name}"
    end
    return unless old.size == 1

    # does acts_as_versioned provide a method that returns the current version?
    PublicBody.find(old.first)
  end

  def self.without_request_email
    joins(:translations).
      where(public_body_translations: { request_email: '' }).
      not_defunct
  end

  def self.with_request_email
    joins(:translations).
      where.not(public_body_translations: { request_email: '' })
  end

  # If tagged "not_apply", then FOI/EIR no longer applies to authority at all
  # and the site will not accept further requests for them
  def not_apply?
    has_tag?('not_apply')
  end

  scope :foi_applies, -> { without_tag('not_apply') }

  # If tagged "foi_no", then the authority is not subject to FOI law but
  # requests may still be made through the site (e.g. they may have agreed to
  # respond to requests on a voluntary basis)
  # This will apply in all cases if the site has been configured not to state
  # that authorities have a legal obligation
  def not_subject_to_law?
    has_tag?('foi_no') || !AlaveteliConfiguration.authority_must_respond
  end

  # If tagged "defunct", then the authority no longer exists at all
  def defunct?
    has_tag?('defunct')
  end

  scope :not_defunct, -> { without_tag('defunct') }

  # Are all requests to this body under the Environmental Information
  # Regulations?
  def eir_only?
    has_tag?('eir_only')
  end

  def site_administration?
    has_tag?('site_administration')
  end

  # Can an FOI (etc.) request be made to this body?
  def is_requestable?
    has_request_email? && !defunct? && !not_apply?
  end

  scope :is_requestable, -> { with_request_email.not_defunct.foi_applies }

  # Strict superset of is_requestable?
  def is_followupable?
    has_request_email?
  end

  def has_request_email?
    !request_email.blank? && request_email != 'blank'
  end

  # Also used as not_followable_reason
  def not_requestable_reason
    if defunct?
      'defunct'
    elsif not_apply?
      'not_apply'
    elsif !has_request_email?
      'bad_contact'
    else
      raise "not_requestable_reason called with type that has no reason"
    end
  end

  def special_not_requestable_reason?
    defunct? || not_apply?
  end

  def created_at_numeric
    # format it here as no datetime support in Xapian's value ranges
    created_at.strftime("%Y%m%d%H%M%S")
  end

  def variety
    "authority"
  end

  def legislations
    @legislations ||= Legislation.for_public_body(self)
  end

  def legislation
    legislations.first
  end

  # The "internal admin" is a special body for internal use.
  def self.internal_admin_body
    matching_pbs = AlaveteliLocalization.
      with_locale(AlaveteliLocalization.default_locale) do
      default_scoped.where(url_name: 'internal_admin_authority')
    end

    if matching_pbs.empty?
      # "internal admin" exists but has the wrong default locale - fix & return
      if (invalid_locale = PublicBody::Translation.
                             find_by_url_name('internal_admin_authority'))
        found_pb = PublicBody.find(invalid_locale.public_body_id)
        AlaveteliLocalization.
          with_locale(AlaveteliLocalization.default_locale) do
          found_pb.name = "Internal admin authority"
          found_pb.request_email = AlaveteliConfiguration.contact_email
          found_pb.save!
        end
        found_pb
      else
        AlaveteliLocalization.
          with_locale(AlaveteliLocalization.default_locale) do
          default_scoped.
            create!(name: 'Internal admin authority',
                    short_name: "",
                    request_email: AlaveteliConfiguration.contact_email,
                    home_page: nil,
                    publication_scheme: nil,
                    last_edit_editor: "internal_admin",
                    last_edit_comment:                       "Made by PublicBody.internal_admin_body")
        end
      end
    elsif matching_pbs.length == 1
      matching_pbs[0]
    else
      raise "Multiple public bodies (#{matching_pbs.length}) found with url_name 'internal_admin_authority'"
    end
  end

  # Import from a string in CSV format.
  # Just tests things and returns messages if dry_run is true.
  # Returns an array of [array of errors, array of notes]. If there
  # are errors, always rolls back (as with dry_run).
  def self.import_csv(csv, tag, tag_behaviour, dry_run, editor, available_locales = [])
    tmp_csv = nil
    Tempfile.open('alaveteli') do |f|
      f.write csv
      tmp_csv = f
    end
    PublicBody.import_csv_from_file(tmp_csv.path, tag, tag_behaviour, dry_run, editor, available_locales)
  end

  # Import from a CSV file.
  # Just tests things and returns messages if dry_run is true.
  # Returns an array of [array of errors, array of notes]. If there
  # are errors, always rolls back (as with dry_run).
  def self.import_csv_from_file(csv_filename, tag, tag_behaviour, dry_run, editor, available_locales = [])
    errors = []
    notes = []
    begin
      ActiveRecord::Base.transaction do
        # Use the default locale when retrieving existing bodies; otherwise
        # matching names won't work afterwards, and we'll create new bodies instead
        # of updating them
        bodies_by_name = {}
        set_of_existing = Set.new
        internal_admin_body_id = PublicBody.internal_admin_body.id
        AlaveteliLocalization.
          with_locale(AlaveteliLocalization.default_locale) do
          bodies = (tag.nil? || tag.empty?) ? PublicBody.includes(:translations) : PublicBody.find_by_tag(tag)
          bodies.each do |existing_body|
            # Hide InternalAdminBody from import notes
            next if existing_body.id == internal_admin_body_id

            bodies_by_name[existing_body.name] = existing_body
            set_of_existing.add(existing_body.name)
          end
        end

        set_of_importing = Set.new
        # Default values in case no field list is given
        field_names = { 'name' => 1, 'request_email' => 2 }
        line = 0

        import_options = { field_names: field_names,
                          available_locales: available_locales,
                          tag: tag,
                          tag_behaviour: tag_behaviour,
                          editor: editor,
                          notes: notes,
                          errors: errors }

        CSV.foreach(csv_filename) do |row|
          line += 1

          # Parse the first line as a field list if it starts with '#'
          if (line==1) && row.first.to_s =~(/^#(.*)$/)
            row[0] = row[0][1..-1] # Remove the # sign on first field
            row.each_with_index { |field, i| field_names[field] = i }
            next
          end

          fields = {}
          field_names.each { |name, i| fields[name] = row[i] }

          yield line, fields if block_given?

          name = row[field_names['name']]
          email = row[field_names['request_email']]
          next if name.nil?

          name.strip!
          email.strip! unless email.nil?

          if !email.nil? && !email.empty? && !MySociety::Validate.is_valid_email(email)
            errors.push "error: line #{line}: invalid email '#{email}' for authority '#{name}'"
            next
          end

          public_body = bodies_by_name[name] || PublicBody.new(name: "",
                                                               short_name: "",
                                                               request_email: "")

          public_body.import_values_from_csv_row(row, line, name, import_options)
          set_of_importing.add(name)
        end

        # Give an error listing ones that are to be deleted
        deleted_ones = set_of_existing - set_of_importing
        if !deleted_ones.empty?
          notes.push "Notes: Some " + tag + " bodies are in database, but not in CSV file:\n    " + Array(deleted_ones).sort.join("\n    ") + "\nYou may want to delete them manually.\n"
        end

        # Rollback if a dry run, or we had errors
        raise ImportCSVDryRun if dry_run || (!errors.empty?)
      end
    rescue ImportCSVDryRun
      # Ignore
    end

    [errors, notes]
  end

  def self.localized_csv_field_name(locale, field_name)
    if AlaveteliLocalization.default_locale?(locale)
      field_name
    else
      "#{field_name}.#{locale}"
    end
  end

  # import values from a csv row (that may include localized columns)
  def import_values_from_csv_row(row, line, name, options)
    is_new = new_record?
    edit_info = if is_new
      { action: "creating new authority",
        comment: 'Created from spreadsheet' }
    else
      { action: "updating authority",
        comment: 'Updated from spreadsheet' }
    end
    locales = options[:available_locales]
    locales = [AlaveteliLocalization.default_locale] if locales.empty?
    locales.each do |locale|
      AlaveteliLocalization.with_locale(locale) do
        changed = set_locale_fields_from_csv_row(is_new, locale, row, options)
        unless changed.empty?
          options[:notes].push "line #{ line }: #{ edit_info[:action] } '#{ name }' (locale: #{ locale }):\n\t#{ changed.to_json }"
          self.last_edit_comment = edit_info[:comment]
          self.publication_scheme = publication_scheme || ""
          self.last_edit_editor = options[:editor]

          begin
            save!
          rescue ActiveRecord::RecordInvalid
            errors.each do |error|
              options[:errors].push "error: line #{ line }: " \
                "#{ error.full_message } for authority '#{ name }'"
            end
            next
          end
        end
      end
    end
  end

  # Sets attribute values for a locale from a csv row
  def set_locale_fields_from_csv_row(is_new, locale, row, options)
    changed = ActiveSupport::OrderedHash.new
    csv_field_names = options[:field_names]
    csv_import_fields.each do |field_name, _field_notes|
      localized_field_name = self.class.localized_csv_field_name(locale, field_name)
      column = csv_field_names[localized_field_name]
      value = column && row[column]
      # Tags are a special case, as we support adding to the field,
      # not just setting a new value
      if field_name == 'tag_string'
        new_tags = [value, options[:tag]].select { |new_tag| !new_tag.blank? }
        if new_tags.empty?
          value = nil
        else
          value = new_tags.join(" ")
          value = "#{value} #{tag_string}"if options[:tag_behaviour] == 'add'
        end
      end

      if value && read_attribute_value(field_name, locale) != value
        if is_new
          changed[field_name] = value
        else
          changed[field_name] = "#{read_attribute_value(field_name, locale)}:" \
                                " #{value}"
        end
        assign_attributes({ field_name => value })
      end
    end
    changed
  end

  # Does this user have the power of FOI officer for this body?
  def is_foi_officer?(user)
    user_domain = user.email_domain
    our_domain = request_email_domain

    return false if user_domain.nil? || our_domain.nil?

    our_domain == user_domain
  end

  def request_email
    if AlaveteliConfiguration.override_all_public_body_request_emails.blank? ||
       read_attribute(:request_email).blank?
      read_attribute(:request_email)
    else
      AlaveteliConfiguration.override_all_public_body_request_emails
    end
  end

  # Domain name of the request email
  def request_email_domain
    PublicBody.extract_domain_from_email(request_email)
  end

  alias foi_officer_domain_required request_email_domain

  # Return the canonicalised domain part of an email address
  #
  # TODO: Extract to library class
  def self.extract_domain_from_email(email)
    email =~ /@(.*)/
    $1.nil? ? nil : $1.downcase
  end

  def notes
    Note.sort(all_notes)
  end

  def notes_as_string
    notes.map(&:to_plain_text).join(' ')
  end

  def has_notes?
    notes.present?
  end

  def json_for_api
    {
      id: id,
      url_name: url_name,
      name: name,
      short_name: short_name,
      # :request_email  # we hide this behind a captcha, to stop people
      # doing bulk requests easily
      created_at: created_at,
      updated_at: updated_at,
      # don't add the history as some edit comments contain sensitive
      # information
      # :version, :last_edit_editor, :last_edit_comment
      home_page: calculated_home_page,
      notes: notes_as_string,
      publication_scheme: publication_scheme.to_s,
      disclosure_log: disclosure_log.to_s,
      tags: tag_array,
      info: {
        requests_count: info_requests_count,
        requests_successful_count: info_requests_successful_count,
        requests_not_held_count: info_requests_not_held_count,
        requests_overdue_count: info_requests_overdue_count,
        requests_visible_classified_count:           info_requests_visible_classified_count
      }
    }
  end

  def expire_requests
    InfoRequestExpireJob.perform_later(self, :info_requests)
  end

  def self.where_clause_for_stats(minimum_requests, total_column)
    # When producing statistics for public bodies, we want to
    # exclude any that are tagged with 'test' - we use a
    # sub-select to find the IDs of those public bodies.
    test_tagged_query = "SELECT model_id FROM has_tag_string_tags" \
      " WHERE model_type = 'PublicBody' AND name = 'test'"
    "#{total_column} >= #{minimum_requests} " \
    "AND id NOT IN (#{test_tagged_query})"
  end

  # Return data for the 'n' public bodies with the highest (or
  # lowest) number of requests, but only returning data for those
  # with at least 'minimum_requests' requests.
  def self.get_request_totals(n, highest, minimum_requests)
    ordering = "info_requests_visible_count"
    ordering += " DESC" if highest
    where_clause = where_clause_for_stats minimum_requests,
                  'info_requests_visible_count'
    public_bodies = PublicBody.order(ordering).
                      where(where_clause).
                        limit(n).
                          to_a
    public_bodies.reverse! if highest
    y_values = public_bodies.map(&:info_requests_visible_count)
    {
      'public_bodies' => public_bodies,
      'y_values' => y_values,
      'y_max' => y_values.max,
    'totals' => y_values }
  end

  # Return data for the 'n' public bodies with the highest (or
  # lowest) score according to the metric of the value in 'column'
  # divided by the total number of requests, expressed as a
  # percentage.  This only returns data for those public bodies with
  # at least 'minimum_requests' requests.
  def self.get_request_percentages(column, n, highest, minimum_requests)
    total_column = "info_requests_visible_classified_count"
    ordering = "y_value"
    ordering += " DESC" if highest
    y_value_column = "(cast(#{column} as float) / #{total_column})"
    where_clause = where_clause_for_stats minimum_requests, total_column
    where_clause += " AND #{column} IS NOT NULL"
    public_bodies = PublicBody.select("*, #{y_value_column} AS y_value").
                                order(ordering).
                                  where(where_clause).
                                    limit(n).
                                      to_a
    public_bodies.reverse! if highest
    y_values = public_bodies.map { |pb| pb.y_value.to_f }

    original_values = public_bodies.map { |pb| pb.send(column) }
    # If these are all nil, then probably the values have never
    # been set; some have to be set by a rake task.  In that case,
    # just return nil:
    return nil unless original_values.any? { |ov| !ov.nil? }

    original_totals = public_bodies.map { |pb| pb.send(total_column) }
    # Calculate confidence intervals, as offsets from the proportion:
    cis_below = []
    cis_above = []
    original_totals.each_with_index.map { |total, i|
      lower_ci, higher_ci = ci_bounds original_values[i], total, 0.05
      cis_below.push(y_values[i] - lower_ci)
      cis_above.push(higher_ci - y_values[i])
    }
    # Turn the y values and confidence interval offsets into
    # percentages:
    [y_values, cis_below, cis_above].each { |l|
      l.map! { |v| 100 * v }
    }
    {
      'public_bodies' => public_bodies,
      'y_values' => y_values,
      'cis_below' => cis_below,
      'cis_above' => cis_above,
      'y_max' => 100,
    'totals' => original_totals }
  end

  def self.popular_bodies(locale)
    # get some example searches and public bodies to display
    # either from config, or based on a (slow!) query if not set
    body_short_names = AlaveteliConfiguration.
                         frontpage_publicbody_examples.
                           split(/\s*;\s*/)
    underscore_locale = locale.gsub '-', '_'
    bodies = []
    AlaveteliLocalization.with_locale(locale) do
      if body_short_names.empty?
        # This is too slow
        bodies = visible.
                  where('public_body_translations.locale = ?',
                         underscore_locale).
                    order(info_requests_visible_count: :desc).
                      limit(32).
                        joins(:translations)
      else
        bodies = where("public_body_translations.locale = ?
                        AND public_body_translations.url_name in (?)",
                        underscore_locale, body_short_names).
                  joins(:translations)
      end
    end
    bodies
  end

  class << self
    alias original_with_tag with_tag
  end

  def self.with_tag(tag)
    return all if tag.size == 1 || tag.nil? || tag == 'all'

    if tag == 'other'
      tags = PublicBody.category_list.distinct.
        where.not(category_tag: [nil, '', 'other']).
        pluck(:category_tag)
      where.not("EXISTS(#{tag_search_sql(tags)})")
    else
      original_with_tag(tag)
    end
  end

  def self.with_query(query, tag)
    like_query = "%#{query}%"
    has_first_letter = tag.size == 1

    underscore_locale = AlaveteliLocalization.locale
    underscore_default_locale = AlaveteliLocalization.default_locale
    where_parameters = {
      locale: underscore_locale,
      query: like_query,
      first_letter: tag
    }

    if AlaveteliConfiguration.public_body_list_fallback_to_default_locale
      # Unfortunately, when we might fall back to the
      # default locale, this is a rather complex query:
      if DatabaseCollation.supports?(underscore_locale)
        select_sql = %Q(public_bodies.*, COALESCE(current_locale.name, default_locale.name) COLLATE "#{underscore_locale}" AS display_name)
      else
        select_sql = %Q(public_bodies.*, COALESCE(current_locale.name, default_locale.name) AS display_name)
      end

      select(select_sql).
        joins(
          "LEFT OUTER JOIN public_body_translations as current_locale ON " \
          "(public_bodies.id = current_locale.public_body_id AND " \
          "current_locale.locale = '#{sanitize_sql(underscore_locale)}')"
        ).
        joins(
          "LEFT OUTER JOIN public_body_translations as default_locale ON " \
          "(public_bodies.id = default_locale.public_body_id AND " \
          "default_locale.locale = " \
          "'#{sanitize_sql(underscore_default_locale)}')"
        ).
        where("(#{get_public_body_list_translated_condition('current_locale', has_first_letter)}) OR " \
              "(#{get_public_body_list_translated_condition('default_locale', has_first_letter)}) ", where_parameters).
        where('COALESCE(current_locale.name, default_locale.name) IS NOT NULL').
        order(:display_name)
    else
      # The simpler case where we're just searching in the current locale:
      where_condition = get_public_body_list_translated_condition('public_body_translations', has_first_letter, true)

      if DatabaseCollation.supports?(underscore_locale)
        where(where_condition, where_parameters).
          joins(:translations).
          order(Arel.sql(%Q(public_body_translations.name COLLATE "#{underscore_locale}")))
      else
        where(where_condition, where_parameters).
          joins(:translations).
            merge(PublicBody::Translation.order(:name))
      end
    end
  end

  # This method updates the count columns of the PublicBody that
  # store the number of "not held", "to some extent successful" and
  # "both visible and classified" requests.
  def update_counter_cache
    success_states = %w(successful partially_successful)

    mappings = {
      info_requests_not_held_count: { awaiting_description: false,
                                      described_state: 'not_held' },
      info_requests_successful_count: { awaiting_description: false,
                                        described_state: success_states },
      info_requests_visible_classified_count: { awaiting_description: false },
      info_requests_visible_count: {}
    }

    info_request_scope = InfoRequest.where(public_body_id: id).is_searchable

    updated_counts = mappings.each_with_object({}) do |(column, params), memo|
      memo[column] = info_request_scope.where(params).count
    end

    update_columns(updated_counts)
  end

  def questions
    PublicBodyQuestion.fetch(self)
  end

  def cached_urls
    [
      public_body_path(self),
      list_public_bodies_path,
      '^/body/list'
    ]
  end

  def request_created
    update_not_many_requests_tag
  end

  private

  # If the url_name has changed, then all requested_from: queries will break
  # unless we update index for every event for every request linked to it.
  def reindex_requested_from
    expire_requests if saved_change_to_attribute?(:url_name)
  end

  def invalidate_cached_pages
    NotifyCacheJob.perform_later(self)
  end

  # Read an attribute value (without using locale fallbacks if the
  # attribute is translated)
  def read_attribute_value(name, locale)
    if self.class.translated?(name.to_sym)
      if globalize.stash.contains?(locale, name)
        globalize.stash.read(locale, name)
      else
        translation_for(locale).send(name)
      end
    else
      send(name)
    end
  end

  def request_email_if_requestable
    # Request_email can be blank, meaning we don't have details
    if is_requestable?
      unless MySociety::Validate.is_valid_email(request_email)
        errors.add(:request_email,
                   "Request email doesn't look like a valid email address")
      end
    end
  end

  def name_for_search
    name.downcase
  end

  def self.get_public_body_list_translated_condition(table, has_first_letter=false, locale=nil)
    result = "(upper(#{table}.name) LIKE upper(:query)" \
      " OR upper(#{table}.short_name) LIKE upper(:query))"
    result += " AND #{table}.first_letter = :first_letter" if has_first_letter
    result += " AND #{table}.locale = :locale" if locale
    result
  end
  private_class_method :get_public_body_list_translated_condition

  def update_auto_applied_tags
    update_missing_email_tag
    update_not_many_requests_tag
  end

  def update_missing_email_tag
    if missing_email? && !defunct?
      add_tag_if_not_already_present('missing_email')
    else
      remove_tag('missing_email')
    end
  end

  def missing_email?
    !has_request_email?
  end

  def update_not_many_requests_tag
    if is_requestable? && not_many_public_requests?
      add_tag_if_not_already_present('not_many_requests')
    else
      remove_tag('not_many_requests')
    end
  end

  def not_many_public_requests?
    info_requests.is_searchable.size < not_many_public_requests_size
  end
end