Add approval voting to budgets
Co-Authored-By: Javi Martín <javim@elretirao.net>
This commit is contained in:
committed by
Javi Martín
parent
009c33d4e5
commit
1e3e8c1304
@@ -63,7 +63,7 @@ class Admin::BudgetHeadingsController < Admin::BaseController
|
||||
end
|
||||
|
||||
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))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -70,6 +70,7 @@ class Admin::BudgetsController < Admin::BaseController
|
||||
descriptions = Budget::Phase::PHASE_KINDS.map { |p| "description_#{p}" }.map(&:to_sym)
|
||||
valid_attributes = [:phase,
|
||||
:currency_symbol,
|
||||
:voting_style,
|
||||
administrator_ids: [],
|
||||
valuator_ids: []
|
||||
] + descriptions
|
||||
|
||||
@@ -3,6 +3,12 @@ module BudgetsHelper
|
||||
["balloting", "reviewing_ballots", "finished"].include? budget.phase
|
||||
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)
|
||||
tag.div do
|
||||
concat(heading.name + " ")
|
||||
|
||||
@@ -20,11 +20,13 @@ class Budget < ApplicationRecord
|
||||
end
|
||||
|
||||
CURRENCY_SYMBOLS = %w[€ $ £ ¥].freeze
|
||||
VOTING_STYLES = %w[knapsack approval].freeze
|
||||
|
||||
validates_translation :name, presence: true
|
||||
validates :phase, inclusion: { in: Budget::Phase::PHASE_KINDS }
|
||||
validates :currency_symbol, presence: true
|
||||
validates :slug, presence: true, format: /\A[a-z0-9\-_]+\z/
|
||||
validates :voting_style, inclusion: { in: VOTING_STYLES }
|
||||
|
||||
has_many :investments, dependent: :destroy
|
||||
has_many :ballots, dependent: :destroy
|
||||
@@ -196,6 +198,10 @@ class Budget < ApplicationRecord
|
||||
investments.winners.map(&:milestone_tag_list).flatten.uniq.sort
|
||||
end
|
||||
|
||||
def approval_voting?
|
||||
voting_style == "approval"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_phases
|
||||
|
||||
@@ -63,7 +63,7 @@ class Budget
|
||||
def voting_style
|
||||
@voting_style ||= voting_style_class.new(self)
|
||||
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,
|
||||
:formatted_amount_limit, :formatted_amount_spent, :not_enough_resources_error,
|
||||
:percentage_spent, :reason_for_not_being_ballotable, :voted_info,
|
||||
@@ -72,7 +72,7 @@ class Budget
|
||||
private
|
||||
|
||||
def voting_style_class
|
||||
Budget::VotingStyles::Knapsack
|
||||
"Budget::VotingStyles::#{budget.voting_style.camelize}".constantize
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,6 +35,7 @@ class Budget
|
||||
format: /\A(-|\+)?([1-8]?\d(?:\.\d{1,})?|90(?:\.0{1,6})?)\z/
|
||||
validates :longitude, length: { maximum: 22 }, allow_blank: true, \
|
||||
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
|
||||
|
||||
|
||||
25
app/models/budget/voting_styles/approval.rb
Normal file
25
app/models/budget/voting_styles/approval.rb
Normal 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
|
||||
@@ -21,23 +21,39 @@ class Budget::VotingStyles::Base
|
||||
|
||||
def amount_available_info(heading)
|
||||
I18n.t("budgets.ballots.show.amount_available.#{name}",
|
||||
amount: formatted_amount_available(heading))
|
||||
count: formatted_amount_available(heading))
|
||||
end
|
||||
|
||||
def amount_spent_info(heading)
|
||||
I18n.t("budgets.ballots.show.amount_spent.#{name}",
|
||||
amount: formatted_amount_spent(heading))
|
||||
count: formatted_amount_spent(heading))
|
||||
end
|
||||
|
||||
def amount_limit_info(heading)
|
||||
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
|
||||
|
||||
def percentage_spent(heading)
|
||||
100.0 * amount_spent(heading) / amount_limit(heading)
|
||||
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
|
||||
|
||||
def investments(heading)
|
||||
|
||||
@@ -11,10 +11,6 @@ class Budget::VotingStyles::Knapsack < Budget::VotingStyles::Base
|
||||
"insufficient funds"
|
||||
end
|
||||
|
||||
def amount_available(heading)
|
||||
amount_limit(heading) - amount_spent(heading)
|
||||
end
|
||||
|
||||
def amount_spent(heading)
|
||||
investments_price(heading)
|
||||
end
|
||||
@@ -23,18 +19,6 @@ class Budget::VotingStyles::Knapsack < Budget::VotingStyles::Base
|
||||
ballot.budget.heading_price(heading)
|
||||
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)
|
||||
ballot.budget.formatted_amount(amount)
|
||||
end
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
<div class="small-12 medium-6 column">
|
||||
<%= 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,
|
||||
maxlength: 8,
|
||||
data: { toggle_focus: "population-info" },
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
<tr id="<%= dom_id(@group) %>">
|
||||
<th><%= Budget::Heading.human_attribute_name(:name) %></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(:allow_custom_content) %></th>
|
||||
<th><%= t("admin.actions.actions") %></th>
|
||||
@@ -23,6 +26,9 @@
|
||||
<tr id="<%= dom_id(heading) %>" class="heading">
|
||||
<td><%= link_to heading.name, edit_admin_budget_group_heading_path(@budget, @group, 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.allow_custom_content ? t("admin.shared.true_value") : t("admin.shared.false_value") %>
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
<div class="small-12 medium-6 column">
|
||||
<%= f.select :phase, budget_phases_select_options %>
|
||||
</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 %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,5 @@
|
||||
</div>
|
||||
|
||||
<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) %>
|
||||
<span id="amount-available" class="amount-available">
|
||||
<small><%= t("budgets.progress_bar.available") %></small>
|
||||
<span><%= ballot.formatted_amount_available(heading) %></span>
|
||||
</span>
|
||||
<%= render "budgets/ballot/progress_bar/#{ballot.budget.voting_style}", ballot: ballot, heading: heading %>
|
||||
</p>
|
||||
|
||||
3
app/views/budgets/ballot/progress_bar/_approval.html.erb
Normal file
3
app/views/budgets/ballot/progress_bar/_approval.html.erb
Normal file
@@ -0,0 +1,3 @@
|
||||
<%= sanitize(t("budgets.progress_bar.votes",
|
||||
count: ballot.amount_spent(heading),
|
||||
limit: ballot.amount_limit(heading))) %>
|
||||
5
app/views/budgets/ballot/progress_bar/_knapsack.html.erb
Normal file
5
app/views/budgets/ballot/progress_bar/_knapsack.html.erb
Normal 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>
|
||||
@@ -127,6 +127,9 @@ ignore_unused:
|
||||
- "budgets.phase.*"
|
||||
- "budgets.investments.index.orders.*"
|
||||
- "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.*"
|
||||
- "activerecord.*"
|
||||
- "activemodel.*"
|
||||
|
||||
@@ -141,6 +141,9 @@ en:
|
||||
description_finished: "Description when the budget is finished"
|
||||
phase: "Phase"
|
||||
currency_symbol: "Currency"
|
||||
voting_style: "Final voting style"
|
||||
voting_style_knapsack: "Knapsack"
|
||||
voting_style_approval: "Approval"
|
||||
budget/translation:
|
||||
name: "Name"
|
||||
budget/investment:
|
||||
@@ -203,6 +206,7 @@ en:
|
||||
name: "Heading name"
|
||||
price: "Amount"
|
||||
population: "Population (optional)"
|
||||
max_ballot_lines: "Votes allowed"
|
||||
budget/heading/translation:
|
||||
name: "Heading name"
|
||||
budget/phase:
|
||||
|
||||
@@ -149,6 +149,7 @@ en:
|
||||
success_notice: "Heading deleted successfully"
|
||||
unable_notice: "You cannot delete a Heading that has associated investments"
|
||||
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."
|
||||
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."
|
||||
|
||||
@@ -4,11 +4,22 @@ en:
|
||||
show:
|
||||
title: Your ballot
|
||||
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:
|
||||
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:
|
||||
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!"
|
||||
remove: Remove vote
|
||||
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>"
|
||||
no_ballots_allowed: Selecting phase is closed
|
||||
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
|
||||
casted_offline: You have already participated offline
|
||||
groups:
|
||||
@@ -92,8 +104,12 @@ en:
|
||||
knapsack:
|
||||
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>"
|
||||
approval:
|
||||
one: "<strong>You voted one proposal</strong>"
|
||||
other: "<strong>You voted %{count} proposals</strong>"
|
||||
change_vote_info:
|
||||
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"
|
||||
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."
|
||||
@@ -154,6 +170,10 @@ en:
|
||||
progress_bar:
|
||||
assigned: "You have assigned: "
|
||||
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:
|
||||
group: Group
|
||||
phase: Actual phase
|
||||
|
||||
@@ -143,6 +143,9 @@ es:
|
||||
description_finished: "Descripción cuando el presupuesto ha finalizado / Resultados"
|
||||
phase: "Fase"
|
||||
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:
|
||||
name: "Nombre"
|
||||
budget/investment:
|
||||
@@ -205,6 +208,7 @@ es:
|
||||
name: "Nombre de la partida"
|
||||
price: "Cantidad"
|
||||
population: "Población (opcional)"
|
||||
max_ballot_lines: "Votos permitidos"
|
||||
budget/heading/translation:
|
||||
name: "Nombre de la partida"
|
||||
budget/phase:
|
||||
|
||||
@@ -149,6 +149,7 @@ es:
|
||||
success_notice: "Partida presupuestaria eliminada correctamente"
|
||||
unable_notice: "No se puede eliminar una partida presupuestaria con proyectos asociados"
|
||||
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."
|
||||
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."
|
||||
|
||||
@@ -4,11 +4,22 @@ es:
|
||||
show:
|
||||
title: Mis votos
|
||||
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:
|
||||
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:
|
||||
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!"
|
||||
remove: Quitar voto
|
||||
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>"
|
||||
no_ballots_allowed: El periodo de votación está cerrado.
|
||||
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
|
||||
casted_offline: Ya has participado presencialmente
|
||||
groups:
|
||||
@@ -92,8 +104,12 @@ es:
|
||||
knapsack:
|
||||
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>"
|
||||
approval:
|
||||
one: "<strong>Has votado un proyecto</strong>"
|
||||
other: "<strong>Has votado %{count} proyectos</strong>"
|
||||
change_vote_info:
|
||||
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"
|
||||
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."
|
||||
@@ -154,6 +170,10 @@ es:
|
||||
progress_bar:
|
||||
assigned: "Has asignado: "
|
||||
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:
|
||||
group: Grupo
|
||||
phase: Fase actual
|
||||
|
||||
6
db/migrate/20190418114431_add_approval_voting_fields.rb
Normal file
6
db/migrate/20190418114431_add_approval_voting_fields.rb
Normal 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
|
||||
@@ -214,6 +214,7 @@ ActiveRecord::Schema.define(version: 20200519120717) do
|
||||
t.boolean "allow_custom_content", default: false
|
||||
t.text "latitude"
|
||||
t.text "longitude"
|
||||
t.integer "max_ballot_lines", default: 1
|
||||
t.datetime "created_at"
|
||||
t.datetime "updated_at"
|
||||
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_publishing_prices"
|
||||
t.text "description_informing"
|
||||
t.string "voting_style", default: "knapsack"
|
||||
end
|
||||
|
||||
create_table "campaigns", id: :serial, force: :cascade do |t|
|
||||
|
||||
@@ -55,6 +55,14 @@ FactoryBot.define do
|
||||
results_enabled { true }
|
||||
stats_enabled { true }
|
||||
end
|
||||
|
||||
trait :knapsack do
|
||||
voting_style { "knapsack" }
|
||||
end
|
||||
|
||||
trait :approval do
|
||||
voting_style { "approval" }
|
||||
end
|
||||
end
|
||||
|
||||
factory :budget_group, class: "Budget::Group" do
|
||||
|
||||
14
spec/helpers/budgets_helper_spec.rb
Normal file
14
spec/helpers/budgets_helper_spec.rb
Normal 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
|
||||
@@ -40,6 +40,41 @@ describe Budget::Ballot::Line do
|
||||
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
|
||||
it "is not valid if investment is unselected" do
|
||||
investment.update!(selected: false)
|
||||
|
||||
@@ -65,6 +65,20 @@ describe Budget::Ballot do
|
||||
expect(ballot.amount_spent(heading1)).to eq 50000
|
||||
expect(ballot.amount_spent(heading2)).to eq 20000
|
||||
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
|
||||
|
||||
describe "#amount_available" do
|
||||
@@ -91,6 +105,20 @@ describe Budget::Ballot do
|
||||
|
||||
expect(ballot.amount_available(heading1)).to eq 500
|
||||
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
|
||||
|
||||
describe "#heading_for_group" do
|
||||
|
||||
@@ -323,4 +323,13 @@ describe Budget::Heading do
|
||||
expect(Budget::Heading.allow_custom_content).to eq [translated_heading]
|
||||
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
|
||||
|
||||
@@ -1086,6 +1086,31 @@ describe Budget::Investment do
|
||||
|
||||
expect(inv2.reason_for_not_being_ballotable_by(user, ballot)).to eq(:not_enough_money)
|
||||
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
|
||||
|
||||
@@ -355,4 +355,25 @@ describe Budget do
|
||||
expect(budget.investments_milestone_tags).to eq(["tag1"])
|
||||
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
|
||||
|
||||
@@ -171,6 +171,30 @@ describe "Admin budget headings" do
|
||||
expect(page).to have_css(".is-invalid-label", text: "Amount")
|
||||
expect(page).to have_content "can't be blank"
|
||||
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
|
||||
|
||||
context "Edit" do
|
||||
|
||||
@@ -102,7 +102,7 @@ describe "Admin budgets" do
|
||||
end
|
||||
|
||||
context "New" do
|
||||
scenario "Create budget" do
|
||||
scenario "Create budget - Knapsack voting (default)" do
|
||||
visit admin_budgets_path
|
||||
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 "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
|
||||
|
||||
scenario "Name is mandatory" do
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
require "rails_helper"
|
||||
|
||||
describe "Votes" do
|
||||
describe "Investments" do
|
||||
let(:manuela) { create(:user, verified_at: Time.current) }
|
||||
let(:budget) { create(:budget, :selecting) }
|
||||
let(:manuela) { create(:user, verified_at: Time.current) }
|
||||
|
||||
context "Investments - Knapsack" do
|
||||
let(:budget) { create(:budget, phase: "selecting") }
|
||||
let(:group) { create(:budget_group, budget: budget) }
|
||||
let(:heading) { create(:budget_heading, group: group) }
|
||||
|
||||
@@ -194,4 +195,33 @@ describe "Votes" do
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user