Add approval voting to budgets

Co-Authored-By: Javi Martín <javim@elretirao.net>
This commit is contained in:
Ziyan Junaideen
2020-07-16 17:23:16 +02:00
committed by Javi Martín
parent 009c33d4e5
commit 1e3e8c1304
34 changed files with 368 additions and 38 deletions

View File

@@ -63,7 +63,7 @@ class Admin::BudgetHeadingsController < Admin::BaseController
end end
def budget_heading_params def budget_heading_params
valid_attributes = [:price, :population, :allow_custom_content, :latitude, :longitude] valid_attributes = [:price, :population, :allow_custom_content, :latitude, :longitude, :max_ballot_lines]
params.require(:budget_heading).permit(*valid_attributes, translation_params(Budget::Heading)) params.require(:budget_heading).permit(*valid_attributes, translation_params(Budget::Heading))
end end
end end

View File

@@ -70,6 +70,7 @@ class Admin::BudgetsController < Admin::BaseController
descriptions = Budget::Phase::PHASE_KINDS.map { |p| "description_#{p}" }.map(&:to_sym) descriptions = Budget::Phase::PHASE_KINDS.map { |p| "description_#{p}" }.map(&:to_sym)
valid_attributes = [:phase, valid_attributes = [:phase,
:currency_symbol, :currency_symbol,
:voting_style,
administrator_ids: [], administrator_ids: [],
valuator_ids: [] valuator_ids: []
] + descriptions ] + descriptions

View File

@@ -3,6 +3,12 @@ module BudgetsHelper
["balloting", "reviewing_ballots", "finished"].include? budget.phase ["balloting", "reviewing_ballots", "finished"].include? budget.phase
end end
def budget_voting_styles_select_options
Budget::VOTING_STYLES.map do |style|
[Budget.human_attribute_name("voting_style_#{style}"), style]
end
end
def heading_name_and_price_html(heading, budget) def heading_name_and_price_html(heading, budget)
tag.div do tag.div do
concat(heading.name + " ") concat(heading.name + " ")

View File

@@ -20,11 +20,13 @@ class Budget < ApplicationRecord
end end
CURRENCY_SYMBOLS = %w[€ $ £ ¥].freeze CURRENCY_SYMBOLS = %w[€ $ £ ¥].freeze
VOTING_STYLES = %w[knapsack approval].freeze
validates_translation :name, presence: true validates_translation :name, presence: true
validates :phase, inclusion: { in: Budget::Phase::PHASE_KINDS } validates :phase, inclusion: { in: Budget::Phase::PHASE_KINDS }
validates :currency_symbol, presence: true validates :currency_symbol, presence: true
validates :slug, presence: true, format: /\A[a-z0-9\-_]+\z/ validates :slug, presence: true, format: /\A[a-z0-9\-_]+\z/
validates :voting_style, inclusion: { in: VOTING_STYLES }
has_many :investments, dependent: :destroy has_many :investments, dependent: :destroy
has_many :ballots, dependent: :destroy has_many :ballots, dependent: :destroy
@@ -196,6 +198,10 @@ class Budget < ApplicationRecord
investments.winners.map(&:milestone_tag_list).flatten.uniq.sort investments.winners.map(&:milestone_tag_list).flatten.uniq.sort
end end
def approval_voting?
voting_style == "approval"
end
private private
def generate_phases def generate_phases

View File

@@ -63,7 +63,7 @@ class Budget
def voting_style def voting_style
@voting_style ||= voting_style_class.new(self) @voting_style ||= voting_style_class.new(self)
end end
delegate :amount_available, :amount_available_info, :amount_spent, :amount_spent_info, delegate :amount_available, :amount_available_info, :amount_spent, :amount_spent_info, :amount_limit,
:amount_limit_info, :change_vote_info, :enough_resources?, :formatted_amount_available, :amount_limit_info, :change_vote_info, :enough_resources?, :formatted_amount_available,
:formatted_amount_limit, :formatted_amount_spent, :not_enough_resources_error, :formatted_amount_limit, :formatted_amount_spent, :not_enough_resources_error,
:percentage_spent, :reason_for_not_being_ballotable, :voted_info, :percentage_spent, :reason_for_not_being_ballotable, :voted_info,
@@ -72,7 +72,7 @@ class Budget
private private
def voting_style_class def voting_style_class
Budget::VotingStyles::Knapsack "Budget::VotingStyles::#{budget.voting_style.camelize}".constantize
end end
end end
end end

View File

@@ -35,6 +35,7 @@ class Budget
format: /\A(-|\+)?([1-8]?\d(?:\.\d{1,})?|90(?:\.0{1,6})?)\z/ format: /\A(-|\+)?([1-8]?\d(?:\.\d{1,})?|90(?:\.0{1,6})?)\z/
validates :longitude, length: { maximum: 22 }, allow_blank: true, \ validates :longitude, length: { maximum: 22 }, allow_blank: true, \
format: /\A(-|\+)?((?:1[0-7]|[1-9])?\d(?:\.\d{1,})?|180(?:\.0{1,})?)\z/ format: /\A(-|\+)?((?:1[0-7]|[1-9])?\d(?:\.\d{1,})?|180(?:\.0{1,})?)\z/
validates :max_ballot_lines, numericality: { greater_than_or_equal_to: 1 }
delegate :budget, :budget_id, to: :group, allow_nil: true delegate :budget, :budget_id, to: :group, allow_nil: true

View File

@@ -0,0 +1,25 @@
class Budget::VotingStyles::Approval < Budget::VotingStyles::Base
def enough_resources?(investment)
amount_available(investment.heading) > 0
end
def reason_for_not_being_ballotable(investment)
:not_enough_available_votes unless enough_resources?(investment)
end
def not_enough_resources_error
"insufficient votes"
end
def amount_spent(heading)
investments(heading).count
end
def amount_limit(heading)
heading.max_ballot_lines
end
def format(amount)
amount
end
end

View File

@@ -21,23 +21,39 @@ class Budget::VotingStyles::Base
def amount_available_info(heading) def amount_available_info(heading)
I18n.t("budgets.ballots.show.amount_available.#{name}", I18n.t("budgets.ballots.show.amount_available.#{name}",
amount: formatted_amount_available(heading)) count: formatted_amount_available(heading))
end end
def amount_spent_info(heading) def amount_spent_info(heading)
I18n.t("budgets.ballots.show.amount_spent.#{name}", I18n.t("budgets.ballots.show.amount_spent.#{name}",
amount: formatted_amount_spent(heading)) count: formatted_amount_spent(heading))
end end
def amount_limit_info(heading) def amount_limit_info(heading)
I18n.t("budgets.ballots.show.amount_limit.#{name}", I18n.t("budgets.ballots.show.amount_limit.#{name}",
amount: formatted_amount_limit(heading)) count: formatted_amount_limit(heading))
end
def amount_available(heading)
amount_limit(heading) - amount_spent(heading)
end end
def percentage_spent(heading) def percentage_spent(heading)
100.0 * amount_spent(heading) / amount_limit(heading) 100.0 * amount_spent(heading) / amount_limit(heading)
end end
def formatted_amount_available(heading)
format(amount_available(heading))
end
def formatted_amount_spent(heading)
format(amount_spent(heading))
end
def formatted_amount_limit(heading)
format(amount_limit(heading))
end
private private
def investments(heading) def investments(heading)

View File

@@ -11,10 +11,6 @@ class Budget::VotingStyles::Knapsack < Budget::VotingStyles::Base
"insufficient funds" "insufficient funds"
end end
def amount_available(heading)
amount_limit(heading) - amount_spent(heading)
end
def amount_spent(heading) def amount_spent(heading)
investments_price(heading) investments_price(heading)
end end
@@ -23,18 +19,6 @@ class Budget::VotingStyles::Knapsack < Budget::VotingStyles::Base
ballot.budget.heading_price(heading) ballot.budget.heading_price(heading)
end end
def formatted_amount_available(heading)
format(amount_available(heading))
end
def formatted_amount_spent(heading)
format(amount_spent(heading))
end
def formatted_amount_limit(heading)
format(amount_limit(heading))
end
def format(amount) def format(amount)
ballot.budget.formatted_amount(amount) ballot.budget.formatted_amount(amount)
end end

View File

@@ -16,6 +16,12 @@
<div class="small-12 medium-6 column"> <div class="small-12 medium-6 column">
<%= f.text_field :price, maxlength: 8 %> <%= f.text_field :price, maxlength: 8 %>
<% if @heading.budget.approval_voting? %>
<%= f.number_field :max_ballot_lines,
hint: t("admin.budget_headings.form.max_ballot_lines_info") %>
<% end %>
<%= f.text_field :population, <%= f.text_field :population,
maxlength: 8, maxlength: 8,
data: { toggle_focus: "population-info" }, data: { toggle_focus: "population-info" },

View File

@@ -13,6 +13,9 @@
<tr id="<%= dom_id(@group) %>"> <tr id="<%= dom_id(@group) %>">
<th><%= Budget::Heading.human_attribute_name(:name) %></th> <th><%= Budget::Heading.human_attribute_name(:name) %></th>
<th><%= Budget::Heading.human_attribute_name(:price) %></th> <th><%= Budget::Heading.human_attribute_name(:price) %></th>
<% if @budget.approval_voting? %>
<th><%= Budget::Heading.human_attribute_name(:max_ballot_lines) %></th>
<% end %>
<th><%= Budget::Heading.human_attribute_name(:population) %></th> <th><%= Budget::Heading.human_attribute_name(:population) %></th>
<th><%= Budget::Heading.human_attribute_name(:allow_custom_content) %></th> <th><%= Budget::Heading.human_attribute_name(:allow_custom_content) %></th>
<th><%= t("admin.actions.actions") %></th> <th><%= t("admin.actions.actions") %></th>
@@ -23,6 +26,9 @@
<tr id="<%= dom_id(heading) %>" class="heading"> <tr id="<%= dom_id(heading) %>" class="heading">
<td><%= link_to heading.name, edit_admin_budget_group_heading_path(@budget, @group, heading) %></td> <td><%= link_to heading.name, edit_admin_budget_group_heading_path(@budget, @group, heading) %></td>
<td><%= @budget.formatted_heading_price(heading) %></td> <td><%= @budget.formatted_heading_price(heading) %></td>
<% if @budget.approval_voting? %>
<td><%= heading.max_ballot_lines %></td>
<% end %>
<td><%= heading.population %></td> <td><%= heading.population %></td>
<td> <td>
<%= heading.allow_custom_content ? t("admin.shared.true_value") : t("admin.shared.false_value") %> <%= heading.allow_custom_content ? t("admin.shared.true_value") : t("admin.shared.false_value") %>

View File

@@ -16,7 +16,12 @@
<div class="small-12 medium-6 column"> <div class="small-12 medium-6 column">
<%= f.select :phase, budget_phases_select_options %> <%= f.select :phase, budget_phases_select_options %>
</div> </div>
<div class="small-12 medium-3 column end">
<div class="small-12 medium-4 column">
<%= f.select :voting_style, budget_voting_styles_select_options %>
</div>
<div class="small-12 medium-2 column end">
<%= f.select :currency_symbol, budget_currency_symbol_select_options %> <%= f.select :currency_symbol, budget_currency_symbol_select_options %>
</div> </div>
</div> </div>

View File

@@ -10,9 +10,5 @@
</div> </div>
<p id="amount-spent" class="spent-amount-text" style="width: <%= ballot.percentage_spent(heading) %>%"> <p id="amount-spent" class="spent-amount-text" style="width: <%= ballot.percentage_spent(heading) %>%">
<small><%= t("budgets.progress_bar.assigned") %></small><%= ballot.formatted_amount_spent(heading) %> <%= render "budgets/ballot/progress_bar/#{ballot.budget.voting_style}", ballot: ballot, heading: heading %>
<span id="amount-available" class="amount-available">
<small><%= t("budgets.progress_bar.available") %></small>
<span><%= ballot.formatted_amount_available(heading) %></span>
</span>
</p> </p>

View File

@@ -0,0 +1,3 @@
<%= sanitize(t("budgets.progress_bar.votes",
count: ballot.amount_spent(heading),
limit: ballot.amount_limit(heading))) %>

View File

@@ -0,0 +1,5 @@
<small><%= t("budgets.progress_bar.assigned") %></small><%= ballot.formatted_amount_spent(heading) %>
<span id="amount-available" class="amount-available">
<small><%= t("budgets.progress_bar.available") %></small>
<span><%= ballot.formatted_amount_available(heading) %></span>
</span>

View File

@@ -127,6 +127,9 @@ ignore_unused:
- "budgets.phase.*" - "budgets.phase.*"
- "budgets.investments.index.orders.*" - "budgets.investments.index.orders.*"
- "budgets.index.section_header.*" - "budgets.index.section_header.*"
- "budgets.ballots.show.amount_available.*"
- "budgets.ballots.show.amount_limit.*"
- "budgets.ballots.show.amount_spent.*"
- "budgets.investments.index.sidebar.voted_info.*" - "budgets.investments.index.sidebar.voted_info.*"
- "activerecord.*" - "activerecord.*"
- "activemodel.*" - "activemodel.*"

View File

@@ -141,6 +141,9 @@ en:
description_finished: "Description when the budget is finished" description_finished: "Description when the budget is finished"
phase: "Phase" phase: "Phase"
currency_symbol: "Currency" currency_symbol: "Currency"
voting_style: "Final voting style"
voting_style_knapsack: "Knapsack"
voting_style_approval: "Approval"
budget/translation: budget/translation:
name: "Name" name: "Name"
budget/investment: budget/investment:
@@ -203,6 +206,7 @@ en:
name: "Heading name" name: "Heading name"
price: "Amount" price: "Amount"
population: "Population (optional)" population: "Population (optional)"
max_ballot_lines: "Votes allowed"
budget/heading/translation: budget/heading/translation:
name: "Heading name" name: "Heading name"
budget/phase: budget/phase:

View File

@@ -149,6 +149,7 @@ en:
success_notice: "Heading deleted successfully" success_notice: "Heading deleted successfully"
unable_notice: "You cannot delete a Heading that has associated investments" unable_notice: "You cannot delete a Heading that has associated investments"
form: form:
max_ballot_lines_info: 'Maximum number of projects a user can vote on this heading during the "Voting projects" phase. Only for budgets using approval voting.'
population_info: "Budget Heading population field is used for Statistic purposes at the end of the Budget to show for each Heading that represents an area with population what percentage voted. The field is optional so you can leave it empty if it doesn't apply." population_info: "Budget Heading population field is used for Statistic purposes at the end of the Budget to show for each Heading that represents an area with population what percentage voted. The field is optional so you can leave it empty if it doesn't apply."
coordinates_info: "If latitude and longitude are provided, the investments page for this heading will include a map. This map will be centered using those coordinates." coordinates_info: "If latitude and longitude are provided, the investments page for this heading will include a map. This map will be centered using those coordinates."
content_blocks_info: "If allow content block is checked, you will be able to create custom content related to this heading from the section Settings > Custom content blocks. This content will appear on the investments page for this heading." content_blocks_info: "If allow content block is checked, you will be able to create custom content related to this heading from the section Settings > Custom content blocks. This content will appear on the investments page for this heading."

View File

@@ -4,11 +4,22 @@ en:
show: show:
title: Your ballot title: Your ballot
amount_available: amount_available:
knapsack: "You still have <span>%{amount}</span> to invest." knapsack: "You still have <span>%{count}</span> to invest."
approval:
zero: "You can still cast <span>%{count}</span> votes."
one: "You can still cast <span>%{count}</span> vote."
other: "You can still cast <span>%{count}</span> votes."
amount_spent: amount_spent:
knapsack: "Amount spent <span>%{amount}</span>" knapsack: "Amount spent <span>%{count}</span>"
approval:
zero: "Votes cast: <span>%{count}</span>"
one: "Votes cast: <span>%{count}</span>"
other: "Votes cast: <span>%{count}</span>"
amount_limit: amount_limit:
knapsack: "%{amount}" knapsack: "%{count}"
approval:
one: "You can vote <span>1</span> project"
other: "You can vote up to <span>%{count}</span> projects"
no_balloted_group_yet: "You have not voted on this group yet, go vote!" no_balloted_group_yet: "You have not voted on this group yet, go vote!"
remove: Remove vote remove: Remove vote
voted: voted:
@@ -25,6 +36,7 @@ en:
not_enough_money: "You have already assigned the available budget.<br><small>Remember you can %{change_ballot} at any time</small>" not_enough_money: "You have already assigned the available budget.<br><small>Remember you can %{change_ballot} at any time</small>"
no_ballots_allowed: Selecting phase is closed no_ballots_allowed: Selecting phase is closed
different_heading_assigned: "You have already voted a different heading: %{heading_link}" different_heading_assigned: "You have already voted a different heading: %{heading_link}"
not_enough_available_votes: "You have reached the maximum number of votes allowed"
change_ballot: change your votes change_ballot: change your votes
casted_offline: You have already participated offline casted_offline: You have already participated offline
groups: groups:
@@ -92,8 +104,12 @@ en:
knapsack: knapsack:
one: "<strong>You voted one proposal with a cost of %{amount_spent}</strong>" one: "<strong>You voted one proposal with a cost of %{amount_spent}</strong>"
other: "<strong>You voted %{count} proposals with a cost of %{amount_spent}</strong>" other: "<strong>You voted %{count} proposals with a cost of %{amount_spent}</strong>"
approval:
one: "<strong>You voted one proposal</strong>"
other: "<strong>You voted %{count} proposals</strong>"
change_vote_info: change_vote_info:
knapsack: "You can %{link} at any time until the close of this phase. No need to spend all the money available." knapsack: "You can %{link} at any time until the close of this phase. No need to spend all the money available."
approval: "You can %{link} at any time until the close of this phase."
change_vote_link: "change your vote" change_vote_link: "change your vote"
different_heading_assigned: "You have active votes in another heading: %{heading_link}" different_heading_assigned: "You have active votes in another heading: %{heading_link}"
change_ballot: "If your change your mind you can remove your votes in %{check_ballot} and start again." change_ballot: "If your change your mind you can remove your votes in %{check_ballot} and start again."
@@ -154,6 +170,10 @@ en:
progress_bar: progress_bar:
assigned: "You have assigned: " assigned: "You have assigned: "
available: "Available budget: " available: "Available budget: "
votes:
zero: "You have selected <strong>0</strong> projects out of <strong>%{limit}</strong>"
one: "You have selected <strong>1</strong> project out of <strong>%{limit}</strong>"
other: "You have selected <strong>%{count}</strong> projects out of <strong>%{limit}</strong>"
show: show:
group: Group group: Group
phase: Actual phase phase: Actual phase

View File

@@ -143,6 +143,9 @@ es:
description_finished: "Descripción cuando el presupuesto ha finalizado / Resultados" description_finished: "Descripción cuando el presupuesto ha finalizado / Resultados"
phase: "Fase" phase: "Fase"
currency_symbol: "Divisa" currency_symbol: "Divisa"
voting_style: "Estilo de la votación final"
voting_style_knapsack: Bolsa de dinero
voting_style_approval: Por aprobación
budget/translation: budget/translation:
name: "Nombre" name: "Nombre"
budget/investment: budget/investment:
@@ -205,6 +208,7 @@ es:
name: "Nombre de la partida" name: "Nombre de la partida"
price: "Cantidad" price: "Cantidad"
population: "Población (opcional)" population: "Población (opcional)"
max_ballot_lines: "Votos permitidos"
budget/heading/translation: budget/heading/translation:
name: "Nombre de la partida" name: "Nombre de la partida"
budget/phase: budget/phase:

View File

@@ -149,6 +149,7 @@ es:
success_notice: "Partida presupuestaria eliminada correctamente" success_notice: "Partida presupuestaria eliminada correctamente"
unable_notice: "No se puede eliminar una partida presupuestaria con proyectos asociados" unable_notice: "No se puede eliminar una partida presupuestaria con proyectos asociados"
form: form:
max_ballot_lines_info: 'Máximo número de proyectos que un usuario puede votar en esta partida durante la fase "Votación final". Solamente se aplica a presupuestos con votación por aprobación.'
population_info: "El campo población de las partidas presupuestarias se usa con fines estadísticos únicamente, con el objetivo de mostrar el porcentaje de votos habidos en cada partida que represente un área con población. Es un campo opcional, así que puedes dejarlo en blanco si no aplica." population_info: "El campo población de las partidas presupuestarias se usa con fines estadísticos únicamente, con el objetivo de mostrar el porcentaje de votos habidos en cada partida que represente un área con población. Es un campo opcional, así que puedes dejarlo en blanco si no aplica."
coordinates_info: "Si se añaden los campos latitud y longitud, en la página de proyectos de esta partida aparecerá un mapa, que estará centrado en esas coordenadas." coordinates_info: "Si se añaden los campos latitud y longitud, en la página de proyectos de esta partida aparecerá un mapa, que estará centrado en esas coordenadas."
content_blocks_info: "Si se permite el bloque de contenidos, se tendrá la oportunidad de crear bloques de contenido relativos a esta partida desde la sección Configuración > Personalizar bloques. Este contenido aparecerá en la página de proyectos de esta partida." content_blocks_info: "Si se permite el bloque de contenidos, se tendrá la oportunidad de crear bloques de contenido relativos a esta partida desde la sección Configuración > Personalizar bloques. Este contenido aparecerá en la página de proyectos de esta partida."

View File

@@ -4,11 +4,22 @@ es:
show: show:
title: Mis votos title: Mis votos
amount_available: amount_available:
knapsack: "Te quedan <span>%{amount}</span> para invertir" knapsack: "Te quedan <span>%{count}</span> para invertir"
approval:
zero: "Te quedan <span>0</span> votos disponibles"
one: "Te queda <span>1</span> voto disponible"
other: "Te quedan <span>%{count}</span> votos disponibles"
amount_spent: amount_spent:
knapsack: "Coste total <span>%{amount}</span>" knapsack: "Coste total <span>%{count}</span>"
approval:
zero: "Votos: <span>%{count}</span>"
one: "Votos: <span>%{count}</span>"
other: "Votos: <span>%{count}</span>"
amount_limit: amount_limit:
knapsack: "%{amount}" knapsack: "%{count}"
approval:
one: "Puedes votar <span>1</span> proyecto"
other: "Puedes votar hasta <span>%{count}</span> proyectos"
no_balloted_group_yet: "Todavía no has votado proyectos de este grupo, ¡vota!" no_balloted_group_yet: "Todavía no has votado proyectos de este grupo, ¡vota!"
remove: Quitar voto remove: Quitar voto
voted: voted:
@@ -25,6 +36,7 @@ es:
not_enough_money: "Ya has asignado el presupuesto disponible.<br><small>Recuerda que puedes %{change_ballot} en cualquier momento</small>" not_enough_money: "Ya has asignado el presupuesto disponible.<br><small>Recuerda que puedes %{change_ballot} en cualquier momento</small>"
no_ballots_allowed: El periodo de votación está cerrado. no_ballots_allowed: El periodo de votación está cerrado.
different_heading_assigned: "Ya has votado proyectos de otra partida: %{heading_link}" different_heading_assigned: "Ya has votado proyectos de otra partida: %{heading_link}"
not_enough_available_votes: "No tienes más votos disponibles"
change_ballot: cambiar tus votos change_ballot: cambiar tus votos
casted_offline: Ya has participado presencialmente casted_offline: Ya has participado presencialmente
groups: groups:
@@ -92,8 +104,12 @@ es:
knapsack: knapsack:
one: "<strong>Has votado un proyecto por un valor de %{amount_spent}</strong>" one: "<strong>Has votado un proyecto por un valor de %{amount_spent}</strong>"
other: "<strong>Has votado %{count} proyectos por un valor de %{amount_spent}</strong>" other: "<strong>Has votado %{count} proyectos por un valor de %{amount_spent}</strong>"
approval:
one: "<strong>Has votado un proyecto</strong>"
other: "<strong>Has votado %{count} proyectos</strong>"
change_vote_info: change_vote_info:
knapsack: "Puedes %{link} en cualquier momento hasta el cierre de esta fase. No hace falta que gastes todo el dinero disponible." knapsack: "Puedes %{link} en cualquier momento hasta el cierre de esta fase. No hace falta que gastes todo el dinero disponible."
approval: "Puedes %{link} en cualquier momento hasta el cierre de esta fase."
change_vote_link: "cambiar tus votos" change_vote_link: "cambiar tus votos"
different_heading_assigned: "Ya apoyaste proyectos de otra sección del presupuesto: %{heading_link}" different_heading_assigned: "Ya apoyaste proyectos de otra sección del presupuesto: %{heading_link}"
change_ballot: "Si cambias de opinión puedes borrar tus votos en %{check_ballot} y volver a empezar." change_ballot: "Si cambias de opinión puedes borrar tus votos en %{check_ballot} y volver a empezar."
@@ -154,6 +170,10 @@ es:
progress_bar: progress_bar:
assigned: "Has asignado: " assigned: "Has asignado: "
available: "Presupuesto disponible: " available: "Presupuesto disponible: "
votes:
zero: "Has seleccionado <strong>0</strong> proyectos de <strong>%{limit}</strong>"
one: "Has seleccionado <strong>1</strong> proyecto de <strong>%{limit}</strong>"
other: "Has seleccionado <strong>%{count}</strong> proyectos de <strong>%{limit}</strong>"
show: show:
group: Grupo group: Grupo
phase: Fase actual phase: Fase actual

View File

@@ -0,0 +1,6 @@
class AddApprovalVotingFields < ActiveRecord::Migration[4.2]
def change
add_column :budgets, :voting_style, :string, default: "knapsack"
add_column :budget_headings, :max_ballot_lines, :integer, default: 1
end
end

View File

@@ -214,6 +214,7 @@ ActiveRecord::Schema.define(version: 20200519120717) do
t.boolean "allow_custom_content", default: false t.boolean "allow_custom_content", default: false
t.text "latitude" t.text "latitude"
t.text "longitude" t.text "longitude"
t.integer "max_ballot_lines", default: 1
t.datetime "created_at" t.datetime "created_at"
t.datetime "updated_at" t.datetime "updated_at"
t.index ["group_id"], name: "index_budget_headings_on_group_id" t.index ["group_id"], name: "index_budget_headings_on_group_id"
@@ -360,6 +361,7 @@ ActiveRecord::Schema.define(version: 20200519120717) do
t.text "description_drafting" t.text "description_drafting"
t.text "description_publishing_prices" t.text "description_publishing_prices"
t.text "description_informing" t.text "description_informing"
t.string "voting_style", default: "knapsack"
end end
create_table "campaigns", id: :serial, force: :cascade do |t| create_table "campaigns", id: :serial, force: :cascade do |t|

View File

@@ -55,6 +55,14 @@ FactoryBot.define do
results_enabled { true } results_enabled { true }
stats_enabled { true } stats_enabled { true }
end end
trait :knapsack do
voting_style { "knapsack" }
end
trait :approval do
voting_style { "approval" }
end
end end
factory :budget_group, class: "Budget::Group" do factory :budget_group, class: "Budget::Group" do

View File

@@ -0,0 +1,14 @@
require "rails_helper"
describe BudgetsHelper do
describe "#budget_voting_styles_select_options" do
it "provides vote kinds" do
types = [
["Knapsack", "knapsack"],
["Approval", "approval"]
]
expect(budget_voting_styles_select_options).to eq(types)
end
end
end

View File

@@ -40,6 +40,41 @@ describe Budget::Ballot::Line do
end end
end end
describe "Approval voting" do
before do
budget.update!(voting_style: "approval")
heading.update!(max_ballot_lines: 1)
end
it "is valid if there are votes left" do
expect(ballot_line).to be_valid
end
it "is not valid if there are no votes left" do
create(:budget_ballot_line, ballot: ballot,
investment: create(:budget_investment, :selected, heading: heading))
expect(ballot_line).not_to be_valid
end
it "is valid if insufficient funds but enough votes" do
investment.update!(price: heading.price + 1)
expect(ballot_line).to be_valid
end
it "validates votes when creating lines at the same time", :race_condition do
other_investment = create(:budget_investment, :selected, heading: heading)
other_line = build(:budget_ballot_line, ballot: ballot, investment: other_investment)
[ballot_line, other_line].map do |line|
Thread.new { line.save }
end.each(&:join)
expect(Budget::Ballot::Line.count).to be 1
end
end
describe "Selectibility" do describe "Selectibility" do
it "is not valid if investment is unselected" do it "is not valid if investment is unselected" do
investment.update!(selected: false) investment.update!(selected: false)

View File

@@ -65,6 +65,20 @@ describe Budget::Ballot do
expect(ballot.amount_spent(heading1)).to eq 50000 expect(ballot.amount_spent(heading1)).to eq 50000
expect(ballot.amount_spent(heading2)).to eq 20000 expect(ballot.amount_spent(heading2)).to eq 20000
end end
it "returns the votes cast on a specific heading for approval voting" do
budget = create(:budget, :approval)
heading1 = create(:budget_heading, budget: budget, max_ballot_lines: 2)
heading2 = create(:budget_heading, budget: budget, max_ballot_lines: 3)
ballot = create(:budget_ballot, budget: budget)
ballot.investments << create(:budget_investment, :selected, heading: heading1)
ballot.investments << create(:budget_investment, :selected, heading: heading1)
ballot.investments << create(:budget_investment, :selected, heading: heading2)
expect(ballot.amount_spent(heading1)).to eq 2
expect(ballot.amount_spent(heading2)).to eq 1
end
end end
describe "#amount_available" do describe "#amount_available" do
@@ -91,6 +105,20 @@ describe Budget::Ballot do
expect(ballot.amount_available(heading1)).to eq 500 expect(ballot.amount_available(heading1)).to eq 500
end end
it "returns the amount of votes left for approval voting" do
budget = create(:budget, :approval)
heading1 = create(:budget_heading, budget: budget, max_ballot_lines: 2)
heading2 = create(:budget_heading, budget: budget, max_ballot_lines: 3)
ballot = create(:budget_ballot, budget: budget)
ballot.investments << create(:budget_investment, :selected, heading: heading1)
ballot.investments << create(:budget_investment, :selected, heading: heading1)
ballot.investments << create(:budget_investment, :selected, heading: heading2)
expect(ballot.amount_available(heading1)).to eq 0
expect(ballot.amount_available(heading2)).to eq 2
end
end end
describe "#heading_for_group" do describe "#heading_for_group" do

View File

@@ -323,4 +323,13 @@ describe Budget::Heading do
expect(Budget::Heading.allow_custom_content).to eq [translated_heading] expect(Budget::Heading.allow_custom_content).to eq [translated_heading]
end end
end end
describe "#max_ballot_lines" do
it "must be at least 1" do
expect(build(:budget_heading, max_ballot_lines: 1)).to be_valid
expect(build(:budget_heading, max_ballot_lines: 10)).to be_valid
expect(build(:budget_heading, max_ballot_lines: -1)).not_to be_valid
expect(build(:budget_heading, max_ballot_lines: 0)).not_to be_valid
end
end
end end

View File

@@ -1086,6 +1086,31 @@ describe Budget::Investment do
expect(inv2.reason_for_not_being_ballotable_by(user, ballot)).to eq(:not_enough_money) expect(inv2.reason_for_not_being_ballotable_by(user, ballot)).to eq(:not_enough_money)
end end
context "Approval voting" do
before { budget.update!(phase: "balloting", voting_style: "approval") }
let(:group) { create(:budget_group, budget: budget) }
it "does not reject investments based on available money" do
heading = create(:budget_heading, group: group, max_ballot_lines: 2)
inv1 = create(:budget_investment, :selected, heading: heading, price: heading.price)
inv2 = create(:budget_investment, :selected, heading: heading, price: heading.price)
ballot = create(:budget_ballot, user: user, budget: budget, investments: [inv1])
expect(inv2.reason_for_not_being_ballotable_by(user, ballot)).to be nil
end
it "rejects if not enough available votes" do
heading = create(:budget_heading, group: group, max_ballot_lines: 1)
inv1 = create(:budget_investment, :selected, heading: heading)
inv2 = create(:budget_investment, :selected, heading: heading)
ballot = create(:budget_ballot, user: user, budget: budget, investments: [inv1])
reason = inv2.reason_for_not_being_ballotable_by(user, ballot)
expect(reason).to eq(:not_enough_available_votes)
end
end
end end
end end
end end

View File

@@ -355,4 +355,25 @@ describe Budget do
expect(budget.investments_milestone_tags).to eq(["tag1"]) expect(budget.investments_milestone_tags).to eq(["tag1"])
end end
end end
describe "#voting_style" do
context "Validations" do
it { expect(build(:budget, :approval)).to be_valid }
it { expect(build(:budget, :knapsack)).to be_valid }
it { expect(build(:budget, voting_style: "Oups!")).not_to be_valid }
end
context "Related supportive methods" do
describe "#approval_voting?" do
it { expect(build(:budget, :approval).approval_voting?).to be true }
it { expect(build(:budget, :knapsack).approval_voting?).to be false }
end
end
context "Defaults" do
it "defaults to knapsack voting style" do
expect(build(:budget).voting_style).to eq "knapsack"
end
end
end
end end

View File

@@ -171,6 +171,30 @@ describe "Admin budget headings" do
expect(page).to have_css(".is-invalid-label", text: "Amount") expect(page).to have_css(".is-invalid-label", text: "Amount")
expect(page).to have_content "can't be blank" expect(page).to have_content "can't be blank"
end end
describe "Max votes is optional", :js do
scenario "do no show max_ballot_lines field for knapsack budgets" do
visit new_admin_budget_group_heading_path(budget, group)
expect(page).not_to have_field "Votes allowed"
end
scenario "create heading with max_ballot_lines for appoval budgets" do
budget.update!(voting_style: "approval")
visit new_admin_budget_group_heading_path(budget, group)
expect(page).to have_field "Votes allowed", with: 1
fill_in "Heading name", with: "All City"
fill_in "Amount", with: "1000"
fill_in "Votes allowed", with: 14
click_button "Create new heading"
expect(page).to have_content "Heading created successfully!"
within("tr", text: "All City") { expect(page).to have_content 14 }
end
end
end end
context "Edit" do context "Edit" do

View File

@@ -102,7 +102,7 @@ describe "Admin budgets" do
end end
context "New" do context "New" do
scenario "Create budget" do scenario "Create budget - Knapsack voting (default)" do
visit admin_budgets_path visit admin_budgets_path
click_link "Create new budget" click_link "Create new budget"
@@ -113,6 +113,21 @@ describe "Admin budgets" do
expect(page).to have_content "New participatory budget created successfully!" expect(page).to have_content "New participatory budget created successfully!"
expect(page).to have_content "M30 - Summer campaign" expect(page).to have_content "M30 - Summer campaign"
expect(Budget.last.voting_style).to eq "knapsack"
end
scenario "Create budget - Approval voting", :js do
visit admin_budgets_path
click_link "Create new budget"
fill_in "Name", with: "M30 - Summer campaign"
select "Accepting projects", from: "budget[phase]"
select "Approval", from: "Final voting style"
click_button "Create Budget"
expect(page).to have_content "New participatory budget created successfully!"
expect(page).to have_content "M30 - Summer campaign"
expect(Budget.last.voting_style).to eq "approval"
end end
scenario "Name is mandatory" do scenario "Name is mandatory" do

View File

@@ -1,9 +1,10 @@
require "rails_helper" require "rails_helper"
describe "Votes" do describe "Votes" do
describe "Investments" do let(:manuela) { create(:user, verified_at: Time.current) }
let(:manuela) { create(:user, verified_at: Time.current) }
let(:budget) { create(:budget, :selecting) } context "Investments - Knapsack" do
let(:budget) { create(:budget, phase: "selecting") }
let(:group) { create(:budget_group, budget: budget) } let(:group) { create(:budget_group, budget: budget) }
let(:heading) { create(:budget_heading, group: group) } let(:heading) { create(:budget_heading, group: group) }
@@ -194,4 +195,33 @@ describe "Votes" do
end end
end end
end end
context "Investments - Approval" do
let(:budget) { create(:budget, :balloting, :approval) }
before { login_as(manuela) }
scenario "Budget limit is ignored", :js do
group = create(:budget_group, budget: budget)
heading = create(:budget_heading, group: group, max_ballot_lines: 2)
investment1 = create(:budget_investment, :selected, heading: heading, price: heading.price)
investment2 = create(:budget_investment, :selected, heading: heading, price: heading.price)
visit budget_investments_path(budget, heading_id: heading.id)
add_to_ballot(investment1.title)
expect(page).to have_content("Remove vote")
expect(page).to have_content("You have selected 1 project out of 2")
within(".budget-investment", text: investment2.title) do
find("div.ballot").hover
expect(page).not_to have_content("You have already assigned the available budget")
end
visit budget_ballot_path(budget)
expect(page).to have_content("you can change your vote at any time until this phase is closed")
end
end
end end