mysociety/alaveteli

View on GitHub
app/models/user.rb

Summary

Maintainability
D
2 days
Test Coverage
# == Schema Information
# Schema version: 20230301110831
#
# Table name: users
#
#  id                                :integer          not null, primary key
#  email                             :string           not null
#  name                              :string           not null
#  hashed_password                   :string           not null
#  salt                              :string
#  created_at                        :datetime         not null
#  updated_at                        :datetime         not null
#  email_confirmed                   :boolean          default(FALSE), not null
#  url_name                          :text             not null
#  last_daily_track_email            :datetime         default(Sat, 01 Jan 2000 00:00:00.000000000 GMT +00:00)
#  ban_text                          :text             default(""), not null
#  about_me                          :text             default(""), not null
#  locale                            :string
#  email_bounced_at                  :datetime
#  email_bounce_message              :text             default(""), not null
#  no_limit                          :boolean          default(FALSE), not null
#  receive_email_alerts              :boolean          default(TRUE), not null
#  can_make_batch_requests           :boolean          default(FALSE), not null
#  otp_enabled                       :boolean          default(FALSE), not null
#  otp_secret_key                    :string
#  otp_counter                       :integer          default(1)
#  confirmed_not_spam                :boolean          default(FALSE), not null
#  comments_count                    :integer          default(0), not null
#  info_requests_count               :integer          default(0), not null
#  track_things_count                :integer          default(0), not null
#  request_classifications_count     :integer          default(0), not null
#  public_body_change_requests_count :integer          default(0), not null
#  info_request_batches_count        :integer          default(0), not null
#  daily_summary_hour                :integer
#  daily_summary_minute              :integer
#  closed_at                         :datetime
#  login_token                       :string
#  receive_user_messages             :boolean          default(TRUE), not null
#  user_messages_count               :integer          default(0), not null
#

class User < ApplicationRecord
  include AlaveteliFeatures::Helpers
  include AlaveteliPro::PhaseCounts
  include User::Authentication
  include User::LoginToken
  include User::OneTimePassword
  include User::Slug
  include User::SpreadableAlerts
  include User::Survey
  include Rails.application.routes.url_helpers
  include LinkToHelper

  DEFAULT_CONTENT_LIMITS = {
    info_requests: AlaveteliConfiguration.max_requests_per_user_per_day,
    comments: AlaveteliConfiguration.max_requests_per_user_per_day,
    user_messages: AlaveteliConfiguration.max_requests_per_user_per_day
  }.freeze

  cattr_accessor :content_limits, default: DEFAULT_CONTENT_LIMITS

  rolify before_add: :setup_pro_account,
         after_add: :assign_role_features,
         after_remove: :assign_role_features
  strip_attributes allow_empty: true

  admin_columns include: [:user_messages_count],
                exclude: [:otp_secret_key, :url_name]

  attr_accessor :no_xapian_reindex

  has_many :info_requests,
           -> { order(created_at: :desc) },
           inverse_of: :user,
           dependent: :destroy
  has_many :info_request_events,
           -> { reorder(created_at: :desc) },
           through: :info_requests
  has_many :embargoes,
           inverse_of: :user,
           through: :info_requests
  has_many :outgoing_messages,
           inverse_of: :user,
           through: :info_requests
  has_many :draft_info_requests,
           -> { order(created_at: :desc) },
           inverse_of: :user,
           dependent: :destroy
  has_many :user_info_request_sent_alerts,
           inverse_of: :user,
           dependent: :destroy
  has_many :post_redirects,
           -> { order(created_at: :desc) },
           inverse_of: :user,
           dependent: :destroy
  has_many :track_things,
           -> { order(created_at: :desc) },
           inverse_of: :tracking_user,
           foreign_key: 'tracking_user_id',
           dependent: :destroy
  has_many :citations,
           -> { order(created_at: :desc) },
           inverse_of: :user,
           dependent: :destroy
  has_many :comments,
           -> { order(created_at: :desc) },
           inverse_of: :user,
           dependent: :destroy
  has_many :public_body_change_requests,
           -> { order(created_at: :desc) },
           inverse_of: :user,
           dependent: :destroy
  has_one :profile_photo,
          inverse_of: :user,
          dependent: :destroy
  has_many :censor_rules,
           -> { order(created_at: :desc) },
           inverse_of: :user,
           dependent: :destroy
  has_many :info_request_batches,
           -> { order(created_at: :desc) },
           inverse_of: :user,
           dependent: :destroy
  has_many :draft_info_request_batches,
           -> { order(created_at: :desc) },
           inverse_of: :user,
           dependent: :destroy,
           class_name: 'AlaveteliPro::DraftInfoRequestBatch'
  has_many :request_classifications,
           inverse_of: :user,
           dependent: :destroy
  has_one :pro_account,
          inverse_of: :user,
          dependent: :destroy
  has_many :request_summaries,
           inverse_of: :user,
           dependent: :destroy,
           class_name: 'AlaveteliPro::RequestSummary'
  has_many :notifications,
           inverse_of: :user,
           dependent: :destroy
  has_many :track_things_sent_emails,
           inverse_of: :user,
           dependent: :destroy
  has_many :track_things_sent_emails,
           dependent: :destroy
  has_many :announcements,
           inverse_of: :user
  has_many :announcement_dismissals,
           inverse_of: :user,
           dependent: :destroy
  has_many :memberships, class_name: 'Project::Membership'
  has_many :projects, through: :memberships

  has_many :sign_ins,
           class_name: 'User::SignIn',
           inverse_of: :user,
           dependent: :destroy

  has_many :user_messages,
           -> { order(created_at: :desc) },
           inverse_of: :user,
           dependent: :destroy

  scope :active, -> { not_banned.not_closed }
  scope :banned, -> { where.not(ban_text: '') }
  scope :not_banned, -> { where(ban_text: '') }
  scope :closed, -> { where.not(closed_at: nil) }
  scope :not_closed, -> { where(closed_at: nil) }

  validates_presence_of :email, message: _('Please enter your email address')
  validates_presence_of :name, message: _('Please enter your name')

  validates_length_of :about_me,
                      maximum: 500,
                      message: _('Please keep it shorter than 500 characters')

  validates :email,
            uniqueness: { case_sensitive: false,
                          message: _('This email is already in use') }

  validate :email_and_name_are_valid

  after_update :update_pro_account
  after_update :reindex_referencing_models, :invalidate_cached_pages,
               unless: :no_xapian_reindex

  acts_as_xapian texts: [:name, :about_me],
                 values: [
                   [:created_at_numeric, 1, 'created_at', :number] # for sorting
                 ],
                 terms: [[:variety, 'V', 'variety']],
                 if: :indexed_by_search?

  def self.search(query)
    where(<<~SQL, query: query)
      lower(users.name) LIKE lower('%'||:query||'%') OR
      lower(users.email) LIKE lower('%'||:query||'%') OR
      lower(users.about_me) LIKE lower('%'||:query||'%')
    SQL
  end

  def self.pro
    with_role(:pro)
  end

  # Return user given login email, password and other form parameters (e.g. name)
  #
  # The specific_user_login parameter says that login as a particular user is
  # expected, so no parallel registration form is being displayed.
  def self.authenticate_from_form(params, specific_user_login = false)
    params[:email].strip!

    if specific_user_login
      auth_fail_message = _("Either the email or password was not recognised, please try again.")
    else
      auth_fail_message = _("Either the email or password was not recognised, please try again. Or create a new account using the form on the left.")
    end

    user = find_user_by_email(params[:email])
    if user
      # There is user with email, check password
      unless user.has_this_password?(params[:password])
        user.errors.add(:base, auth_fail_message)
      end

      if user.has_this_password?(params[:password]) && user.closed?
        logger.info "Closed user attempted login: #{ params[:email] }"
        user.errors.add(:base, _('This account has been closed.'))
      end
    else
      # No user of same email, make one (that we don't save in the database)
      # for the forms code to use.
      user = User.new(params)
      # deliberately same message as above so as not to leak whether registered
      user.errors.add(:base, auth_fail_message)
    end
    user
  end

  def self.authenticate_from_session(session)
    return unless session[:user_id]

    find_by(id: session[:user_id], login_token: session[:user_login_token])
  end

  # Case-insensitively find a user from their email
  def self.find_user_by_email(email)
    return nil if email.blank?

    where('lower(email) = lower(?)', email.strip).first
  end

  # The "internal admin" is a special user for internal use.
  def self.internal_admin_user
    user = find_by(email: AlaveteliConfiguration.contact_email)
    return user if user

    password = PostRedirect.generate_random_token

    create!(
      name: 'Internal admin user',
      email: AlaveteliConfiguration.contact_email,
      password: password,
      password_confirmation: password
    )
  end

  # Should the user be kept logged into their own account
  # if they follow a /c/ redirect link belonging to another user?
  def self.stay_logged_in_on_redirect?(user)
    user&.is_admin?
  end

  def self.record_bounce_for_email(email, message)
    user = User.find_user_by_email(email)
    return false if user.nil?

    user.record_bounce(message) if user.email_bounced_at.nil?
    true
  end

  def self.find_similar_named_users(user)
    User.where('name ILIKE ? AND email_confirmed = ? AND id <> ?',
                user.name, true, user.id).order(:created_at)
  end

  def view_hidden?
    is_admin?
  end

  def view_embargoed?
    is_pro_admin?
  end

  def view_hidden_and_embargoed?
    view_hidden? && view_embargoed?
  end

  def transactions(*associations)
    opts = {}
    opts[:transaction_associations] = associations if associations.any?
    TransactionCalculator.new(self, opts)
  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
    'user'
  end

  # requested_by: and commented_by: search queries also need updating after save
  def reindex_referencing_models
    return unless saved_change_to_attribute?(:url_name)

    expire_comments
    expire_requests
  end

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

  def expire_comments
    comments.find_each(&:reindex_request_events)
  end

  def invalidate_cached_pages
    NotifyCacheJob.perform_later(self)
  end

  def locale
    (super || AlaveteliLocalization.locale).to_s
  end

  def name
    _name = read_attribute(:name)
    if suspended?
      _name = _('{{user_name}} (Account suspended)', user_name: _name)
    end
    _name
  end

  # When name is changed, also change the url name
  def name=(name)
    write_attribute(:name, name.try(:strip))
  end

  def previous_names
    outgoing_messages.unscope(:order).
      distinct(:from_name).
      where.not(from_name: read_attribute(:name)).
      pluck(:from_name)
  end

  def safe_previous_names
    outgoing_messages.map(&:safe_from_name).uniq - [read_attribute(:name)]
  end

  # For use in to/from in email messages
  def name_and_email
    MailHandler.address_from_name_and_email(name, email)
  end

  # Returns list of requests which the user hasn't described (and last
  # changed more than a day ago)
  def get_undescribed_requests
    info_requests.
      where(awaiting_description: true).
      where("#{ InfoRequest.last_event_time_clause } < ?", 1.day.ago)
  end

  # Does the user magically gain powers as if they owned every request?
  # e.g. Can classify it
  def owns_every_request?
    is_admin?
  end

  def can_admin_roles
    roles.
      flat_map { |role| Role.grants_and_revokes(role.name.to_sym) }.
      compact.
      uniq
  end

  def can_admin_role?(role)
    can_admin_roles.include?(role)
  end

  # Does the user get "(admin)" links on each page on the main site?
  def admin_page_links?
    is_admin?
  end

  def banned?
    ban_text.present?
  end

  def close
    close!
  rescue ActiveRecord::RecordInvalid
    false
  end

  def close!
    update!(closed_at: Time.zone.now, receive_email_alerts: false)
  end

  def closed?
    closed_at.present?
  end

  def erase
    erase!
  rescue ActiveRecord::RecordInvalid
    false
  end

  def erase!
    raise ActiveRecord::RecordInvalid unless closed?

    sha = Digest::SHA1.hexdigest(rand.to_s)

    transaction do
      slugs.destroy_all
      sign_ins.destroy_all
      profile_photo&.destroy!

      outgoing_messages.update!(
        from_name: _('[Name Removed]')
      )

      update!(
        name: _('[Name Removed]'),
        email: "#{sha}@invalid",
        url_name: sha,
        about_me: '',
        password: MySociety::Util.generate_token
      )
    end
  end

  def anonymise!
    return if info_requests.none? && comments.none?

    current_name = read_attribute(:name)
    [current_name, *previous_names].each do |name|
      censor_rules.create!(text: name,
                           replacement: _('[Name Removed]'),
                           last_edit_editor: 'User#anonymise!',
                           last_edit_comment: 'User#anonymise!')
    end
  end

  def close_and_anonymise
    transaction do
      close!
      anonymise!
      erase!
    end
  end

  def active?
    !banned? && !closed?
  end

  def suspended?
    !active?
  end

  def prominence
    return 'hidden' if banned?
    return 'backpage' if closed?
    return 'backpage' unless email_confirmed?

    'normal'
  end

  # Various ways the user can be banned, and text to describe it if failed
  def can_file_requests?
    active? && !exceeded_limit?(:info_requests)
  end

  def can_make_followup?
    active?
  end

  def can_make_comments?
    return false unless active?
    return true if no_limit? || is_admin? || is_pro_admin?

    !exceeded_limit?(:comments) &&
      !Comment.exceeded_creation_rate?(comments)
  end

  def can_contact_other_users?
    active? && !exceeded_limit?(:user_messages)
  end

  def exceeded_limit?(content)
    return false if no_limit?
    return false if can_make_batch_requests?
    return false if content_limit(content).blank?

    # Has the User created too much of the content in the past 24 hours?
    recent_content =
      content.to_s.classify.constantize.
        where(["user_id = ? AND created_at > now() - '1 day'::interval", id]).
        count

    recent_content >= content_limit(content)
  end

  def next_request_permitted_at
    return nil if no_limit

    n_most_recent_requests =
      InfoRequest.
        where(["user_id = ? AND created_at > now() - '1 day'::interval", id]).
          order(created_at: :desc).
            limit(AlaveteliConfiguration.max_requests_per_user_per_day)

    if n_most_recent_requests.size < AlaveteliConfiguration.max_requests_per_user_per_day
      return nil
    end

    nth_most_recent_request = n_most_recent_requests[-1]
    nth_most_recent_request.created_at + 1.day
  end

  def can_fail_html
    if banned?
      text = ban_text.strip
    elsif closed?
      text = _('Account closed at user request')
    else
      raise 'Unknown reason for ban'
    end
    text = CGI.escapeHTML(text)
    text = MySociety::Format.make_clickable(text, contract: 1)
    text = text.gsub(/\n/, '<br>')
    text.html_safe
  end

  # Returns domain part of user's email address
  def email_domain
    PublicBody.extract_domain_from_email(email)
  end

  # A photograph of the user (to make it all more human)
  def set_profile_photo(new_profile_photo)
    ActiveRecord::Base.transaction do
      profile_photo.destroy unless profile_photo.nil?
      self.profile_photo = new_profile_photo
      save!
    end
  end

  def show_profile_photo?
    active? && profile_photo
  end

  def about_me_already_exists?
    return false if about_me.blank?

    self.class.where(about_me: about_me).where.not(id: id).any?
  end

  # Return about me text for display as HTML
  # TODO: Move this to a view helper
  def get_about_me_for_html_display
    text = about_me.strip
    text = CGI.escapeHTML(text)
    text = MySociety::Format.make_clickable(text, contract: 1, nofollow: true)
    text = text.gsub(/\n/, '<br>')
    text.html_safe
  end

  def json_for_api
    {
      id: id,
      url_name: url_name,
      name: name,
      ban_text: ban_text,
      about_me: about_me
      # :profile_photo => self.profile_photo # ought to have this, but too hard to get URL out for now
      # created_at / updated_at we only show the year on the main page for privacy reasons, so don't put here
    }
  end

  def record_bounce(message)
    update!(
      email_bounced_at: Time.zone.now,
      email_bounce_message: convert_string_to_utf8(message).string
    )
  end

  def confirm(save_record = false)
    self.email_confirmed = true
    save! if save_record
  end

  def confirm!
    confirm
    save!
  end

  def should_be_emailed?
    active? && email_confirmed? && receive_email_alerts? && !email_bounced_at
  end

  def indexed_by_search?
    email_confirmed && active?
  end

  # Notify a user about an info_request_event, allowing the user's preferences
  # to determine how that notification is delivered.
  def notify(info_request_event)
    Notification.create(
      info_request_event: info_request_event,
      frequency: Notification.frequencies[notification_frequency],
      user: self
    )
  end

  # Return a timestamp for the next time a user should be sent a daily summary
  def next_daily_summary_time
    summary_time = Time.zone.now.change(daily_summary_time)
    summary_time += 1.day if summary_time < Time.zone.now
    summary_time
  end

  def daily_summary_time
    { hour: daily_summary_hour,
      min: daily_summary_minute }
  end

  # With what frequency does the user want to be notified?
  def notification_frequency
    if features.enabled?(:notifications)
      Notification::DAILY
    else
      Notification::INSTANTLY
    end
  end

  def features
    # Will return enabled and disabled features. Call #enabled? to see the
    # current state
    AlaveteliFeatures.features.with_actor(self)
  end

  def features=(new_features)
    features.assign_features(new_features)
  end

  # Define an id number for use with the Flipper gem's user-by-user feature
  # flagging. We prefix with the class because features can be enabled for
  # other types of objects (e.g Roles) in the same way and will be stored in
  # the same table. See:
  # https://github.com/jnunemaker/flipper/blob/master/docs/Gates.md
  def flipper_id
    "User;#{id}"
  end

  def cached_urls
    [
      user_path(self)
    ]
  end

  private

  def email_and_name_are_valid
    if email != "" && !MySociety::Validate.is_valid_email(email)
      errors.add(:email, _("Please enter a valid email address"))
    end
    if MySociety::Validate.is_valid_email(name)
      errors.add(:name, _("Please enter your name, not your email address, in the name field."))
    end
  end

  def assign_role_features(_role)
    features.assign_role_features
  end

  def setup_pro_account(role)
    return unless role == Role.pro_role

    pro_account || build_pro_account if feature_enabled?(:pro_pricing)
  end

  def update_pro_account
    pro_account.update_stripe_customer if pro_account
  end

  def content_limit(content)
    content_limits[content]
  end
end