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? %>
<%= callout(t("polls.show.already_voted_in_booth")) %>
<% 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")) %>
<% end %>
<% end %>
<% else %>
<% if current_user.nil? %>
<%= callout(not_logged_in_text, html_class: "primary") %>

View File

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

View File

@@ -26,7 +26,11 @@ class PollsController < ApplicationController
@web_vote = Poll::WebVote.new(@poll, current_user)
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")
end
else
@comment_tree = CommentTree.new(@poll, params[:page], @current_order)
render :show

View File

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

View File

@@ -42,11 +42,11 @@ class Poll::Stats
end
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
def total_web_white
0
voters.where(origin: "web").count - total_web_valid
end
def total_web_null

View File

@@ -14,10 +14,6 @@ class Poll::WebVote
all_valid = true
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|
question.answers.where(author: user).destroy_all
next unless params[question.id.to_s]

View File

@@ -604,6 +604,7 @@ en:
show:
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_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
cant_answer_not_logged_in: "You must %{signin} or %{signup} to participate."
comments_tab: Comments

View File

@@ -11,6 +11,7 @@ en:
poll_question_option_video: "Video created successfully"
poll_question_option_image: "Image uploaded successfully"
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_notification: "Your message has been sent correctly."
budget_investment: "Budget Investment created successfully."

View File

@@ -604,6 +604,7 @@ es:
show:
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_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
cant_answer_not_logged_in: "Necesitas %{signin} o %{signup} para participar."
comments_tab: Comentarios

View File

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

View File

@@ -14,8 +14,6 @@ describe Poll::Stats do
end
describe "total participants" do
before { allow(stats).to receive(:total_web_white).and_return(1) }
it "supports every channel" do
3.times { create(:poll_voter, :from_web, poll: poll) }
create(:poll_recount, :from_booth, poll: poll,
@@ -49,15 +47,29 @@ describe Poll::Stats do
end
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
3.times { create(:poll_voter, :from_web, poll: poll) }
2.times do
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)
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
it "returns 0" do
expect(stats.total_web_null).to eq(0)
@@ -93,8 +105,8 @@ describe Poll::Stats do
describe "valid percentage by channel" 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_voter, :from_web, poll: poll)
expect(stats.valid_percentage_web).to eq(33.333)
expect(stats.valid_percentage_booth).to eq(66.667)
@@ -123,7 +135,7 @@ describe Poll::Stats do
describe "#total_valid_votes" 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: 4, null_amount: 20)
@@ -150,10 +162,9 @@ describe Poll::Stats do
end
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
3.times { create(:poll_voter, :from_web, poll: poll) }
create(:poll_recount, :from_booth, poll: poll,
total_amount: 8,
white_amount: 5,

View File

@@ -55,20 +55,21 @@ describe Poll::WebVote do
expect(question.answers).to be_blank
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({})
expect(poll.reload.voters.size).to eq 0
expect(poll.reload.voters.size).to eq 1
expect(question.reload.answers.size).to eq 0
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_voter, poll: poll, user: user)
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
end

View File

@@ -247,8 +247,9 @@ describe "Polls" do
within_fieldset("Which ones are better?") { uncheck "Answer A" }
click_button "Vote"
expect(page).to have_content "Thank you for voting!"
expect(page).not_to have_content "You have already participated"
expect(page).to have_content "Thank you for voting! Your vote has been registered as a blank vote."
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
expect(page).to have_field type: :checkbox, checked: false, count: 3