ballot 1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|