openaustralia/morph

View on GitHub
app/models/user.rb

Summary

Maintainability
A
3 hrs
Test Coverage
F
48%
# typed: strict
# frozen_string_literal: true

# A real human being (hopefully)
class User < Owner
  extend T::Sig

  devise :trackable, :rememberable, :omniauthable, omniauth_providers: [:github]
  has_many :organizations_users, dependent: :destroy
  has_many :organizations, through: :organizations_users
  has_many :alerts, dependent: :destroy
  has_many :contributions, dependent: :destroy
  has_many :scrapers_contributed_to, through: :contributions, source: :scraper

  # In most cases people have contributed to the scrapers that they own so we
  # really don't want to see these twice. This method just removes their own
  # scrapers from the list
  sig { returns(ActiveRecord::AssociationRelation) }
  def other_scrapers_contributed_to
    scrapers_contributed_to.where.not(owner: self)
  end

  # A list of all owners thst this user can write to. Includes itself
  sig { returns(T::Array[Owner]) }
  def all_owners
    [self] + organizations.to_a
  end

  # Send all alerts. This method should be run from a daily cron job
  sig { void }
  def self.process_alerts
    User.all.find_each(&:process_alerts)
  end

  sig { void }
  def process_alerts
    return if watched_broken_scrapers_ordered_by_urgency.empty?

    AlertMailer.alert_email(
      self,
      watched_broken_scrapers_ordered_by_urgency,
      watched_successful_scrapers
    ).deliver_now
  rescue Net::SMTPSyntaxError
    Rails.logger.warn "Warning: user #{nickname} has invalid email address #{email} " \
                      "(tried to send alert)"
  end

  sig { override.returns(T::Boolean) }
  def user?
    true
  end

  sig { override.returns(T::Boolean) }
  def organization?
    false
  end

  sig { params(object: T.any(Owner, Scraper)).void }
  def toggle_watch(object)
    if watching?(object)
      alerts.where(watch: object).first.destroy
    else
      # If we're starting to watch a whole bunch of scrapers (by watching a
      # user/org) and we're already following one of those scrapers individually
      # then remove the individual alert
      watch object
      if object.is_a?(Owner)
        alerts.where(watch_id: object.scrapers,
                     watch_type: "Scraper").destroy_all
      end
    end
  end

  sig { params(object: T.any(Owner, Scraper)).void }
  def watch(object)
    alerts.create(watch: object) unless watching?(object)
  end

  sig { void }
  def watch_all_owners
    all_owners.each do |object|
      watch object
    end
  end

  # Only include scrapers that finished in the last 48 hours
  sig { returns(T::Array[Scraper]) }
  def watched_successful_scrapers
    all_scrapers_watched.select do |s|
      s.finished_successfully? && s.finished_recently?
    end
  end

  sig { returns(T::Array[Scraper]) }
  def watched_broken_scrapers
    all_scrapers_watched.select do |s|
      s.finished_with_errors? && s.finished_recently?
    end
  end

  # Puts scrapers that have most recently failed first
  sig { returns(T::Array[Scraper]) }
  def watched_broken_scrapers_ordered_by_urgency
    watched_broken_scrapers.sort do |a, b|
      time_a = a.latest_successful_run_time
      time_b = b.latest_successful_run_time
      if time_b.nil? && time_a.nil?
        0
      elsif time_b.nil?
        -1
      elsif time_a.nil?
        1
      else
        T.must(time_b <=> time_a)
      end
    end
  end

  sig { returns(T::Array[Organization]) }
  def organizations_watched
    alerts.map(&:watch).select { |w| w.is_a?(Organization) }
  end

  sig { returns(T::Array[User]) }
  def users_watched
    alerts.map(&:watch).select { |w| w.is_a?(User) }
  end

  sig { returns(T::Array[Owner]) }
  def owners_watched
    alerts.map(&:watch).select { |w| w.is_a?(Owner) }
  end

  sig { returns(T::Array[Scraper]) }
  def scrapers_watched
    alerts.map(&:watch).select { |w| w.is_a?(Scraper) }
  end

  sig { returns(T::Array[Scraper]) }
  def all_scrapers_watched
    s = scrapers_watched
    owners_watched.each { |owner| s += owner.scrapers }
    s.uniq
  end

  # Are we watching this scraper because we're watching the owner
  # of the scraper?
  sig { params(scraper: Scraper).returns(T::Boolean) }
  def indirectly_watching?(scraper)
    watching?(T.must(scraper.owner))
  end

  sig { params(object: T.any(Owner, Scraper)).returns(T::Boolean) }
  def watching?(object)
    alerts.map(&:watch).include? object
  end

  sig { void }
  def refresh_organizations!
    refreshed_organizations = github.organizations(T.must(nickname)).map do |data|
      org = Organization.find_or_create_by!(uid: data.id.to_s, nickname: data.login)
      org.refresh_info_from_github!(self)
      org
    end

    # Watch any new organizations
    (refreshed_organizations - organizations.to_a).each do |o|
      watch o
    end

    self.organizations = refreshed_organizations
  end

  sig { params(auth: T.untyped, _signed_in_resource: T.nilable(User)).returns(User) }
  def self.find_for_github_oauth(auth, _signed_in_resource = nil)
    user = User.find_or_create_by(provider: auth.provider, uid: auth.uid)
    user.update(nickname: auth.info.nickname,
                access_token: auth.credentials.token)
    user.refresh_info_from_github!
    # Also every time you login it should update the list of organizations that
    # the user is attached to but do this in a background job
    RefreshUserOrganizationsWorker.perform_async(T.must(user.id))
    user
  end

  sig { void }
  def refresh_info_from_github!
    user = github.user_from_github(T.must(nickname))
    update(name: user.name,
           gravatar_url: user.rels.avatar.href,
           blog: user.blog,
           company: user.company,
           location: user.location,
           email: github.primary_email)
  rescue Octokit::Unauthorized, Octokit::NotFound
    false
  end

  sig { params(nickname: String).returns(User) }
  def self.find_or_create_by_nickname(nickname)
    u = User.find_by(nickname: nickname)
    if u.nil?
      u = User.create(nickname: nickname)
      u.refresh_info_from_github!
    end
    u
  end

  sig { returns(T::Boolean) }
  def active_for_authentication?
    !suspended?
  end

  # TODO: Move this to locale
  sig { returns(String) }
  def inactive_message
    "Your account has been suspended. " \
      "Please contact us if you think this is in error."
  end

  sig { returns(T::Boolean) }
  def never_alerted?
    alerted_at.blank?
  end

  # Note that calling this method will fail if the user has no access_token. This will be
  # the case if the user has not yet logged since the switch-over of the week of Oct 10 2022.
  sig { returns(Morph::Github) }
  def github
    Morph::Github.new(user_nickname: T.must(nickname), user_access_token: T.must(access_token))
  end
end