Merge pull request #5762 from consuldemocracy/unique_index_on_poll_voters
Add unique index to poll voters table
This commit is contained in:
@@ -14,18 +14,14 @@ class Officing::VotersController < Officing::BaseController
|
|||||||
@poll = Poll.find(voter_params[:poll_id])
|
@poll = Poll.find(voter_params[:poll_id])
|
||||||
@user = User.find(voter_params[:user_id])
|
@user = User.find(voter_params[:user_id])
|
||||||
|
|
||||||
@user.with_lock do
|
@voter = Poll::Voter.create_with(
|
||||||
@voter = Poll::Voter.new(document_type: @user.document_type,
|
document_type: @user.document_type,
|
||||||
document_number: @user.document_number,
|
document_number: @user.document_number,
|
||||||
user: @user,
|
origin: "booth",
|
||||||
poll: @poll,
|
officer: current_user.poll_officer,
|
||||||
origin: "booth",
|
booth_assignment: current_booth.booth_assignments.find_by(poll: @poll),
|
||||||
officer: current_user.poll_officer,
|
officer_assignment: officer_assignment(@poll)
|
||||||
booth_assignment: current_booth.booth_assignments.find_by(poll: @poll),
|
).find_or_create_by!(user: @user, poll: @poll)
|
||||||
officer_assignment: officer_assignment(@poll))
|
|
||||||
|
|
||||||
@voter.save!
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class PollsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def answer
|
def answer
|
||||||
|
raise CanCan::AccessDenied if @poll.voted_in_booth?(current_user)
|
||||||
|
|
||||||
@web_vote = Poll::WebVote.new(@poll, current_user)
|
@web_vote = Poll::WebVote.new(@poll, current_user)
|
||||||
|
|
||||||
if @web_vote.update(answer_params)
|
if @web_vote.update(answer_params)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ class Poll
|
|||||||
validates :officer_assignment_id, presence: true, if: ->(voter) { voter.origin == "booth" }
|
validates :officer_assignment_id, presence: true, if: ->(voter) { voter.origin == "booth" }
|
||||||
|
|
||||||
validates :document_number, presence: true, unless: :skip_user_verification?
|
validates :document_number, presence: true, unless: :skip_user_verification?
|
||||||
validates :user_id, uniqueness: { scope: [:poll_id], message: :has_voted }
|
|
||||||
validates :origin, inclusion: { in: ->(*) { VALID_ORIGINS }}
|
validates :origin, inclusion: { in: ->(*) { VALID_ORIGINS }}
|
||||||
|
|
||||||
before_validation :set_demographic_info, :set_document_info, :set_denormalized_booth_assignment_id
|
before_validation :set_demographic_info, :set_document_info, :set_denormalized_booth_assignment_id
|
||||||
|
|||||||
@@ -289,14 +289,10 @@ class User < ApplicationRecord
|
|||||||
def take_votes_from(other_user)
|
def take_votes_from(other_user)
|
||||||
return if other_user.blank?
|
return if other_user.blank?
|
||||||
|
|
||||||
with_lock do
|
Poll::Voter.where(user_id: other_user.id).find_each do |poll_voter|
|
||||||
Poll::Voter.where(user_id: other_user.id).find_each do |poll_voter|
|
transaction(requires_new: true) { poll_voter.update_column(:user_id, id) }
|
||||||
if Poll::Voter.where(poll: poll_voter.poll, user_id: id).any?
|
rescue ActiveRecord::RecordNotUnique
|
||||||
poll_voter.delete
|
poll_voter.delete
|
||||||
else
|
|
||||||
poll_voter.update_column(:user_id, id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Budget::Ballot.where(user_id: other_user.id).update_all(user_id: id)
|
Budget::Ballot.where(user_id: other_user.id).update_all(user_id: id)
|
||||||
|
|||||||
@@ -576,8 +576,6 @@ en:
|
|||||||
attributes:
|
attributes:
|
||||||
document_number:
|
document_number:
|
||||||
not_in_census: "Document not in census"
|
not_in_census: "Document not in census"
|
||||||
user_id:
|
|
||||||
has_voted: "User has already voted"
|
|
||||||
legislation/process:
|
legislation/process:
|
||||||
attributes:
|
attributes:
|
||||||
end_date:
|
end_date:
|
||||||
|
|||||||
@@ -576,8 +576,6 @@ es:
|
|||||||
attributes:
|
attributes:
|
||||||
document_number:
|
document_number:
|
||||||
not_in_census: "Este documento no aparece en el censo"
|
not_in_census: "Este documento no aparece en el censo"
|
||||||
user_id:
|
|
||||||
has_voted: "Este usuario ya ha votado"
|
|
||||||
legislation/process:
|
legislation/process:
|
||||||
attributes:
|
attributes:
|
||||||
end_date:
|
end_date:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddUniqueIndexToPollVoters < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
add_index :poll_voters, [:user_id, :poll_id], unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2025_03_13_014205) do
|
ActiveRecord::Schema[7.1].define(version: 2025_08_14_122241) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -1209,6 +1209,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_03_13_014205) do
|
|||||||
t.index ["officer_assignment_id"], name: "index_poll_voters_on_officer_assignment_id"
|
t.index ["officer_assignment_id"], name: "index_poll_voters_on_officer_assignment_id"
|
||||||
t.index ["poll_id", "document_number", "document_type"], name: "doc_by_poll"
|
t.index ["poll_id", "document_number", "document_type"], name: "doc_by_poll"
|
||||||
t.index ["poll_id"], name: "index_poll_voters_on_poll_id"
|
t.index ["poll_id"], name: "index_poll_voters_on_poll_id"
|
||||||
|
t.index ["user_id", "poll_id"], name: "index_poll_voters_on_user_id_and_poll_id", unique: true
|
||||||
t.index ["user_id"], name: "index_poll_voters_on_user_id"
|
t.index ["user_id"], name: "index_poll_voters_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,19 @@ require "rails_helper"
|
|||||||
|
|
||||||
describe Officing::VotersController do
|
describe Officing::VotersController do
|
||||||
describe "POST create" do
|
describe "POST create" do
|
||||||
|
let(:officer) { create(:poll_officer) }
|
||||||
|
before { sign_in(officer.user) }
|
||||||
|
|
||||||
it "does not create two records with two simultaneous requests", :race_condition do
|
it "does not create two records with two simultaneous requests", :race_condition do
|
||||||
officer = create(:poll_officer)
|
|
||||||
poll = create(:poll, officers: [officer])
|
poll = create(:poll, officers: [officer])
|
||||||
user = create(:user, :level_two)
|
user = create(:user, :level_two)
|
||||||
|
|
||||||
sign_in(officer.user)
|
|
||||||
|
|
||||||
2.times.map do
|
2.times.map do
|
||||||
Thread.new do
|
Thread.new do
|
||||||
post :create, params: {
|
post :create, params: {
|
||||||
voter: { poll_id: poll.id, user_id: user.id },
|
voter: { poll_id: poll.id, user_id: user.id },
|
||||||
format: :js
|
format: :js
|
||||||
}
|
}
|
||||||
rescue ActiveRecord::RecordInvalid
|
|
||||||
end
|
end
|
||||||
end.each(&:join)
|
end.each(&:join)
|
||||||
|
|
||||||
@@ -24,7 +23,6 @@ describe Officing::VotersController do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it "stores officer and booth information" do
|
it "stores officer and booth information" do
|
||||||
officer = create(:poll_officer)
|
|
||||||
user = create(:user, :in_census)
|
user = create(:user, :in_census)
|
||||||
poll1 = create(:poll, name: "Would you be interested in XYZ?")
|
poll1 = create(:poll, name: "Would you be interested in XYZ?")
|
||||||
poll2 = create(:poll, name: "Testing polls")
|
poll2 = create(:poll, name: "Testing polls")
|
||||||
@@ -37,7 +35,6 @@ describe Officing::VotersController do
|
|||||||
|
|
||||||
validate_officer
|
validate_officer
|
||||||
set_officing_booth(booth)
|
set_officing_booth(booth)
|
||||||
sign_in(officer.user)
|
|
||||||
|
|
||||||
post :create, params: {
|
post :create, params: {
|
||||||
voter: { poll_id: poll1.id, user_id: user.id },
|
voter: { poll_id: poll1.id, user_id: user.id },
|
||||||
@@ -61,5 +58,20 @@ describe Officing::VotersController do
|
|||||||
expect(voter2.booth_assignment).to eq(assignment2)
|
expect(voter2.booth_assignment).to eq(assignment2)
|
||||||
expect(voter2.officer_assignment).to eq(assignment2.officer_assignments.first)
|
expect(voter2.officer_assignment).to eq(assignment2.officer_assignments.first)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "does not overwrite non key attributes when a web voter already exists" do
|
||||||
|
user = create(:user, :level_two, document_number: "11223344Z")
|
||||||
|
poll = create(:poll, officers: [officer])
|
||||||
|
existing = create(:poll_voter, poll: poll, user: user, origin: "web")
|
||||||
|
|
||||||
|
expect do
|
||||||
|
post :create, params: { voter: { poll_id: poll.id, user_id: user.id }, format: :js }
|
||||||
|
expect(response).to be_successful
|
||||||
|
end.not_to change { Poll::Voter.count }
|
||||||
|
|
||||||
|
existing.reload
|
||||||
|
expect(existing.origin).to eq "web"
|
||||||
|
expect(existing.document_number).to eq "11223344Z"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -28,5 +28,18 @@ describe PollsController do
|
|||||||
|
|
||||||
expect(Poll::Answer.count).to eq 1
|
expect(Poll::Answer.count).to eq 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "denies access when users have already voted in a booth" do
|
||||||
|
poll = create(:poll)
|
||||||
|
user = create(:user, :level_two)
|
||||||
|
create(:poll_voter, :from_booth, poll: poll, user: user)
|
||||||
|
|
||||||
|
sign_in(user)
|
||||||
|
|
||||||
|
post :answer, params: { id: poll.id, web_vote: {}}
|
||||||
|
|
||||||
|
expect(response).to redirect_to "/"
|
||||||
|
expect(flash[:alert]).to eq "You do not have permission to access this page."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,49 +4,6 @@ describe "polls tasks" do
|
|||||||
let(:poll) { create(:poll) }
|
let(:poll) { create(:poll) }
|
||||||
let(:user) { create(:user, :level_two) }
|
let(:user) { create(:user, :level_two) }
|
||||||
|
|
||||||
describe "polls:remove_duplicate_voters" do
|
|
||||||
before { Rake::Task["polls:remove_duplicate_voters"].reenable }
|
|
||||||
|
|
||||||
it "removes duplicate voters" do
|
|
||||||
second_user = create(:user, :level_two)
|
|
||||||
|
|
||||||
voter = create(:poll_voter, poll: poll, user: user)
|
|
||||||
second_voter = create(:poll_voter, poll: poll, user: second_user)
|
|
||||||
other_user_voter = create(:poll_voter, poll: poll, user: create(:user, :level_two))
|
|
||||||
other_poll_voter = create(:poll_voter, poll: create(:poll), user: user)
|
|
||||||
|
|
||||||
2.times { insert(:poll_voter, poll_id: poll.id, user_id: user.id) }
|
|
||||||
insert(:poll_voter, poll_id: poll.id, user_id: second_user.id)
|
|
||||||
|
|
||||||
expect(Poll::Voter.count).to eq 7
|
|
||||||
|
|
||||||
Rake.application.invoke_task("polls:remove_duplicate_voters")
|
|
||||||
|
|
||||||
expect(Poll::Voter.count).to eq 4
|
|
||||||
expect(Poll::Voter.all).to match_array [voter, second_voter, other_user_voter, other_poll_voter]
|
|
||||||
end
|
|
||||||
|
|
||||||
it "removes duplicate voters on tenants" do
|
|
||||||
create(:tenant, schema: "voters")
|
|
||||||
|
|
||||||
Tenant.switch("voters") do
|
|
||||||
poll = create(:poll)
|
|
||||||
user = create(:user, :level_two)
|
|
||||||
|
|
||||||
create(:poll_voter, poll: poll, user: user)
|
|
||||||
insert(:poll_voter, poll_id: poll.id, user_id: user.id)
|
|
||||||
|
|
||||||
expect(Poll::Voter.count).to eq 2
|
|
||||||
end
|
|
||||||
|
|
||||||
Rake.application.invoke_task("polls:remove_duplicate_voters")
|
|
||||||
|
|
||||||
Tenant.switch("voters") do
|
|
||||||
expect(Poll::Voter.count).to eq 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "polls:remove_duplicate_answers" do
|
describe "polls:remove_duplicate_answers" do
|
||||||
before { Rake::Task["polls:remove_duplicate_answers"].reenable }
|
before { Rake::Task["polls:remove_duplicate_answers"].reenable }
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ describe Poll::Voter do
|
|||||||
|
|
||||||
voter = build(:poll_voter, user: user, poll: poll)
|
voter = build(:poll_voter, user: user, poll: poll)
|
||||||
|
|
||||||
expect(voter).not_to be_valid
|
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
||||||
expect(voter.errors.messages[:user_id]).to eq(["User has already voted"])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "is not valid if the user has already voted in the same poll/booth" do
|
it "is not valid if the user has already voted in the same poll/booth" do
|
||||||
@@ -42,8 +41,7 @@ describe Poll::Voter do
|
|||||||
|
|
||||||
voter = build(:poll_voter, user: user, poll: poll, booth_assignment: booth_assignment)
|
voter = build(:poll_voter, user: user, poll: poll, booth_assignment: booth_assignment)
|
||||||
|
|
||||||
expect(voter).not_to be_valid
|
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
||||||
expect(voter.errors.messages[:user_id]).to eq(["User has already voted"])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "is not valid if the user has already voted in different booth in the same poll" do
|
it "is not valid if the user has already voted in different booth in the same poll" do
|
||||||
@@ -51,8 +49,7 @@ describe Poll::Voter do
|
|||||||
|
|
||||||
voter = build(:poll_voter, :from_booth, user: user, poll: poll, booth: booth)
|
voter = build(:poll_voter, :from_booth, user: user, poll: poll, booth: booth)
|
||||||
|
|
||||||
expect(voter).not_to be_valid
|
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
||||||
expect(voter.errors.messages[:user_id]).to eq(["User has already voted"])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "is valid if the user has already voted in the same booth in different poll" do
|
it "is valid if the user has already voted in the same booth in different poll" do
|
||||||
@@ -68,8 +65,8 @@ describe Poll::Voter do
|
|||||||
create(:poll_voter, :from_web, user: answer.author, poll: answer.poll)
|
create(:poll_voter, :from_web, user: answer.author, poll: answer.poll)
|
||||||
|
|
||||||
voter = build(:poll_voter, poll: answer.question.poll, user: answer.author)
|
voter = build(:poll_voter, poll: answer.question.poll, user: answer.author)
|
||||||
expect(voter).not_to be_valid
|
|
||||||
expect(voter.errors.messages[:user_id]).to eq(["User has already voted"])
|
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
||||||
end
|
end
|
||||||
|
|
||||||
context "Skip verification is enabled" do
|
context "Skip verification is enabled" do
|
||||||
@@ -83,7 +80,7 @@ describe Poll::Voter do
|
|||||||
|
|
||||||
voter = build(:poll_voter, user: user, poll: poll)
|
voter = build(:poll_voter, user: user, poll: poll)
|
||||||
|
|
||||||
expect(voter).not_to be_valid
|
expect { voter.save }.to raise_error ActiveRecord::RecordNotUnique
|
||||||
end
|
end
|
||||||
|
|
||||||
it "is valid if other users have voted in the same poll" do
|
it "is valid if other users have voted in the same poll" do
|
||||||
@@ -146,6 +143,15 @@ describe Poll::Voter do
|
|||||||
expect(voter).to be_valid
|
expect(voter).to be_valid
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe ".create_or_find_by!" do
|
||||||
|
it "finds the voter when it already exists instead of failing the validation" do
|
||||||
|
existing_voter = Poll::Voter.create_with(origin: "web")
|
||||||
|
.create_or_find_by!(user: voter.user, poll: voter.poll)
|
||||||
|
|
||||||
|
expect(existing_voter).to eq voter
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "scopes" do
|
describe "scopes" do
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ describe Poll::WebVote do
|
|||||||
expect(question.reload.answers.size).to eq 0
|
expect(question.reload.answers.size).to eq 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "raises an exception when the user has already voted in a booth" do
|
||||||
|
create(:poll_voter, :from_booth, poll: poll, user: user)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
web_vote.update(question.id.to_s => { option_id: option_yes.id.to_s })
|
||||||
|
end.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
|
||||||
|
expect(poll.reload.voters.size).to eq 1
|
||||||
|
expect(poll.voters.first.user).to eq user
|
||||||
|
expect(question.reload.answers.size).to eq 0
|
||||||
|
end
|
||||||
|
|
||||||
context "creating answers at the same time", :race_condition do
|
context "creating answers at the same time", :race_condition do
|
||||||
it "does not create two voters or two answers for two different answers" do
|
it "does not create two voters or two answers for two different answers" do
|
||||||
[option_yes, option_no].map do |option|
|
[option_yes, option_no].map do |option|
|
||||||
|
|||||||
Reference in New Issue
Block a user