pupilfirst/pupilfirst

View on GitHub
app/models/user.rb

Summary

Maintainability
A
2 hrs
Test Coverage
A
94%
# JSON fields schema:
#
# preferences: {
#   daily_digest: bool - default true. There may be users without this key.
# }
class User < ApplicationRecord
  acts_as_taggable

  belongs_to :school
  belongs_to :organisation, optional: true
  has_many :organisation_admins, dependent: :restrict_with_error
  has_many :organisations, through: :organisation_admins
  has_many :students, dependent: :restrict_with_error
  has_many :teams, through: :students
  has_many :cohorts, through: :students
  has_many :course_authors, dependent: :restrict_with_error
  has_many :communities, through: :students
  has_many :courses, through: :students
  has_one :faculty, dependent: :restrict_with_error
  has_one :school_admin, dependent: :restrict_with_error
  has_many :markdown_attachments, dependent: :nullify
  has_many :issued_certificates, dependent: :nullify
  has_many :locked_topics,
           class_name: "Topic",
           foreign_key: "locked_by_id",
           inverse_of: :locked_by,
           dependent: :nullify
  has_many :post_likes, dependent: :nullify
  has_many :text_versions, dependent: :nullify
  has_many :course_exports, dependent: :nullify
  has_many :created_posts,
           class_name: "Post",
           foreign_key: "creator_id",
           inverse_of: :creator,
           dependent: :nullify
  has_many :edited_posts,
           class_name: "Post",
           foreign_key: "editor_id",
           inverse_of: :editor,
           dependent: :nullify
  has_many :coach_notes,
           class_name: "CoachNote",
           foreign_key: "author_id",
           inverse_of: :author,
           dependent: :nullify
  has_many :topic_subscription, dependent: :destroy
  has_many :notifications,
           foreign_key: :recipient_id,
           inverse_of: :recipient,
           dependent: :destroy
  has_many :discord_messages, dependent: :destroy
  has_many :user_standings, dependent: :destroy

  has_many :submission_comments, dependent: :destroy
  has_many :moderation_reports, dependent: :destroy
  has_many :reactions, dependent: :destroy

  # database_authenticable is required by devise_for to generate the session routes
  devise :database_authenticatable,
         :trackable,
         :rememberable,
         :omniauthable,
         :recoverable,
         omniauth_providers: %i[google_oauth2 facebook github discord]

  normalize_attribute :name, :about, :affiliation, :preferred_name

  validates :email,
            presence: true,
            email: true,
            uniqueness: {
              scope: :school_id
            }

  has_one_attached :avatar

  scope :with_email, ->(email) { where("lower(email) = ?", email.downcase) }

  before_save :capitalize_name_fragments

  def capitalize_name_fragments
    return unless name_changed?

    self.name =
      name
        .split
        .map do |name_fragment|
          name_fragment[0] = name_fragment[0].capitalize
          name_fragment
        end
        .join(" ")
  end

  attr_reader :delete_account_token_original
  attr_reader :api_token

  def regenerate_login_token
    @original_login_token = SecureRandom.urlsafe_base64
    update!(
      login_token_digest: Digest::SHA2.base64digest(@original_login_token),
      login_token_generated_at: Time.zone.now
    )
  end

  def original_login_token
    @original_login_token || raise("Original login token is unavailable")
  end

  def regenerate_reset_password_token
    @original_reset_password_token = SecureRandom.urlsafe_base64
    update!(
      reset_password_token:
        Digest::SHA2.base64digest(@original_reset_password_token)
    )
  end

  def original_reset_password_token
    @original_reset_password_token ||
      raise("Original reset password token is unavailable")
  end

  def regenerate_delete_account_token
    @delete_account_token_original = SecureRandom.urlsafe_base64
    update!(
      delete_account_token_digest:
        Digest::SHA2.hexdigest(@delete_account_token_original)
    )
  end

  def original_update_email_token
    @original_update_email_token ||
      raise("Original update email token is unavailable")
  end

  def regenerate_update_email_token
    @original_update_email_token = SecureRandom.urlsafe_base64
    update!(
      update_email_token:
        Digest::SHA2.base64digest(@original_update_email_token)
    )
  end

  def self.find_by_hashed_delete_account_token(delete_account_token)
    find_by(
      delete_account_token_digest: Digest::SHA2.hexdigest(delete_account_token)
    )
  end

  def self.find_by_hashed_update_email_token(token)
    find_by(update_email_token: token)
  end

  def regenerate_api_token
    @api_token = SecureRandom.urlsafe_base64
    update!(api_token_digest: Digest::SHA2.base64digest(@api_token))
  end

  def email_bounced?
    BounceReport.exists?(email: email)
  end

  def login_token_expiration_time
    (
      login_token_generated_at +
        Rails.application.secrets.login_token_time_limit
    ).strftime("%B %-d, %Y at %-l:%M %p")
  end

  # True if the user has ever signed in, handled by Users::ConfirmationService.
  def confirmed?
    confirmed_at.present?
  end

  def display_name
    email
  end

  def avatar_variant(version)
    case version
    when :mid
      avatar.variant(resize_to_fill: [320, 320, crop: :attention]).processed
    when :thumb
      avatar.variant(resize_to_fill: [100, 100, crop: :attention]).processed
    else
      avatar
    end
  end

  def initials_avatar(background_shape: nil)
    logo = Scarf::InitialAvatar.new(name, background_shape: background_shape)
    "data:image/svg+xml;base64,#{Base64.encode64(logo.svg)}"
  end

  def avatar_url(variant: nil)
    return unless avatar.attached?

    if variant.blank?
      Rails.application.routes.url_helpers.rails_public_blob_url(avatar)
    else
      Rails.application.routes.url_helpers.rails_public_blob_url(
        avatar_variant(variant)
      )
    end
  end

  # TODO: Remove User#image_or_avatar_url when all of its usages are gone. Use the avatar_url method instead, and generate initial avatars client-side.
  def image_or_avatar_url(variant: nil, background_shape: nil)
    ActiveSupport::Deprecation.warn("Use `avatar_url` instead")

    if avatar.attached?
      if variant.blank?
        Rails.application.routes.url_helpers.rails_public_blob_url(avatar)
      else
        Rails.application.routes.url_helpers.rails_public_blob_url(
          avatar_variant(variant)
        )
      end
    else
      initials_avatar(background_shape: background_shape)
    end
  end

  def full_title
    if title.present? && affiliation.present?
      "#{title}, #{affiliation}"
    else
      title.presence || affiliation.presence
    end
  end

  def discord_account_connected?
    discord_user_id.present?
  end
end