QutBioacoustics/baw-server

View on GitHub
app/models/user.rb

Summary

Maintainability
A
0 mins
Test Coverage
# frozen_string_literal: true

# == Schema Information
#
# Table name: users
#
#  id                     :integer          not null, primary key
#  authentication_token   :string
#  confirmation_sent_at   :datetime
#  confirmation_token     :string
#  confirmed_at           :datetime
#  current_sign_in_at     :datetime
#  current_sign_in_ip     :string
#  email                  :string           not null
#  encrypted_password     :string           not null
#  failed_attempts        :integer          default(0)
#  image_content_type     :string
#  image_file_name        :string
#  image_file_size        :bigint
#  image_updated_at       :datetime
#  invitation_token       :string
#  last_seen_at           :datetime
#  last_sign_in_at        :datetime
#  last_sign_in_ip        :string
#  locked_at              :datetime
#  preferences            :text
#  rails_tz               :string(255)
#  remember_created_at    :datetime
#  reset_password_sent_at :datetime
#  reset_password_token   :string
#  roles_mask             :integer
#  sign_in_count          :integer          default(0)
#  tzinfo_tz              :string(255)
#  unconfirmed_email      :string
#  unlock_token           :string
#  user_name              :string           not null
#  created_at             :datetime
#  updated_at             :datetime
#
# Indexes
#
#  index_users_on_authentication_token  (authentication_token) UNIQUE
#  index_users_on_confirmation_token    (confirmation_token) UNIQUE
#  index_users_on_email                 (email) UNIQUE
#  users_user_name_unique               (user_name) UNIQUE
#
require 'role_model'

class User < ApplicationRecord
  ADMIN_USER_NAME = 'Admin'
  HARVESTER_USER_NAME = 'Harvester'

  # ensures timezones are handled consistently
  include TimeZoneAttribute

  attr_accessor :skip_creation_email

  # Include default devise modules. Others available are:
  # and :omniauthable
  devise :database_authenticatable, :registerable,
    :recoverable, :rememberable, :trackable, :validatable,
    :confirmable, :lockable, :timeoutable

  # http://www.phase2technology.com/blog/authentication-permissions-and-roles-in-rails-with-devise-cancan-and-role-model/
  include RoleModel

  # user must always have an authentication token
  before_save :ensure_authentication_token

  # Virtual attribute for authenticating by either :user_name or :email
  # This is in addition to real persisted fields.
  attr_writer :login

  def login
    @login || user_name || email
  end

  # if you want to use a different integer attribute to store the
  # roles in, set it with roles_attribute :my_roles_attribute,
  # :roles_mask is the default name
  roles_attribute :roles_mask

  # declare the valid roles -- do not change the order if you add more
  # roles later, always append them at the end!
  roles :admin, :user, :harvester, :guest

  has_attached_file :image,
    styles: { span4: '300x300#', span3: '220x220#', span2: '140x140#', span1: '60x60#',
              spanhalf: '30x30#' },
    default_url: '/images/user/user_:style.png'

  # relations
  # Don't include the catch-all association to permissions
  #has_many :accessible_projects, through: :permissions, source: :project
  has_many :readable_projects, -> { where("permissions.level = 'reader'") }, through: :permissions, source: :project
  has_many :writable_projects, -> { where("permissions.level = 'writer'") }, through: :permissions, source: :project
  has_many :owned_projects, -> { where("permissions.level = 'owner'") }, through: :permissions, source: :project

  # relations for creator, updater, deleter, and others.
  has_many :created_audio_events, class_name: 'AudioEvent', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_audio_events, class_name: 'AudioEvent', foreign_key: :updater_id, inverse_of: :updater
  has_many :deleted_audio_events, class_name: 'AudioEvent', foreign_key: :deleter_id, inverse_of: :deleter

  has_many :created_audio_event_comments, class_name: 'AudioEventComment', foreign_key: :creator_id,
    inverse_of: :creator
  has_many :updated_audio_event_comments, class_name: 'AudioEventComment', foreign_key: :updater_id,
    inverse_of: :updater
  has_many :deleted_audio_event_comments, class_name: 'AudioEventComment', foreign_key: :deleter_id,
    inverse_of: :deleter
  has_many :flagged_audio_event_comments, class_name: 'AudioEventComment', foreign_key: :flagger_id,
    inverse_of: :flagger

  has_many :created_audio_recordings, class_name: 'AudioRecording', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_audio_recordings, class_name: 'AudioRecording', foreign_key: :updater_id, inverse_of: :updater
  has_many :deleted_audio_recordings, class_name: 'AudioRecording', foreign_key: :deleter_id, inverse_of: :deleter
  has_many :uploaded_audio_recordings, class_name: 'AudioRecording', foreign_key: :uploader_id, inverse_of: :uploader

  has_many :created_taggings, class_name: 'Tagging', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_taggings, class_name: 'Tagging', foreign_key: :updater_id, inverse_of: :updater

  has_many :created_bookmarks, class_name: 'Bookmark', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_bookmarks, class_name: 'Bookmark', foreign_key: :updater_id, inverse_of: :updater

  has_many :created_saved_searches, lambda {
                                      includes :project
                                    }, class_name: 'SavedSearch', foreign_key: :creator_id, inverse_of: :creator
  has_many :deleted_saved_searches, lambda {
                                      includes :project
                                    }, class_name: 'SavedSearch', foreign_key: :deleter_id, inverse_of: :deleter

  has_many :created_analysis_jobs, class_name: 'AnalysisJob', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_analysis_jobs, class_name: 'AnalysisJob', foreign_key: :updater_id, inverse_of: :updater
  has_many :deleted_analysis_jobs, class_name: 'AnalysisJob', foreign_key: :deleter_id, inverse_of: :deleter

  has_many :permissions, inverse_of: :user
  has_many :created_permissions, class_name: 'Permission', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_permissions, class_name: 'Permission', foreign_key: :updater_id, inverse_of: :updater

  has_many :created_projects, class_name: 'Project', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_projects, class_name: 'Project', foreign_key: :updater_id, inverse_of: :updater
  has_many :deleted_projects, class_name: 'Project', foreign_key: :deleter_id, inverse_of: :deleter

  has_many :created_regions, class_name: 'Region', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_regions, class_name: 'Region', foreign_key: :updater_id, inverse_of: :updater
  has_many :deleted_regions, class_name: 'Region', foreign_key: :deleter_id, inverse_of: :deleter

  has_many :created_harvests, class_name: 'Harvest', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_harvests, class_name: 'Harvest', foreign_key: :updater_id, inverse_of: :updater

  has_many :created_scripts, class_name: 'Script', foreign_key: :creator_id, inverse_of: :creator

  has_many :created_sites, class_name: 'Site', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_sites, class_name: 'Site', foreign_key: :updater_id, inverse_of: :updater
  has_many :deleted_sites, class_name: 'Site', foreign_key: :creator_id, inverse_of: :deleter

  has_many :created_tags, class_name: 'Tag', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_tags, class_name: 'Tag', foreign_key: :updater_id, inverse_of: :updater

  has_many :created_tag_groups, class_name: 'TagGroup', foreign_key: :creator_id, inverse_of: :creator

  has_many :created_datasets, class_name: 'Dataset', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_datasets, class_name: 'Dataset', foreign_key: :updater_id, inverse_of: :updater
  has_many :created_dataset_items, class_name: 'DatasetItem', foreign_key: :creator_id, inverse_of: :creator
  has_many :created_progress_events, class_name: 'ProgressEvent', foreign_key: :creator_id, inverse_of: :creator

  has_many :created_studies, class_name: 'Study', foreign_key: :creator_id, inverse_of: :creator
  has_many :created_questions, class_name: 'Question', foreign_key: :creator_id, inverse_of: :creator
  has_many :created_responses, class_name: 'Response', foreign_key: :creator_id, inverse_of: :creator
  has_many :updated_studies, class_name: 'Study', foreign_key: :updater_id, inverse_of: :updater
  has_many :updated_questions, class_name: 'Question', foreign_key: :updater_id, inverse_of: :updater

  has_one :statistics, class_name: Statistics::UserStatistics.name

  # scopes
  scope :users, -> { where(roles_mask: 2) }
  scope :recently_seen,
    lambda { |time|
      where(
        (arel_table[:last_seen_at] > time)
        .or(arel_table[:current_sign_in_at] > time)
        .or(arel_table[:last_sign_in_at] > time)
      )
    }

  # store preferences as json in a text column
  serialize :preferences, JSON

  # validations
  validates :user_name,
    presence: true,
    uniqueness: { case_sensitive: false },
    format: {
      with: /\A[a-zA-Z0-9 _-]+\z/,
      message: 'Only letters, numbers, spaces ( ), underscores (_) and dashes (-) are valid.'
    },
    if: proc { |user| user.user_name_changed? }

  validate :excluded_login, on: :create

  def excluded_login
    reserved_user_names = ['admin', 'harvester', 'analysis_runner', 'root', 'superuser', 'administrator', 'admins',
                           'administrators']
    errors.add(:login, 'is reserved') if reserved_user_names.include?(login.downcase)
    errors.add(:user_name, 'is reserved') if reserved_user_names.include?(user_name.downcase)
  end

  # format, uniqueness, and presence are validated by devise
  # Validatable component
  # validates :email,
  #           presence: true,
  #           uniqueness: true,
  #           format: {with:VALID_EMAIL_REGEX, message: 'Basic email validation failed. It should have at least 1 `@` and 1 `.`'}

  validates :roles_mask, presence: true
  validates_attachment_content_type :image, content_type: %r{^image/(jpg|jpeg|pjpeg|png|x-png|gif)$},
    message: 'file type %{value} is not allowed (only jpeg/png/gif images)'

  # before and after methods
  before_validation :ensure_user_role

  after_create :special_after_create_actions

  # get's a file system safe ([-A-Za-z0-9_]) version of the user name
  # @return [String]
  def safe_user_name
    user_name
      .gsub("'", '')
      .gsub(/[^-_A-Za-z0-9]+/, '-')
      .delete_prefix('-')
      .delete_suffix('-')
  end

  # Get the last time this user was seen.
  # @return [DateTime] Date this user was last seen
  def get_last_seen
    last_seen = last_seen_at
    last_seen = current_sign_in_at if last_seen.blank?
    last_seen = last_sign_in_at if last_seen.blank?
    last_seen
  end

  # Length of time this person has been a member.
  # @return [DateTime] Membership duration
  def get_membership_duration
    Time.zone.now - created_at
  end

  def ensure_authentication_token
    self.authentication_token = generate_authentication_token if authentication_token.blank?
  end

  def reset_authentication_token!
    self.authentication_token = generate_authentication_token
    save
  end

  def self.same_user?(user1, user2)
    if user1.blank? || user2.blank?
      false
    else
      user1 == user2
    end
  end

  def self.profile_paths(user)
    [Api::UrlHelpers.my_account_path, Api::UrlHelpers.user_account_path(user)]
  end

  def self.profile_edit_paths(user)
    [Api::UrlHelpers.edit_user_registration_path, Api::UrlHelpers.edit_user_account_path(user)]
  end

  # Change the behaviour of the auth action to use :login rather than :email.
  # Because we want to change the behavior of the login action, we have to overwrite
  # the find_for_database_authentication method. The method's stack works like this:
  # find_for_database_authentication calls find_for_authentication which calls
  # find_first_by_auth_conditions. Overriding the find_for_database_authentication
  # method allows you to edit database authentication; overriding find_for_authentication
  # allows you to redefine authentication at a specific point (such as token, LDAP or database).
  # Finally, if you override the find_first_by_auth_conditions method, you can customize
  # finder methods (such as authentication, account unlocking or password recovery).
  def self.find_first_by_auth_conditions(warden_conditions)
    conditions = warden_conditions.dup
    login = conditions.delete(:login)
    if login
      where(conditions)
        .where(['lower(user_name) = :value OR lower(email) = :value', { value: login.downcase }])
        .first
    else
      where(conditions).first
    end
  end

  # @see http://stackoverflow.com/a/19071745/31567
  def self.find_by_authentication_token(authentication_token = nil)
    where(authentication_token:).first if authentication_token
  end

  # Store the current_user id in the thread so it can be accessed by models
  def self.stamper=(object)
    object_stamper = object.is_a?(ActiveRecord::Base) ? object.send(object.class.primary_key.to_s.to_sym) : object
    Thread.current["#{to_s.downcase}_#{object_id}_stamper"] = object_stamper
  end

  # Retrieves the existing stamper (current_user id) for the current request.
  def self.stamper
    Thread.current["#{to_s.downcase}_#{object_id}_stamper"]
  end

  # Finds the admin user
  # @return [User]
  def self.admin_user
    @admin_user ||= User.where(user_name: ADMIN_USER_NAME).first
  end

  # Finds the harvester user
  # @return [User]
  def self.harvester_user
    @admin_user ||= User.where(user_name: ADMIN_USER_NAME).first
  end

  # Define filter api settings
  def self.filter_settings
    {
      valid_fields: [:id, :user_name, :roles_mask, :last_seen_at, :created_at, :updated_at],
      render_fields: [:id, :user_name, :roles_mask],
      text_fields: [:user_name],
      custom_fields: lambda { |item, user|
                       # 'item' is the user being processed, 'user' is the currently logged in user
                       is_admin = Access::Core.is_admin?(user)
                       is_same_user = item == user

                       # do a query for the attributes that may not be in the projection
                       # instance or id can be nil
                       fresh_user = User.find(item.id)

                       user_hash =
                         {
                           timezone_information: fresh_user.timezone,
                           roles_mask_names: fresh_user.roles,
                           image_urls: Api::Image.image_urls(fresh_user.image)
                         }

                       if is_admin || is_same_user
                         user_hash[:last_seen_at] = fresh_user.last_seen_at
                         user_hash[:preferences] = fresh_user.preferences
                       end

                       user_hash[:is_confirmed] = fresh_user.confirmed? if is_admin

                       [item, user_hash]
                     },
      controller: :user_accounts,
      action: :filter,
      defaults: {
        order_by: :user_name,
        direction: :asc
      }
    }
  end

  private

  def ensure_user_role
    roles << :user if roles_mask.blank?
  end

  def special_after_create_actions
    return if skip_creation_email

    # WARNING: if this raises an error, the user will not be created and the page will be redirected to the home page
    # notify us of new user sign ups
    user_info_hash = { name: user_name, email: }
    user_info = DataClass::NewUserInfo.new(user_info_hash)
    PublicMailer.new_user_message(self, user_info).deliver_now
  end

  def generate_authentication_token
    loop do
      token = Devise.friendly_token
      break token unless User.where(authentication_token: token).first
    end
  end
end