rubycentral/cfp-app

View on GitHub
app/models/proposal.rb

Summary

Maintainability
B
6 hrs
Test Coverage
# frozen_string_literal: true

require 'digest/sha1'

class Proposal < ApplicationRecord
  include Proposal::State

  has_many :public_comments, dependent: :destroy
  has_many :internal_comments, dependent: :destroy
  has_many :ratings, dependent: :destroy
  has_many :speakers, -> { order created_at: :asc }, dependent: :destroy
  has_many :taggings, dependent: :destroy
  has_many :proposal_taggings, -> { proposal }, class_name: 'Tagging'
  has_many :review_taggings, -> { review }, class_name: 'Tagging'
  has_many :invitations, dependent: :destroy

  belongs_to :event
  has_one :time_slot
  has_one :program_session
  belongs_to :session_format
  belongs_to :track, optional: true

  validates :title, :abstract, :session_format, presence: true
  validate :abstract_length
  validates :title, length: { maximum: 60 }
  validates_inclusion_of :state, in: valid_states, allow_nil: true, message: "'%{value}' not a valid state."
  validates_inclusion_of :state, in: FINAL_STATES, allow_nil: false, message: "'%{value}' not a confirmable state.",
                                 if: :confirmed_at_changed?

  serialize :last_change
  serialize :proposal_data, Hash

  has_paper_trail only: %i[title abstract details pitch]

  attr_accessor :tags, :review_tags, :updating_user

  accepts_nested_attributes_for :public_comments, reject_if: proc { |comment_attributes| comment_attributes[:body].blank? }
  accepts_nested_attributes_for :speakers

  before_create :set_uuid, :set_updated_by_speaker_at
  before_update :save_attr_history
  after_save :save_tags, :save_review_tags

  scope :accepted, -> { where(state: ACCEPTED) }
  scope :waitlisted, -> { where(state: WAITLISTED) }
  scope :submitted, -> { where(state: SUBMITTED) }
  scope :confirmed, -> { where('confirmed_at IS NOT NULL') }

  scope :soft_accepted, -> { where(state: SOFT_ACCEPTED) }
  scope :soft_waitlisted, -> { where(state: SOFT_WAITLISTED) }
  scope :soft_rejected, -> { where(state: SOFT_REJECTED) }
  scope :soft_states, -> { where(state: SOFT_STATES) }
  scope :working_program, -> { where(state: [SOFT_ACCEPTED, SOFT_WAITLISTED, ACCEPTED, WAITLISTED]) }

  scope :unrated, -> { where('id NOT IN ( SELECT proposal_id FROM ratings )') }
  scope :rated, -> { where('id IN ( SELECT proposal_id FROM ratings )') }
  scope :not_withdrawn, -> { where.not(state: WITHDRAWN) }
  scope :not_owned_by, ->(user) { where.not(id: user.proposals.map(&:id)) }
  scope :for_state, lambda { |state|
    where(state: state).order(:title).includes(:event, { speakers: :user }, :review_taggings)
  }
  scope :in_track, lambda { |track_id|
    track_id = nil if track_id.try(:strip) == ''
    where(track_id: track_id)
  }

  scope :emails, -> { joins(speakers: :user).pluck(:email).uniq }

  # Return all reviewers for this proposal.
  # A user is considered a reviewer if they meet the following criteria
  # - They are an teammate for this event
  # AND
  # - They have rated or made a public comment on this proposal, and are not a speaker on this proposal
  def reviewers
    User.joins(:teammates,
               'LEFT OUTER JOIN ratings AS r ON r.user_id = users.id',
               'LEFT OUTER JOIN comments AS c ON c.user_id = users.id')
        .where("teammates.event_id = ? AND (r.proposal_id = ? or (c.proposal_id = ? AND c.type = 'PublicComment'))",
               event.id, id, id)
        .where.not(id: speakers.map(&:user_id)).distinct
  end

  def emailable_reviewers
    reviewers.merge(Teammate.all_emails)
  end

  def unmentioned_reviewers(mention_names, commenter_id)
    reviewers.where.not(id: commenter_id)
             .where.not(teammates: { mention_name: mention_names })
  end

  def mentioned_event_staff(mention_names, commenter_id)
    event.staff.includes(:teammates)
         .where.not(id: commenter_id)
         .where(teammates: { event: event, mention_name: mention_names })
  end

  # Return all proposals from speakers of this proposal. Does not include this proposal.
  def other_speakers_proposals
    proposals = []
    speakers.each do |speaker|
      speaker.proposals.each do |p|
        proposals << p if p.id != id && p.event_id == event.id
      end
    end
    proposals
  end

  def custom_fields=(custom_fields)
    proposal_data[:custom_fields] = custom_fields
  end

  def custom_fields
    proposal_data[:custom_fields] || {}
  end

  def update_state(new_state)
    update(state: new_state)
  end

  def finalize
    transaction do
      update_state(SOFT_TO_FINAL[state]) if SOFT_TO_FINAL.key?(state)
      if becomes_program_session?
        ps = ProgramSession.create_from_proposal(self)
        ps.persisted?
      else
        true
      end
    end
  rescue ActiveRecord::RecordInvalid
    false
  end

  def withdraw
    update(state: WITHDRAWN)
    reviewers.each do |reviewer|
      Notification.create_for(reviewer, proposal: self,
                                        message: "Proposal, #{title}, withdrawn")
    end
  end

  def confirm
    update(confirmed_at: DateTime.current)
    program_session.confirm if program_session.present?
  end

  def promote
    update(state: ACCEPTED) if state == WAITLISTED
  end

  def decline
    update(state: WITHDRAWN, confirmed_at: DateTime.current)
    program_session.update(state: ProgramSession::DECLINED)
  end

  def draft?
    state == SUBMITTED
  end

  def finalized?
    FINAL_STATES.include?(state)
  end

  def becomes_program_session?
    BECOMES_PROGRAM_SESSION.include?(state)
  end

  def confirmed?
    confirmed_at.present?
  end

  def awaiting_confirmation?
    finalized? && (accepted? || waitlisted?) && !confirmed?
  end

  def speaker_can_edit?(user)
    has_speaker?(user) && !(withdrawn? || accepted? || confirmed?)
  end

  def speaker_can_withdraw?(user)
    speaker_can_edit?(user) && has_reviewer_activity?
  end

  def speaker_can_delete?(user)
    speaker_can_edit?(user) && !has_reviewer_activity?
  end

  def to_param
    uuid
  end

  def average_rating
    return nil if ratings.empty?

    ratings.map(&:score).inject(:+).to_f / ratings.size
  end

  def standard_deviation
    unless ratings.empty?
      scores = ratings.map(&:score)

      squared_reducted_total = 0.0
      average = scores.inject(:+) / scores.length.to_f

      scores.each do |score|
        squared_reducted_total += (score - average)**2
      end
      Math.sqrt(squared_reducted_total / scores.length)
    end
  end

  def has_speaker?(user)
    speakers.where(user_id: user).exists?
  end

  def has_invited?(user)
    user.pending_invitations.map(&:proposal_id).include?(id)
  end

  def was_rated_by_user?(user)
    ratings.any? { |r| r.user_id == user.id }
  end

  def tags
    proposal_taggings.to_a.map(&:tag)
  end

  def review_tags
    review_taggings.to_a.map(&:tag)
  end

  def has_reviewer_comments?
    has_public_reviewer_comments? || has_internal_reviewer_comments?
  end

  def speaker_update_and_notify(attributes)
    old_title = title
    speaker_updates = attributes.merge({ updated_by_speaker_at: Time.current })
    if update(speaker_updates)
      field_names = last_change.join(', ')
      reviewers.each do |reviewer|
        Notification.create_for(reviewer, proposal: self,
                                          message: "Proposal, #{old_title}, updated [ #{field_names} ]")
      end
    end
  end

  def has_reviewer_activity?
    ratings.present? || has_reviewer_comments?
  end

  def changeset_fields
    versions[1..]&.map(&:changeset)&.flat_map(&:keys)&.uniq
  end

  private

  def abstract_length
    return unless abstract_changed? && abstract.gsub(/\r/, '').gsub(/\n/, '').length > 600

    errors.add(:abstract, 'is too long (maximum is 600 characters)')
  end

  def save_tags
    update_tags(proposal_taggings, @tags, false) if @tags
  end

  def save_review_tags
    update_tags(review_taggings, @review_tags, true) if @review_tags
  end

  def update_tags(old, new, internal)
    old.destroy_all
    tags = new.uniq.sort.map do |t|
      { tag: t.strip, internal: internal } if t.present?
    end.compact
    taggings.create(tags)
  end

  def has_public_reviewer_comments?
    public_comments.reject { |comment| speakers.include?(comment.user_id) }.any?
  end

  def has_internal_reviewer_comments?
    internal_comments.reject { |comment| speakers.include?(comment.user_id) }.any?
  end

  def save_attr_history
    if updating_user&.organizer_for_event?(event)
      # Erase the record of last change if the proposal is updated by an
      # organizer
      self.last_change = nil
    else
      changes_whitelist = %w[pitch abstract details title]
      self.last_change = changes_whitelist & changed
    end
  end

  def set_uuid
    self.uuid = Digest::SHA1.hexdigest([event_id, title, created_at, rand(100)].map(&:to_s).join('-'))[0, 10]
  end

  def set_updated_by_speaker_at
    self.updated_by_speaker_at = Time.current
  end
end

# == Schema Information
#
# Table name: proposals
#
#  id                    :integer          not null, primary key
#  event_id              :integer
#  state                 :string           default("submitted")
#  uuid                  :string
#  title                 :string
#  session_format_id     :integer
#  track_id              :integer
#  abstract              :text
#  details               :text
#  pitch                 :text
#  last_change           :text
#  confirmation_notes    :text
#  proposal_data         :text
#  updated_by_speaker_at :datetime
#  confirmed_at          :datetime
#  created_at            :datetime
#  updated_at            :datetime
#
# Indexes
#
#  index_proposals_on_event_id           (event_id)
#  index_proposals_on_session_format_id  (session_format_id)
#  index_proposals_on_track_id           (track_id)
#  index_proposals_on_uuid               (uuid) UNIQUE
#