effective_polls 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +132 -0
  4. data/Rakefile +32 -0
  5. data/app/assets/config/effective_polls_manifest.js +1 -0
  6. data/app/assets/stylesheets/effective_polls.scss +0 -0
  7. data/app/controllers/admin/poll_notifications_controller.rb +15 -0
  8. data/app/controllers/admin/poll_questions_controller.rb +15 -0
  9. data/app/controllers/admin/polls_controller.rb +23 -0
  10. data/app/controllers/effective/ballots_controller.rb +70 -0
  11. data/app/controllers/effective/polls_controller.rb +19 -0
  12. data/app/datatables/admin/effective_poll_notifications_datatable.rb +43 -0
  13. data/app/datatables/admin/effective_poll_questions_datatable.rb +28 -0
  14. data/app/datatables/admin/effective_poll_results_datatable.rb +50 -0
  15. data/app/datatables/admin/effective_polls_datatable.rb +29 -0
  16. data/app/datatables/effective_polls_datatable.rb +30 -0
  17. data/app/helpers/effective_polls_helper.rb +23 -0
  18. data/app/mailers/effective/polls_mailer.rb +23 -0
  19. data/app/models/effective/access_denied.rb +17 -0
  20. data/app/models/effective/ballot.rb +73 -0
  21. data/app/models/effective/ballot_response.rb +92 -0
  22. data/app/models/effective/ballot_response_option.rb +6 -0
  23. data/app/models/effective/poll.rb +149 -0
  24. data/app/models/effective/poll_notification.rb +144 -0
  25. data/app/models/effective/poll_question.rb +76 -0
  26. data/app/models/effective/poll_question_option.rb +26 -0
  27. data/app/views/admin/poll_notifications/_form.html.haml +55 -0
  28. data/app/views/admin/poll_questions/_form.html.haml +100 -0
  29. data/app/views/admin/polls/_form.html.haml +20 -0
  30. data/app/views/admin/polls/_form_content.html.haml +27 -0
  31. data/app/views/admin/polls/_form_poll.html.haml +32 -0
  32. data/app/views/admin/polls/_poll.html.haml +2 -0
  33. data/app/views/admin/polls/results.html.haml +6 -0
  34. data/app/views/effective/ballot_responses/_ballot_response.html.haml +2 -0
  35. data/app/views/effective/ballot_responses/_fields.html.haml +14 -0
  36. data/app/views/effective/ballot_responses/fields/_choose_one.html.haml +1 -0
  37. data/app/views/effective/ballot_responses/fields/_date.html.haml +1 -0
  38. data/app/views/effective/ballot_responses/fields/_email.html.haml +1 -0
  39. data/app/views/effective/ballot_responses/fields/_long_answer.html.haml +1 -0
  40. data/app/views/effective/ballot_responses/fields/_number.html.haml +1 -0
  41. data/app/views/effective/ballot_responses/fields/_select_all_that_apply.html.haml +1 -0
  42. data/app/views/effective/ballot_responses/fields/_select_upto_1.html.haml +1 -0
  43. data/app/views/effective/ballot_responses/fields/_select_upto_2.html.haml +1 -0
  44. data/app/views/effective/ballot_responses/fields/_select_upto_3.html.haml +1 -0
  45. data/app/views/effective/ballot_responses/fields/_select_upto_4.html.haml +1 -0
  46. data/app/views/effective/ballot_responses/fields/_select_upto_5.html.haml +1 -0
  47. data/app/views/effective/ballot_responses/fields/_short_answer.html.haml +1 -0
  48. data/app/views/effective/ballot_responses/fields/_upload_file.html.haml +1 -0
  49. data/app/views/effective/ballot_responses/responses/_choose_one.html.haml +1 -0
  50. data/app/views/effective/ballot_responses/responses/_date.html.haml +1 -0
  51. data/app/views/effective/ballot_responses/responses/_email.html.haml +1 -0
  52. data/app/views/effective/ballot_responses/responses/_long_answer.html.haml +1 -0
  53. data/app/views/effective/ballot_responses/responses/_number.html.haml +1 -0
  54. data/app/views/effective/ballot_responses/responses/_select_all_that_apply.html.haml +5 -0
  55. data/app/views/effective/ballot_responses/responses/_select_upto_1.html.haml +1 -0
  56. data/app/views/effective/ballot_responses/responses/_select_upto_2.html.haml +5 -0
  57. data/app/views/effective/ballot_responses/responses/_select_upto_3.html.haml +5 -0
  58. data/app/views/effective/ballot_responses/responses/_select_upto_4.html.haml +5 -0
  59. data/app/views/effective/ballot_responses/responses/_select_upto_5.html.haml +5 -0
  60. data/app/views/effective/ballot_responses/responses/_short_answer.html.haml +1 -0
  61. data/app/views/effective/ballot_responses/responses/_upload_file.html.haml +4 -0
  62. data/app/views/effective/ballots/_ballot.html.haml +14 -0
  63. data/app/views/effective/ballots/complete.html.haml +11 -0
  64. data/app/views/effective/ballots/start.html.haml +17 -0
  65. data/app/views/effective/ballots/submit.html.haml +17 -0
  66. data/app/views/effective/ballots/vote.html.haml +19 -0
  67. data/app/views/effective/poll_results/_poll_result.html.haml +2 -0
  68. data/app/views/effective/poll_results/_results.html.haml +38 -0
  69. data/app/views/effective/poll_results/results/_choose_one.html.haml +2 -0
  70. data/app/views/effective/poll_results/results/_date.html.haml +14 -0
  71. data/app/views/effective/poll_results/results/_email.html.haml +14 -0
  72. data/app/views/effective/poll_results/results/_long_answer.html.haml +2 -0
  73. data/app/views/effective/poll_results/results/_number.html.haml +14 -0
  74. data/app/views/effective/poll_results/results/_poll_question_option.html.haml +17 -0
  75. data/app/views/effective/poll_results/results/_select_all_that_apply.html.haml +2 -0
  76. data/app/views/effective/poll_results/results/_select_upto_1.html.haml +2 -0
  77. data/app/views/effective/poll_results/results/_select_upto_2.html.haml +2 -0
  78. data/app/views/effective/poll_results/results/_select_upto_3.html.haml +2 -0
  79. data/app/views/effective/poll_results/results/_select_upto_4.html.haml +2 -0
  80. data/app/views/effective/poll_results/results/_select_upto_5.html.haml +2 -0
  81. data/app/views/effective/poll_results/results/_short_answer.html.haml +2 -0
  82. data/app/views/effective/poll_results/results/_upload_file.html.haml +3 -0
  83. data/app/views/effective/polls/_form.html.haml +10 -0
  84. data/app/views/effective/polls/_poll.html.haml +2 -0
  85. data/app/views/layouts/effective_polls_mailer_layout.html.haml +7 -0
  86. data/config/effective_polls.rb +59 -0
  87. data/config/routes.rb +23 -0
  88. data/db/migrate/01_create_effective_polls.rb.erb +92 -0
  89. data/lib/effective_polls.rb +50 -0
  90. data/lib/effective_polls/engine.rb +11 -0
  91. data/lib/effective_polls/version.rb +3 -0
  92. data/lib/generators/effective_polls/install_generator.rb +46 -0
  93. data/lib/generators/templates/effective_polls_mailer_preview.rb +35 -0
  94. data/lib/tasks/effective_polls_tasks.rake +24 -0
  95. metadata +276 -0
@@ -0,0 +1,29 @@
1
+ class Admin::EffectivePollsDatatable < Effective::Datatable
2
+ filters do
3
+ scope :upcoming
4
+ scope :available
5
+ scope :completed
6
+ scope :all
7
+ end
8
+
9
+ datatable do
10
+ order :start_at, :desc
11
+
12
+ col :id, visible: false
13
+ col :created_at, visible: false
14
+ col :updated_at, visible: false
15
+
16
+ col :title
17
+ col :start_at
18
+ col :end_at
19
+ col :audience
20
+
21
+ col :poll_questions, visible: false
22
+
23
+ actions_col
24
+ end
25
+
26
+ collection do
27
+ Effective::Poll.all.deep
28
+ end
29
+ end
@@ -0,0 +1,30 @@
1
+ # Displays available polls that the current_user may complete
2
+
3
+ class EffectivePollsDatatable < Effective::Datatable
4
+ datatable do
5
+ order :start_at
6
+
7
+ col :start_at, visible: false
8
+
9
+ col :title
10
+ col :available_date, label: 'Date'
11
+
12
+ actions_col(actions: []) do |poll|
13
+ ballot = poll.ballots.where(user: current_user).first
14
+
15
+ if ballot.blank?
16
+ dropdown_link_to('Start', effective_polls.poll_ballot_build_path(poll, :new, :start))
17
+ elsif ballot.completed?
18
+ 'Complete'
19
+ else
20
+ dropdown_link_to('Continue', effective_polls.poll_ballot_build_path(poll, ballot, ballot.next_step))
21
+ end
22
+ end
23
+ end
24
+
25
+ collection do
26
+ raise('expected a current_user') unless current_user.present?
27
+ Effective::Poll.available.select { |poll| poll.available_for?(current_user) }
28
+ end
29
+
30
+ end
@@ -0,0 +1,23 @@
1
+ module EffectivePollsHelper
2
+
3
+ # Used by admin/polls form
4
+ def effective_polls_audience_scope_collection
5
+ # Normalize the collection into [[label, value], [label, value]]
6
+ scopes = Array(EffectivePolls.audience_user_scopes).map do |key, value|
7
+ (key.present? && value.present?) ? [key, value] : [key.to_s.titleize, key]
8
+ end
9
+
10
+ # Makes sure the User model responds to all values
11
+ scopes.each do |_, scope|
12
+ unless defined?(User) && User.respond_to?(scope)
13
+ raise("invalid effective_polls config.audience_user_scopes value. The user model must respond to the scope User.#{scope}")
14
+ end
15
+ end
16
+
17
+ # Append the number of users in this scope
18
+ scopes.map! do |label, scope|
19
+ ["#{label} (#{pluralize(User.send(scope).count, 'user')})", scope]
20
+ end
21
+ end
22
+
23
+ end
@@ -0,0 +1,23 @@
1
+ module Effective
2
+ class PollsMailer < ActionMailer::Base
3
+ default from: EffectivePolls.mailer[:default_from]
4
+ layout EffectivePolls.mailer[:layout].presence || 'effective_polls_mailer_layout'
5
+
6
+ def poll_upcoming_reminder(poll_notification, bcc)
7
+ mail(bcc: bcc, body: poll_notification.body, subject: poll_notification.subject)
8
+ end
9
+
10
+ def poll_start(poll_notification, bcc)
11
+ mail(bcc: bcc, body: poll_notification.body, subject: poll_notification.subject)
12
+ end
13
+
14
+ def poll_reminder(poll_notification, bcc)
15
+ mail(bcc: bcc, body: poll_notification.body, subject: poll_notification.subject)
16
+ end
17
+
18
+ def poll_end(poll_notification, bcc)
19
+ mail(bcc: bcc, body: poll_notification.body, subject: poll_notification.subject)
20
+ end
21
+
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ unless defined?(Effective::AccessDenied)
2
+ module Effective
3
+ class AccessDenied < StandardError
4
+ attr_reader :action, :subject
5
+
6
+ def initialize(message = nil, action = nil, subject = nil)
7
+ @message = message
8
+ @action = action
9
+ @subject = subject
10
+ end
11
+
12
+ def to_s
13
+ @message || I18n.t(:'unauthorized.default', :default => 'Access Denied')
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,73 @@
1
+ module Effective
2
+ class Ballot < ActiveRecord::Base
3
+ attr_accessor :current_user
4
+ attr_accessor :current_step
5
+
6
+ belongs_to :poll
7
+ belongs_to :user
8
+
9
+ has_many :ballot_responses, dependent: :destroy
10
+ accepts_nested_attributes_for :ballot_responses
11
+
12
+ acts_as_tokened
13
+
14
+ acts_as_wizard(
15
+ start: 'Start',
16
+ vote: 'Ballot',
17
+ submit: 'Review', # They submit on this step
18
+ complete: 'Complete'
19
+ )
20
+
21
+ effective_resource do
22
+ # Acts as tokened
23
+ token :string, permitted: false
24
+
25
+ # Acts as Wizard
26
+ wizard_steps :text, permitted: false
27
+
28
+ # More fields
29
+ completed_at :datetime, permitted: false
30
+
31
+ current_step permitted: true
32
+ timestamps
33
+ end
34
+
35
+ scope :deep, -> { includes(:poll, :user, ballot_responses: [:poll, :poll_question, :poll_question_options]) }
36
+ scope :sorted, -> { order(:id) }
37
+ scope :completed, -> { where.not(completed_at: nil) }
38
+
39
+ before_validation(if: -> { new_record? }) do
40
+ self.user ||= current_user
41
+ end
42
+
43
+ validates :user_id, uniqueness: {
44
+ scope: :poll_id, allow_blank: true, message: 'ballot already exists for this poll'
45
+ }
46
+
47
+ # I seem to need this even tho I accept_nested_attributes
48
+ validates :ballot_responses, associated: true
49
+
50
+ def to_s
51
+ 'ballot'
52
+ end
53
+
54
+ # Find or build
55
+ def ballot_response(poll_question)
56
+ ballot_response = ballot_responses.find { |br| br.poll_question_id == poll_question.id }
57
+ ballot_response ||= ballot_responses.build(poll: poll_question.poll, poll_question: poll_question)
58
+ end
59
+
60
+ # This is the review step where they click Submit Ballot
61
+ def submit!
62
+ wizard_steps[:complete] ||= Time.zone.now
63
+ self.completed_at ||= Time.zone.now
64
+
65
+ save!
66
+ end
67
+
68
+ def completed?
69
+ completed_at.present?
70
+ end
71
+
72
+ end
73
+ end
@@ -0,0 +1,92 @@
1
+ module Effective
2
+ class BallotResponse < ActiveRecord::Base
3
+ belongs_to :ballot
4
+
5
+ belongs_to :poll
6
+ belongs_to :poll_question
7
+
8
+ has_many :ballot_response_options, dependent: :delete_all
9
+ has_many :poll_question_options, through: :ballot_response_options
10
+
11
+ has_one_attached :upload_file
12
+
13
+ effective_resource do
14
+ # The response
15
+ date :date
16
+ email :string
17
+ number :integer
18
+ long_answer :text
19
+ short_answer :text
20
+
21
+ timestamps
22
+ end
23
+
24
+ scope :completed, -> { where(ballot: Effective::Ballot.completed) }
25
+ scope :deep, -> { includes(:ballot, :poll, :poll_question, :poll_question_options) }
26
+
27
+ validates :poll, presence: true
28
+ validates :ballot, presence: true
29
+ validates :poll_question, presence: true
30
+
31
+ # Sanity check. These shouldn't happen.
32
+ validate(if: -> { poll.present? && ballot.present? }) do
33
+ self.errors.add(:ballot, 'must match poll') unless ballot.poll_id == poll.id
34
+ end
35
+
36
+ validate(if: -> { poll.present? && poll_question.present? }) do
37
+ self.errors.add(:poll_question, 'must match poll') unless poll_question.poll_id == poll.id
38
+ end
39
+
40
+ validates :date, presence: true, if: -> { poll_question&.required? && poll_question.date? }
41
+ validates :email, presence: true, email: true, if: -> { poll_question&.required? && poll_question.email? }
42
+ validates :number, presence: true, if: -> { poll_question&.required? && poll_question.number? }
43
+ validates :long_answer, presence: true, if: -> { poll_question&.required? && poll_question.long_answer? }
44
+ validates :short_answer, presence: true, if: -> { poll_question&.required? && poll_question.short_answer? }
45
+ validates :upload_file, presence: true, if: -> { poll_question&.required? && poll_question.upload_file? }
46
+ validates :poll_question_option_ids, presence: true, if: -> { poll_question&.required? && poll_question.poll_question_option? }
47
+
48
+ validates :poll_question_option_ids, if: -> { poll_question&.choose_one? },
49
+ length: { maximum: 1, message: 'please choose 1 option only' }
50
+
51
+ validates :poll_question_option_ids, if: -> { poll_question&.select_upto_1? },
52
+ length: { maximum: 1, message: 'please select 1 option or fewer' }
53
+
54
+ validates :poll_question_option_ids, if: -> { poll_question&.select_upto_2? },
55
+ length: { maximum: 2, message: 'please select 2 options or fewer' }
56
+
57
+ validates :poll_question_option_ids, if: -> { poll_question&.select_upto_3? },
58
+ length: { maximum: 3, message: 'please select 3 options or fewer' }
59
+
60
+ validates :poll_question_option_ids, if: -> { poll_question&.select_upto_4? },
61
+ length: { maximum: 4, message: 'please select 4 options or fewer' }
62
+
63
+ validates :poll_question_option_ids, if: -> { poll_question&.select_upto_5? },
64
+ length: { maximum: 5, message: 'please select 5 options or fewer' }
65
+
66
+ def to_s
67
+ 'ballot reponse'
68
+ end
69
+
70
+ def response
71
+ return nil unless poll_question.present?
72
+
73
+ return date if poll_question.date?
74
+ return email if poll_question.email?
75
+ return number if poll_question.number?
76
+ return long_answer if poll_question.long_answer?
77
+ return short_answer if poll_question.short_answer?
78
+ return upload_file if poll_question.upload_file?
79
+
80
+ return poll_question_options.first if poll_question.choose_one?
81
+ return poll_question_options.first if poll_question.select_upto_1?
82
+ return poll_question_options if poll_question.poll_question_option?
83
+
84
+ raise('unknown response for unexpected poll question category')
85
+ end
86
+
87
+ def category_partial
88
+ poll_question&.category_partial
89
+ end
90
+
91
+ end
92
+ end
@@ -0,0 +1,6 @@
1
+ module Effective
2
+ class BallotResponseOption < ActiveRecord::Base
3
+ belongs_to :ballot_response
4
+ belongs_to :poll_question_option
5
+ end
6
+ end
@@ -0,0 +1,149 @@
1
+ module Effective
2
+ class Poll < ActiveRecord::Base
3
+ attr_accessor :skip_started_validation
4
+
5
+ has_rich_text :all_steps_content
6
+ has_rich_text :start_content
7
+ has_rich_text :vote_content
8
+ has_rich_text :submit_content
9
+ has_rich_text :complete_content
10
+
11
+ has_many :poll_notifications, -> { order(:id) }, inverse_of: :poll, dependent: :destroy
12
+ accepts_nested_attributes_for :poll_notifications, allow_destroy: true
13
+
14
+ has_many :poll_questions, -> { order(:position) }, inverse_of: :poll, dependent: :destroy
15
+ accepts_nested_attributes_for :poll_questions, allow_destroy: true
16
+
17
+ has_many :ballots
18
+ has_many :ballot_responses
19
+
20
+ # For the poll_results screens
21
+ has_many :completed_ballots, -> { Effective::Ballot.completed }, class_name: 'Effective::Ballot'
22
+ has_many :completed_ballot_responses, -> { where(ballot: Effective::Ballot.completed) }, class_name: 'Effective::BallotResponse'
23
+
24
+ acts_as_tokened
25
+
26
+ AUDIENCES = ['All Users', 'Individual Users', 'Selected Users']
27
+
28
+ effective_resource do
29
+ # Acts as tokened
30
+ token :string, permitted: false
31
+
32
+ title :string
33
+
34
+ start_at :datetime
35
+ end_at :datetime
36
+
37
+ audience :string
38
+ audience_scope :text # An Array of user_ids or named scopes on the User model
39
+
40
+ timestamps
41
+ end
42
+
43
+ serialize :audience_scope, Array
44
+
45
+ scope :deep, -> {
46
+ includes(poll_questions: :poll_question_options)
47
+ .with_rich_text_all_steps_content
48
+ .with_rich_text_start_content
49
+ .with_rich_text_vote_content
50
+ .with_rich_text_submit_content
51
+ .with_rich_text_complete_content
52
+ }
53
+
54
+ scope :deep_results, -> {
55
+ includes(poll_questions: :poll_question_options)
56
+ .includes(ballots: [ballot_responses: [:poll, :poll_question, :poll_question_options]])
57
+ }
58
+
59
+ scope :sorted, -> { order(:start_at) }
60
+ scope :editable, -> { upcoming }
61
+
62
+ scope :upcoming, -> { where('start_at > ?', Time.zone.now) }
63
+ scope :available, -> { where('start_at <= ? AND (end_at > ? OR end_at IS NULL)', Time.zone.now, Time.zone.now) }
64
+ scope :completed, -> { where('end_at < ?', Time.zone.now) }
65
+
66
+ validates :title, presence: true
67
+ validates :start_at, presence: true
68
+
69
+ validates :audience, inclusion: { in: AUDIENCES }
70
+ validates :audience_scope, presence: true, unless: -> { audience == 'All Users' }
71
+
72
+ validate(if: -> { started? }, unless: -> { skip_started_validation }) do
73
+ self.errors.add(:base, 'has already started. a poll cannot be changed after it has started.')
74
+ end
75
+
76
+ validate(if: -> { start_at.present? && !started? }, unless: -> { skip_started_validation }) do
77
+ self.errors.add(:start_at, 'must be a future date') if start_at < Time.zone.now
78
+ end
79
+
80
+ validate(if: -> { start_at.present? && end_at.present? }) do
81
+ self.errors.add(:end_at, 'must be after the start date') unless end_at > start_at
82
+ end
83
+
84
+ def to_s
85
+ title.presence || 'New Poll'
86
+ end
87
+
88
+ def available_for?(user)
89
+ raise('expected a user') unless user.kind_of?(User)
90
+ available? && users.include?(user)
91
+ end
92
+
93
+ def users
94
+ case audience
95
+ when 'All Users'
96
+ User.all
97
+ when 'Individual Users'
98
+ User.where(id: audience_scope)
99
+ when 'Selected Users'
100
+ collection = User.none
101
+ audience_scope.each { |scope| collection = collection.or(User.send(scope)) }
102
+ collection
103
+ else
104
+ raise('unexpected audience')
105
+ end
106
+ end
107
+
108
+ def emails(exclude_completed: false)
109
+ if exclude_completed
110
+ users.where.not(id: completed_ballots.select('user_id as id')).pluck(:email)
111
+ else
112
+ users.pluck(:email)
113
+ end
114
+ end
115
+
116
+ def available?
117
+ started? && !ended?
118
+ end
119
+
120
+ def started?
121
+ start_at_was.present? && Time.zone.now >= start_at_was
122
+ end
123
+
124
+ def ended?
125
+ end_at.present? && end_at < Time.zone.now
126
+ end
127
+
128
+ def available_date
129
+ if start_at && end_at && start_at.to_date == end_at.to_date
130
+ "#{start_at.strftime('%F at %H:%M')} to #{end_at.strftime('%H:%M')}"
131
+ elsif start_at && end_at
132
+ "#{start_at.strftime('%F at %H:%M')} to #{end_at.strftime('%F %H:%M')}"
133
+ elsif start_at
134
+ "#{start_at.strftime('%F at %H:%M')}"
135
+ end
136
+ end
137
+
138
+ def audience_scope
139
+ Array(super) - [nil, '']
140
+ end
141
+
142
+ # Returns all completed_ballot_responses
143
+ def poll_results(poll_question: nil)
144
+ return completed_ballot_responses if poll_question.nil?
145
+ completed_ballot_responses.select { |br| br.poll_question_id == poll_question.id }
146
+ end
147
+
148
+ end
149
+ end