voting 0.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/CHANGELOG.md +3 -0
- data/LICENSE +21 -0
- data/README.md +229 -0
- data/lib/generators/voting/install_generator.rb +19 -0
- data/lib/generators/voting/templates/db/migrate/create_voting_tables.rb +35 -0
- data/lib/voting.rb +10 -0
- data/lib/voting/models/voting/extension.rb +85 -0
- data/lib/voting/models/voting/vote.rb +45 -0
- data/lib/voting/models/voting/voting.rb +73 -0
- data/lib/voting/version.rb +5 -0
- data/spec/factories/article.rb +5 -0
- data/spec/factories/author.rb +5 -0
- data/spec/factories/category.rb +5 -0
- data/spec/factories/comment.rb +5 -0
- data/spec/factories/voting/vote.rb +8 -0
- data/spec/factories/voting/voting.rb +9 -0
- data/spec/models/extension/after_save_spec.rb +37 -0
- data/spec/models/extension/as_spec.rb +26 -0
- data/spec/models/extension/down_spec.rb +26 -0
- data/spec/models/extension/order_by_voting_spec.rb +94 -0
- data/spec/models/extension/up_spec.rb +26 -0
- data/spec/models/extension/vote_for_spec.rb +26 -0
- data/spec/models/extension/vote_spec.rb +26 -0
- data/spec/models/extension/voted_question_spec.rb +38 -0
- data/spec/models/extension/voted_records_spec.rb +12 -0
- data/spec/models/extension/voted_spec.rb +40 -0
- data/spec/models/extension/votes_records_spec.rb +12 -0
- data/spec/models/extension/votes_spec.rb +40 -0
- data/spec/models/extension/voting_records_spec.rb +12 -0
- data/spec/models/extension/voting_spec.rb +40 -0
- data/spec/models/extension/voting_warm_up_spec.rb +115 -0
- data/spec/models/vote/create_spec.rb +273 -0
- data/spec/models/vote/vote_for_spec.rb +40 -0
- data/spec/models/vote_spec.rb +27 -0
- data/spec/models/voting/update_voting_spec.rb +28 -0
- data/spec/models/voting/values_data_spec.rb +24 -0
- data/spec/models/voting_spec.rb +26 -0
- data/spec/rails_helper.rb +11 -0
- data/spec/support/common.rb +22 -0
- data/spec/support/database_cleaner.rb +19 -0
- data/spec/support/db/migrate/create_articles_table.rb +7 -0
- data/spec/support/db/migrate/create_authors_table.rb +7 -0
- data/spec/support/db/migrate/create_categories_table.rb +9 -0
- data/spec/support/db/migrate/create_comments_table.rb +7 -0
- data/spec/support/factory_bot.rb +9 -0
- data/spec/support/migrate.rb +11 -0
- data/spec/support/models/article.rb +7 -0
- data/spec/support/models/author.rb +5 -0
- data/spec/support/models/category.rb +5 -0
- data/spec/support/models/comment.rb +5 -0
- data/spec/support/shared_context/with_database_records.rb +22 -0
- data/spec/support/shoulda.rb +10 -0
- metadata +257 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: a4aa7764b2a518434df6182d2f0a0101b9d3079c
|
4
|
+
data.tar.gz: e6a96257d3a5f67a6b8224f21d538285db21c79c
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: '01686bdfc40e30e8ce89208e3f3b2e21425382abc2fb7f9dee4f9f2205e5b63886e8564703198d2e6cae610866a839aa8bfa0352cb00ac5d46fe80e1104096ae'
|
7
|
+
data.tar.gz: d3aa1a7f97da1fa5b2c145e7887beb74ab8277007e89b71a70d9baee8a4bc94138c1236c2e4c6e4c58f1e17ea6b5d744794ecf51d6bd8e3f40623a6b106f80e1
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2018 Washington Botelho
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,229 @@
|
|
1
|
+
# Voting
|
2
|
+
|
3
|
+
[](https://travis-ci.org/wbotelhos/voting)
|
4
|
+
[](https://badge.fury.io/rb/voting)
|
5
|
+
[](https://liberapay.com/wbotelhos)
|
6
|
+
|
7
|
+
A Binomial proportion confidence interval voting system with scope and cache enabled.
|
8
|
+
|
9
|
+
## Description
|
10
|
+
|
11
|
+
Voting uses **Binomial proportion confidence interval** to calculate the voting. Inspired on [Evan Miller Article](https://www.evanmiller.org/how-not-to-sort-by-average-rating.html) and used by [Reddit](https://redditblog.com/2009/10/15/reddits-new-comment-sorting-system), [Yelp](https://www.yelpblog.com/2011/02/the-most-romantic-city-on-yelp-is), [Digg](web.archive.org/web/20110415020820/http://about.digg.com/blog/algorithm-experiments-better-comments) and probably other, Voting gives you cache and option to work with scopes over a **binary** voting system.
|
12
|
+
|
13
|
+
If you are looking for **5 stars** voting system, check [Rating](https://github.com/wbotelhos/rating) :star2:
|
14
|
+
|
15
|
+
## Install
|
16
|
+
|
17
|
+
Add the following code on your `Gemfile` and run `bundle install`:
|
18
|
+
|
19
|
+
```ruby
|
20
|
+
gem 'voting'
|
21
|
+
```
|
22
|
+
|
23
|
+
Run the following task to create a Voting migration:
|
24
|
+
|
25
|
+
```bash
|
26
|
+
rails g voting:install
|
27
|
+
```
|
28
|
+
|
29
|
+
Then execute the migrations to create the tables `voting_votes` and `voting_votings`:
|
30
|
+
|
31
|
+
```bash
|
32
|
+
rake db:migrate
|
33
|
+
```
|
34
|
+
|
35
|
+
## Usage
|
36
|
+
|
37
|
+
Just add the callback `voting` to your model:
|
38
|
+
|
39
|
+
```ruby
|
40
|
+
class Author < ApplicationRecord
|
41
|
+
voting
|
42
|
+
end
|
43
|
+
```
|
44
|
+
|
45
|
+
Now this model can vote or receive votes.
|
46
|
+
|
47
|
+
### vote
|
48
|
+
|
49
|
+
You can vote on some resource:
|
50
|
+
|
51
|
+
```ruby
|
52
|
+
author_1 = Author.first
|
53
|
+
author_2 = Author.last
|
54
|
+
resource = Comment.last
|
55
|
+
|
56
|
+
author_1.vote(resource, 1) # +1 vote
|
57
|
+
author_2.vote(resource, -1) # -1 vote
|
58
|
+
```
|
59
|
+
|
60
|
+
### voting
|
61
|
+
|
62
|
+
A voted resource exposes a cached data about it state:
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
resource = Comment.last
|
66
|
+
|
67
|
+
resource.voting
|
68
|
+
```
|
69
|
+
|
70
|
+
It will return a `Voting::Voting` object that keeps:
|
71
|
+
|
72
|
+
`author`: the author of this vote;
|
73
|
+
|
74
|
+
`estimate`: the Binomial proportion confidence interval value;
|
75
|
+
|
76
|
+
`negative`: the sum of negative votes for this resource;
|
77
|
+
|
78
|
+
`positive`: the sum of positive votes for this resource;
|
79
|
+
|
80
|
+
`resource`: the self object that received this vote;
|
81
|
+
|
82
|
+
`scopeable`: the object used as scope;
|
83
|
+
|
84
|
+
### vote_for
|
85
|
+
|
86
|
+
You can retrieve the vote an author gave to a specific resource:
|
87
|
+
|
88
|
+
```ruby
|
89
|
+
author = Author.last
|
90
|
+
resource = Comment.last
|
91
|
+
|
92
|
+
author.vote_for resource
|
93
|
+
```
|
94
|
+
|
95
|
+
It will return a `Voting::Vote` object that keeps:
|
96
|
+
|
97
|
+
`author`: the author of vote;
|
98
|
+
|
99
|
+
`resource`: the resource that received the vote;
|
100
|
+
|
101
|
+
`negative`: the -1 value when vote was negative;
|
102
|
+
|
103
|
+
`positive`: the 1 value when vote was positive;
|
104
|
+
|
105
|
+
`scopeable`: the object used as scope;
|
106
|
+
|
107
|
+
### voted?
|
108
|
+
|
109
|
+
Maybe you want just to know if some author already voted some resource and receive `true` or `false`:
|
110
|
+
|
111
|
+
```ruby
|
112
|
+
author = Author.last
|
113
|
+
resource = Comment.last
|
114
|
+
|
115
|
+
author.voted? resource
|
116
|
+
```
|
117
|
+
|
118
|
+
### votes
|
119
|
+
|
120
|
+
You can retrieve all votes received by some resource:
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
resource = Article.last
|
124
|
+
|
125
|
+
resource.votes
|
126
|
+
```
|
127
|
+
|
128
|
+
It will return a collection of `Voting::Vote` object.
|
129
|
+
|
130
|
+
### voted
|
131
|
+
|
132
|
+
In the same way you can retrieve all votes that some author made:
|
133
|
+
|
134
|
+
```ruby
|
135
|
+
author = Author.last
|
136
|
+
|
137
|
+
author.voted
|
138
|
+
```
|
139
|
+
|
140
|
+
It will return a collection of `Voting::Vote` object.
|
141
|
+
|
142
|
+
### order_by_voting
|
143
|
+
|
144
|
+
You can list resource ordered by voting data:
|
145
|
+
|
146
|
+
```ruby
|
147
|
+
Comment.order_by_voting
|
148
|
+
```
|
149
|
+
|
150
|
+
It will return a collection of resource ordered by `estimate desc` as default.
|
151
|
+
The order column and direction can be changed:
|
152
|
+
|
153
|
+
```ruby
|
154
|
+
Comment.order_by_voting :negative, :asc
|
155
|
+
```
|
156
|
+
|
157
|
+
It will return a collection of resource ordered by `Voting::Voting` table data.
|
158
|
+
|
159
|
+
### Records
|
160
|
+
|
161
|
+
Maybe you want to recover all records, so you can add the suffix `_records` on relations:
|
162
|
+
|
163
|
+
```ruby
|
164
|
+
author = Author.last
|
165
|
+
resource = Comment.last
|
166
|
+
|
167
|
+
author.vote resource, 1
|
168
|
+
|
169
|
+
author.voting_records
|
170
|
+
|
171
|
+
author.voted_records
|
172
|
+
|
173
|
+
comment.voting_records
|
174
|
+
```
|
175
|
+
|
176
|
+
### As
|
177
|
+
|
178
|
+
If you have a model that will only be able to vote but not to receive a vote, configure it as `author`.
|
179
|
+
An author model still can be voted, but won't generate a Voting record with all values as zero to warm up the cache.
|
180
|
+
|
181
|
+
```ruby
|
182
|
+
voting as: :author
|
183
|
+
```
|
184
|
+
|
185
|
+
### Scoping
|
186
|
+
|
187
|
+
If you need to warm up a record with scope, you need to setup the `scoping` relation.
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
class Resource < ApplicationRecord
|
191
|
+
voting scoping: :categories
|
192
|
+
end
|
193
|
+
```
|
194
|
+
|
195
|
+
Now, when a resource is created, the cache will be generated for each related `category` as `scopeable`.
|
196
|
+
|
197
|
+
# Toggle
|
198
|
+
|
199
|
+
The toggle functions works out of box, so if you vote up twice or vote twice down, the vote will be canceled.
|
200
|
+
When you do that, the vote record is **not destroyed** instead, it receives zero on `negative` and `positive` column.
|
201
|
+
|
202
|
+
# Alias
|
203
|
+
|
204
|
+
You can to use alias to directly call `vote` with positive or negative data.
|
205
|
+
|
206
|
+
```ruby
|
207
|
+
author = Author.last
|
208
|
+
resource = Comment.last
|
209
|
+
|
210
|
+
author.up resource # +1
|
211
|
+
author.down resource # -1
|
212
|
+
```
|
213
|
+
|
214
|
+
### Options
|
215
|
+
|
216
|
+
`down`: makes a negative vote;
|
217
|
+
|
218
|
+
`up`: makes a positive vote;
|
219
|
+
|
220
|
+
### References
|
221
|
+
|
222
|
+
- [Evan Miller](https://www.evanmiller.org/how-not-to-sort-by-average-rating.html)
|
223
|
+
- [Jonathan Landy](http://efavdb.com/ranking-revisited)
|
224
|
+
- [Wilson Score Interval](https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Wilson_score_interval)
|
225
|
+
- [How to Count Thumb-Ups and Thumb-Downs](http://www.dcs.bbk.ac.uk/~dell/publications/dellzhang_ictir2011.pdf)
|
226
|
+
|
227
|
+
## Love it!
|
228
|
+
|
229
|
+
Via [PayPal](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=X8HEP2878NDEG&item_name=voting) or [LiberPay](https://liberapay.com/wbotelhos). Thanks! (:
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Voting
|
4
|
+
class InstallGenerator < Rails::Generators::Base
|
5
|
+
source_root File.expand_path('../templates', __FILE__)
|
6
|
+
|
7
|
+
desc 'creates Voting migration'
|
8
|
+
|
9
|
+
def create_migration
|
10
|
+
template 'db/migrate/create_voting_tables.rb', "db/migrate/#{timestamp}_create_voting_tables.rb"
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
def timestamp
|
16
|
+
Time.current.strftime '%Y%m%d%H%M%S'
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class CreateVotingTables < ActiveRecord::Migration[5.0]
|
4
|
+
def change
|
5
|
+
create_table :voting_votes do |t|
|
6
|
+
t.integer :negative, default: 0, null: false
|
7
|
+
t.integer :positive, default: 0, null: false
|
8
|
+
|
9
|
+
t.references :author, index: true, null: false, polymorphic: true
|
10
|
+
t.references :resource, index: true, null: false, polymorphic: true
|
11
|
+
t.references :scopeable, index: true, null: true, polymorphic: true
|
12
|
+
|
13
|
+
t.timestamps null: false
|
14
|
+
end
|
15
|
+
|
16
|
+
add_index :voting_votes, %i[author_id author_type resource_id resource_type scopeable_id scopeable_type],
|
17
|
+
name: :index_voting_votes_on_author_and_resource_and_scopeable,
|
18
|
+
unique: true
|
19
|
+
|
20
|
+
create_table :voting_votings do |t|
|
21
|
+
t.decimal :estimate, default: 0, mull: false, precision: 25, scale: 20
|
22
|
+
t.integer :negative, default: 0, null: false
|
23
|
+
t.integer :positive, default: 0, null: false
|
24
|
+
|
25
|
+
t.references :resource, index: true, null: false, polymorphic: true
|
26
|
+
t.references :scopeable, index: true, null: true, polymorphic: true
|
27
|
+
|
28
|
+
t.timestamps null: false
|
29
|
+
end
|
30
|
+
|
31
|
+
add_index :voting_votings, %i[resource_id resource_type scopeable_id scopeable_type],
|
32
|
+
name: :index_voting_votings_on_resource_and_scopeable,
|
33
|
+
unique: true
|
34
|
+
end
|
35
|
+
end
|
data/lib/voting.rb
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Voting
|
4
|
+
module Extension
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
included do
|
8
|
+
def down(resource, author: self, scope: nil)
|
9
|
+
vote resource, -1, author: author, scope: scope
|
10
|
+
end
|
11
|
+
|
12
|
+
def up(resource, author: self, scope: nil)
|
13
|
+
vote resource, 1, author: author, scope: scope
|
14
|
+
end
|
15
|
+
|
16
|
+
def vote(resource, value, author: self, scope: nil)
|
17
|
+
Vote.create author: author, resource: resource, scopeable: scope, value: value
|
18
|
+
end
|
19
|
+
|
20
|
+
def vote_for(resource, scope: nil)
|
21
|
+
Vote.vote_for author: self, resource: resource, scopeable: scope
|
22
|
+
end
|
23
|
+
|
24
|
+
def voted?(resource, scope: nil)
|
25
|
+
!vote_for(resource, scope: scope).nil?
|
26
|
+
end
|
27
|
+
|
28
|
+
def votes(scope: nil)
|
29
|
+
votes_records.where scopeable: scope
|
30
|
+
end
|
31
|
+
|
32
|
+
def voted(scope: nil)
|
33
|
+
voted_records.where scopeable: scope
|
34
|
+
end
|
35
|
+
|
36
|
+
def voting(scope: nil)
|
37
|
+
voting_records.find_by scopeable: scope
|
38
|
+
end
|
39
|
+
|
40
|
+
def voting_warm_up(scoping: nil)
|
41
|
+
return Voting.find_or_create_by(resource: self) if scoping.blank?
|
42
|
+
|
43
|
+
[scoping].flatten.compact.map do |attribute|
|
44
|
+
next unless respond_to?(attribute)
|
45
|
+
|
46
|
+
[public_send(attribute)].flatten.compact.map do |object|
|
47
|
+
Voting.find_or_create_by! resource: self, scopeable: object
|
48
|
+
end
|
49
|
+
end.flatten.compact
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
module ClassMethods
|
54
|
+
def voting(as: nil, scoping: nil)
|
55
|
+
after_save -> { voting_warm_up scoping: scoping }, unless: -> { as == :author }
|
56
|
+
|
57
|
+
has_many :voting_records,
|
58
|
+
as: :resource,
|
59
|
+
class_name: '::Voting::Voting',
|
60
|
+
dependent: :destroy
|
61
|
+
|
62
|
+
has_many :votes_records,
|
63
|
+
as: :resource,
|
64
|
+
class_name: '::Voting::Vote',
|
65
|
+
dependent: :destroy
|
66
|
+
|
67
|
+
has_many :voted_records,
|
68
|
+
as: :author,
|
69
|
+
class_name: '::Voting::Vote',
|
70
|
+
dependent: :destroy
|
71
|
+
|
72
|
+
scope :order_by_voting, ->(column = :estimate, direction = :desc, scope: nil) {
|
73
|
+
scope_values = {
|
74
|
+
scopeable_id: scope&.id,
|
75
|
+
scopeable_type: scope&.class&.base_class&.name
|
76
|
+
}
|
77
|
+
|
78
|
+
includes(:voting_records)
|
79
|
+
.where(Voting.table_name => scope_values)
|
80
|
+
.order("#{Voting.table_name}.#{column} #{direction}")
|
81
|
+
}
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Voting
|
4
|
+
class Vote < ActiveRecord::Base
|
5
|
+
self.table_name = 'voting_votes'
|
6
|
+
|
7
|
+
after_save :update_voting
|
8
|
+
|
9
|
+
belongs_to :author, polymorphic: true
|
10
|
+
belongs_to :resource, polymorphic: true
|
11
|
+
belongs_to :scopeable, polymorphic: true
|
12
|
+
|
13
|
+
validates :author, :negative, :positive, :resource, presence: true
|
14
|
+
validates :negative, :positive, inclusion: { in: [0, 1] }
|
15
|
+
|
16
|
+
validates :author_id, uniqueness: {
|
17
|
+
case_sensitive: false,
|
18
|
+
scope: %i[author_type resource_id resource_type scopeable_id scopeable_type]
|
19
|
+
}
|
20
|
+
|
21
|
+
def self.create(author:, resource:, scopeable: nil, value:)
|
22
|
+
record = find_or_initialize_by(author: author, resource: resource, scopeable: scopeable)
|
23
|
+
attribute = value.positive? ? :positive : :negative
|
24
|
+
canceled = record.persisted? && value.abs == record[attribute]
|
25
|
+
|
26
|
+
record.negative = 0
|
27
|
+
record.positive = 0
|
28
|
+
record[attribute] = value.abs unless canceled
|
29
|
+
|
30
|
+
record.save!
|
31
|
+
|
32
|
+
record
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.vote_for(author:, resource:, scopeable: nil)
|
36
|
+
find_by author: author, resource: resource, scopeable: scopeable
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def update_voting
|
42
|
+
::Voting::Voting.update_voting resource, scopeable
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|