osbridge/openconferenceware

View on GitHub
app/models/open_conference_ware/proposal.rb

Summary

Maintainability
D
2 days
Test Coverage
module OpenConferenceWare

  # == Schema Information
  #
  # Table name: proposals
  #
  #  id                  :integer          not null, primary key
  #  user_id             :integer
  #  presenter           :string(255)
  #  affiliation         :string(255)
  #  email               :string(255)
  #  website             :string(255)
  #  biography           :text
  #  title               :string(255)
  #  description         :text
  #  agreement           :boolean          default(TRUE)
  #  created_at          :datetime
  #  updated_at          :datetime
  #  event_id            :integer
  #  submitted_at        :datetime
  #  note_to_organizers  :text
  #  excerpt             :text
  #  track_id            :integer
  #  session_type_id     :integer
  #  status              :string(255)      default("proposed"), not null
  #  room_id             :integer
  #  start_time          :datetime
  #  audio_url           :string(255)
  #  speaking_experience :text
  #  audience_level      :string(255)
  #  notified_at         :datetime
  #

  class Proposal < OpenConferenceWare::Base
    # Provide ::validate_url_attribute
    include NormalizeUrlMixin

    # Provide ::event_tracks? and other methods for accessing SETTING
    include SettingsCheckersMixin

    # Provide ::overlaps?
    include ScheduleOverlapsMixin

    # Public attributes for export
    include PublicAttributesMixin
    set_public_attributes :id, :user_id,
      :presenter, :affiliation, :website,
      :biography, :title, :description,
      :created_at, :updated_at, :submitted_at,
      :start_time, :end_time,
      :event_id, :event_title,
      :room_id, :room_title,
      :session_type_id, :session_type_title,
      :track_id, :track_title,
      :user_ids, :user_titles

    # Provide #tags
    acts_as_taggable_on :tags

    # Acts As State Machine
    include AASM

    aasm(column: :status) do

      state :proposed, initial: true
      state :accepted
      state :waitlisted
      state :rejected
      state :confirmed
      state :declined
      state :junk
      state :cancelled

      event :accept do
        transitions from: :proposed, to: :accepted
        transitions from: :rejected, to: :accepted
        transitions from: :waitlisted, to: :accepted
      end

      event :reject do
        transitions from: :proposed, to: :rejected
        transitions from: :accepted, to: :rejected
        transitions from: :waitlisted, to: :rejected
      end

      event :waitlist do
        transitions from: :proposed, to: :waitlisted
        transitions from: :accepted, to: :waitlisted
        transitions from: :rejected, to: :waitlisted
      end

      event :confirm do
        transitions from: :accepted, to: :confirmed
      end

      event :decline do
        transitions from: :accepted, to: :declined
      end

      event :accept_and_confirm do
        transitions from: :proposed, to: :confirmed
        transitions from: :waitlisted, to: :confirmed
      end

      event :accept_and_decline do
        transitions from: :proposed, to: :declined
        transitions from: :waitlisted, to: :declined
      end

      event :mark_as_junk do
        transitions from: :proposed, to: :junk
      end

      event :reset_status do
        transitions from: %w(accepted rejected waitlisted confirmed declined junk cancelled), to: :proposed
      end

      event :cancel  do
        transitions from: :confirmed, to: :cancelled
      end
    end

    # Associations
    belongs_to :event
    belongs_to :track
    belongs_to :session_type
    belongs_to :room
    has_many :comments, dependent: :destroy
    has_many :user_favorites, dependent: :destroy
    has_many :users_who_favor, through: :user_favorites, source: :user
    has_many :selector_votes

    has_and_belongs_to_many :users do
      def fullnames
        self.map(&:fullname).join(', ')
      end

      def emails
        self.map(&:email).join(', ')
      end
    end

    # Named scopes
    scope :unconfirmed, lambda { where("status != ?", "confirmed") }
    scope :populated,   lambda { order(:submitted_at).includes( {event: [:rooms, :tracks]}, :session_type, :track, :room, :users ) }
    scope :scheduled,   lambda { where("start_time IS NOT NULL") }
    scope :located,     lambda { where("room_id IS NOT NULL") }
    scope :for_event,   lambda { |event| where(event_id: event) }

    # Validations
    validates_presence_of :title, :description, :event_id
    validates_acceptance_of :agreement,                     accept: true, message: "must be accepted", if: -> { OpenConferenceWare.agreement.present? }
    validates_presence_of :excerpt,                         if: :proposal_excerpts?
    validates_presence_of :track,                           if: :event_tracks?
    validates_presence_of :session_type,                    if: :event_session_types?
    validates_presence_of :presenter, :email, :biography,   unless: :user_profiles?
    validates_presence_of :speaking_experience,             if: :proposal_speaking_experience?
    validates_presence_of :audience_level,                  if: Proc.new { Proposal.audience_levels.present? }
    validates_inclusion_of :audience_level,                 if: Proc.new { Proposal.audience_levels.present? }, allow_blank: true,
                                                            in: OpenConferenceWare.proposal_audience_levels ?
                                                                  OpenConferenceWare.proposal_audience_levels.flatten.map { |level| level.with_indifferent_access['slug'] } :
                                                                  []
    validate :validate_complete_user_profile,               if: :user_profiles?
    validate :url_validator

    # Triggers
    before_save :populate_submitted_at

    # CSV Export

    base_comma_attributes = Proc.new {
      id
      submitted_at
      track title: "Track" if OpenConferenceWare.have_event_tracks
      title
      excerpt if OpenConferenceWare.have_proposal_excerpts
      description
      audience_level_label "Audience Level"

      if OpenConferenceWare.have_event_session_types
        session_type title: "Session Type"
        session_type duration: "Duration"
      end

      # TODO how to better support multiple speakers!?
      if OpenConferenceWare.have_multiple_presenters
        users fullnames: "Speakers"
      else
        presenter
        affiliation
        website
        biography
      end
    }

    schedule_comma_attributes = Proc.new {
      room name: "Room Name"
      start_time("Start Time") {|t| t.try(:xmlschema) }
    }

    comma do
      instance_eval &base_comma_attributes
    end

    comma :schedule do
      instance_eval &base_comma_attributes
      instance_eval &schedule_comma_attributes
    end

    comma :admin do
      instance_eval &base_comma_attributes
      speaking_experience
      status
      instance_eval &schedule_comma_attributes

      if OpenConferenceWare.have_multiple_presenters
        users :emails
      else
        email
      end
      note_to_organizers
      comments_text
      user_favorites size: 'Favorites count'
    end

    comma :selector_votes do
      instance_eval &base_comma_attributes

      user_favorites size: 'Favorites count'
      selector_vote_points 'Selector points'
      selector_votes_for_comma 'Selector votes'
      selector_votes_count 'Selector votes count'
      selector_votes_average 'Selector votes average'
      comments_for_comma 'Comments'
    end

    # Return the first User owner. Burst into flames if no user or multiple users listed.
    def user
      raise ArgumentError, "Can't lookup user when in multiple presenters mode" if multiple_presenters?
      return self.users.first
    end

    # generates a unique slug for the proposal
    def slug
      return "#{OpenConferenceWare.organization_slug}#{event.try(:slug)}-%04d" % id
    end

    # returns a proposal's duration based on its session type
    def duration
      self.session_type.try(:duration)
    end

    # Return the time this session ends.
    def end_time
      if self.start_time
        self.start_time + (self.duration || 0).minutes
      else
        nil
      end
    end

    # Return array of arrays, the first representing the current state, the rest
    # representing optional states. Of each pair, the first element is the title,
    # the second is the status.
    def titles_and_statuses
      result = [["(currently '#{self.aasm.current_state.to_s.titleize}')", nil]]
      result += self.aasm.events(aasm.current_state).map{|s|[s.to_s.titleize, s.to_s]}.sort_by{|title, state| title}
      return result
    end

    # allows an interface to state machine through update_attributes transition key
    attr_accessor :transition
    def transition=(event)
      send("#{event}!") if !event.blank? && aasm.events(aasm.current_state).include?(event.to_sym)
    end

    # Is this +user+ allowed to alter this proposal?
    def can_alter?(user)
      return false unless user

      user = User.get(user)
      if user.admin?
        return true
      else
        return self.users(true).include?(user)
      end
    end

    # Return the comments as text.
    def comments_text
      return self.comments.inject("") do |string, comment|
        string +
          (string.empty? ? "" : "\n") +
          comment.email +
          ": " +
          comment.message
      end
    end

    # Save original created_at time because it doesn't survive database reloads.
    def populate_submitted_at
      self.submitted_at ||= self.created_at || Time.now
      return true
    end

    # Validation for making sure user has a complete profile
    def validate_complete_user_profile
      unless self.user_has_complete_profile?
        self.errors.add(:user, "must have a complete profile")
      end
    end

    # Does this profile have a user with a complete profile?
    def user_has_complete_profile?
      self.users.each do |user|
        if user.blank? || !user.complete_profile?
          return false
        end
      end
      return true
    end

    # Add user by record or id if needed. Return user object if added.
    def add_user(user)
      user = User.get(user)

      if self.users.include?(user)
        return nil
      else
        CacheWatcher.expire
        self.users << user
        return user
      end
    end

    # Remove user by record or id if needed. Return user object if removed.
    def remove_user(user)
      user = User.get(user)

      if self.users.include?(user)
        CacheWatcher.expire
        self.users.delete(user)
        return user
      else
        return nil
      end
    end

    # Return the object with profile information (e.g., biography). The
    # object can be either a Proposal or a User, or false when there isn't
    # just one presenter per proposal.
    def profile
      if multiple_presenters?
        return false
      elsif user_profiles?
        return user
      else
        return self
      end
    end

    # Validate that the record has a blank or valid URL, else add a
    # validation error.
    def url_validator
      validate_url_attribute(:website)
    end

    # Return string with a "mailto:" link for contacting the proposal's speakers.
    def mailto_link
      link = "mailto:"
      return link << mailto_emails
    end

    # Return string with the proposal's speakers' emails.
    def mailto_emails
      if multiple_presenters?
        return self.users.map(&:email).join(", ")
      else
        return self.profile.email
      end
    end

    # Returns a string labeling a proposal object as either a proposal or a session depending on its state.
    def kind_label
      return self.confirmed? ? 'session' : 'proposal'
    end

    # Returns URL of session notes for this proposal, if available.
    #
    # Reads optional OpenConferenceWare.session_notes_url_format. This 'printf' format
    # contains positional variables that filled by Proposal#session_notes_url:
    #   * %1 => site's public URL
    #   * %2 => parent OR event slug
    #   * %3 => event slug
    #
    # E.g., '%1$s%2$s/wiki/' may translate to 'http://my_site.com/my_parent_slug/wiki'
    def session_notes_url
      escape = lambda{|string| self.class._session_notes_url_escape(string)}

      if OpenConferenceWare.public_url && OpenConferenceWare.session_notes_url_format && ! self.title.blank?
        return (
          sprintf(
            OpenConferenceWare.session_notes_url_format,
            OpenConferenceWare.public_url,
            escape[self.event.parent_or_self.slug],
            escape[self.event.slug]
          ) \
          + escape[self.title]
        )
      end
    end

    # Return escaped string for use in a URL in the session notes wiki.
    def self._session_notes_url_escape(string)
      return CGI.escape(string.gsub(/\s/, '_').gsub(/[\\\/\(\)\[\]]+/, '-').gsub(/[<>]/,'').squeeze('_').squeeze('-'))
    end

    # Return the proposal's title downcased or nil.
    def title_downcased
      return self.title.try(:downcase)
    end

    # Return array of +proposals+ sorted by +field+ (e.g., "title") in +ascending+ order.
    def self.sort(proposals, field="title", is_ascending=true, random_seed = nil)
      return proposals if proposals.empty?

      proposals = \
        case field.to_sym
        when :track
          partitioned = proposals.partition{|proposal| proposal.track.nil?}
          without_tracks = partitioned.first.sort_by(&:title)
          with_tracks = partitioned.last.select(&:track).sort_by{|proposal| [proposal.track, proposal.title]}
          with_tracks + without_tracks
        when :start_time
          proposals.select{|proposal| !proposal.start_time.nil? }.sort_by{|proposal| proposal.start_time.to_i }.concat(proposals.select{|proposal| proposal.start_time.nil?})
        when :submitted_at
          proposals.sort_by(&:submitted_at)
        when :title
          proposals.sort_by{|proposal| proposal.title_downcased}
        when :status
          proposals.sort_by{|proposal| [proposal.status, proposal.title_downcased]}
        when :random
          randomized_ids = proposals.first.randomized_ids(random_seed)
          proposals.sort_by{|proposal| randomized_ids.index(proposal.id) }
        else
          proposals.sort_by(&:submitted_at)
        end
      proposals = proposals.reverse unless is_ascending

      return proposals
    end

    # Return a string of iCalendar data for the given +items+.
    #
    # Options:
    # * title: String to use as the calendar title. Optional.
    # * url_helper: Lambda that's called with an item that should return
    #   the URL for the item. Optional, defaults to not returning a URL.
    def self.to_icalendar(items, opts={})
      title = opts[:title] || "Schedule"
      url_helper = opts[:url_helper]

      calendar = Vpim::Icalendar.create2(Vpim::PRODID)
      calendar.title = title
      calendar.time_zone = Time.zone.tzinfo.name
      items.each do |item|
        next if item.start_time.nil?
        calendar.add_event do |e|
          e.dtstart     item.start_time
          e.dtend       item.start_time + item.duration.minutes if item.duration
          e.summary     item.title
          e.created     item.created_at if item.created_at
          e.lastmod     item.updated_at if item.updated_at
          e.description(
            (item.respond_to?(:users) ? "#{item.users.map(&:fullname).join(', ')}: " : '') \
            + item.excerpt.gsub(/\s+/," ")
          )
          if item.room
            e.set_text  'LOCATION', item.room.name
          end
          if url_helper
            url = url_helper.call(item)
            e.url       url
            e.uid       url
          end
        end
      end
      return calendar.encode.sub(/CALSCALE:Gregorian/, "CALSCALE:Gregorian\nMETHOD:PUBLISH")
    end

    def self.populated_proposals_for(container)
      args = [:event, :room, :session_type, :track, :users]
      case container
      when User
        # Can't eager fetch users for users for some reason, yet all other combinations work fine.
        args.delete(:users)
      end
      return container.proposals.includes(args)
    end

    # Is this proposal related to the +event+, as in to the event, its parent or children?
    def related_to_event?(some_event)
      for an_event in [some_event, some_event.parent, some_event.parent_or_self.children].compact.flatten
        return true if self.event_id == an_event.id
      end
      return false
    end

    # Return next proposal in this event after this one, or nil if none.
    def next_proposal
      return self.event.proposals.where("id > ?", self.id).order("created_at ASC").first
    end

    # Return previous proposal in this event after this one, or nil if none.
    def previous_proposal
      return self.event.proposals.where("id < ?", self.id).order("created_at DESC").first
    end

    def next_random_proposal(seed = nil, user_id = nil)
      ids = randomized_ids(seed, user_id)
      next_id = ids[ids.index(self.id) + 1]
      next_id && Proposal.find( next_id )
    end

    def previous_random_proposal(seed = nil, user_id = nil)
      ids = randomized_ids(seed, user_id)
      prev_index = ids.index(self.id) - 1
      prev_id = ids[prev_index]
      prev_index >= 0 && prev_id && Proposal.find( prev_id )
    end

    def randomized_ids(seed = nil, user_id = nil)
      proposals = event.proposals.order_by_rand(seed: seed).find(:all)
      if user_id
        new_proposals = proposals.select { |p| p.has_voted?(user_id) }
        # if all proposals have been voted on, then return a random one
        if new_proposals
          proposal = new_proposals
        end
      end
      proposals.map { |p| p.id }
    end

    # return true if the user has voted for this proposal
    def has_voted?(user_id)
      return self.selector_votes.select { |v| v.user_id == user_id }.size > 0
    end

    # Return the integer sum of the selector votes rating for this proposal. Skips
    # the "-1" votes because these mean "I don't know how to rate this proposal".
    def selector_vote_points
      return self.selector_votes.map(&:rating).reject{|o| o == -1}.sum
    end

    # Return the integer number of votes submitted that aren't abstensions.
    def selector_votes_count
      return self.selector_votes.map(&:rating).reject{|o| o == -1}.size
    end

    # Return the average vote (not including abstensions)
    def selector_votes_average
      return (self.selector_vote_points.to_f / self.selector_votes_count.to_f).round(2)
    end

    #---[ Accessors for getting the titles of related objects ]-------------

    def track_title
      return self.track.try(:title)
    end

    def room_title
      return self.room.try(:name)
    end

    def session_type_title
      return self.session_type.try(:title)
    end

    def event_title
      return self.event.try(:slug)
    end
    alias_method :event_slug, :event_title

    def user_titles
      return self.users.map(&:label).map(&:to_s)
    end
    alias_method :user_labels, :user_titles

    #---[ Audience level ]--------------------------------------------------

    # Return the audience levels. May be nil if not defined.
    #
    # Structure: array of hashes with a "label" and "slug".
    #
    # Example:
    #   [
    #     {"label"=>"Beginner", "slug"=>"a"},
    #     {"label"=>"Intermediate", "slug"=>"b"},
    #     {"label"=>"Advanced", "slug"=>"c"}
    #   ]
    def self.audience_levels
      OpenConferenceWare.proposal_audience_levels.present? &&
      OpenConferenceWare.proposal_audience_levels.map(&:with_indifferent_access)
    end

    # Return the text hint describing the audience level UI control.
    def self.audience_level_hint
      OpenConferenceWare.proposal_audience_level_hint
    end

    # Return the string label for the audience level, or nil if not set.
    def audience_level_label
      if ! self.audience_level.blank? && self.class.audience_levels
        return self.class.audience_levels.find { |level| level['slug'] == self.audience_level }['label']
      end
    end

    #---[ Notify speakers ]---------------------------------------------

    # returns [sent-emails, already-notified-emails]
    def notify_accepted_speakers
      if accepted?
        if !notified_at
          SpeakerMailer.speaker_accepted_email(self).deliver
          self.notified_at = Time.now
          self.save
          return [self.mailto_emails, nil]
        else
          return [nil, self.mailto_emails]
        end
      end
      return [nil, nil]
    end

    # returns [sent-emails, already-notified-emails]
    def notify_rejected_speakers
      if rejected?
        if !notified_at
          SpeakerMailer.speaker_rejected_email(self).deliver
          self.notified_at = Time.now
          self.save
          return [ self.mailto_emails, nil ]
        else
          return [ nil, self.mailto_emails ]
        end
      end
      return [ nil, nil ]
    end

    #---[ Accessors for comma ]---------------------------------------------

    def selector_votes_for_comma
      return self.selector_votes.map do |selector_vote|
        "#{selector_vote.rating == -1 ? 'Abstain' : selector_vote.rating}: #{selector_vote.comment}"
      end.join("\n")
    end

    def comments_for_comma
      return self.comments.map do |comment|
        "#{comment.email}: #{comment.message}"
      end.join("\n")
    end
  end
end