Allow blank votes in polls via web

With the old interface, there wasn't a clear way to send a blank ballot.
But now that we've got a form, there's an easy way: clicking on "Vote"
while leaving the form blank.
This commit is contained in:
Javi Martín
2025-07-09 12:50:16 +02:00
parent cd134ed44f
commit 7ea4f63b07
13 changed files with 49 additions and 23 deletions

View File

@@ -2,8 +2,12 @@
<% if voted_in_booth? %> <% if voted_in_booth? %>
<%= callout(t("polls.show.already_voted_in_booth")) %> <%= callout(t("polls.show.already_voted_in_booth")) %>
<% elsif voted_in_web? %> <% elsif voted_in_web? %>
<% if voted_blank? %>
<%= callout(t("polls.show.already_voted_blank_in_web")) %>
<% else %>
<%= callout(t("polls.show.already_voted_in_web")) %> <%= callout(t("polls.show.already_voted_in_web")) %>
<% end %> <% end %>
<% end %>
<% else %> <% else %>
<% if current_user.nil? %> <% if current_user.nil? %>
<%= callout(not_logged_in_text, html_class: "primary") %> <%= callout(not_logged_in_text, html_class: "primary") %>

View File

@@ -16,6 +16,10 @@ class Polls::CalloutComponent < ApplicationComponent
poll.voted_in_web?(current_user) poll.voted_in_web?(current_user)
end end
def voted_blank?
poll.answers.where(author: current_user).none?
end
def callout(text, html_class: "warning") def callout(text, html_class: "warning")
tag.div(text, class: "callout #{html_class}") tag.div(text, class: "callout #{html_class}")
end end

View File

@@ -26,7 +26,11 @@ class PollsController < ApplicationController
@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)
if answer_params.blank?
redirect_to @poll, notice: t("flash.actions.create.poll_voter_blank")
else
redirect_to @poll, notice: t("flash.actions.create.poll_voter") redirect_to @poll, notice: t("flash.actions.create.poll_voter")
end
else else
@comment_tree = CommentTree.new(@poll, params[:page], @current_order) @comment_tree = CommentTree.new(@poll, params[:page], @current_order)
render :show render :show

View File

@@ -23,6 +23,7 @@ class Poll < ApplicationRecord
has_many :officer_assignments, through: :booth_assignments has_many :officer_assignments, through: :booth_assignments
has_many :officers, through: :officer_assignments has_many :officers, through: :officer_assignments
has_many :questions, inverse_of: :poll, dependent: :destroy has_many :questions, inverse_of: :poll, dependent: :destroy
has_many :answers, through: :questions
has_many :comments, as: :commentable, inverse_of: :commentable has_many :comments, as: :commentable, inverse_of: :commentable
has_many :ballot_sheets has_many :ballot_sheets

View File

@@ -42,11 +42,11 @@ class Poll::Stats
end end
def total_web_valid def total_web_valid
voters.where(origin: "web").count - total_web_white voters.where(origin: "web", user_id: poll.answers.select(:author_id).distinct).count
end end
def total_web_white def total_web_white
0 voters.where(origin: "web").count - total_web_valid
end end
def total_web_null def total_web_null

View File

@@ -14,10 +14,6 @@ class Poll::WebVote
all_valid = true all_valid = true
user.with_lock do user.with_lock do
unless questions.any? { |question| params.dig(question.id.to_s, :option_id).present? }
Poll::Voter.find_by(user: user, poll: poll, origin: "web")&.destroy!
end
questions.each do |question| questions.each do |question|
question.answers.where(author: user).destroy_all question.answers.where(author: user).destroy_all
next unless params[question.id.to_s] next unless params[question.id.to_s]

View File

@@ -604,6 +604,7 @@ en:
show: show:
already_voted_in_booth: "You have already participated in a physical booth. You can not participate again." already_voted_in_booth: "You have already participated in a physical booth. You can not participate again."
already_voted_in_web: "You have already participated in this poll. If you vote again it will be overwritten." already_voted_in_web: "You have already participated in this poll. If you vote again it will be overwritten."
already_voted_blank_in_web: "You have already participated in this poll by casting a blank vote. If you vote again it will be overwritten."
back: Back to voting back: Back to voting
cant_answer_not_logged_in: "You must %{signin} or %{signup} to participate." cant_answer_not_logged_in: "You must %{signin} or %{signup} to participate."
comments_tab: Comments comments_tab: Comments

View File

@@ -11,6 +11,7 @@ en:
poll_question_option_video: "Video created successfully" poll_question_option_video: "Video created successfully"
poll_question_option_image: "Image uploaded successfully" poll_question_option_image: "Image uploaded successfully"
poll_voter: "Thank you for voting!" poll_voter: "Thank you for voting!"
poll_voter_blank: "Thank you for voting! Your vote has been registered as a blank vote."
proposal: "Proposal created successfully." proposal: "Proposal created successfully."
proposal_notification: "Your message has been sent correctly." proposal_notification: "Your message has been sent correctly."
budget_investment: "Budget Investment created successfully." budget_investment: "Budget Investment created successfully."

View File

@@ -604,6 +604,7 @@ es:
show: show:
already_voted_in_booth: "Ya has participado en esta votación en urnas presenciales, no puedes volver a participar." already_voted_in_booth: "Ya has participado en esta votación en urnas presenciales, no puedes volver a participar."
already_voted_in_web: "Ya has participado en esta votación. Si vuelves a votar se sobreescribirá tu resultado anterior." already_voted_in_web: "Ya has participado en esta votación. Si vuelves a votar se sobreescribirá tu resultado anterior."
already_voted_blank_in_web: "Ya has participado en esta votación mediante un voto en blanco. Si vuelves a votar se sobreescribirá tu resultado anterior."
back: Volver a votaciones back: Volver a votaciones
cant_answer_not_logged_in: "Necesitas %{signin} o %{signup} para participar." cant_answer_not_logged_in: "Necesitas %{signin} o %{signup} para participar."
comments_tab: Comentarios comments_tab: Comentarios

View File

@@ -11,6 +11,7 @@ es:
poll_question_option_video: "Vídeo creado correctamente" poll_question_option_video: "Vídeo creado correctamente"
poll_question_option_image: "Imagen cargada correctamente" poll_question_option_image: "Imagen cargada correctamente"
poll_voter: "¡Gracias por votar!" poll_voter: "¡Gracias por votar!"
poll_voter_blank: "¡Gracias por votar! Tu voto se ha contabilizado como en blanco."
proposal: "Propuesta creada correctamente." proposal: "Propuesta creada correctamente."
proposal_notification: "Tu mensaje ha sido enviado correctamente." proposal_notification: "Tu mensaje ha sido enviado correctamente."
budget_investment: "Proyecto de gasto creado correctamente." budget_investment: "Proyecto de gasto creado correctamente."

View File

@@ -14,8 +14,6 @@ describe Poll::Stats do
end end
describe "total participants" do describe "total participants" do
before { allow(stats).to receive(:total_web_white).and_return(1) }
it "supports every channel" do it "supports every channel" do
3.times { create(:poll_voter, :from_web, poll: poll) } 3.times { create(:poll_voter, :from_web, poll: poll) }
create(:poll_recount, :from_booth, poll: poll, create(:poll_recount, :from_booth, poll: poll,
@@ -49,15 +47,29 @@ describe Poll::Stats do
end end
describe "#total_web_valid" do describe "#total_web_valid" do
before { allow(stats).to receive(:total_web_white).and_return(1) } it "returns only votes containing answers" do
question = create(:poll_question, :yes_no, poll: poll)
it "returns only valid votes" do 2.times do
3.times { create(:poll_voter, :from_web, poll: poll) } voter = create(:poll_voter, :from_web, poll: poll)
create(:poll_answer, author: voter.user, question: question)
end
create(:poll_voter, :from_web, poll: poll)
expect(stats.total_web_valid).to eq(2) expect(stats.total_web_valid).to eq(2)
end end
end end
describe "#total_web_white" do
it "returns voters with no answers" do
question = create(:poll_question, :yes_no, poll: poll)
3.times { create(:poll_voter, :from_web, poll: poll) }
create(:poll_answer, author: poll.voters.last.user, question: question)
expect(stats.total_web_white).to eq(2)
end
end
describe "#total_web_null" do describe "#total_web_null" do
it "returns 0" do it "returns 0" do
expect(stats.total_web_null).to eq(0) expect(stats.total_web_null).to eq(0)
@@ -93,8 +105,8 @@ describe Poll::Stats do
describe "valid percentage by channel" do describe "valid percentage by channel" do
it "is relative to the total amount of valid votes" do it "is relative to the total amount of valid votes" do
allow(stats).to receive(:total_web_valid).and_return(1)
create(:poll_recount, :from_booth, poll: poll, total_amount: 2) create(:poll_recount, :from_booth, poll: poll, total_amount: 2)
create(:poll_voter, :from_web, poll: poll)
expect(stats.valid_percentage_web).to eq(33.333) expect(stats.valid_percentage_web).to eq(33.333)
expect(stats.valid_percentage_booth).to eq(66.667) expect(stats.valid_percentage_booth).to eq(66.667)
@@ -123,7 +135,7 @@ describe Poll::Stats do
describe "#total_valid_votes" do describe "#total_valid_votes" do
it "counts valid votes from every channel" do it "counts valid votes from every channel" do
2.times { create(:poll_voter, :from_web, poll: poll) } allow(stats).to receive(:total_web_valid).and_return(2)
create(:poll_recount, :from_booth, poll: poll, total_amount: 3, white_amount: 10) create(:poll_recount, :from_booth, poll: poll, total_amount: 3, white_amount: 10)
create(:poll_recount, :from_booth, poll: poll, total_amount: 4, null_amount: 20) create(:poll_recount, :from_booth, poll: poll, total_amount: 4, null_amount: 20)
@@ -150,10 +162,9 @@ describe Poll::Stats do
end end
describe "total percentage by type" do describe "total percentage by type" do
before { allow(stats).to receive(:total_web_white).and_return(1) } before { allow(stats).to receive_messages(total_web_white: 1, total_web_valid: 2) }
it "is relative to the total amount of votes" do it "is relative to the total amount of votes" do
3.times { create(:poll_voter, :from_web, poll: poll) }
create(:poll_recount, :from_booth, poll: poll, create(:poll_recount, :from_booth, poll: poll,
total_amount: 8, total_amount: 8,
white_amount: 5, white_amount: 5,

View File

@@ -55,20 +55,21 @@ describe Poll::WebVote do
expect(question.answers).to be_blank expect(question.answers).to be_blank
end end
it "does not create voters or answers when leaving everything blank" do it "creates a voter but does not create answers when leaving everything blank" do
web_vote.update({}) web_vote.update({})
expect(poll.reload.voters.size).to eq 0 expect(poll.reload.voters.size).to eq 1
expect(question.reload.answers.size).to eq 0 expect(question.reload.answers.size).to eq 0
end end
it "deletes existing answers and voter when no answers are given" do it "deletes existing answers but keeps voters when no answers are given" do
create(:poll_answer, question: question, author: user, option: option_yes) create(:poll_answer, question: question, author: user, option: option_yes)
create(:poll_voter, poll: poll, user: user) create(:poll_voter, poll: poll, user: user)
web_vote.update({}) web_vote.update({})
expect(poll.reload.voters.size).to eq 0 expect(poll.reload.voters.size).to eq 1
expect(poll.voters.first.user).to eq user
expect(question.reload.answers.size).to eq 0 expect(question.reload.answers.size).to eq 0
end end

View File

@@ -247,8 +247,9 @@ describe "Polls" do
within_fieldset("Which ones are better?") { uncheck "Answer A" } within_fieldset("Which ones are better?") { uncheck "Answer A" }
click_button "Vote" click_button "Vote"
expect(page).to have_content "Thank you for voting!" expect(page).to have_content "Thank you for voting! Your vote has been registered as a blank vote."
expect(page).not_to have_content "You have already participated" expect(page).to have_content "You have already participated in this poll by casting a blank vote. " \
"If you vote again it will be overwritten."
within_fieldset("Which ones are better?") do within_fieldset("Which ones are better?") do
expect(page).to have_field type: :checkbox, checked: false, count: 3 expect(page).to have_field type: :checkbox, checked: false, count: 3