otwcode/otwarchive

View on GitHub
app/models/user.rb

Summary

Maintainability
D
2 days
Test Coverage
class User < ApplicationRecord
  audited redacted: [:encrypted_password, :password_salt]
  include WorksOwner
  include PasswordResetsLimitable
  include UserLoggable

  devise :database_authenticatable,
         :confirmable,
         :registerable,
         :rememberable,
         :trackable,
         :validatable,
         :lockable,
         :recoverable

  # Must come after Devise modules in order to alias devise_valid_password?
  # properly
  include BackwardsCompatiblePasswordDecryptor

  # Allows other models to get the current user with User.current_user
  cattr_accessor :current_user

  # Authorization plugin
  acts_as_authorized_user
  acts_as_authorizable
  has_many :roles_users
  has_many :roles, through: :roles_users, dependent: :destroy

  ### BETA INVITATIONS ###
  has_many :invitations, as: :creator
  has_one :invitation, as: :invitee
  has_many :user_invite_requests, dependent: :destroy

  attr_accessor :invitation_token
  # attr_accessible :invitation_token
  after_create :mark_invitation_redeemed, :remove_from_queue

  has_many :external_authors, dependent: :destroy
  has_many :external_creatorships, foreign_key: "archivist_id"

  has_many :fannish_next_of_kins, dependent: :delete_all, inverse_of: :kin, foreign_key: :kin_id
  has_one :fannish_next_of_kin, dependent: :destroy

  has_many :favorite_tags, dependent: :destroy

  has_one :default_pseud, -> { where(is_default: true) }, class_name: "Pseud", inverse_of: :user
  delegate :id, to: :default_pseud, prefix: true

  has_many :pseuds, dependent: :destroy
  validates_associated :pseuds

  has_one :profile, dependent: :destroy
  validates_associated :profile

  has_one :preference, dependent: :destroy
  validates_associated :preference

  has_many :blocks_as_blocked, class_name: "Block", dependent: :delete_all, inverse_of: :blocked, foreign_key: :blocked_id
  has_many :blocks_as_blocker, class_name: "Block", dependent: :delete_all, inverse_of: :blocker, foreign_key: :blocker_id
  has_many :blocked_users, through: :blocks_as_blocker, source: :blocked

  # The block (if it exists) with this user as the blocker and
  # User.current_user as the blocked:
  has_one :block_of_current_user,
          -> { where(blocked: User.current_user) },
          class_name: "Block", foreign_key: :blocker_id, inverse_of: :blocker

  # The block (if it exists) with User.current_user as the blocker and this
  # user as the blocked:
  has_one :block_by_current_user,
          -> { where(blocker: User.current_user) },
          class_name: "Block", foreign_key: :blocked_id, inverse_of: :blocked

  has_many :mutes_as_muted, class_name: "Mute", dependent: :delete_all, inverse_of: :muted, foreign_key: :muted_id
  has_many :mutes_as_muter, class_name: "Mute", dependent: :delete_all, inverse_of: :muter, foreign_key: :muter_id
  has_many :muted_users, through: :mutes_as_muter, source: :muted

  # The mute (if it exists) with User.current_user as the muter and this
  # user as the muted:
  has_one :mute_by_current_user,
          -> { where(muter: User.current_user) },
          class_name: "Mute", foreign_key: :muted_id, inverse_of: :muted

  has_many :skins, foreign_key: "author_id", dependent: :nullify
  has_many :work_skins, foreign_key: "author_id", dependent: :nullify

  before_create :create_default_associateds
  before_destroy :remove_user_from_kudos

  before_update :add_renamed_at, if: :will_save_change_to_login?
  after_update :update_pseud_name
  after_update :log_change_if_login_was_edited
  after_update :log_email_change, if: :saved_change_to_email?

  after_commit :reindex_user_creations_after_rename

  has_many :collection_participants, through: :pseuds
  has_many :collections, through: :collection_participants
  has_many :invited_collections, -> { where("collection_participants.participant_role = ?", CollectionParticipant::INVITED) }, through: :collection_participants, source: :collection
  has_many :participated_collections, -> { where("collection_participants.participant_role IN (?)", [CollectionParticipant::OWNER, CollectionParticipant::MODERATOR, CollectionParticipant::MEMBER]) }, through: :collection_participants, source: :collection
  has_many :maintained_collections, -> { where("collection_participants.participant_role IN (?)", [CollectionParticipant::OWNER, CollectionParticipant::MODERATOR]) }, through: :collection_participants, source: :collection
  has_many :owned_collections, -> { where("collection_participants.participant_role = ?", CollectionParticipant::OWNER) }, through: :collection_participants, source: :collection

  has_many :challenge_signups, through: :pseuds
  has_many :offer_assignments, through: :pseuds
  has_many :pinch_hit_assignments, through: :pseuds
  has_many :request_claims, class_name: "ChallengeClaim", foreign_key: "claiming_user_id", inverse_of: :claiming_user
  has_many :gifts, -> { where(rejected: false) }, through: :pseuds
  has_many :gift_works, -> { distinct }, through: :pseuds
  has_many :rejected_gifts, -> { where(rejected: true) }, class_name: "Gift", through: :pseuds
  has_many :rejected_gift_works, -> { distinct }, through: :pseuds
  has_many :readings, dependent: :delete_all
  has_many :bookmarks, through: :pseuds
  has_many :bookmark_collection_items, through: :bookmarks, source: :collection_items
  has_many :comments, through: :pseuds
  has_many :kudos

  # Nested associations through creatorships got weird after 3.0.x
  has_many :creatorships, through: :pseuds

  has_many :works, -> { distinct }, through: :pseuds
  has_many :series, -> { distinct }, through: :pseuds
  has_many :chapters, through: :pseuds

  has_many :related_works, through: :works
  has_many :parent_work_relationships, through: :works

  has_many :tags, through: :works
  has_many :filters, through: :works
  has_many :direct_filters, through: :works

  has_many :bookmark_tags, through: :bookmarks, source: :tags

  has_many :subscriptions, dependent: :destroy
  has_many :followings,
           class_name: "Subscription",
           as: :subscribable,
           dependent: :destroy
  has_many :subscribed_users,
           through: :subscriptions,
           source: :subscribable,
           source_type: "User"
  has_many :subscribers,
           through: :followings,
           source: :user

  thread_cattr_accessor :should_update_wrangling_activity
  has_many :wrangling_assignments, dependent: :destroy
  has_many :fandoms, through: :wrangling_assignments
  has_many :wrangled_tags, class_name: "Tag", as: :last_wrangler
  has_one :last_wrangling_activity, dependent: :destroy

  has_many :inbox_comments, dependent: :destroy
  has_many :feedback_comments, -> { where(is_deleted: false, approved: true).order(created_at: :desc) }, through: :inbox_comments

  has_many :log_items, dependent: :destroy
  validates_associated :log_items

  after_update :expire_caches

  def expire_caches
    return unless saved_change_to_login?
    series.each(&:expire_byline_cache)
    self.works.each do |work|
      work.touch
      work.expire_caches
    end
  end

  def remove_user_from_kudos
    # TODO: AO3-2195 Display orphaned kudos (no users; no IPs so not counted as guest kudos).
    Kudo.where(user: self).update_all(user_id: nil)
  end

  def read_inbox_comments
    inbox_comments.where(read: true)
  end
  def unread_inbox_comments
    inbox_comments.where(read: false)
  end
  def unread_inbox_comments_count
    unread_inbox_comments.with_bad_comments_removed.count
  end

  scope :alphabetical, -> { order(:login) }
  scope :starting_with, -> (letter) { where('login like ?', "#{letter}%") }
  scope :valid, -> { where(banned: false, suspended: false) }
  scope :out_of_invites, -> { where(out_of_invites: true) }

  validates :login,
            length: { within: ArchiveConfig.LOGIN_LENGTH_MIN..ArchiveConfig.LOGIN_LENGTH_MAX },
            format: {
              with: /\A[A-Za-z0-9]\w*[A-Za-z0-9]\Z/,
              min_login: ArchiveConfig.LOGIN_LENGTH_MIN,
              max_login: ArchiveConfig.LOGIN_LENGTH_MAX
            },
            uniqueness: true,
            not_forbidden_name: { if: :will_save_change_to_login? }
  validate :username_is_not_recently_changed, if: :will_save_change_to_login?

  # allow nil so can save existing users
  validates_length_of :password,
                      within: ArchiveConfig.PASSWORD_LENGTH_MIN..ArchiveConfig.PASSWORD_LENGTH_MAX,
                      allow_nil: true,
                      too_short: ts("is too short (minimum is %{min_pwd} characters)",
                                    min_pwd: ArchiveConfig.PASSWORD_LENGTH_MIN),
                      too_long: ts("is too long (maximum is %{max_pwd} characters)",
                                   max_pwd: ArchiveConfig.PASSWORD_LENGTH_MAX)

  validates :email, email_format: true, uniqueness: true

  # Virtual attribute for age check and terms of service
    attr_accessor :age_over_13
    attr_accessor :terms_of_service
    # attr_accessible :age_over_13, :terms_of_service

  validates_acceptance_of :terms_of_service,
                          allow_nil: false,
                          message: ts("^Sorry, you need to accept the Terms of Service in order to sign up."),
                          if: :first_save?

  validates_acceptance_of :age_over_13,
                          allow_nil: false,
                          message: ts("^Sorry, you have to be over 13!"),
                          if: :first_save?

  def to_param
    login
  end

  # Override of Devise method to allow user to login with login OR username as
  # well as to make login case insensitive without losing user-preferred case
  # for login display
  def self.find_first_by_auth_conditions(tainted_conditions, options = {})
    conditions = devise_parameter_filter.filter(tainted_conditions).merge(options)
    login = conditions.delete(:login)
    relation = self.where(conditions)

    if login.present?
      # MySQL is case-insensitive with utf8mb4_unicode_ci so we don't have to use
      # lowercase values
      relation = relation.where(["login = :value OR email = :value",
                                 value: login])
    end

    relation.first
  end

  def self.for_claims(claims_ids)
    joins(:request_claims).
    where("challenge_claims.id IN (?)", claims_ids)
  end

  # Find users with a particular role and/or by name, email, and/or id
  # Options: inactive, page, exact
  def self.search_by_role(role, name, email, user_id, options = {})
    return if role.blank? && name.blank? && email.blank? && user_id.blank?

    users = User.distinct.order(:login)
    if options[:inactive]
      users = users.where("confirmed_at IS NULL")
    end
    if role.present?
      users = users.joins(:roles).where("roles.id = ?", role.id)
    end
    if name.present?
      users = users.filter_by_name(name, options[:exact])
    end
    if email.present?
      users = users.filter_by_email(email, options[:exact])
    end
    if user_id.present?
      users = users.where(["users.id = ?", user_id])
    end
    users.paginate(page: options[:page] || 1)
  end

  # Scope to look for users by pseud name:
  def self.filter_by_name(name, exact)
    if exact
      joins(:pseuds).where(["pseuds.name = ?", name])
    else
      joins(:pseuds).where(["pseuds.name LIKE ?", "%#{name}%"])
    end
  end

  # Scope to look for users by email:
  def self.filter_by_email(email, exact)
    if exact
      where(["email = ?", email])
    else
      where(["email LIKE ?", "%#{email}%"])
    end
  end

  def self.search_multiple_by_email(emails = [])
    # Normalise and dedupe emails
    all_emails = emails.map(&:downcase)
    unique_emails = all_emails.uniq
    # Find users and their email addresses
    users = User.where(email: unique_emails)
    found_emails = users.map(&:email).map(&:downcase)
    # Remove found users from the total list of unique emails and count duplicates
    not_found_emails = unique_emails - found_emails
    num_duplicates = emails.size - unique_emails.size

    [users, not_found_emails, num_duplicates]
  end

  ### AUTHENTICATION AND PASSWORDS
  def active?
    !confirmed_at.nil?
  end

  def activate
    return false if self.active?
    self.update_attribute(:confirmed_at, Time.now.utc)
  end

  def create_default_associateds
    self.pseuds << Pseud.new(name: self.login, is_default: true)
    self.profile = Profile.new
    self.preference = Preference.new(locale: Locale.default)
  end

  def prevent_password_resets?
    is_protected_user? || no_resets?
  end

  protected
    def first_save?
      self.new_record?
    end

  public

  # Returns an array (of pseuds) of this user's co-authors
  def coauthors
     works.collect(&:pseuds).flatten.uniq - pseuds
  end

  # Gets the user's most recent unposted work
  def unposted_work
    return @unposted_work if @unposted_work
    @unposted_work = unposted_works.first
  end

  def unposted_works
    return @unposted_works if @unposted_works
    @unposted_works = works.where(posted: false).order("works.created_at DESC")
  end

  # removes ALL unposted works
  def wipeout_unposted_works
    works.where(posted: false).destroy_all
  end

  # Removes all of the user's series that don't have any listed works.
  def destroy_empty_series
    series.left_joins(:serial_works).where(serial_works: { id: nil }).
      destroy_all
  end

  # Checks authorship of any sort of object
  def is_author_of?(item)
    if item.respond_to?(:pseud_id)
      pseud_ids.include?(item.pseud_id)
    elsif item.respond_to?(:user_id)
      id == item.user_id
    elsif item.respond_to?(:pseuds)
      item.pseuds.pluck(:user_id).include?(id)
    elsif item.respond_to?(:author)
      self == item.author
    elsif item.respond_to?(:creator_id)
      self.id == item.creator_id
    else
      false
    end
  end

  # Gets the number of works by this user that the current user can see
  def visible_work_count
    Work.owned_by(self).visible_to_user(User.current_user).revealed.non_anon.distinct.count(:id)
  end

  # Gets the user account for authored objects if orphaning is enabled
  def self.orphan_account
    User.fetch_orphan_account if ArchiveConfig.ORPHANING_ALLOWED
  end

  # Is this user an authorized tag wrangler?
  def tag_wrangler
    self.is_tag_wrangler?
  end

  def is_tag_wrangler?
    has_role?(:tag_wrangler)
  end

  # Is this user an authorized archivist?
  def archivist
    self.is_archivist?
  end

  def is_archivist?
    has_role?(:archivist)
  end

  # Is this user an authorized official?
  def official
    has_role?(:official)
  end

  # Is this user a protected user? These are users experiencing certain types
  # of harassment.
  def protected_user
    self.is_protected_user?
  end

  def is_protected_user?
    has_role?(:protected_user)
  end

  # Is this user assigned the no resets role? These users do not wish to receive
  # password resets.
  def no_resets?
    has_role?(:no_resets)
  end

  # Creates log item tracking changes to user
  def create_log_item(options = {})
    options.reverse_merge! note: "System Generated", user_id: self.id
    LogItem.create(options)
  end

  def update_last_wrangling_activity
    return unless is_tag_wrangler?

    last_activity = LastWranglingActivity.find_or_create_by(user: self)
    last_activity.touch
  end

  # Returns true if user is the sole author of a work
  # Should also be true if the user has used more than one of their pseuds on a work
  def is_sole_author_of?(item)
   other_pseuds = item.pseuds - pseuds
   self.is_author_of?(item) && other_pseuds.blank?
 end

  # Returns array of works where the user is the sole author
  def sole_authored_works
    @sole_authored_works = []
    works.where(posted: 1).each do |w|
      if self.is_sole_author_of?(w)
        @sole_authored_works << w
      end
    end
    return @sole_authored_works
  end

  # Returns array of the user's co-authored works
  def coauthored_works
    @coauthored_works = []
    works.where(posted: 1).each do |w|
      unless self.is_sole_author_of?(w)
        @coauthored_works << w
      end
    end
    return @coauthored_works
  end

  #  Returns array of collections where the user is the sole author
  def sole_owned_collections
    self.collections.to_a.delete_if { |collection| !(collection.all_owners - pseuds).empty? }
  end

  ### BETA INVITATIONS ###

  #If a new user has an invitation_token (meaning they were invited), the method sets the redeemed_at column for that invitation to Time.now
  def mark_invitation_redeemed
    unless self.invitation_token.blank?
      invitation = Invitation.find_by(token: self.invitation_token)
      if invitation
        self.update_attribute(:invitation_id, invitation.id)
        invitation.mark_as_redeemed(self)
      end
    end
  end

  # Existing users should be removed from the invitations queue
  def remove_from_queue
    invite_request = InviteRequest.find_by(email: self.email)
    invite_request.destroy if invite_request
  end

  def fix_user_subscriptions
    # Delete any subscriptions the user has to deleted items because this causes
    # the user's subscription page to error
    @subscriptions = subscriptions.includes(:subscribable)
    @subscriptions.to_a.each do |sub|
      if sub.name.nil?
        sub.destroy
      end
    end
  end

  def set_user_work_dates
    # Fix user stats page error caused by the existence of works with nil revised_at dates
    works.each do |work|
      if work.revised_at.nil?
        work.save
      end
      IndexQueue.enqueue(work, :main)
    end
  end

  def reindex_user_creations
    IndexQueue.enqueue_ids(Work, works.pluck(:id), :main)
    IndexQueue.enqueue_ids(Bookmark, bookmarks.pluck(:id), :main)
    IndexQueue.enqueue_ids(Series, series.pluck(:id), :main)
    IndexQueue.enqueue_ids(Pseud, pseuds.pluck(:id), :main)
  end

  private

  # Create and/or return a user account for holding orphaned works
  def self.fetch_orphan_account
    orphan_account = User.find_or_create_by(login: "orphan_account")
    if orphan_account.new_record?
      Rails.logger.fatal "You must have a User with the login 'orphan_account'. Please create one."
    end
    orphan_account
  end

  def update_pseud_name
    return unless saved_change_to_login? && login_before_last_save.present?
    old_pseud = pseuds.where(name: login_before_last_save).first
    if login.downcase == login_before_last_save.downcase
      old_pseud.name = login
      old_pseud.save!
    else
      new_pseud = pseuds.where(name: login).first
      # do nothing if they already have the matching pseud
      if new_pseud.blank?
        if old_pseud.present?
          # change the old pseud to match
          old_pseud.name = login
          old_pseud.save!(validate: false)
        else
          # shouldn't be able to get here, but just in case
          Pseud.create!(name: login, user_id: id)
        end
      end
    end
  end

  def reindex_user_creations_after_rename
    return unless saved_change_to_login? && login_before_last_save.present?
    # Everything is indexed with the user's byline,
    # which has the old username, so they all need to be reindexed.
    reindex_user_creations
  end

  def add_renamed_at
    self.renamed_at = Time.current
  end

  def log_change_if_login_was_edited
    create_log_item(action: ArchiveConfig.ACTION_RENAME, note: "Old Username: #{login_before_last_save}; New Username: #{login}") if saved_change_to_login?
  end

  def log_email_change
    current_admin = User.current_user if User.current_user.is_a?(Admin)
    options = {
      action: ArchiveConfig.ACTION_NEW_EMAIL,
      admin_id: current_admin&.id
    }
    options[:note] = "Change made by #{current_admin&.login}" if current_admin
    create_log_item(options)
  end

  def remove_stale_from_autocomplete
    self.class.remove_from_autocomplete(self.autocomplete_search_string_was, self.autocomplete_prefixes, self.autocomplete_value_was)
  end

  def username_is_not_recently_changed
    change_interval_days = ArchiveConfig.USER_RENAME_LIMIT_DAYS
    return unless renamed_at && change_interval_days.days.ago <= renamed_at

    errors.add(:login,
               :changed_too_recently,
               count: change_interval_days,
               renamed_at: I18n.l(renamed_at, format: :long))
  end

  # Extra callback to make sure readings are deleted in an order consistent
  # with the ReadingsJob.
  #
  # TODO: In the long term, it might be better to change the indexes on the
  # readings table so that it deletes things in the correct order by default if
  # we just set dependent: :delete_all, but for now we need to explicitly sort
  # by work_id to make sure that the readings are locked in the correct order.
  before_destroy :clear_readings, prepend: true
  def clear_readings
    readings.order(:work_id).each(&:delete)
  end
end