effective_polls 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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