ballot 1.0
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.
- checksums.yaml +7 -0
- data/Contributing.md +68 -0
- data/History.md +5 -0
- data/Licence.md +27 -0
- data/Manifest.txt +68 -0
- data/README.rdoc +264 -0
- data/Rakefile +71 -0
- data/bin/ballot_generator +9 -0
- data/lib/ballot.rb +25 -0
- data/lib/ballot/action_controller.rb +32 -0
- data/lib/ballot/active_record.rb +152 -0
- data/lib/ballot/active_record/votable.rb +145 -0
- data/lib/ballot/active_record/vote.rb +35 -0
- data/lib/ballot/active_record/voter.rb +99 -0
- data/lib/ballot/railtie.rb +19 -0
- data/lib/ballot/sequel.rb +170 -0
- data/lib/ballot/sequel/vote.rb +99 -0
- data/lib/ballot/votable.rb +445 -0
- data/lib/ballot/vote.rb +129 -0
- data/lib/ballot/voter.rb +320 -0
- data/lib/ballot/words.rb +32 -0
- data/lib/generators/ballot.rb +40 -0
- data/lib/generators/ballot/install/install_generator.rb +27 -0
- data/lib/generators/ballot/install/templates/active_record/migration.rb +19 -0
- data/lib/generators/ballot/install/templates/sequel/migration.rb +25 -0
- data/lib/generators/ballot/standalone.rb +89 -0
- data/lib/generators/ballot/standalone/support.rb +70 -0
- data/lib/generators/ballot/summary/summary_generator.rb +27 -0
- data/lib/generators/ballot/summary/templates/active_record/migration.rb +15 -0
- data/lib/generators/ballot/summary/templates/sequel/migration.rb +20 -0
- data/lib/sequel/plugins/ballot_votable.rb +180 -0
- data/lib/sequel/plugins/ballot_voter.rb +125 -0
- data/test/active_record/ballot_votable_test.rb +16 -0
- data/test/active_record/ballot_voter_test.rb +13 -0
- data/test/active_record/rails_generator_test.rb +28 -0
- data/test/active_record/votable_voter_test.rb +19 -0
- data/test/generators/rails-activerecord/Rakefile +2 -0
- data/test/generators/rails-activerecord/app/.keep +0 -0
- data/test/generators/rails-activerecord/bin/rails +5 -0
- data/test/generators/rails-activerecord/config/application.rb +17 -0
- data/test/generators/rails-activerecord/config/boot.rb +3 -0
- data/test/generators/rails-activerecord/config/database.yml +12 -0
- data/test/generators/rails-activerecord/config/environment.rb +3 -0
- data/test/generators/rails-activerecord/config/routes.rb +3 -0
- data/test/generators/rails-activerecord/config/secrets.yml +5 -0
- data/test/generators/rails-activerecord/db/seeds.rb +1 -0
- data/test/generators/rails-activerecord/log/.keep +0 -0
- data/test/generators/rails-sequel/Rakefile +2 -0
- data/test/generators/rails-sequel/app/.keep +0 -0
- data/test/generators/rails-sequel/bin/rails +5 -0
- data/test/generators/rails-sequel/config/application.rb +14 -0
- data/test/generators/rails-sequel/config/boot.rb +3 -0
- data/test/generators/rails-sequel/config/database.yml +12 -0
- data/test/generators/rails-sequel/config/environment.rb +3 -0
- data/test/generators/rails-sequel/config/routes.rb +3 -0
- data/test/generators/rails-sequel/config/secrets.yml +5 -0
- data/test/generators/rails-sequel/db/seeds.rb +1 -0
- data/test/generators/rails-sequel/log/.keep +0 -0
- data/test/minitest_config.rb +14 -0
- data/test/sequel/ballot_votable_test.rb +45 -0
- data/test/sequel/ballot_voter_test.rb +42 -0
- data/test/sequel/rails_generator_test.rb +25 -0
- data/test/sequel/votable_voter_test.rb +19 -0
- data/test/sequel/vote_test.rb +105 -0
- data/test/support/active_record_setup.rb +145 -0
- data/test/support/generators_setup.rb +129 -0
- data/test/support/sequel_setup.rb +164 -0
- data/test/support/shared_examples/votable_examples.rb +630 -0
- data/test/support/shared_examples/voter_examples.rb +600 -0
- metadata +333 -0
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
module Ballot
|
5
|
+
# = Ballot Railtie
|
6
|
+
class Railtie < ::Rails::Railtie # :nodoc:
|
7
|
+
initializer 'ballot' do
|
8
|
+
ActiveSupport.on_load(:active_record) do
|
9
|
+
require 'ballot/active_record'
|
10
|
+
Ballot::ActiveRecord.inject!
|
11
|
+
end
|
12
|
+
|
13
|
+
ActiveSupport.on_load(:action_controller) do
|
14
|
+
require 'ballot/action_controller'
|
15
|
+
include Ballot::ActionController
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
module Ballot
|
5
|
+
# Extensions to Sequel::Model to support Ballot.
|
6
|
+
module Sequel
|
7
|
+
# Class method extensions to Sequel::Model to support Ballot.
|
8
|
+
module ClassMethods
|
9
|
+
# Sequel::Model classes are not votable by default.
|
10
|
+
def ballot_votable?
|
11
|
+
false
|
12
|
+
end
|
13
|
+
|
14
|
+
# Sequel::Model classes are not voters by default.
|
15
|
+
def ballot_voter?
|
16
|
+
false
|
17
|
+
end
|
18
|
+
|
19
|
+
# This macro makes migrating from ActiveRecord to Sequel (mostly)
|
20
|
+
# painless. The preferred way is to simply enable the Sequel plug-in
|
21
|
+
# directly:
|
22
|
+
#
|
23
|
+
# class Voter
|
24
|
+
# plugin :ballot_voter
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# class Votable
|
28
|
+
# plugin :ballot_votable
|
29
|
+
# end
|
30
|
+
def acts_as_ballot(*types)
|
31
|
+
types.each do |type|
|
32
|
+
case type.to_s
|
33
|
+
when 'votable'
|
34
|
+
warn 'Prefer using the Sequel::Model plugin :ballot_votable directly.'
|
35
|
+
plugin :ballot_votable
|
36
|
+
when 'voter'
|
37
|
+
warn 'Prefer using the Sequel::Model plugin :ballot_voter directly.'
|
38
|
+
plugin :ballot_voter
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# A shorthand version of <tt>acts_as_ballot :votable</tt>.
|
44
|
+
def acts_as_ballot_votable
|
45
|
+
acts_as_ballot :votable
|
46
|
+
end
|
47
|
+
|
48
|
+
# A shorthand version of <tt>acts_as_ballot :voter</tt>.
|
49
|
+
def acts_as_ballot_voter
|
50
|
+
acts_as_ballot :voter
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Delegate the question of #ballot_votable? to the class.
|
55
|
+
def ballot_votable?
|
56
|
+
self.class.ballot_votable?
|
57
|
+
end
|
58
|
+
|
59
|
+
# Delegate the question of #ballot_voter? to the class.
|
60
|
+
def ballot_voter?
|
61
|
+
self.class.ballot_voter?
|
62
|
+
end
|
63
|
+
|
64
|
+
class << self
|
65
|
+
# Respond with the canonical name for this model. This differs if the
|
66
|
+
# model is STI-enabled.
|
67
|
+
def type_name(model)
|
68
|
+
return model if model.kind_of?(String)
|
69
|
+
model = model.model if model.kind_of?(::Sequel::Model)
|
70
|
+
if model.respond_to?(:sti_dataset)
|
71
|
+
model.sti_dataset.model.name
|
72
|
+
else
|
73
|
+
model.name
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
# Return a valid Votable object from the provided item or +nil+.
|
78
|
+
# Permitted values are a Votable, a hash with the key +:votable+, or a
|
79
|
+
# hash with the keys +:votable_type+ and +:votable_id+.
|
80
|
+
def votable_for(item)
|
81
|
+
votable =
|
82
|
+
if item.kind_of?(::Sequel::Plugins::BallotVotable::InstanceMethods)
|
83
|
+
item
|
84
|
+
elsif item.kind_of?(Hash)
|
85
|
+
if item[:votable]
|
86
|
+
item[:votable]
|
87
|
+
elsif item[:votable_type] && item[:votable_id]
|
88
|
+
__instance_of_model(item[:votable_type], item[:votable_id])
|
89
|
+
elsif item[:votable_gid]
|
90
|
+
fail 'GlobalID is not enabled.' unless defined?(::GlobalID)
|
91
|
+
|
92
|
+
# NOTE: Until GlobalID has patches or a plug-in to work with
|
93
|
+
# Sequel, this is more likely to fail than to succeed.
|
94
|
+
GlobalID::Locator.locate(item[:votable_gid])
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
votable if votable && votable.kind_of?(::Sequel::Model) &&
|
99
|
+
votable.ballot_votable?
|
100
|
+
end
|
101
|
+
|
102
|
+
# Return the id and canonical votable type name for the item, using
|
103
|
+
# #votable_for.
|
104
|
+
def votable_id_and_type_name_for(item)
|
105
|
+
__id_and_type_name(votable_for(item))
|
106
|
+
end
|
107
|
+
|
108
|
+
# Return a valid Voter object from the provided item or +nil+. Permitted
|
109
|
+
# values are a Voter, a hash with the key +:voter+, or a hash with the
|
110
|
+
# keys +:voter_type+ and +:voter_id+.
|
111
|
+
def voter_for(item)
|
112
|
+
voter =
|
113
|
+
if item.kind_of?(::Sequel::Plugins::BallotVoter::InstanceMethods)
|
114
|
+
item
|
115
|
+
elsif item.kind_of?(Hash)
|
116
|
+
if item[:voter]
|
117
|
+
item[:voter]
|
118
|
+
elsif item[:voter_type] && item[:voter_id]
|
119
|
+
__instance_of_model(item[:voter_type], item[:voter_id])
|
120
|
+
elsif item[:voter_gid]
|
121
|
+
fail 'GlobalID is not enabled.' unless defined?(::GlobalID)
|
122
|
+
|
123
|
+
# This should actually be GlobalID::Sequel::Locator when I
|
124
|
+
# get that ported.
|
125
|
+
GlobalID::Locator.locate(item[:voter_gid])
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
voter if voter && voter.kind_of?(::Sequel::Model) &&
|
130
|
+
voter.ballot_voter?
|
131
|
+
end
|
132
|
+
|
133
|
+
# Return the id and canonical voter type name for the item, using
|
134
|
+
# #voter_for.
|
135
|
+
def voter_id_and_type_name_for(item)
|
136
|
+
__id_and_type_name(voter_for(item))
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
# Return the id and canonical type name for the item.
|
142
|
+
def __id_and_type_name(item)
|
143
|
+
[ item.id, type_name(item) ] if item
|
144
|
+
end
|
145
|
+
|
146
|
+
def __instance_of_model(model, id)
|
147
|
+
constantize(model)[id]
|
148
|
+
rescue
|
149
|
+
nil
|
150
|
+
end
|
151
|
+
|
152
|
+
def constantize(s)
|
153
|
+
s = s.to_s
|
154
|
+
return s.constantize if s.respond_to?(:constantize)
|
155
|
+
unless (m = VALID_CONSTANT_NAME_REGEXP.match(s))
|
156
|
+
fail NameError, "#{s.inspect} is not a valid constant name!"
|
157
|
+
end
|
158
|
+
Object.module_eval("::#{m[1]}", __FILE__, __LINE__)
|
159
|
+
end
|
160
|
+
|
161
|
+
VALID_CONSTANT_NAME_REGEXP = /\A(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)\z/ #:nodoc:
|
162
|
+
private_constant :VALID_CONSTANT_NAME_REGEXP
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
unless Sequel::Model < Ballot::Sequel
|
168
|
+
Sequel::Model.send(:include, Ballot::Sequel)
|
169
|
+
Sequel::Model.extend Ballot::Sequel::ClassMethods
|
170
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
module Ballot
|
5
|
+
module Sequel
|
6
|
+
# The Sequel implementation of Ballot::Vote.
|
7
|
+
class Vote < ::Sequel::Model(:ballot_votes)
|
8
|
+
dataset_module do
|
9
|
+
subset(:up, vote: true) #:nodoc:
|
10
|
+
subset(:down, vote: false) #:nodoc:
|
11
|
+
|
12
|
+
def for_type(model_class) #:nodoc:
|
13
|
+
where(votable_type: Ballot::Sequel.type_name(model_class))
|
14
|
+
end
|
15
|
+
|
16
|
+
def by_type(model_class) #:nodoc:
|
17
|
+
where(voter_type: Ballot::Sequel.type_name(model_class))
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
plugin :validation_helpers
|
22
|
+
plugin :timestamps, update_on_create: true
|
23
|
+
|
24
|
+
votable_setter = ->(votable_instance) {
|
25
|
+
if votable_instance
|
26
|
+
self[:votable_id] = votable_instance.pk
|
27
|
+
self[:votable_type] = Ballot::Sequel.type_name(votable_instance)
|
28
|
+
end
|
29
|
+
}
|
30
|
+
votable_dataset = -> {
|
31
|
+
return if votable_type.nil? || votable_id.nil?
|
32
|
+
klass = self.class.send(:constantize, votable_type)
|
33
|
+
klass.where(klass.primary_key => votable_id)
|
34
|
+
}
|
35
|
+
votable_eager_loader = ->(eo) {
|
36
|
+
id_map = {}
|
37
|
+
eo[:rows].each do |model|
|
38
|
+
model.associations[:votable] = nil
|
39
|
+
next if model.votable_type.nil? || model.votable_id.nil?
|
40
|
+
((id_map[model.votable_type] ||= {})[model.votable_id] ||= []) << model
|
41
|
+
end
|
42
|
+
id_map.each do |klass_name, ids|
|
43
|
+
klass = constantize(camelize(klass_name))
|
44
|
+
klass.where(klass.primary_key => ids.keys).all do |related_obj|
|
45
|
+
ids[related_obj.pk].each do |model|
|
46
|
+
model.associations[:votable] = related_obj
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
}
|
51
|
+
|
52
|
+
many_to_one :votable,
|
53
|
+
reciprocal: :votes,
|
54
|
+
reciprocal_type: :many_to_one,
|
55
|
+
setter: votable_setter,
|
56
|
+
dataset: votable_dataset,
|
57
|
+
eager_loader: votable_eager_loader
|
58
|
+
|
59
|
+
voter_setter = ->(voter_instance) {
|
60
|
+
if voter_instance
|
61
|
+
self[:voter_id] = voter_instance.pk
|
62
|
+
self[:voter_type] = Ballot::Sequel.type_name(voter_instance)
|
63
|
+
end
|
64
|
+
}
|
65
|
+
voter_dataset = -> {
|
66
|
+
return if voter_type.nil? || voter_id.nil?
|
67
|
+
klass = self.class.send(:constantize, voter_type)
|
68
|
+
klass.where(klass.primary_key => voter_id)
|
69
|
+
}
|
70
|
+
voter_eager_loader = ->(eo) {
|
71
|
+
id_map = {}
|
72
|
+
eo[:rows].each do |model|
|
73
|
+
model.associations[:voter] = nil
|
74
|
+
next if model.voter_type.nil? || model.voter_id.nil?
|
75
|
+
((id_map[model.voter_type] ||= {})[model.voter_id] ||= []) << model
|
76
|
+
end
|
77
|
+
id_map.each do |klass_name, ids|
|
78
|
+
klass = constantize(camelize(klass_name))
|
79
|
+
klass.where(klass.primary_key => ids.keys).all do |related_obj|
|
80
|
+
ids[related_obj.pk].each do |model|
|
81
|
+
model.associations[:voter] = related_obj
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
}
|
86
|
+
|
87
|
+
many_to_one :voter,
|
88
|
+
reciprocal: :votes,
|
89
|
+
reciprocal_type: :many_to_one,
|
90
|
+
setter: voter_setter,
|
91
|
+
dataset: voter_dataset,
|
92
|
+
eager_loader: voter_eager_loader
|
93
|
+
|
94
|
+
def validate # :nodoc:
|
95
|
+
validates_presence %i(votable_id votable_type voter_id voter_type)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,445 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
module Ballot
|
5
|
+
# Methods added to a model that is marked as a Votable.
|
6
|
+
module Votable
|
7
|
+
##
|
8
|
+
# If the model is caching the ballot summary (in a JSON-serialized column
|
9
|
+
# called +cached_ballot_summary+), we want to ensure that it is initialized
|
10
|
+
# to a Hash if it is not set.
|
11
|
+
def initialize(*)
|
12
|
+
super
|
13
|
+
self.cached_ballot_summary ||= {} if caching_ballot_summary?
|
14
|
+
end
|
15
|
+
|
16
|
+
##
|
17
|
+
# Indicate whether a ballot has been registered on the current instance of
|
18
|
+
# this model. Votes are registered if and only if the vote is a *change*
|
19
|
+
# from the previous vote.
|
20
|
+
#
|
21
|
+
# post = Post.create(title: 'my amazing post')
|
22
|
+
# copy = post.dup
|
23
|
+
# post.down_ballot_by current_user
|
24
|
+
# post.ballot_registered? # => true
|
25
|
+
# copy.up_ballot_by current_user # => true
|
26
|
+
# copy.up_ballot_by_current_user # => false
|
27
|
+
# post.ballot_registered? # => true
|
28
|
+
def ballot_registered?
|
29
|
+
@ballot_registered
|
30
|
+
end
|
31
|
+
|
32
|
+
#-----
|
33
|
+
# :section: Recording Votes
|
34
|
+
#-----
|
35
|
+
|
36
|
+
##
|
37
|
+
# :method: ballot_by
|
38
|
+
# :call-seq:
|
39
|
+
# ballot_by(voter = nil, kwargs = {})
|
40
|
+
# ballot_by(voter)
|
41
|
+
# ballot_by(voter_id: id, voter_type: type)
|
42
|
+
# ballot_by(voter_gid: gid)
|
43
|
+
# ballot_by(voter, scope: scope, vote: false, weight: true)
|
44
|
+
#
|
45
|
+
# Record a Vote for this Votable by the provided +voter+. The +voter+ may
|
46
|
+
# be specified as its own parameter, or through the keyword arguments
|
47
|
+
# +voter_id+, +voter_type+, +voter_gid+, or +voter+ (note that the
|
48
|
+
# parameter +voter+ will override the keyword argument +voter+, if both are
|
49
|
+
# provided).
|
50
|
+
#
|
51
|
+
# Additional named arguments may be provided through +kwargs+:
|
52
|
+
#
|
53
|
+
# scope:: The scope of the vote to be recorded. Defaults to +nil+.
|
54
|
+
# vote:: The vote to be recorded. Defaults to +true+ and is parsed through
|
55
|
+
# Ballot::Words.truthy?.
|
56
|
+
# weight:: The weight of the vote to be recorded. Defaults to +1+.
|
57
|
+
# duplicate:: Allow a duplicate vote to be recorded. This is not
|
58
|
+
# recommended as it has negative performance implications at
|
59
|
+
# scale.
|
60
|
+
#
|
61
|
+
# Other arguments are ignored.
|
62
|
+
#
|
63
|
+
# \ActiveRecord:: There are no special notes for ActiveRecord.
|
64
|
+
# \Sequel:: GlobalID does not currently provide support for Sequel. The use
|
65
|
+
# of +voter_gid+ in this case will probably fail.
|
66
|
+
|
67
|
+
##
|
68
|
+
# Records a positive vote by the provided +voter+ with options provided in
|
69
|
+
# +kwargs+. Any value passed to the +vote+ keyword argument will be
|
70
|
+
# ignored. See #ballot_by for more details.
|
71
|
+
def up_ballot_by(voter = nil, kwargs = {})
|
72
|
+
ballot_by(voter, kwargs.merge(vote: true))
|
73
|
+
end
|
74
|
+
|
75
|
+
##
|
76
|
+
# Records a negative vote by the provided +voter+ with options provided in
|
77
|
+
# +kwargs+. Any value passed to the +vote+ keyword argument will be
|
78
|
+
# ignored. See #ballot_by for more details.
|
79
|
+
def down_ballot_by(voter = nil, kwargs = {})
|
80
|
+
ballot_by(voter, kwargs.merge(vote: false))
|
81
|
+
end
|
82
|
+
|
83
|
+
##
|
84
|
+
# :method: remove_ballot_by
|
85
|
+
# :call-seq:
|
86
|
+
# remove_ballot_by(voter = nil, kwargs = {})
|
87
|
+
# remove_ballot_by(voter)
|
88
|
+
# remove_ballot_by(voter_id: id, voter_type: type)
|
89
|
+
# remove_ballot_by(voter_gid: gid)
|
90
|
+
# remove_ballot_by(voter, scope: scope)
|
91
|
+
#
|
92
|
+
# Remove any votes for this Votable by the provided +voter+. The +voter+
|
93
|
+
# may be specified as its own parameter, or through the keyword arguments
|
94
|
+
# +voter_id+, +voter_type+, +voter_gid+, or +voter+ (note that the
|
95
|
+
# parameter +voter+ will override the keyword argument +voter+, if both are
|
96
|
+
# provided).
|
97
|
+
#
|
98
|
+
# Only the +scope+ argument is available through +kwargs+:
|
99
|
+
#
|
100
|
+
# scope:: The scope of the vote to be recorded. Defaults to +nil+.
|
101
|
+
#
|
102
|
+
# Other arguments are ignored.
|
103
|
+
#
|
104
|
+
# \ActiveRecord:: There are no special notes for \ActiveRecord.
|
105
|
+
# \Sequel:: GlobalID does not currently provide support for \Sequel, so
|
106
|
+
# there are many cases where attempting to use +voter_gid+ will
|
107
|
+
# fail.
|
108
|
+
|
109
|
+
#-----
|
110
|
+
# :section: Finding Votes
|
111
|
+
#-----
|
112
|
+
|
113
|
+
##
|
114
|
+
# :method: ballots_for
|
115
|
+
#
|
116
|
+
# The votes attached to this Votable.
|
117
|
+
#
|
118
|
+
# \ActiveRecord:: This is generated by the polymorphic association
|
119
|
+
# <tt>has_many :ballots_for</tt>.
|
120
|
+
# \Sequel:: This is generated by the polymorphic association
|
121
|
+
# <tt>one_to_many :ballots_for</tt>
|
122
|
+
|
123
|
+
##
|
124
|
+
# :method: ballots_for_dataset
|
125
|
+
#
|
126
|
+
# The \Sequel association dataset for votes attached to this Votable.
|
127
|
+
#
|
128
|
+
# \ActiveRecord:: This does not exist for \ActiveRecord.
|
129
|
+
# \Sequel:: This is generated by the polymorphic association
|
130
|
+
# <tt>one_to_many :ballots_for</tt>
|
131
|
+
|
132
|
+
##
|
133
|
+
# Returns ballots for this Votable where the recorded vote is positive.
|
134
|
+
#
|
135
|
+
# \ActiveRecord:: There are no special notes for ActiveRecord.
|
136
|
+
# \Sequel:: This method returns the _dataset_; if vote objects are desired,
|
137
|
+
# use <tt>up_ballots_for.all</tt>.
|
138
|
+
def up_ballots_for(scope: nil)
|
139
|
+
find_ballots_for(vote: true, scope: scope)
|
140
|
+
end
|
141
|
+
|
142
|
+
##
|
143
|
+
# Returns ballots for this Votable where the recorded vote is negative.
|
144
|
+
#
|
145
|
+
# \ActiveRecord:: There are no special notes for ActiveRecord.
|
146
|
+
# \Sequel:: This method returns the _dataset_; if vote objects are desired,
|
147
|
+
# use <tt>down_ballots_for.all</tt>.
|
148
|
+
def down_ballots_for(scope: nil)
|
149
|
+
find_ballots_for(vote: false, scope: scope)
|
150
|
+
end
|
151
|
+
|
152
|
+
#-----
|
153
|
+
# :section: Voter Inquiries
|
154
|
+
#-----
|
155
|
+
|
156
|
+
##
|
157
|
+
# :method: ballot_by?
|
158
|
+
# :call-seq:
|
159
|
+
# ballot_by?(voter = nil, kwargs = {})
|
160
|
+
# ballot_by?(voter)
|
161
|
+
# ballot_by?(voter_id: id, voter_type: type)
|
162
|
+
# ballot_by?(voter_gid: gid)
|
163
|
+
# ballot_by?(voter, scope: scope, vote: false, weight: true)
|
164
|
+
#
|
165
|
+
# Returns +true+ if the provided +voter+ has made votes for this Votable
|
166
|
+
# matching the provided criteria. The +voter+ may be specified as its own
|
167
|
+
# parameter, or through the keyword arguments +voter_id+, +voter_type+,
|
168
|
+
# +voter_gid+, or +voter+ (note that the parameter +voter+ will override
|
169
|
+
# the keyword argument +voter+, if both are provided).
|
170
|
+
#
|
171
|
+
# Additional named arguments may be provided through +kwargs+:
|
172
|
+
#
|
173
|
+
# scope:: The scope of the vote to be recorded. Defaults to +nil+.
|
174
|
+
# vote:: The vote to be queried. If present, is parsed through
|
175
|
+
# Ballot::Words.truthy?.
|
176
|
+
#
|
177
|
+
# Other arguments are ignored.
|
178
|
+
#
|
179
|
+
# \ActiveRecord:: There are no special notes for ActiveRecord.
|
180
|
+
# \Sequel:: GlobalID does not currently provide support for Sequel. The use
|
181
|
+
# of +voter_gid+ in this case will probably fail.
|
182
|
+
|
183
|
+
##
|
184
|
+
# Returns +true+ if the provided +voter+ has made positive votes for this
|
185
|
+
# Votable. Any value passed to the +vote+ keyword argument will be ignored.
|
186
|
+
# See #ballot_by? for more details.
|
187
|
+
def up_ballot_by?(voter = nil, kwargs = {})
|
188
|
+
ballot_by?(voter, kwargs.merge(vote: true))
|
189
|
+
end
|
190
|
+
|
191
|
+
##
|
192
|
+
# Returns +true+ if the provided +voter+ has made negative votes for this
|
193
|
+
# Votable. Any value passed to the +vote+ keyword argument will be ignored.
|
194
|
+
# See #ballot_by? for more details.
|
195
|
+
def down_ballot_by?(voter = nil, kwargs = {})
|
196
|
+
ballot_by?(voter, kwargs.merge(vote: false))
|
197
|
+
end
|
198
|
+
|
199
|
+
##
|
200
|
+
# :method: ballots_by_class(model_class, kwargs = {})
|
201
|
+
#
|
202
|
+
# Find ballots cast for this Votable matching the canonical name of the
|
203
|
+
# +model_class+ as the type of Voter.
|
204
|
+
#
|
205
|
+
# Additional named arguments may be provided through +kwargs+:
|
206
|
+
#
|
207
|
+
# scope:: The scope of the vote to be recorded. Defaults to +nil+.
|
208
|
+
# vote:: The vote to be queried. If present, is parsed through
|
209
|
+
# Ballot::Words.truthy?.
|
210
|
+
#
|
211
|
+
# Other arguments are ignored.
|
212
|
+
|
213
|
+
##
|
214
|
+
# Find positive ballots cast by this Voter matching the canonical name of
|
215
|
+
# the +model_class+ as the type of Voter. Any value passed to the +vote+
|
216
|
+
# keyword argument will be ignored. See #ballots_by_class for more details.
|
217
|
+
def up_ballots_by_class(model_class, kwargs = {})
|
218
|
+
ballots_by_class(model_class, kwargs.merge(vote: true))
|
219
|
+
end
|
220
|
+
|
221
|
+
##
|
222
|
+
# Find negative ballots cast by this Voter matching the canonical name of
|
223
|
+
# the +model_class+ as the type of Voter. Any value passed to the +vote+
|
224
|
+
# keyword argument will be ignored. See #ballots_by_class for more details.
|
225
|
+
def down_ballots_by_class(model_class, kwargs = {})
|
226
|
+
ballots_by_class(model_class, kwargs.merge(vote: false))
|
227
|
+
end
|
228
|
+
|
229
|
+
##
|
230
|
+
# Returns the Voter objects that have made votes on this Votable.
|
231
|
+
# Additional query conditions may be specified in +conds+, or in the
|
232
|
+
# +block+ if supported by the ORM. The Voter objects are eager loaded to
|
233
|
+
# minimize the number of queries required to satisfy this request.
|
234
|
+
#
|
235
|
+
# \ActiveRecord:: Polymorphic eager loading is directly supported, using
|
236
|
+
# <tt>ballots_for.includes(:voter)</tt>. Normal
|
237
|
+
# +where+-clause conditions may be provided in +conds+.
|
238
|
+
# \Sequel:: Polymorphic eager loading is not supported by \Sequel, but has
|
239
|
+
# been implemented in Ballot for this method. Normal
|
240
|
+
# +where+-clause conditions may be provided in +conds+ or in
|
241
|
+
# +block+ for \Sequel virtual row support.
|
242
|
+
def ballot_voters(*conds, &block)
|
243
|
+
__eager_ballot_voters(find_ballots_for(*conds, &block))
|
244
|
+
end
|
245
|
+
|
246
|
+
##
|
247
|
+
# Returns the Voter objects that have made positive votes on this Votable.
|
248
|
+
# See #ballot_voters for how +conds+ and +block+ apply.
|
249
|
+
def up_ballot_voters(*conds, &block)
|
250
|
+
__eager_ballot_voters(
|
251
|
+
find_ballots_for(*conds, &block).where(vote: true)
|
252
|
+
)
|
253
|
+
end
|
254
|
+
|
255
|
+
##
|
256
|
+
# Returns the Voter objects that have made negative votes on this Votable.
|
257
|
+
# See #ballot_voters for how +conds+ and +block+ apply.
|
258
|
+
def down_ballot_voters(*conds, &block)
|
259
|
+
__eager_ballot_voters(
|
260
|
+
find_ballots_for(*conds, &block).where(vote: false)
|
261
|
+
)
|
262
|
+
end
|
263
|
+
|
264
|
+
#-----
|
265
|
+
# :section: Ballot Summaries and Caching
|
266
|
+
#-----
|
267
|
+
|
268
|
+
##
|
269
|
+
# :attr_accessor: cached_ballot_summary
|
270
|
+
#
|
271
|
+
# A Hash object used for caching balloting summaries for this Votable. When
|
272
|
+
# caching is enabled, all scopes and values are cached. For each scope,
|
273
|
+
# this caches:
|
274
|
+
#
|
275
|
+
# * The total number of ballots cast (#total_ballots);
|
276
|
+
# * The total number of positive (up) ballots cast (#total_up_ballots);
|
277
|
+
# * The total number of negative (down) ballots cast (#total_down_ballots);
|
278
|
+
# * The ballot score (number of up ballots less the number of down ballots,
|
279
|
+
# #ballot_score);
|
280
|
+
# * The weighted ballot total (sum of ballot weights,
|
281
|
+
# #weighted_ballot_total);
|
282
|
+
# * The weighted ballot score (the sum of up ballot weights less the sum of
|
283
|
+
# down ballot weights; #weighted_ballot_score);
|
284
|
+
# * The weighted ballot average (the weighted ballot score over the number
|
285
|
+
# of votes, #weighted_ballot_average).
|
286
|
+
#
|
287
|
+
# <em>Present only if the column +cached_ballot_summary+ exists on
|
288
|
+
# the underlying Votable.</em>
|
289
|
+
|
290
|
+
##
|
291
|
+
# The total number of ballots cast for this Votable in the provided
|
292
|
+
# +scope+. If +scope+ is not provided, reports for the _default_ scope.
|
293
|
+
#
|
294
|
+
# If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
|
295
|
+
# +false+, returns the cached total.
|
296
|
+
def total_ballots(scope = nil, skip_cache: false)
|
297
|
+
if !skip_cache && caching_ballot_summary?
|
298
|
+
scoped_cache_summary(scope)['total'].to_i
|
299
|
+
else
|
300
|
+
find_ballots_for(scope: scope).count
|
301
|
+
end
|
302
|
+
end
|
303
|
+
|
304
|
+
##
|
305
|
+
# The total number of positive ballots cast for this Votable in the
|
306
|
+
# provided +scope+. If +scope+ is not provided, reports for the _default_
|
307
|
+
# scope.
|
308
|
+
#
|
309
|
+
# If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
|
310
|
+
# +false+, returns the cached total.
|
311
|
+
def total_up_ballots(scope = nil, skip_cache: false)
|
312
|
+
if !skip_cache && caching_ballot_summary?
|
313
|
+
scoped_cache_summary(scope)['up'].to_i
|
314
|
+
else
|
315
|
+
up_ballots_for(scope: scope).count
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
##
|
320
|
+
# The total number of negative ballots cast for this Votable in the
|
321
|
+
# provided +scope+. If +scope+ is not provided, reports for the _default_
|
322
|
+
# scope.
|
323
|
+
#
|
324
|
+
# If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
|
325
|
+
# +false+, returns the cached total.
|
326
|
+
def total_down_ballots(scope = nil, skip_cache: false)
|
327
|
+
if !skip_cache && caching_ballot_summary?
|
328
|
+
scoped_cache_summary(scope)['down'].to_i
|
329
|
+
else
|
330
|
+
down_ballots_for(scope: scope).count
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
##
|
335
|
+
# The computed score of ballots cast (total positive ballots less total
|
336
|
+
# negative ballots) for this Votable in the provided +scope+. If +scope+ is
|
337
|
+
# not provided, reports for the _default_ scope.
|
338
|
+
#
|
339
|
+
# If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
|
340
|
+
# +false+, returns the cached score.
|
341
|
+
def ballot_score(scope = nil, skip_cache: false)
|
342
|
+
if !skip_cache && caching_ballot_summary?
|
343
|
+
scoped_cache_summary(scope)['score'].to_i
|
344
|
+
else
|
345
|
+
total_up_ballots(scope, skip_cache: skip_cache) -
|
346
|
+
total_down_ballots(scope, skip_cache: skip_cache)
|
347
|
+
end
|
348
|
+
end
|
349
|
+
|
350
|
+
##
|
351
|
+
# The weighted total of ballots cast (the sum of all ballot +weights+) for
|
352
|
+
# this Votable in the provided +scope+. If +scope+ is not provided, reports
|
353
|
+
# for the _default_ scope.
|
354
|
+
#
|
355
|
+
# If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
|
356
|
+
# +false+, returns the cached score.
|
357
|
+
def weighted_ballot_total(scope = nil, skip_cache: false)
|
358
|
+
if !skip_cache && caching_ballot_summary?
|
359
|
+
scoped_cache_summary(scope)['weighted_ballot_total'].to_i
|
360
|
+
else
|
361
|
+
find_ballots_for(scope: scope).sum(:weight).to_i
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
##
|
366
|
+
# The weighted score of ballots cast (the sum of all positive ballot
|
367
|
+
# +weight+s less the sum of all negative ballot +weight+s) for this Votable
|
368
|
+
# in the provided +scope+. If +scope+ is not provided, reports for the
|
369
|
+
# _default_ scope.
|
370
|
+
#
|
371
|
+
# If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
|
372
|
+
# +false+, returns the cached score.
|
373
|
+
def weighted_ballot_score(scope = nil, skip_cache: false)
|
374
|
+
if !skip_cache && caching_ballot_summary?
|
375
|
+
scoped_cache_summary(scope)['weighted_ballot_score'].to_i
|
376
|
+
else
|
377
|
+
up_ballots_for(scope: scope).sum(:weight).to_i -
|
378
|
+
down_ballots_for(scope: scope).sum(:weight).to_i
|
379
|
+
end
|
380
|
+
end
|
381
|
+
|
382
|
+
##
|
383
|
+
# The weighted average of ballots cast (the weighted ballot score over the
|
384
|
+
# total number of votes cast) for this Votable in the provided +scope+. If
|
385
|
+
# +scope+ is not provided, reports for the _default_ scope.
|
386
|
+
#
|
387
|
+
# If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
|
388
|
+
# +false+, returns the cached average.
|
389
|
+
def weighted_ballot_average(scope = nil, skip_cache: false)
|
390
|
+
if !skip_cache && caching_ballot_summary?
|
391
|
+
scoped_cache_summary(scope)['weighted_ballot_average'].to_i
|
392
|
+
elsif (count = total_ballots) > 0
|
393
|
+
weighted_ballot_score.to_f / count
|
394
|
+
else
|
395
|
+
0.0
|
396
|
+
end
|
397
|
+
end
|
398
|
+
|
399
|
+
private
|
400
|
+
|
401
|
+
attr_writer :ballot_registered
|
402
|
+
|
403
|
+
def calculate_summary(scope = nil)
|
404
|
+
{}.tap do |summary|
|
405
|
+
summary[scope] ||= {}
|
406
|
+
summary[scope]['total'] = total_ballots(scope, skip_cache: true)
|
407
|
+
summary[scope]['up'] = total_up_ballots(scope, skip_cache: true)
|
408
|
+
summary[scope]['down'] = total_down_ballots(scope, skip_cache: true)
|
409
|
+
summary[scope]['score'] = ballot_score(scope, skip_cache: true)
|
410
|
+
summary[scope]['weighted_ballot_total'] =
|
411
|
+
weighted_ballot_total(scope, skip_cache: true)
|
412
|
+
summary[scope]['weighted_ballot_score'] =
|
413
|
+
weighted_ballot_score(scope, skip_cache: true)
|
414
|
+
summary[scope]['weighted_ballot_average'] =
|
415
|
+
weighted_ballot_average(scope, skip_cache: true)
|
416
|
+
end
|
417
|
+
end
|
418
|
+
|
419
|
+
def scoped_cache_summary(scope = nil)
|
420
|
+
if scope.nil?
|
421
|
+
cached_ballot_summary.fetch(scope) { cached_ballot_summary.fetch('') { {} } }
|
422
|
+
else
|
423
|
+
cached_ballot_summary[scope] || {}
|
424
|
+
end
|
425
|
+
end
|
426
|
+
|
427
|
+
def __ballot_votable_kwargs(voter, kwargs)
|
428
|
+
if voter.kind_of?(Hash)
|
429
|
+
kwargs.merge(voter)
|
430
|
+
elsif voter.nil?
|
431
|
+
kwargs
|
432
|
+
else
|
433
|
+
kwargs.merge(voter: voter)
|
434
|
+
end
|
435
|
+
end
|
436
|
+
|
437
|
+
# Methods added to the Votable model class.
|
438
|
+
module ClassMethods
|
439
|
+
# The class is now a votable record.
|
440
|
+
def ballot_votable?
|
441
|
+
true
|
442
|
+
end
|
443
|
+
end
|
444
|
+
end
|
445
|
+
end
|