app/models/user.rb
# A User is a TaxonWorks user, at present someone who can logon to the private workebench.
#
# All Data Models contain created_by_id and updated_by_id that references a User.
#
# A user may have a number of *attributes* that define roles/subclasses of a sort:
#
# 1) Administrators (User#is_administrator = true). An administrator can do absolutely everything, in any
# project, and across any project, *except* set User#is_administrator = false. It is intended that there
# be only 1-2 administrators per instance of TaxonWorks.
#
# 2) Project Administrators (ProjectMember#is_project_administrator).
# A project administrator can set Project settings and preferences, including the views that a Worker can see.
#
# 3) Superuser. A super_user (code only) is a User that is a profromct administrator OR administrator.
#
# 4) Worker. A worker is a User that can only see parts of the workbench allowed by a ProjectAdministrator.
#
# Data models in TaxonWorks reference People, who may have roles as Sources (or others), i.e. Users are not "data" and
# not linked directly to People records.
#
# Users must never be shared by real-life humans.
#
# @!attribute email
# @return [String]
# the users email, and login.
#
# @!attribute password_digest
# @return [String]
# the users password
#
# @!attribute remember_token
# @return [String]
# @todo
#
# @!attribute is_administrator
# @return [Boolean]
# true if user is an administrator, administrators can do *everything* in any project taxonworks
#
# @!attribute hub_favorites
# @return [Hash]
# per project favorites named from items in user_tasks.yml or hub_data.yml
# format is
# { project_id: {data: [ 'ModelName' ], tasks: [ :task_index_name ] }, ... }
#
# @!attribute password_reset_token
# @return [String]
# if user has requested a password reset the token is stored here
#
# @!attribute password_reset_token_date
# @return [DateTime]
# helps determine how long the password reset token is valid
#
# @!attribute name
# @return [String]
# a users name: Not intended to be a nickname, but this is loosely enforced. Attribute is intended to identify a human who owns this account.
#
# @!attribute current_sign_in_at
# @return [ActiveSupport::TimeWithZone]
# time of current sign in
#
# @!attribute last_sign_in_at
# @return [ActiveSupport::TimeWithZone]
# time of sign in prior to this sign in
#
# @!attribute time_active
# @return [Integer, nil]
# estimated time in seconds
#
# @!attribute last_sign_in_ip
# @return [String]
# IP address of the machine user used to log in from prior to this current log in
#
# @!attribute current_sign_in_ip
# @return [String]
# IP address of the machine user is currently logged in from
#
# @!attribute hub_tab_order
# @return [Array]
# tabs, referenced as Strings, defining the users preference for their order
#
# @!attribute api_access_token
# @return [String]
# authentication token used to authenticate against /api endpoints
#
# @!attribute is_flagged_for_password_reset
# @return [Boolean]
# when true user must reset their password before doing anything further
#
# @!attribute footprints
# @return [Hash]
# tracks the users recent requests
#
# @!attribute sign_in_count
# @return [Integer]
# a count of the number of times a user has logged in
#
# @!attribute self_created [r]
# @return [true, false]
# Only used for when .new_record? is true. If true assigns creator and updater as self.
#
# @!attribute preferences [JSON]
# @return [true, false]
# Only used for when .new_record? is true. If true assigns creator and updater as self.
#
class User < ApplicationRecord
include Shared::Identifiers # TODO: this is required before Housekeeping::Users, resolve
include User::Preferences
include Shared::DataAttributes
include Shared::Notes
include Shared::Tags
include Housekeeping::Users
include Housekeeping::Timestamps
include Housekeeping::AssociationHelpers
include Shared::RandomTokenFields[:password_reset]
has_secure_password
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
HUB_FAVORITES = {'data' => [], 'tasks' => []}.freeze
store :preferences, accessors: [:disable_chime], coder: JSON
attr_accessor :set_new_api_access_token
attr_accessor :self_created
belongs_to :person, inverse_of: :user
before_validation { self.email = email.to_s.downcase }
before_save :generate_api_access_token, if: :set_new_api_access_token
# @todo downcase does not work for non-ascii characters which means our validation for uniqueness will fail ... why?
# @see http://stackoverflow.com/questions/2049502/what-characters-are-allowed-in-email-address
# @see http://unicode-utils.rubyforge.org/
before_save { self.email = email.to_s.downcase }
after_save :configure_self_created, if: :self_created
before_create :set_remember_token
before_create { self.hub_tab_order = DEFAULT_HUB_TAB_ORDER }
validates :email, presence: true,
format: {with: VALID_EMAIL_REGEX},
uniqueness: true
validates :password,
length: {minimum: 8, if: :validate_password?},
confirmation: {if: :validate_password?}
validates :name, presence: true
validates :name, length: {minimum: 2}, unless: -> { self.name.blank? }
has_many :project_members, dependent: :destroy
has_many :projects, through: :project_members
has_many :pinboard_items, dependent: :destroy
scope :is_administrator, -> { where(is_administrator: true) }
# @return [Scope] of projects
def administered_projects
projects.where(id: project_members.where(is_project_administrator: true).pluck(:project_id))
end
# @return [Boolean]
def administers_projects?
administered_projects.any?
end
# @return [Boolean]
def curates_data?
Project::MANIFEST.each do |m|
return true if creates_data_of_type?(m.safe_constantize)
end
false
end
# @return [Array]
def data_types_added
types = []
Project::MANIFEST.each do |m|
types.push(m) if creates_data_of_type?(m.safe_constantize)
end
types
end
def creates_data_of_type?(klass)
klass.column_names.include?('created_by_id') && (klass.where(created_by_id: id).or(klass.where(updated_by_id: id))).any?
end
# @return
def self.batch_create(users: '', create_api_token: false, is_administrator: false, project_id: nil, created_by: nil)
return [] if users.blank? || created_by.nil?
v = []
users.split("\n").each do |r|
next if r.blank?
email, name = r.split(',')
p = SecureRandom.hex
u = User.create(
email:,
name:,
set_new_api_access_token: create_api_token,
is_administrator:,
by: created_by,
password: p,
password_confirmation: p,
is_flagged_for_password_reset: true
)
v.push u
if project_id.present? && u.valid?
ProjectMember.create(user: u, project_id:)
end
end
v
end
# TODO: deprecate for a User filter query
# @param [String, User, Integer, Array] users
# @return [Array of Integers] selected user ids
def self.get_user_ids(*users)
user_ids = []
users.flatten.each { |user|
case user.class.name
when 'String'
# search by name or email
ut = User.arel_table
c1 = ut[:name].eq(user)
.or(ut[:name].matches("%#{user}"))
.or(ut[:name].matches("%#{user}%"))
.or(ut[:email].eq(user))
.or(ut[:email].matches("%#{user}"))
.or(ut[:email].matches("%#{user}%")).to_sql
user_ids.push(User.where(c1).pluck(:id))
when 'User'
user_ids.push(user.id)
when 'Integer'
user_ids.push(user)
end
}
user_ids.flatten.uniq
end
# @param [Integer] project_id
# @return [Scope] of users
def self.not_in_project(project_id)
ids = ProjectMember.where(project_id:).pluck(:user_id)
return where(false) if ids.empty?
User.where(User.arel_table[:id].not_eq_all(ids))
end
# @param [Integer] project_id
# @return [Scope] of ids for users in the project
def self.in_project(project_id = Current.project_id )
ProjectMember.where(project_id:).distinct.pluck(:user_id)
end
# @return [String] of token
def User.secure_random_token
SecureRandom.urlsafe_base64
end
# @param [String] token
# @return [String]
def User.encrypt(token)
Digest::SHA1.hexdigest(token.to_s)
end
# @param [Project] project
# @return [Boolean] true if user is_administrator or is_project_administrator
def is_superuser?(project = nil)
is_administrator || is_project_administrator?(project)
end
# @return [Boolean] true if is_administrator = true
def is_administrator?
is_administrator.blank? ? false : true
end
# @param [Project] project
# @return [Boolean] true if user is_project_administrator for the project passed
def is_project_administrator?(project = nil)
return false if project.nil?
project.project_members.where(user_id: id).first.is_project_administrator
end
# @param [Project, Integer]
# @return [Boolean]
def member_of?(project)
ProjectMember.where(project_id: project, user_id: self.id).any?
end
# @return [Hash]
def hub_favorites
read_attribute(:hub_favorites) || {}
end
# rubocop:disable Style/StringHashKeys
# @param [Hash] options
# @return [Boolean] always true
def add_page_to_favorites(options = {}) # name: nil, kind: nil, project_id: nil
validate_favorite_options(options)
n = options[:name]
p = options[:project_id].to_s
k = options[:kind]
u = hub_favorites.dup
u[p] = HUB_FAVORITES.dup if !u[p]
u[p][k] = u[p][k].push(n).uniq[0..39].sort
update_column(:hub_favorites, u)
true
end
# rubocop:enable Style/StringHashKeys
# TODO: move to User concern
# @param [Hash] options
def remove_page_from_favorites(options = {}) # name: nil, kind: nil, project_id: nil
validate_favorite_options(options)
new_routes = hub_favorites.dup
new_routes[options['project_id'].to_s][options['kind']].delete(options['name'])
update_column(:hub_favorites, new_routes)
end
# TODO: move to User concern
# @param [Hash] options
# @return [Boolean]
def validate_favorite_options(options)
return false if !options.select { |k, v| k.nil? || v.nil? }.empty?
return false if !member_of?(options['project_id'])
true
end
# TODO: move to User concern
# @return [Boolean]
# If user has been active within the last 5 minutes, and at least 5
# seconds past their last activity, update their time_active.
# The latter prevents multiple writes on many async calls.
#
def update_last_seen_at
if !last_seen_at.nil?
t = Time.now - last_seen_at
if t > 5
a = t < 301 ? time_active + t : (time_active || 0)
if t > 5
update_columns(last_seen_at: Time.now, time_active: a)
end
end
else
update_columns(last_seen_at: Time.now)
end
update_project_member_last_seen_at
true
end
# TODO: we still global track at User, this is hit only when that
# ticker ticks
# perhaps seperate for performace
def update_project_member_last_seen_at
if Current.project_id && (pm = project_members.find_by(project_id: Current.project_id))
if !pm.last_seen_at.nil?
t = Time.now - pm.last_seen_at
if t > 5
a = t < 301 ? (pm.time_active || 0) + t : (pm.time_active || 0)
if t > 5
pm.update_columns(last_seen_at: Time.now, time_active: a)
end
end
else
pm.update_columns(last_seen_at: Time.now)
end
end
end
# TODO: move to User concern
# @param [String] recent_route
# @param [Object] recent_object
# @return [Boolean] always true
def add_recently_visited_to_footprint(recent_route, recent_object = nil)
case recent_route
when /\A\/\Z/ # the root path '/'
when /\A\/hub/ # any path which starts with '/hub'
when /\/autocomplete\?/ # any path used for AJAX autocomplete
else
fp = footprints.dup
fp['recently_visited'] ||= []
attrs = {recent_route => {}}
if !recent_object.nil?
attrs[recent_route].merge!(object_type: recent_object.class.to_s, object_id: recent_object.id)
end
fp['recently_visited'].unshift(attrs)
fp['recently_visited'] = fp['recently_visited'].uniq { |a| a.keys }[0..19]
self.footprints_will_change! # if this isn't thrown weird caching happens !
self.update_column(:footprints, fp)
end
true
end
# TODO: This needs to show cross-project pinboard items as well
# @param [Integer] project_id
# @return [Scope] of pinboard items
def pinboard_hash(project_id)
h = {}
pinboard_items.where(project_id:).order('pinned_object_type DESC, position').each do |i|
l = i.pinned_object_type == 'ControlledVocabularyTerm' ? i.pinned_object.class.name : i.pinned_object_type
h[l] ||= []
h[l].push i
end
h
end
# @param [String] klass
# @return [Integer] the total records of this klass created by this user
def total_objects(klass) # klass_name is a string, need .constantize in next line
klass.where(creator: self).count
end
# @param [String] klass_string
# @return [Integer]
def total_objects2(klass_string)
self.send("created_#{klass_string}").count #klass.where(creator:self).count
end
# rubocop:disable Metrics/MethodLength
# @return [Hash]
# @user.get_class_created_updated # => { "projects" => {created: 10, first_created: datetime, updated: 10, last_updated: datetime} }
def get_class_created_updated
# Rails.application.eager_load! if Rails.env.development?
data = {}
User.reflect_on_all_associations(:has_many).each do |r|
key = nil
# puts r.name.to_s
if r.name.to_s =~ /created_/
# puts "after created"
key = :created
elsif r.name.to_s =~ /updated_/
# puts "after updated"
key = :updated
end
if key
n = r.klass.name.underscore.humanize.pluralize
count = self.send(r.name).count
if data[n]
data[n][key] = count
else
data[n] = {key => count}
end
if count == 0
data[n][:first_created] = 'n/a'
data[n][:last_updated] = 'n/a'
else
data[n][:first_created] = self.send(r.name).limit(1).order(created_at: :asc).first.created_at
data[n][:last_updated] = self.send(r.name).limit(1).order(updated_at: :desc).first.updated_at
end
end
end
data
end
# rubocop:enable Metrics/MethodLength
# @return [String]
def generate_api_access_token
self.api_access_token = Utilities::RandomToken.generate
end
# @return [Boolean] always true
def require_password_presence
@require_password_presence = true
end
def orcid
return nil unless person
person.identifiers.where(type: 'Identifier::Global::Orcid').first&.identifier
end
def wikidata_id
return nil unless person
person.identifiers.where(type: 'Identifier::Global::Wikidata').first&.identifier
end
# @return Array of Projects
# A quick, not comprehensive check of what projects User has touched data in
def data_in_projects
scan = [TaxonName, Citation, CollectionObject, CollectingEvent, Image, AssertedDistribution, Role]
found = []
Project.pluck(:id, :name).each do |i, name|
scan.each do |k|
if k.where('(updated_by_id = ? OR created_by_id = ?) AND project_id = ?', id, id, i).any?
found.push name
break
end
end
end
found
end
def transfer_housekeeping(target_user)
models = ApplicationEnumeration.superclass_models.select { |m| m < Housekeeping::Users }
User.transaction do
models.each do |model|
model.where(created_by_id: self.id).update_all(created_by_id: target_user.id)
model.where(updated_by_id: self.id).update_all(updated_by_id: target_user.id)
end
end
end
def transfer_projects_membership(target_user)
User.transaction do
ProjectMember.where(user_id: self.id)
.where.not(project_id: target_user.projects)
.update_all(user_id: target_user.id)
ProjectMember.where(user_id: self.id).delete_all
end
end
private
# @return [String]
def set_remember_token
self.remember_token = User.encrypt(User.secure_random_token)
end
# @return [Boolean]
def validate_password?
password.present? || password_confirmation.present? || @require_password_presence
end
def configure_self_created
if !self.new_record? && self.creator.nil? && self.updater.nil?
self.update_columns(created_by_id: self.id, updated_by_id: self.id) # !?
end
end
end