loomio/loomio

View on GitHub
app/models/poll.rb

Summary

Maintainability
D
1 day
Test Coverage
class Poll < ApplicationRecord
  extend  HasCustomFields
  include CustomCounterCache::Model
  include ReadableUnguessableUrls
  include HasEvents
  include HasMentions
  include MessageChannel
  include SelfReferencing
  include Reactable
  include HasCreatedEvent
  include HasRichText
  include HasTags
  include Discard::Model
  include Searchable

  def self.pg_search_insert_statement(id: nil, author_id: nil, discussion_id: nil)
    content_str = "regexp_replace(CONCAT_WS(' ', polls.title, polls.details, users.name), E'<[^>]+>', '', 'gi')"
    <<~SQL.squish
      INSERT INTO pg_search_documents (
        searchable_type,
        searchable_id,
        poll_id,
        group_id,
        discussion_id,
        author_id,
        authored_at,
        content,
        ts_content,
        created_at,
        updated_at)
      SELECT 'Poll' AS searchable_type,
        polls.id AS searchable_id,
        polls.id AS poll_id,
        polls.group_id as group_id,
        polls.discussion_id AS discussion_id,
        polls.author_id AS author_id,
        polls.created_at AS authored_at,
        #{content_str} AS content,
        to_tsvector('simple', #{content_str}) as ts_content,
        now() AS created_at,
        now() AS updated_at
      FROM polls
        LEFT JOIN users ON users.id = polls.author_id
      WHERE polls.discarded_at IS NULL
        #{id ? " AND polls.id = #{id.to_i} LIMIT 1" : ""}
        #{author_id ? " AND polls.author_id = #{author_id.to_i}" : ""}
        #{discussion_id ? " AND polls.discussion_id = #{discussion_id.to_i}" : ""}
    SQL
  end

  is_rich_text on: :details

  extend  NoSpam
  no_spam_for :title, :details

  set_custom_fields :meeting_duration,
                    :time_zone,
                    :can_respond_maybe

  TEMPLATE_DEFAULT_FIELDS = %w[
    poll_option_name_format
    max_score
    min_score
    dots_per_person
    chart_type
    default_duration_in_days
  ]

  TEMPLATE_DEFAULT_FIELDS.each do |field|
    define_method field, -> {
      self[field] || self[:custom_fields][field] || AppConfig.poll_types.dig(self.poll_type, 'defaults', field) 
    }

    define_method :"#{field}=", ->(value) { 
      self[:custom_fields].delete(field)
      if value == AppConfig.poll_types.dig(self.poll_type, 'defaults', field)
        self[field] = nil
      else
        self[field] = value
      end
      value
    }
  end

  TEMPLATE_VALUES = %w(has_option_icon
                       order_results_by
                       prevent_anonymous
                       vote_method
                       material_icon
                       require_all_choices
                       validate_minimum_stance_choices
                       validate_maximum_stance_choices
                       validate_min_score
                       validate_max_score
                       has_options
                       validate_dots_per_person).freeze

  TEMPLATE_VALUES.each do |field|
    define_method field, -> { AppConfig.poll_types.dig(self.poll_type, field) }
  end
  
  def poll_template
    return PollTemplate.find_by(id: poll_template_id) if poll_template_id
    return PollTemplateService.default_templates.find {|pt| pt.key == poll_template_key } if poll_template_key
    return nil
  end

  def create_missing_created_event!
    self.events.create(
      kind: created_event_kind,
      user_id: author_id,
      created_at: created_at,
      discussion_id: discussion_id)
  end

  def minimum_stance_choices
    if require_all_choices
      poll.poll_options.length
    else
      self[:minimum_stance_choices] || 
      self[:custom_fields][:minimum_stance_choices] || 
      AppConfig.poll_types.dig(self.poll_type, 'defaults', 'minimum_stance_choices') ||
      0
    end
  end

  def maximum_stance_choices
    self[:maximum_stance_choices] ||
    self[:custom_fields][:maximum_stance_choices] ||
    AppConfig.poll_types.dig(self.poll_type, 'defaults', 'maximum_stance_choices') ||
    poll.poll_options.length
  end

  include Translatable
  is_translatable on: [:title, :details]
  is_mentionable on: :details

  belongs_to :author, class_name: "User"
  has_many   :outcomes, dependent: :destroy
  has_one    :current_outcome, -> { where(latest: true) }, class_name: 'Outcome'

  belongs_to :discussion
  belongs_to :group, class_name: "Group"

  enum notify_on_closing_soon: {nobody: 0, author: 1, undecided_voters: 2, voters: 3}
  enum hide_results: {off: 0, until_vote: 1, until_closed: 2}
  enum stance_reason_required: {disabled: 0, optional: 1, required: 2}

  has_many :stances, dependent: :destroy
  has_many :stance_choices, through: :stances
  has_many :voters,       -> { merge(Stance.latest) }, through: :stances, source: :participant
  has_many :admin_voters, -> { merge(Stance.latest.admin) }, through: :stances, source: :participant
  has_many :undecided_voters, -> { merge(Stance.latest.undecided) }, through: :stances, source: :participant
  has_many :decided_voters, -> { merge(Stance.latest.decided) }, through: :stances, source: :participant

  has_many :poll_options, -> { order('priority') }, dependent: :destroy, autosave: true
  accepts_nested_attributes_for :poll_options, allow_destroy: true

  has_many :documents, as: :model, dependent: :destroy

  scope :dangling, -> { joins('left join groups g on polls.group_id = g.id').where('group_id is not null and g.id is null') }
  scope :active, -> { kept.where('polls.closed_at': nil) }
  scope :template, -> { kept.where('polls.template': true) }
  scope :closed, -> { kept.where("polls.closed_at IS NOT NULL") }
  scope :recent, -> { kept.where("polls.closed_at IS NULL or polls.closed_at > ?", 7.days.ago) }
  scope :search_for, ->(fragment) { kept.where("polls.title ilike :fragment", fragment: "%#{fragment}%") }
  scope :lapsed_but_not_closed, -> { active.where("polls.closing_at < ?", Time.now) }
  scope :active_or_closed_after, ->(since) { kept.where("polls.closed_at IS NULL OR polls.closed_at > ?", since) }
  scope :in_organisation, -> (group) { kept.where(group_id: group.id_and_subgroup_ids) }

  scope :closing_soon_not_published, ->(timeframe, recency_threshold = 24.hours.ago) do
     active
    .distinct
    .where(closing_at: timeframe)
    .where("NOT EXISTS (SELECT 1 FROM events
                WHERE events.created_at     > ? AND
                      events.eventable_id   = polls.id AND
                      events.eventable_type = 'Poll' AND
                      events.kind           = 'poll_closing_soon')", recency_threshold)
  end

  validates :poll_type, inclusion: { in: AppConfig.poll_types.keys }
  validates :details, length: {maximum: Rails.application.secrets.max_message_length }

  before_save :clamp_minimum_stance_choices
  validate :closes_in_future
  validate :discussion_group_is_poll_group
  validate :cannot_deanonymize
  validate :cannot_reveal_results_early
  validate :title_if_not_discarded

  alias_method :user, :author

  has_paper_trail only: [
    :author_id,
    :title,
    :details,
    :details_format,
    :closing_at,
    :closed_at,
    :group_id,
    :discussion_id,
    :anonymous,
    :discarded_at,
    :discarded_by,
    :voter_can_add_options,
    :specified_voters_only,
    :stance_reason_required,
    :tags,
    :notify_on_closing_soon,
    :poll_option_names,
    :hide_results]

  update_counter_cache :group, :polls_count
  update_counter_cache :group, :closed_polls_count
  update_counter_cache :discussion, :closed_polls_count
  update_counter_cache :discussion, :anonymous_polls_count

  delegate :locale, to: :author
  delegate :name, to: :author, prefix: true

  def has_score_icons
    vote_method == "time_poll"
  end

  def has_variable_score
    !(min_score == max_score)
  end

  def is_single_choice?
    minimum_stance_choices == 1 && maximum_stance_choices == 1
  end

  def results_include_undecided
    poll_type != "meeting"
  end
  
  def dates_as_options
    poll_option_name_format == 'iso8601'
  end
  
  def chart_column
    case poll_type
    when 'count' then (agree_target ? 'target_percent' : 'voter_percent')
    when 'check', 'proposal' then 'score_percent'
    else
      'max_score_percent'
    end
  end

  def can_respond_maybe
    self[:custom_fields].fetch('can_respond_maybe', false)
  end

  def result_columns
    case poll_type
    when 'proposal'
      %w[chart name score_percent voter_count voters]
    when 'check'
      %w[chart name voter_percent voter_count voters]
    when 'count'
      if agree_target
        %w[chart name target_percent voter_count voters]
      else
        %w[chart name voter_count voters]
      end
    when 'ranked_choice'
      %w[chart name rank score_percent score average]
    when 'dot_vote'
      %w[chart name score_percent score average voter_count]
    when 'score'
      %w[chart name score average voter_count]
    when 'poll'
      %w[chart name score_percent voter_count voters]
    when 'meeting'
      %w[chart name score voters]
    else
      []
    end
  end
  
  def results
    PollService.calculate_results(self, self.poll_options)
  end

  def user_id
    author_id
  end

  def existing_member_ids
    voter_ids
  end

  def decided_voters_count
    voters_count - undecided_voters_count
  end

  def cast_stances_pct
    return 0 if voters_count == 0
    ((decided_voters_count.to_f / voters_count) * 100).to_i
  end

  def undecided_voters
    anonymous? ? User.none : super
  end

  def decided_voters
    anonymous? ? User.none : super
  end

  def unmasked_voters
    User.where(id: stances.latest.pluck(:participant_id))
  end

  def unmasked_undecided_voters
    User.where(id: stances.latest.undecided.pluck(:participant_id))
  end

  def unmasked_decided_voters
    User.where(id: stances.latest.decided.pluck(:participant_id))
  end

  def body
    details
  end

  def body=(val)
    self.details = val
  end

  def body_format
    details_format
  end

  def time_zone
    custom_fields.fetch('time_zone', author.time_zone)
  end

  def parent_event
    if discussion
      discussion.created_event
    else
      nil
    end
  end

  def group
    super || NullGroup.new
  end

  def show_results?(voted: false)
    !! case hide_results
    when 'until_closed'
      closed_at
    when 'until_vote'
      closed_at || voted
    else
      true
    end
  end

  # this should not be run on anonymous polls
  def reset_latest_stances!
    self.transaction do
      self.stances.update_all(latest: false)
      Stance.where("id IN
        (SELECT DISTINCT ON (participant_id) id
         FROM stances
         WHERE poll_id = #{id}
         ORDER BY participant_id, created_at DESC)").update_all(latest: true)
    end
  end

  def total_score
    stance_counts.sum
  end

  def update_counts!
    poll_options.reload.each(&:update_counts!)
    update_columns(
      stance_counts: poll_options.map(&:total_score), # should rename to option scores
      voters_count: stances.latest.count, # should rename to stances_count
      undecided_voters_count: stances.latest.undecided.count,
      versions_count: versions.count
    )
  end

  # people who administer the poll (not necessarily vote)
  def admins
    raise "poll.admins only makes sense for persisted polls" if self.new_record?
    User.active.
      joins("LEFT OUTER JOIN discussion_readers dr ON dr.discussion_id = #{self.discussion_id || 0} AND dr.user_id = users.id").
      joins("LEFT OUTER JOIN memberships m ON m.user_id = users.id AND m.group_id = #{self.group_id || 0}").
      joins("LEFT OUTER JOIN stances s ON s.participant_id = users.id AND s.poll_id = #{self.id || 0}").
      joins("LEFT OUTER JOIN polls p ON p.author_id = users.id AND p.id = #{self.id || 0}").
      where("(m.id  IS NOT NULL AND m.revoked_at IS NULL AND m.admin = TRUE) OR                                             /* group admin */
             (p.author_id = users.id AND p.group_id IS NOT NULL AND m.id IS NOT NULL) OR                                    /* poll author and group member */
             (p.author_id = users.id AND p.group_id IS NULL) OR                                                             /* poll author and no group */
             (p.author_id = users.id AND dr.id IS NOT NULL AND dr.revoked_at IS NULL AND dr.guest = TRUE) OR                /* poll author and discussion guest */
             (dr.id IS NOT NULL AND m.id IS NOT NULL AND dr.revoked_at IS NULL AND dr.admin = TRUE) OR                      /* discussion admin, group member */
             (dr.id IS NOT NULL AND m.id IS NULL     AND dr.revoked_at IS NULL AND dr.admin = TRUE AND dr.guest = TRUE) OR  /* discussion guest admin, not group member */
             (s.id  IS NOT NULL AND m.id IS NOT NULL AND s.revoked_at IS NULL AND latest = TRUE AND s.admin = TRUE) OR      /* poll admin, group member */
             (s.id  IS NOT NULL AND m.id IS NULL     AND s.revoked_at IS NULL AND latest = TRUE AND s.admin = TRUE AND s.guest = TRUE /* poll admin guest */)")
  end

  # people who can read the poll, not necessarily vote
  def members
      User.active.
      joins("LEFT OUTER JOIN discussion_readers dr ON dr.discussion_id = #{self.discussion_id || 0} AND dr.user_id = users.id").
      joins("LEFT OUTER JOIN memberships m ON m.user_id = users.id AND m.group_id = #{self.group_id || 0}").
      joins("LEFT OUTER JOIN stances s ON s.participant_id = users.id AND s.poll_id = #{self.id || 0}").
      where("(dr.id IS NOT NULL AND dr.revoked_at IS NULL AND dr.guest = TRUE) OR
             (m.id  IS NOT NULL AND m.revoked_at IS NULL) OR
             (s.id  IS NOT NULL AND s.revoked_at IS NULL AND s.guest = TRUE AND latest = TRUE)")
  end

  def add_guest!(user, author)
    stances.create!(participant_id: user.id, inviter: author, guest: true, volume: DiscussionReader.volumes[:normal])
  end

  def add_admin!(user, author)
    stances.create!(participant_id: user.id, inviter: author, volume: DiscussionReader.volumes[:normal], admin: true)
  end

  def active?
    (closing_at && closing_at > Time.now) && !closed_at
  end

  def wip?
    closing_at.nil?
  end

  def closed?
    !!closed_at
  end

  def poll_option_names
    poll_options.map(&:name)
  end

  def poll_option_names=(names)
    names    = Array(names)
    existing = Array(poll_options.pluck(:name))
    names = names.sort if poll_type == 'meeting'
    names.each_with_index do |name, priority|
      option = poll_options.find_or_initialize_by(name: name)
      option.priority = priority
      os = AppConfig.poll_types.dig(self.poll_type, 'common_poll_options') || []
      if params = os.find {|o| o['key'] == name } 
        option.name = I18n.t(params['name_i18n'])
        option.icon = params['icon']
        option.meaning = I18n.t(params['meaning_i18n'])
        option.prompt = I18n.t(params['prompt_i18n'])
      end
    end
    removed = (existing - names)
    poll_options.each {|option| option.mark_for_destruction if removed.include?(option.name) }
    names
  end

  alias options= poll_option_names=
  alias options poll_option_names

  def is_new_version?
    !self.poll_options.map(&:persisted?).all? ||
    (['title', 'details', 'closing_at'] & self.changes.keys).any?
  end

  def discussion_id=(discussion_id)
    super.tap { self.group_id = self.discussion&.group_id }
  end

  def discussion=(discussion)
    super.tap { self.group_id = self.discussion&.group_id }
  end

  def prioritise_poll_options!
    if self.poll_type == 'meeting'
      self.poll_options.sort {|a,b| a.name <=> b.name }.each_with_index {|o, i| o.priority = i }
    end
  end

  private

  def title_if_not_discarded
    if !discarded_at && title.to_s.empty?
      errors.add(:title, I18n.t(:"activerecord.errors.messages.blank"))
    end
  end

  def cannot_deanonymize
    if anonymous_changed? && anonymous_was == true
      errors.add :anonymous, :cannot_deanonymize
    end
  end

  def cannot_reveal_results_early
    if hide_results_changed? && (hide_results_was == 'until_closed')
      errors.add :hide_results, :cannot_show_results_early
    end
  end

  def closes_in_future
    return if closed_at
    return if closing_at.nil? 
    return if closing_at > Time.zone.now
    errors.add(:closing_at, I18n.t(:"poll.error.must_be_in_the_future"))
  end

  def discussion_group_is_poll_group
    return if poll.group.nil?
    return if poll.discussion.nil?
    poll.group_id = poll.discussion.group_id if poll.group_id.nil? && poll.discussion.group_id
    return if poll.discussion.group_id == poll.group_id
    self.errors.add(:group, 'Poll group is not discussion group')
  end

  def clamp_minimum_stance_choices
    return if minimum_stance_choices.nil?
    if minimum_stance_choices > poll_options.length
      self.minimum_stance_choices = poll_options.length
    end
  end
end