Merge pull request #3344 from consul/backport-budget_ballots

Allow voting Budget Investments in booths
This commit is contained in:
Javier Martín
2019-04-09 13:54:00 +02:00
committed by GitHub
23 changed files with 380 additions and 30 deletions

View File

@@ -7,7 +7,7 @@ class Admin::Poll::PollsController < Admin::Poll::BaseController
before_action :load_geozones, only: [:new, :create, :edit, :update]
def index
@polls = Poll.order(starts_at: :desc)
@polls = Poll.not_budget.order(starts_at: :desc)
end
def show
@@ -22,7 +22,12 @@ class Admin::Poll::PollsController < Admin::Poll::BaseController
def create
@poll = Poll.new(poll_params.merge(author: current_user))
if @poll.save
redirect_to [:admin, @poll], notice: t("flash.actions.create.poll")
notice = t("flash.actions.create.poll")
if @poll.budget.present?
redirect_to admin_poll_booth_assignments_path(@poll), notice: notice
else
redirect_to [:admin, @poll], notice: notice
end
else
render :new
end
@@ -63,7 +68,7 @@ class Admin::Poll::PollsController < Admin::Poll::BaseController
def poll_params
attributes = [:name, :starts_at, :ends_at, :geozone_restricted, :results_enabled,
:stats_enabled, geozone_ids: [],
:stats_enabled, :budget_id, geozone_ids: [],
image_attributes: image_attributes]
params.require(:poll).permit(*attributes, translation_params(Poll))
end

View File

@@ -6,7 +6,7 @@ class Admin::Poll::QuestionsController < Admin::Poll::BaseController
load_and_authorize_resource :question, class: "Poll::Question"
def index
@polls = Poll.all
@polls = Poll.not_budget
@search = search_params[:search]
@questions = @questions.search(search_params).page(params[:page]).order("created_at DESC")

View File

@@ -11,7 +11,7 @@ class PollsController < ApplicationController
::Poll::Answer # trigger autoload
def index
@polls = @polls.send(@current_filter).includes(:geozones).sort_for_list.page(params[:page])
@polls = @polls.not_budget.send(@current_filter).includes(:geozones).sort_for_list.page(params[:page])
end
def show

View File

@@ -96,4 +96,16 @@ module BudgetsHelper
!current_user.voted_in_group?(investment.group) &&
investment.group.headings.count > 1
end
def link_to_create_budget_poll(budget)
balloting_phase = budget.phases.where(kind: "balloting").first
link_to t("admin.budgets.index.admin_ballots"),
admin_polls_path(poll: {
name: budget.name,
budget_id: budget.id,
starts_at: balloting_phase.starts_at,
ends_at: balloting_phase.ends_at }),
method: :post
end
end

View File

@@ -21,6 +21,8 @@ class Budget < ActiveRecord::Base
has_many :headings, through: :groups
has_many :phases, class_name: Budget::Phase
has_one :poll
before_validation :sanitize_descriptions
after_create :generate_phases

View File

@@ -70,5 +70,9 @@ class Budget
investments.where(group: group).first.heading
end
def casted_offline?
budget.poll&.voted_by?(user)
end
end
end

View File

@@ -248,6 +248,7 @@ class Budget
return :no_ballots_allowed unless budget.balloting?
return :different_heading_assigned_html unless ballot.valid_heading?(heading)
return :not_enough_money_html if ballot.present? && !enough_money?(ballot)
return :casted_offline if ballot.casted_offline?
end
def permission_problem(user)

View File

@@ -23,6 +23,7 @@ class Poll < ActiveRecord::Base
has_and_belongs_to_many :geozones
belongs_to :author, -> { with_hidden }, class_name: "User", foreign_key: "author_id"
belongs_to :budget
validates_translation :name, presence: true
validate :date_range
@@ -33,6 +34,7 @@ class Poll < ActiveRecord::Base
scope :published, -> { where("published = ?", true) }
scope :by_geozone_id, ->(geozone_id) { where(geozones: {id: geozone_id}.joins(:geozones)) }
scope :public_for_api, -> { all }
scope :not_budget, -> { where(budget_id: nil) }
scope :sort_for_list, -> { order(:geozone_restricted, :starts_at, :name) }
@@ -71,10 +73,15 @@ class Poll < ActiveRecord::Base
end
def votable_by?(user)
return false if user_has_an_online_ballot(user)
answerable_by?(user) &&
not_voted_by?(user)
end
def user_has_an_online_ballot(user)
budget.present? && budget.ballots.find_by(user: user)&.lines.present?
end
def self.not_voted_by(user)
where("polls.id not in (?)", poll_ids_voted_by(user))
end
@@ -107,4 +114,7 @@ class Poll < ActiveRecord::Base
end
end
def budget_poll?
budget.present?
end
end

View File

@@ -17,6 +17,7 @@
<th><%= t("admin.budgets.index.table_investments") %></th>
<th><%= t("admin.budgets.index.table_edit_groups") %></th>
<th><%= t("admin.budgets.index.table_edit_budget") %></th>
<th><%= t("admin.budgets.index.table_admin_ballots") %></th>
</tr>
</thead>
<tbody>
@@ -39,6 +40,13 @@
<td class="small">
<%= link_to t("admin.budgets.index.edit_budget"), edit_admin_budget_path(budget) %>
</td>
<td class="small">
<% if budget.poll.present? %>
<%= link_to t("admin.budgets.index.admin_ballots"), admin_poll_booth_assignments_path(budget.poll) %>
<% else %>
<%= link_to_create_budget_poll(budget) %>
<% end %>
</td>
</tr>
<% end %>
</tbody>

View File

@@ -11,7 +11,7 @@
class: "button hollow float-right" %>
<% if @booth_assignments.empty? %>
<div class="callout primary margin-top">
<div class="callout primary margin-top clear">
<%= t("admin.poll_booth_assignments.index.no_booths") %>
</div>
<% else %>

View File

@@ -1,18 +1,20 @@
<ul class="menu simple clear" id="assigned-resources-tabs">
<% if controller_name == "polls" %>
<li class="is-active">
<h2>
<%= t("admin.polls.show.questions_tab") %>
(<%= @poll.questions.count %>)
</h2>
</li>
<% else %>
<li>
<%= link_to admin_poll_path(@poll) do %>
<%= t("admin.polls.show.questions_tab") %>
(<%= @poll.questions.count %>)
<% end %>
</li>
<% unless @poll.budget_poll? %>
<% if controller_name == "polls" %>
<li class="is-active">
<h2>
<%= t("admin.polls.show.questions_tab") %>
(<%= @poll.questions.count %>)
</h2>
</li>
<% else %>
<li>
<%= link_to admin_poll_path(@poll) do %>
<%= t("admin.polls.show.questions_tab") %>
(<%= @poll.questions.count %>)
<% end %>
</li>
<% end %>
<% end %>
<% if controller_name == "booth_assignments" %>

View File

@@ -14,14 +14,18 @@
<thead>
<tr>
<th class="text-center"></th>
<th class="text-center"><%= t("admin.recounts.index.total_final") %></th>
<% unless @poll.budget_poll? %>
<th class="text-center"><%= t("admin.recounts.index.total_final") %></th>
<% end %>
<th class="text-center"><%= t("admin.recounts.index.total_system") %></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong><%= t("admin.recounts.index.all_booths_total") %></strong></td>
<td class="text-center" id="total_final"><%= @all_booths_counts[:final] %></td>
<% unless @poll.budget_poll? %>
<td class="text-center" id="total_final"><%= @all_booths_counts[:final] %></td>
<% end %>
<td class="text-center" id="total_system"><%= @all_booths_counts[:system] %></td>
</tr>
</tbody>
@@ -30,7 +34,9 @@
<table class="fixed margin">
<thead>
<th><%= t("admin.recounts.index.table_booth_name") %></th>
<th class="text-center"><%= t("admin.recounts.index.table_total_recount") %></th>
<% unless @poll.budget_poll? %>
<th class="text-center"><%= t("admin.recounts.index.table_total_recount") %></th>
<% end %>
<th class="text-center"><%= t("admin.recounts.index.table_system_count") %></th>
</thead>
<tbody>
@@ -43,13 +49,15 @@
<%= link_to booth_assignment.booth.name, admin_poll_booth_assignment_path(@poll, booth_assignment, anchor: "tab-recounts") %>
</strong>
</td>
<td class="text-center <%= "count-error" if total_recounts.to_i != system_count %>" id="<%= dom_id(booth_assignment) %>_recount">
<% if total_recounts.present? %>
<strong><%= total_recounts %></strong>
<% else %>
<span>-</span>
<% end %>
</td>
<% unless @poll.budget_poll? %>
<td class="text-center <%= "count-error" if total_recounts.to_i != system_count %>" id="<%= dom_id(booth_assignment) %>_recount">
<% if total_recounts.present? %>
<strong><%= total_recounts %></strong>
<% else %>
<span>-</span>
<% end %>
</td>
<% end %>
<td class="text-center" id="<%= dom_id(booth_assignment) %>_system">
<% if system_count.present? %>
<strong><%= system_count %></strong>

View File

@@ -85,8 +85,10 @@ en:
table_investments: Investments
table_edit_groups: Headings groups
table_edit_budget: Edit
table_admin_ballots: Ballots
edit_groups: Edit headings groups
edit_budget: Edit budget
admin_ballots: Admin ballots
no_budgets: "There are no budgets."
create:
notice: New participatory budget created successfully!

View File

@@ -22,6 +22,7 @@ en:
no_ballots_allowed: Selecting phase is closed
different_heading_assigned_html: "You have already voted a different heading: %{heading_link}"
change_ballot: change your votes
casted_offline: You have already participated offline
groups:
show:
title: Select an option

View File

@@ -85,8 +85,10 @@ es:
table_investments: Proyectos de gasto
table_edit_groups: Grupos de partidas
table_edit_budget: Editar
table_admin_ballots: Urnas
edit_groups: Editar grupos de partidas
edit_budget: Editar presupuesto
admin_ballots: Gestionar urnas
no_budgets: "No hay presupuestos participativos."
create:
notice: "¡Presupuestos participativos creados con éxito!"

View File

@@ -22,6 +22,7 @@ es:
no_ballots_allowed: El periodo de votación está cerrado.
different_heading_assigned_html: "Ya has votado proyectos de otra partida: %{heading_link}"
change_ballot: cambiar tus votos
casted_offline: Ya has participado presencialmente
groups:
show:
title: Selecciona una opción

View File

@@ -0,0 +1,5 @@
class AddBudgetToPolls < ActiveRecord::Migration
def change
add_reference :polls, :budget, index: { unique: true }, foreign_key: true
end
end

View File

@@ -1129,8 +1129,10 @@ ActiveRecord::Schema.define(version: 20190205131722) do
t.boolean "stats_enabled", default: false
t.datetime "created_at"
t.datetime "updated_at"
t.integer "budget_id"
end
add_index "polls", ["budget_id"], name: "index_polls_on_budget_id", unique: true, using: :btree
add_index "polls", ["starts_at", "ends_at"], name: "index_polls_on_starts_at_and_ends_at", using: :btree
create_table "progress_bar_translations", force: :cascade do |t|
@@ -1614,6 +1616,7 @@ ActiveRecord::Schema.define(version: 20190205131722) do
add_foreign_key "poll_recounts", "poll_booth_assignments", column: "booth_assignment_id"
add_foreign_key "poll_recounts", "poll_officer_assignments", column: "officer_assignment_id"
add_foreign_key "poll_voters", "polls"
add_foreign_key "polls", "budgets"
add_foreign_key "proposals", "communities"
add_foreign_key "related_content_scores", "related_contents"
add_foreign_key "related_content_scores", "users"

View File

@@ -0,0 +1,62 @@
require "rails_helper"
feature "Admin Budgets" do
background do
admin = create(:administrator).user
login_as(admin)
end
context "Index" do
scenario "Create poll if the budget does not have a poll associated" do
budget = create(:budget)
visit admin_budgets_path
click_link "Admin ballots"
balloting_phase = budget.phases.where(kind: "balloting").first
expect(current_path).to match(/admin\/polls\/\d+/)
expect(page).to have_content(budget.name)
expect(page).to have_content(balloting_phase.starts_at.to_date)
expect(page).to have_content(balloting_phase.ends_at.to_date)
expect(Poll.count).to eq(1)
expect(Poll.last.budget).to eq(budget)
end
scenario "Display link to poll if the budget has a poll associated" do
budget = create(:budget)
poll = create(:poll, budget: budget)
visit admin_budgets_path
within "#budget_#{budget.id}" do
expect(page).to have_link("Admin ballots", admin_poll_path(poll))
end
end
end
context "Show" do
scenario "Do not show questions section if the budget have a poll associated" do
budget = create(:budget)
poll = create(:poll, budget: budget)
visit admin_poll_path(poll)
within "#poll-resources" do
expect(page).not_to have_content("Questions")
expect(page).to have_content("Booths")
expect(page).to have_content("Officers")
expect(page).to have_content("Recounting")
expect(page).to have_content("Results")
end
end
end
end

View File

@@ -0,0 +1,34 @@
require "rails_helper"
feature "Polls" do
context "Public index" do
scenario "Budget polls should not be listed" do
poll = create(:poll)
budget_poll = create(:poll, budget: create(:budget))
visit polls_path
expect(page).to have_content(poll.name)
expect(page).not_to have_content(budget_poll.name)
end
end
context "Admin index" do
scenario "Budget polls should not appear in the list" do
login_as(create(:administrator).user)
poll = create(:poll)
budget_poll = create(:poll, budget: create(:budget))
visit admin_polls_path
expect(page).to have_content(poll.name)
expect(page).not_to have_content(budget_poll.name)
end
end
end

View File

@@ -0,0 +1,22 @@
require "rails_helper"
feature "Poll Questions" do
before do
admin = create(:administrator).user
login_as(admin)
end
scenario "Do not display polls associated to a budget" do
budget = create(:budget)
poll1 = create(:poll, name: "Citizen Proposal Poll")
poll2 = create(:poll, budget: budget, name: "Participatory Budget Poll")
visit admin_questions_path
expect(page).to have_select("poll_id", text: "Citizen Proposal Poll")
expect(page).not_to have_select("poll_id", text: "Participatory Budget Poll")
end
end

View File

@@ -0,0 +1,146 @@
require "rails_helper"
feature "BudgetPolls", :with_frozen_time do
let(:budget) { create(:budget, :balloting) }
let(:group) { create(:budget_group, budget: budget) }
let(:heading) { create(:budget_heading, group: group) }
let(:investment) { create(:budget_investment, :selected, heading: heading) }
let(:poll) { create(:poll, :current, budget: budget) }
let(:booth) { create(:poll_booth) }
let(:officer) { create(:poll_officer) }
let(:admin) { create(:administrator) }
let!(:user) { create(:user, :in_census) }
background do
create(:poll_shift, officer: officer, booth: booth, date: Date.current, task: :vote_collection)
booth_assignment = create(:poll_booth_assignment, poll: poll, booth: booth)
create(:poll_officer_assignment, officer: officer, booth_assignment: booth_assignment, date: Date.current)
end
context "Offline" do
scenario "A citizen can cast a paper vote", :js do
login_through_form_as_officer(officer.user)
visit new_officing_residence_path
officing_verify_residence
expect(page).to have_content poll.name
within("#poll_#{poll.id}") do
click_button("Confirm vote")
expect(page).not_to have_button("Confirm vote")
expect(page).to have_content "Vote introduced!"
end
expect(Poll::Voter.count).to eq(1)
expect(Poll::Voter.first.origin).to eq("booth")
visit root_path
click_link "Sign out"
login_as(admin.user)
visit admin_poll_recounts_path(poll)
within("#total_system") do
expect(page).to have_content "1"
end
within("#poll_booth_assignment_#{Poll::BoothAssignment.where(poll: poll, booth: booth).first.id}_recounts") do
expect(page).to have_content "1"
end
end
scenario "A citizen cannot vote offline again", :js do
login_through_form_as_officer(officer.user)
visit new_officing_residence_path
officing_verify_residence
within("#poll_#{poll.id}") do
click_button("Confirm vote")
end
visit new_officing_residence_path
officing_verify_residence
within("#poll_#{poll.id}") do
expect(page).to have_content "Has already participated in this poll"
end
end
scenario "A citizen cannot vote online after voting offline", :js do
login_through_form_as_officer(officer.user)
visit new_officing_residence_path
officing_verify_residence
within("#poll_#{poll.id}") do
click_button("Confirm vote")
end
expect(page).to have_content "Vote introduced!"
login_as(user)
visit budget_investment_path(budget, investment)
find("div.ballot").hover
within("#budget_investment_#{investment.id}") do
expect(page).to have_content "You have already participated offline"
expect(page).to have_css(".add a", visible: false)
end
end
end
context "Online" do
scenario "A citizen can cast vote online", :js do
login_as(user)
visit budget_investment_path(budget, investment)
within("#budget_investment_#{investment.id}") do
find(".add a").click
expect(page).to have_content "Remove"
end
end
scenario "A citizen cannot vote online again", :js do
login_as(user)
visit budget_investment_path(budget, investment)
within("#budget_investment_#{investment.id}") do
find(".add a").click
expect(page).to have_content "Remove"
end
visit budget_investment_path(budget, investment)
find("div.ballot").hover
within("#budget_investment_#{investment.id}") do
expect(page).to have_content "Remove vote"
end
end
scenario "A citizen cannot vote offline after voting online", :js do
login_as(user)
visit budget_investment_path(budget, investment)
within("#budget_investment_#{investment.id}") do
find(".add a").click
expect(page).to have_content "Remove"
end
logout
login_through_form_as_officer(officer.user)
visit new_officing_residence_path
officing_verify_residence
expect(page).to have_content poll.name
within("#poll_#{poll.id}") do
expect(page).not_to have_button("Confirm vote")
expect(page).to have_content("Has already participated in this poll")
end
end
end
end

View File

@@ -258,4 +258,24 @@ describe Poll do
end
context "scopes" do
describe "#not_budget" do
it "returns polls not associated to a budget" do
budget = create(:budget)
poll1 = create(:poll)
poll2 = create(:poll)
poll3 = create(:poll, budget: budget)
expect(Poll.not_budget).to include(poll1)
expect(Poll.not_budget).to include(poll2)
expect(Poll.not_budget).not_to include(poll3)
end
end
end
end