diff --git a/app/controllers/admin/budget_headings_controller.rb b/app/controllers/admin/budget_headings_controller.rb index 3dd8ab33c..da701cb7f 100644 --- a/app/controllers/admin/budget_headings_controller.rb +++ b/app/controllers/admin/budget_headings_controller.rb @@ -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 diff --git a/app/controllers/admin/budgets_controller.rb b/app/controllers/admin/budgets_controller.rb index f95c8c6d9..1a953af9e 100644 --- a/app/controllers/admin/budgets_controller.rb +++ b/app/controllers/admin/budgets_controller.rb @@ -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 diff --git a/app/helpers/budgets_helper.rb b/app/helpers/budgets_helper.rb index 0d72d1863..122327830 100644 --- a/app/helpers/budgets_helper.rb +++ b/app/helpers/budgets_helper.rb @@ -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 + " ") diff --git a/app/models/budget.rb b/app/models/budget.rb index 8d2a6ef57..269fd3d61 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -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 diff --git a/app/models/budget/ballot.rb b/app/models/budget/ballot.rb index 34939adcd..a453f9b90 100644 --- a/app/models/budget/ballot.rb +++ b/app/models/budget/ballot.rb @@ -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 diff --git a/app/models/budget/heading.rb b/app/models/budget/heading.rb index 51616c0a2..4e310f81f 100644 --- a/app/models/budget/heading.rb +++ b/app/models/budget/heading.rb @@ -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 diff --git a/app/models/budget/voting_styles/approval.rb b/app/models/budget/voting_styles/approval.rb new file mode 100644 index 000000000..ee1f9138e --- /dev/null +++ b/app/models/budget/voting_styles/approval.rb @@ -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 diff --git a/app/models/budget/voting_styles/base.rb b/app/models/budget/voting_styles/base.rb index 87bca2d59..2ad91b362 100644 --- a/app/models/budget/voting_styles/base.rb +++ b/app/models/budget/voting_styles/base.rb @@ -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) diff --git a/app/models/budget/voting_styles/knapsack.rb b/app/models/budget/voting_styles/knapsack.rb index 4f2c72a19..aa9110ff8 100644 --- a/app/models/budget/voting_styles/knapsack.rb +++ b/app/models/budget/voting_styles/knapsack.rb @@ -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 diff --git a/app/views/admin/budget_headings/_form.html.erb b/app/views/admin/budget_headings/_form.html.erb index 745e2aa59..06eae3926 100644 --- a/app/views/admin/budget_headings/_form.html.erb +++ b/app/views/admin/budget_headings/_form.html.erb @@ -16,6 +16,12 @@
<%= 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" }, diff --git a/app/views/admin/budget_headings/index.html.erb b/app/views/admin/budget_headings/index.html.erb index e7cf193f6..0abc51073 100644 --- a/app/views/admin/budget_headings/index.html.erb +++ b/app/views/admin/budget_headings/index.html.erb @@ -13,6 +13,9 @@ <%= Budget::Heading.human_attribute_name(:name) %> <%= Budget::Heading.human_attribute_name(:price) %> + <% if @budget.approval_voting? %> + <%= Budget::Heading.human_attribute_name(:max_ballot_lines) %> + <% end %> <%= Budget::Heading.human_attribute_name(:population) %> <%= Budget::Heading.human_attribute_name(:allow_custom_content) %> <%= t("admin.actions.actions") %> @@ -23,6 +26,9 @@ <%= link_to heading.name, edit_admin_budget_group_heading_path(@budget, @group, heading) %> <%= @budget.formatted_heading_price(heading) %> + <% if @budget.approval_voting? %> + <%= heading.max_ballot_lines %> + <% end %> <%= heading.population %> <%= heading.allow_custom_content ? t("admin.shared.true_value") : t("admin.shared.false_value") %> diff --git a/app/views/admin/budgets/_form.html.erb b/app/views/admin/budgets/_form.html.erb index 057e676f7..b0c05e38d 100644 --- a/app/views/admin/budgets/_form.html.erb +++ b/app/views/admin/budgets/_form.html.erb @@ -16,7 +16,12 @@
<%= f.select :phase, budget_phases_select_options %>
-
+ +
+ <%= f.select :voting_style, budget_voting_styles_select_options %> +
+ +
<%= f.select :currency_symbol, budget_currency_symbol_select_options %>
diff --git a/app/views/budgets/ballot/_progress_bar.html.erb b/app/views/budgets/ballot/_progress_bar.html.erb index b3c563240..8a41ef59b 100644 --- a/app/views/budgets/ballot/_progress_bar.html.erb +++ b/app/views/budgets/ballot/_progress_bar.html.erb @@ -10,9 +10,5 @@

- <%= t("budgets.progress_bar.assigned") %><%= ballot.formatted_amount_spent(heading) %> - - <%= t("budgets.progress_bar.available") %> - <%= ballot.formatted_amount_available(heading) %> - + <%= render "budgets/ballot/progress_bar/#{ballot.budget.voting_style}", ballot: ballot, heading: heading %>

diff --git a/app/views/budgets/ballot/progress_bar/_approval.html.erb b/app/views/budgets/ballot/progress_bar/_approval.html.erb new file mode 100644 index 000000000..95961a824 --- /dev/null +++ b/app/views/budgets/ballot/progress_bar/_approval.html.erb @@ -0,0 +1,3 @@ +<%= sanitize(t("budgets.progress_bar.votes", + count: ballot.amount_spent(heading), + limit: ballot.amount_limit(heading))) %> diff --git a/app/views/budgets/ballot/progress_bar/_knapsack.html.erb b/app/views/budgets/ballot/progress_bar/_knapsack.html.erb new file mode 100644 index 000000000..b46d6d6de --- /dev/null +++ b/app/views/budgets/ballot/progress_bar/_knapsack.html.erb @@ -0,0 +1,5 @@ +<%= t("budgets.progress_bar.assigned") %><%= ballot.formatted_amount_spent(heading) %> + + <%= t("budgets.progress_bar.available") %> + <%= ballot.formatted_amount_available(heading) %> + diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index f03a38d09..49d3e7bc2 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -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.*" diff --git a/config/locales/en/activerecord.yml b/config/locales/en/activerecord.yml index cd9b4c6f7..9ea6e1c51 100644 --- a/config/locales/en/activerecord.yml +++ b/config/locales/en/activerecord.yml @@ -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: diff --git a/config/locales/en/admin.yml b/config/locales/en/admin.yml index 61f1eca24..2371bc434 100644 --- a/config/locales/en/admin.yml +++ b/config/locales/en/admin.yml @@ -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." diff --git a/config/locales/en/budgets.yml b/config/locales/en/budgets.yml index 8047f44ad..758ab8c84 100644 --- a/config/locales/en/budgets.yml +++ b/config/locales/en/budgets.yml @@ -4,11 +4,22 @@ en: show: title: Your ballot amount_available: - knapsack: "You still have %{amount} to invest." + knapsack: "You still have %{count} to invest." + approval: + zero: "You can still cast %{count} votes." + one: "You can still cast %{count} vote." + other: "You can still cast %{count} votes." amount_spent: - knapsack: "Amount spent %{amount}" + knapsack: "Amount spent %{count}" + approval: + zero: "Votes cast: %{count}" + one: "Votes cast: %{count}" + other: "Votes cast: %{count}" amount_limit: - knapsack: "%{amount}" + knapsack: "%{count}" + approval: + one: "You can vote 1 project" + other: "You can vote up to %{count} 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.
Remember you can %{change_ballot} at any time" 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: "You voted one proposal with a cost of %{amount_spent}" other: "You voted %{count} proposals with a cost of %{amount_spent}" + approval: + one: "You voted one proposal" + other: "You voted %{count} proposals" 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 0 projects out of %{limit}" + one: "You have selected 1 project out of %{limit}" + other: "You have selected %{count} projects out of %{limit}" show: group: Group phase: Actual phase diff --git a/config/locales/es/activerecord.yml b/config/locales/es/activerecord.yml index 0e1f39c92..c3bc2c3e9 100644 --- a/config/locales/es/activerecord.yml +++ b/config/locales/es/activerecord.yml @@ -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: diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml index 0978bd7f7..9cbf60417 100644 --- a/config/locales/es/admin.yml +++ b/config/locales/es/admin.yml @@ -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." diff --git a/config/locales/es/budgets.yml b/config/locales/es/budgets.yml index 9ccc52159..0030ebe59 100644 --- a/config/locales/es/budgets.yml +++ b/config/locales/es/budgets.yml @@ -4,11 +4,22 @@ es: show: title: Mis votos amount_available: - knapsack: "Te quedan %{amount} para invertir" + knapsack: "Te quedan %{count} para invertir" + approval: + zero: "Te quedan 0 votos disponibles" + one: "Te queda 1 voto disponible" + other: "Te quedan %{count} votos disponibles" amount_spent: - knapsack: "Coste total %{amount}" + knapsack: "Coste total %{count}" + approval: + zero: "Votos: %{count}" + one: "Votos: %{count}" + other: "Votos: %{count}" amount_limit: - knapsack: "%{amount}" + knapsack: "%{count}" + approval: + one: "Puedes votar 1 proyecto" + other: "Puedes votar hasta %{count} 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.
Recuerda que puedes %{change_ballot} en cualquier momento" 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: "Has votado un proyecto por un valor de %{amount_spent}" other: "Has votado %{count} proyectos por un valor de %{amount_spent}" + approval: + one: "Has votado un proyecto" + other: "Has votado %{count} proyectos" 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 0 proyectos de %{limit}" + one: "Has seleccionado 1 proyecto de %{limit}" + other: "Has seleccionado %{count} proyectos de %{limit}" show: group: Grupo phase: Fase actual diff --git a/db/migrate/20190418114431_add_approval_voting_fields.rb b/db/migrate/20190418114431_add_approval_voting_fields.rb new file mode 100644 index 000000000..6d48c15ff --- /dev/null +++ b/db/migrate/20190418114431_add_approval_voting_fields.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 573774563..f2002efc6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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| diff --git a/spec/factories/budgets.rb b/spec/factories/budgets.rb index 877742f5e..0d3b9f18e 100644 --- a/spec/factories/budgets.rb +++ b/spec/factories/budgets.rb @@ -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 diff --git a/spec/helpers/budgets_helper_spec.rb b/spec/helpers/budgets_helper_spec.rb new file mode 100644 index 000000000..832f510d8 --- /dev/null +++ b/spec/helpers/budgets_helper_spec.rb @@ -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 diff --git a/spec/models/budget/ballot/line_spec.rb b/spec/models/budget/ballot/line_spec.rb index 2c3c8f1e4..c9c8a887d 100644 --- a/spec/models/budget/ballot/line_spec.rb +++ b/spec/models/budget/ballot/line_spec.rb @@ -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) diff --git a/spec/models/budget/ballot_spec.rb b/spec/models/budget/ballot_spec.rb index a06aad9d4..64cebae2e 100644 --- a/spec/models/budget/ballot_spec.rb +++ b/spec/models/budget/ballot_spec.rb @@ -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 diff --git a/spec/models/budget/heading_spec.rb b/spec/models/budget/heading_spec.rb index 61dfa7d2b..a2b77a7d5 100644 --- a/spec/models/budget/heading_spec.rb +++ b/spec/models/budget/heading_spec.rb @@ -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 diff --git a/spec/models/budget/investment_spec.rb b/spec/models/budget/investment_spec.rb index 8a1b2ef29..1dad0ca50 100644 --- a/spec/models/budget/investment_spec.rb +++ b/spec/models/budget/investment_spec.rb @@ -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 diff --git a/spec/models/budget_spec.rb b/spec/models/budget_spec.rb index 3b85e61d9..7a8a37e9d 100644 --- a/spec/models/budget_spec.rb +++ b/spec/models/budget_spec.rb @@ -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 diff --git a/spec/system/admin/budget_headings_spec.rb b/spec/system/admin/budget_headings_spec.rb index e626521cc..b92d4ce6e 100644 --- a/spec/system/admin/budget_headings_spec.rb +++ b/spec/system/admin/budget_headings_spec.rb @@ -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 diff --git a/spec/system/admin/budgets_spec.rb b/spec/system/admin/budgets_spec.rb index 5ed404756..914c813c8 100644 --- a/spec/system/admin/budgets_spec.rb +++ b/spec/system/admin/budgets_spec.rb @@ -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 diff --git a/spec/system/budgets/votes_spec.rb b/spec/system/budgets/votes_spec.rb index 4291a764c..ed7048ca5 100644 --- a/spec/system/budgets/votes_spec.rb +++ b/spec/system/budgets/votes_spec.rb @@ -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