loomio/loomio

View on GitHub
app/models/user.rb

Summary

Maintainability
C
1 day
Test Coverage
class User < ApplicationRecord
  include CustomCounterCache::Model
  include ReadableUnguessableUrls
  include MessageChannel
  include HasExperiences
  include HasAvatar
  include SelfReferencing
  include NoForbiddenEmails
  include CustomCounterCache::Model
  include HasRichText
  include LocalesHelper

  is_rich_text on: :short_bio

  extend HasTokens
  extend HasDefaults

  extend NoSpam
  no_spam_for :name, :email

  has_paper_trail only: [:name, :username, :email, :email_newsletter, :deactivated_at, :deactivator_id]

  MAX_AVATAR_IMAGE_SIZE_CONST = 100.megabytes
  BOT_EMAILS = {
    helper_bot: ENV['HELPER_BOT_EMAIL'] || 'contact@loomio.org',
    demo_bot:   ENV['DEMO_BOT_EMAIL'] || 'contact+demo@loomio.org'
  }.freeze

  devise :database_authenticatable, :recoverable, :registerable, :rememberable, :lockable, :trackable
  devise :pwned_password unless Rails.env.test?
  attr_accessor :recaptcha
  attr_accessor :restricted
  attr_accessor :token
  attr_accessor :membership_token
  attr_accessor :group_token
  attr_accessor :discussion_reader_token
  attr_accessor :stance_token

  attr_accessor :legal_accepted

  attr_writer   :has_password
  attr_accessor :require_valid_signup
  attr_accessor :require_recaptcha

  before_save :set_legal_accepted_at, if: :legal_accepted

  validates :email, presence: true, email: true, length: {maximum: 200}, if: -> { !bot }

  validates :name,               presence: true, if: :require_valid_signup
  validates :legal_accepted,     presence: true, if: :require_legal_accepted
  validate  :validate_recaptcha,                 if: :require_recaptcha

  has_one_attached :uploaded_avatar

  validates_uniqueness_of :email, conditions: -> { where(email_verified: true) }, if: :email_verified?
  validates_uniqueness_of :username, if: :email
  before_validation :generate_username, if: :email
  validates_length_of :name, maximum: 100
  validates_length_of :username, maximum: 30
  validates_length_of :short_bio, maximum: 5000
  validates_format_of :username, with: /\A[a-z0-9]*\z/, message: I18n.t(:'user.error.username_must_be_alphanumeric')
  validates_confirmation_of :password, if: :password_required?

  validates_length_of :password, minimum: 8, allow_nil: true

  has_many :admin_memberships,
           -> { where('memberships.admin': true, revoked_at: nil) },
           class_name: 'Membership'

  has_many :memberships, -> { active }, dependent: :destroy
  has_many :all_memberships, dependent: :destroy, class_name: "Membership"

  has_many :adminable_groups,
           -> { where(archived_at: nil) },
           through: :admin_memberships,
           class_name: 'Group',
           source: :group

  has_many :membership_requests,
           foreign_key: 'requestor_id',
           dependent: :destroy

  has_many :groups,
           -> { where archived_at: nil },
           through: :memberships

  has_many :discussions, through: :groups

  has_many :authored_discussions, class_name: 'Discussion', foreign_key: 'author_id', dependent: :destroy
  has_many :authored_polls, class_name: 'Poll', foreign_key: :author_id, dependent: :destroy
  has_many :created_groups, class_name: 'Group', foreign_key: :creator_id, dependent: :destroy

  has_many :identities, class_name: "Identities::Base", dependent: :destroy

  has_many :reactions, dependent: :destroy
  has_many :stances, foreign_key: :participant_id, dependent: :destroy
  has_many :participated_polls, through: :stances, source: :poll
  has_many :group_polls, through: :groups, source: :polls

  has_many :discussion_readers, dependent: :destroy
  has_many :guest_discussion_readers, -> { DiscussionReader.active.guests }, class_name: 'DiscussionReader', dependent: :destroy
  has_many :guest_discussions, through: :guest_discussion_readers, source: :discussion
  has_many :guest_stances, -> { Stance.latest.guests }, class_name: 'Stance', dependent: :destroy, foreign_key: :participant_id
  has_many :guest_polls, through: :guest_stances, source: :poll
  has_many :notifications, dependent: :destroy
  has_many :comments, dependent: :destroy
  has_many :documents, foreign_key: :author_id, dependent: :destroy
  has_many :login_tokens, dependent: :destroy
  has_many :events, dependent: :destroy

  has_many :tags, through: :groups

  before_save :set_avatar_initials
  initialized_with_token :unsubscribe_token,        -> { Devise.friendly_token }
  initialized_with_token :email_api_key,            -> { SecureRandom.hex(16) }
  initialized_with_token :api_key,                  -> { SecureRandom.hex(16) }

  enum default_membership_volume: [:mute, :quiet, :normal, :loud]

  scope :active, -> { where(deactivated_at: nil) }
  scope :inactive, -> { where("deactivated_at IS NOT NULL") }
  scope :sorted_by_name, -> { order("lower(name)") }
  scope :admins, -> { where(is_admin: true) }
  scope :coordinators, -> { joins(:memberships).where('memberships.admin = ?', true).group('users.id') }
  scope :verified, -> { where(email_verified: true) }
  scope :unverified, -> { where(email_verified: false) }
  scope :search_for, -> (q) { where("users.name ilike :first OR users.name ilike :other OR users.username ilike :first OR users.email ilike :first", first: "#{q}%", other:  "% #{q}%") }
  scope :visible_by, -> (user) { distinct.active.verified.joins(:memberships).where("memberships.group_id": user.group_ids).where.not(id: user.id) }
  scope :humans, -> { where(bot: false) }
  scope :bots, -> { where(bot: true) }

  scope :mention_search, -> (user, model, query) do
    return self.none unless model.present?
    ids = []

    if model.group_id
      ids += Membership.active.where(group_id: model.group_id).pluck(:user_id) if model.group_id
    end

    if model.discussion_id
      ids += DiscussionReader.active.guests.where(discussion_id: model.discussion_id).pluck(:user_id)
    end

    if model.poll_id
      ids += Stance.latest.guests.where(poll_id: model.poll_id).pluck(:participant_id)
    end

    if model.respond_to?(:poll_ids) and model.poll_ids.any?
      ids += Stance.latest.guests.where(poll_id: model.poll_ids).pluck(:participant_id)
    end

    active.search_for(query).where(id: ids)
  end

  scope :email_when_proposal_closing_soon, -> { active.where(email_when_proposal_closing_soon: true) }

  scope :email_proposal_closing_soon_for, -> (group) {
     email_when_proposal_closing_soon
    .joins(:memberships)
    .where('memberships.group_id': group.id)
  }

  def default_format
    if experiences['html-editor.uses-markdown']
      'md'
    else
      'html'
    end
  end

  def date_time_pref
    self[:date_time_pref] || 'day_abbr'
  end

  def author
    self
  end

  def is_paying?
    group_ids = self.group_ids.concat(self.groups.pluck(:parent_id).compact).uniq
    Group.where(id: group_ids).where(parent_id: nil).joins(:subscription).where.not('subscriptions.plan': 'trial').exists?
  end

  def is_paying
    is_paying?
  end

  def invitations_rate_limit
    if user.is_paying?
      ENV.fetch('PAID_INVITATIONS_RATE_LIMIT', 50000)
    else
      ENV.fetch('TRIAL_INVITATIONS_RATE_LIMIT', 500)
    end.to_i
  end

  def browseable_group_ids
    Group.where(
      "id in (:group_ids) OR
      (parent_id in (:group_ids) AND is_visible_to_parent_members = TRUE)",
      group_ids: self.group_ids).pluck(:id)
  end

  def set_legal_accepted_at
    self.legal_accepted_at = Time.now
  end

  def require_legal_accepted
    self.require_valid_signup && ENV['TERMS_URL']
  end

  def self.email_status_for(email)
    find_by(email: email)&.email_status || :unused
  end

  def self.find_for_database_authentication(warden_conditions)
    super(warden_conditions.merge(email_verified: true))
  end

  define_counter_cache(:memberships_count) {|user| user.memberships.count }

  def associate_with_identity(identity)
    if existing = identities.find_by(user: self, uid: identity.uid, identity_type: identity.identity_type)
      existing.update(access_token: identity.access_token)
      identity = existing
    else
      identities.push(identity)
    end

    update(name: identity.name) if self.name.nil?
    identity.assign_logo! unless self.avatar_url
    self
  end

  def identity_for(type)
    identities.find_by(identity_type: type)
  end

  def first_name
    name.split(' ').first
  end

  def last_name
    name.split(' ').drop(1).join(' ')
  end

  def remember_me
    true
  end

  def is_logged_in?
    true
  end

  def has_password
    self.encrypted_password.present?
  end

  def email_status
    if deactivated_at.present? then :inactive else :active end
  end

  def name_and_email
    "\"#{name}\" <#{email}>"
  end

  # Provide can? and cannot? as methods for checking permissions
  def ability
    @ability ||= ::Ability::Base.new(self)
  end

  delegate :can?, :cannot?, :to => :ability

  def is_member_of?(group)
    !!memberships.find_by(group_id: group&.id)
  end

  def is_admin_of?(group)
    !!memberships.find_by(group_id: group&.id, admin: true)
  end

  def first_name
    self.name.to_s.split(' ').first
  end

  def time_zone
    return 'UTC' if self[:time_zone] == "Etc/Unknown"
    self[:time_zone] || 'UTC'
  end

  def self.helper_bot
    verified.find_by(email: BOT_EMAILS[:helper_bot]) ||
    create!(email: BOT_EMAILS[:helper_bot],
            name: 'Loomio Helper Bot',
            password: SecureRandom.hex(20),
            email_verified: true,
            avatar_kind: :gravatar)
  end

  def self.demo_bot
    verified.find_by(email: BOT_EMAILS[:helper_bot]) ||
    create!(email: BOT_EMAILS[:demo_bot],
            name: 'Loomio Demo bot',
            email_verified: true,
            avatar_kind: :gravatar)
  end

  def name
    if deactivated_at && AppConfig.app_features[:scrub_user_deactivate]
      I18n.t('profile_page.deactivated_user')
    else
      self[:name]
    end
  end

  def name_or_username
    self[:name] || self[:username]
  end

  # http://stackoverflow.com/questions/5140643/how-to-soft-delete-user-with-devise/8107966#8107966
  def active_for_authentication?
    super && !deactivated_at
  end

  def locale
    first_supported_locale([selected_locale, detected_locale].compact)
  end

  def update_detected_locale(locale)
    self.update_attribute(:detected_locale, locale) if self.detected_locale&.to_sym != locale.to_sym
  end

  def generate_username
    self.username ||= ::UsernameGenerator.new(self).generate
  end

  def send_devise_notification(notification, *args)
    I18n.with_locale(locale) { devise_mailer.send(notification, self, *args).deliver_now }
  end

  def self.ransackable_attributes(auth_object = nil)
    [
    "avatar_initials",
    "avatar_kind",
    "city",
    "content_locale",
    "country",
    "created_at",
    "current_sign_in_at",
    "current_sign_in_ip",
    "date_time_pref",
    "deactivated_at",
    "detected_locale",
    "email",
    "email_catch_up",
    "email_catch_up_day",
    "email_newsletter",
    "email_on_participation",
    "email_verified",
    "email_when_mentioned",
    "email_when_proposal_closing_soon",
    "id",
    "is_admin",
    "key",
    "last_seen_at",
    "last_sign_in_at",
    "last_sign_in_ip",
    "legal_accepted_at",
    "link_previews",
    "location",
    "locked_at",
    "memberships_count",
    "name",
    "region",
    "secret_token",
    "selected_locale",
    "short_bio",
    "short_bio_format",
    "sign_in_count",
    "time_zone",
    "updated_at",
    "uploaded_avatar_content_type",
    "uploaded_avatar_file_name",
    "uploaded_avatar_file_size",
    "uploaded_avatar_updated_at",
    "username"]
  end

  protected

  def password_required?
    !password.nil? || !password_confirmation.nil?
  end

  private

  def validate_recaptcha
    return unless ENV['RECAPTCHA_APP_KEY']
    return if Clients::Recaptcha.instance.validate(self.recaptcha)
    # Sentry.capture_message("recaptcha failed", extra: {email: email})
    self.errors.add(:recaptcha, I18n.t(:"user.error.recaptcha"))
  end
end