diff --git a/app/controllers/admin/budget_groups_controller.rb b/app/controllers/admin/budget_groups_controller.rb index 512e2bfcd..d8048de48 100644 --- a/app/controllers/admin/budget_groups_controller.rb +++ b/app/controllers/admin/budget_groups_controller.rb @@ -17,7 +17,7 @@ class Admin::BudgetGroupsController < Admin::BaseController private def budget_group_params - params.require(:budget_group).permit(:name) + params.require(:budget_group).permit(:name, :max_votable_headings) end end diff --git a/app/models/budget/group.rb b/app/models/budget/group.rb index a32c7c90d..bfbfd4741 100644 --- a/app/models/budget/group.rb +++ b/app/models/budget/group.rb @@ -16,9 +16,9 @@ class Budget private - def generate_slug? - slug.nil? || budget.drafting? - end + def generate_slug? + slug.nil? || budget.drafting? + end end end diff --git a/app/models/budget/investment.rb b/app/models/budget/investment.rb index dff2dd2e0..53d27a741 100644 --- a/app/models/budget/investment.rb +++ b/app/models/budget/investment.rb @@ -231,21 +231,20 @@ class Budget end def valid_heading?(user) - !different_heading_assigned?(user) + voted_in?(heading, user) || + can_vote_in_another_heading?(user) end - def different_heading_assigned?(user) - other_heading_ids = group.heading_ids - [heading.id] - voted_in?(other_heading_ids, user) + def can_vote_in_another_heading?(user) + headings_voted_by_user(user).count < group.max_votable_headings end - def voted_in?(heading_ids, user) - heading_ids.include? heading_voted_by_user?(user) + def headings_voted_by_user(user) + user.votes.for_budget_investments(budget.investments.where(group: group)).votables.map(&:heading_id).uniq end - def heading_voted_by_user?(user) - user.votes.for_budget_investments(budget.investments.where(group: group)) - .votables.map(&:heading_id).first + def voted_in?(heading, user) + headings_voted_by_user(user).include?(heading.id) end def ballotable_by?(user) diff --git a/app/views/admin/budgets/_group_form.html.erb b/app/views/admin/budgets/_group_form.html.erb index c200973f0..0bb83b1b7 100644 --- a/app/views/admin/budgets/_group_form.html.erb +++ b/app/views/admin/budgets/_group_form.html.erb @@ -8,6 +8,19 @@ maxlength: 50, placeholder: t("admin.budgets.form.group"), class: "input-group-field" %> + + <% if group.persisted? %> +
<%= t("votes.budget_investments.#{reason}", + count: investment.group.max_votable_headings, verify_account: link_to(t("votes.verify_account"), verification_path), signin: link_to(t("votes.signin"), new_user_session_path), signup: link_to(t("votes.signup"), new_user_registration_path) diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index e778ba104..628e2a15b 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -176,11 +176,11 @@ ignore_unused: - 'admin.site_customization.pages.page.status_*' - 'admin.legislation.processes.process.*' - 'legislation.processes.index.*' + - 'votes.budget_investments.different_heading_assigned*' # - '{devise,kaminari,will_paginate}.*' # - 'simple_form.{yes,no}' # - 'simple_form.{placeholders,hints,labels}.*' # - 'simple_form.{error_notification,required}.:' - ## Exclude these keys from the `i18n-tasks eq-base' report: # ignore_eq_base: # all: diff --git a/config/locales/en/admin.yml b/config/locales/en/admin.yml index baa678996..ad03c3a13 100644 --- a/config/locales/en/admin.yml +++ b/config/locales/en/admin.yml @@ -119,6 +119,7 @@ en: table_amount: Amount table_population: Population 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." + max_votable_headings: "Maxium number of headings in which a user can vote" winners: calculate: Calculate Winner Investments calculated: Winners being calculated, it may take a minute. diff --git a/config/locales/en/budgets.yml b/config/locales/en/budgets.yml index b0b393b42..48d3b3462 100644 --- a/config/locales/en/budgets.yml +++ b/config/locales/en/budgets.yml @@ -130,7 +130,9 @@ en: already_added: You have already added this investment project already_supported: You have already supported this investment project. Share it! support_title: Support this project - confirm_group: "You can only support investments in one heading. If you continue you cannot change your decision. Are you sure?" + confirm_group: + one: "You can only support investments in %{count} district. If you continue you cannot change the election of your district. Are you sure?" + other: "You can only support investments in %{count} district. If you continue you cannot change the election of your district. Are you sure?" supports: one: 1 support other: "%{count} supports" diff --git a/config/locales/en/general.yml b/config/locales/en/general.yml index 01ba0c2c3..66f392921 100644 --- a/config/locales/en/general.yml +++ b/config/locales/en/general.yml @@ -771,7 +771,9 @@ en: organization: Organizations are not permitted to vote unfeasible: Unfeasible investment projects can not be supported not_voting_allowed: Voting phase is closed - different_heading_assigned: You can only support investment projects in one heading + different_heading_assigned: + one: "You can only support investment projects in %{count} district" + other: "You can only support investment projects in %{count} districts" welcome: debates: description: For meeting, discussing and sharing the things that matter to us in our city. diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml index 9fb9fae63..1cbbf2d14 100644 --- a/config/locales/es/admin.yml +++ b/config/locales/es/admin.yml @@ -119,6 +119,7 @@ es: table_amount: Cantidad table_population: Població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." + max_votable_headings: "Máximo número de partidas en que un usuario puede votar" winners: calculate: Calcular propuestas ganadoras calculated: Calculando ganadoras, puede tardar un minuto. diff --git a/config/locales/es/budgets.yml b/config/locales/es/budgets.yml index ab1a1e4ac..67ad55d5f 100644 --- a/config/locales/es/budgets.yml +++ b/config/locales/es/budgets.yml @@ -130,7 +130,9 @@ es: already_added: Ya has añadido este proyecto de gasto already_supported: Ya has apoyado este proyecto de gasto. ¡Compártelo! support_title: Apoyar este proyecto - confirm_group: "Sólo puedes apoyar proyectos de una partida. Si sigues adelante no podrás cambiar esta decisión. ¿Estás seguro?" + confirm_group: + one: "Sólo puedes apoyar proyectos en %{count} distritos. Si sigues adelante no podrás cambiar la elección de este distrito. ¿Estás seguro?" + other: "Sólo puedes apoyar proyectos en %{count} distritos. Si sigues adelante no podrás cambiar la elección de este distrito. ¿Estás seguro?" supports: zero: Sin apoyos one: 1 apoyo diff --git a/config/locales/es/general.yml b/config/locales/es/general.yml index 2c3113e5f..08bbbdba6 100644 --- a/config/locales/es/general.yml +++ b/config/locales/es/general.yml @@ -770,7 +770,9 @@ es: organization: Las organizaciones no pueden votar. unfeasible: No se pueden votar propuestas inviables. not_voting_allowed: El periodo de votación está cerrado. - different_heading_assigned: Sólo puedes apoyar proyectos de gasto de una partida + different_heading_assigned: + one: "Sólo puedes apoyar proyectos de gasto de %{count} distrito" + other: "Sólo puedes apoyar proyectos de gasto de %{count} distritos" welcome: debates: description: Encontrarnos, debatir y compartir lo que nos parece importante en nuestra ciudad. diff --git a/db/migrate/20180320104823_add_max_votable_headings_to_budget_groups.rb b/db/migrate/20180320104823_add_max_votable_headings_to_budget_groups.rb new file mode 100644 index 000000000..e030b7b13 --- /dev/null +++ b/db/migrate/20180320104823_add_max_votable_headings_to_budget_groups.rb @@ -0,0 +1,5 @@ +class AddMaxVotableHeadingsToBudgetGroups < ActiveRecord::Migration + def change + add_column :budget_groups, :max_votable_headings, :integer, default: 1 + end +end diff --git a/db/schema.rb b/db/schema.rb index a32417cc1..945f0d52e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180220211105) do +ActiveRecord::Schema.define(version: 20180320104823) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -101,8 +101,9 @@ ActiveRecord::Schema.define(version: 20180220211105) do create_table "budget_groups", force: :cascade do |t| t.integer "budget_id" - t.string "name", limit: 50 + t.string "name", limit: 50 t.string "slug" + t.integer "max_votable_headings", default: 1 end add_index "budget_groups", ["budget_id"], name: "index_budget_groups_on_budget_id", using: :btree diff --git a/spec/features/admin/budget_groups_spec.rb b/spec/features/admin/budget_groups_spec.rb index 90eaa90ac..d7c03d2e3 100644 --- a/spec/features/admin/budget_groups_spec.rb +++ b/spec/features/admin/budget_groups_spec.rb @@ -65,4 +65,49 @@ feature 'Admin can change the groups name' do expect(page).to have_content('has already been taken') end + context "Maximum votable headings" do + + background do + 3.times { create(:budget_heading, group: group) } + end + + scenario "Defaults to 1 heading per group", :js do + visit admin_budget_path(group.budget) + + within("#budget_group_#{group.id}") do + click_link 'Edit group' + + expect(page).to have_select('budget_group_max_votable_headings', selected: '1') + end + end + + scenario "Update", :js do + visit admin_budget_path(group.budget) + + within("#budget_group_#{group.id}") do + click_link 'Edit group' + + select '2', from: 'budget_group_max_votable_headings' + click_button 'Save group' + end + + visit admin_budget_path(group.budget) + + within("#budget_group_#{group.id}") do + click_link 'Edit group' + + expect(page).to have_select('budget_group_max_votable_headings', selected: '2') + end + end + + scenario "Do not display maxium votable headings' select in new form", :js do + visit admin_budget_path(group.budget) + + click_link 'Add new group' + + expect(page).to have_field('budget_group_name') + expect(page).to_not have_field('budget_group_max_votable_headings') + end + + end end diff --git a/spec/features/budgets/votes_spec.rb b/spec/features/budgets/votes_spec.rb index 6da622b44..5a56af51b 100644 --- a/spec/features/budgets/votes_spec.rb +++ b/spec/features/budgets/votes_spec.rb @@ -103,5 +103,74 @@ feature 'Votes' do expect(page).not_to have_css("budget_investment_#{investment.id}_votes") end end + + context "Voting in multiple headings of a single group" do + + let(:new_york) { heading } + let(:san_francisco) { create(:budget_heading, group: group) } + let(:third_heading) { create(:budget_heading, group: group) } + + let!(:new_york_investment) { create(:budget_investment, heading: new_york) } + let!(:san_francisco_investment) { create(:budget_investment, heading: san_francisco) } + let!(:third_heading_investment) { create(:budget_investment, heading: third_heading) } + + background do + group.update(max_votable_headings: 2) + end + + scenario "From Index", :js do + visit budget_investments_path(budget, heading_id: new_york.id) + + within("#budget_investment_#{new_york_investment.id}") do + find('.in-favor a').click + + expect(page).to have_content "1 support" + expect(page).to have_content "You have already supported this investment project. Share it!" + end + + visit budget_investments_path(budget, heading_id: san_francisco.id) + + within("#budget_investment_#{san_francisco_investment.id}") do + find('.in-favor a').click + + expect(page).to have_content "1 support" + expect(page).to have_content "You have already supported this investment project. Share it!" + end + + visit budget_investments_path(budget, heading_id: third_heading.id) + + within("#budget_investment_#{third_heading_investment.id}") do + find('.in-favor a').click + + expect(page).to have_content "You can only support investment projects in 2 districts" + + expect(page).to_not have_content "1 support" + expect(page).to_not have_content "You have already supported this investment project. Share it!" + end + end + + scenario "From show", :js do + visit budget_investment_path(budget, new_york_investment) + + find('.in-favor a').click + expect(page).to have_content "1 support" + expect(page).to have_content "You have already supported this investment project. Share it!" + + visit budget_investment_path(budget, san_francisco_investment) + + find('.in-favor a').click + expect(page).to have_content "1 support" + expect(page).to have_content "You have already supported this investment project. Share it!" + + visit budget_investment_path(budget, third_heading_investment) + + find('.in-favor a').click + expect(page).to have_content "You can only support investment projects in 2 districts" + + expect(page).to_not have_content "1 support" + expect(page).to_not have_content "You have already supported this investment project. Share it!" + end + + end end end diff --git a/spec/models/budget/investment_spec.rb b/spec/models/budget/investment_spec.rb index b8b3d423b..cdee81087 100644 --- a/spec/models/budget/investment_spec.rb +++ b/spec/models/budget/investment_spec.rb @@ -589,6 +589,36 @@ describe Budget::Investment do expect(salamanca_investment.valid_heading?(user)).to eq(false) end + it "accepts votes in multiple headings of the same group" do + group.update(max_votable_headings: 2) + + carabanchel = create(:budget_heading, group: group) + salamanca = create(:budget_heading, group: group) + + carabanchel_investment = create(:budget_investment, heading: carabanchel) + salamanca_investment = create(:budget_investment, heading: salamanca) + + create(:vote, votable: carabanchel_investment, voter: user) + + expect(salamanca_investment.valid_heading?(user)).to eq(true) + end + + it "accepts votes in any heading previously voted in" do + group.update(max_votable_headings: 2) + + carabanchel = create(:budget_heading, group: group) + salamanca = create(:budget_heading, group: group) + + carabanchel_investment = create(:budget_investment, heading: carabanchel) + salamanca_investment = create(:budget_investment, heading: salamanca) + + create(:vote, votable: carabanchel_investment, voter: user) + create(:vote, votable: salamanca_investment, voter: user) + + expect(carabanchel_investment.valid_heading?(user)).to eq(true) + expect(salamanca_investment.valid_heading?(user)).to eq(true) + end + it "allows votes in a group with a single heading" do all_city_investment = create(:budget_investment, heading: heading) expect(all_city_investment.valid_heading?(user)).to eq(true) @@ -627,9 +657,84 @@ describe Budget::Investment do expect(carabanchel_investment.valid_heading?(user)).to eq(true) end + + describe "#can_vote_in_another_heading?" do + + let(:districts) { create(:budget_group, budget: budget) } + let(:carabanchel) { create(:budget_heading, group: districts) } + let(:salamanca) { create(:budget_heading, group: districts) } + let(:latina) { create(:budget_heading, group: districts) } + + let(:carabanchel_investment) { create(:budget_investment, heading: carabanchel) } + let(:salamanca_investment) { create(:budget_investment, heading: salamanca) } + let(:latina_investment) { create(:budget_investment, heading: latina) } + + it "returns true if the user has voted in less headings than the maximum" do + districts.update(max_votable_headings: 2) + + create(:vote, votable: carabanchel_investment, voter: user) + + expect(salamanca_investment.can_vote_in_another_heading?(user)).to eq(true) + end + + it "returns false if the user has already voted in the maximum number of headings" do + districts.update(max_votable_headings: 2) + + create(:vote, votable: carabanchel_investment, voter: user) + create(:vote, votable: salamanca_investment, voter: user) + + expect(latina_investment.can_vote_in_another_heading?(user)).to eq(false) + end + end end end + describe "#headings_voted_by_user" do + it "returns the headings voted by a user" do + user1 = create(:user) + user2 = create(:user) + + budget = create(:budget) + group = create(:budget_group, budget: budget) + + new_york = create(:budget_heading, group: group) + san_franciso = create(:budget_heading, group: group) + another_heading = create(:budget_heading, group: group) + + new_york_investment = create(:budget_investment, heading: new_york) + san_franciso_investment = create(:budget_investment, heading: san_franciso) + another_investment = create(:budget_investment, heading: san_franciso) + + create(:vote, votable: new_york_investment, voter: user1) + create(:vote, votable: san_franciso_investment, voter: user1) + + expect(another_investment.headings_voted_by_user(user1)).to include(new_york.id) + expect(another_investment.headings_voted_by_user(user1)).to include(san_franciso.id) + expect(another_investment.headings_voted_by_user(user1)).to_not include(another_heading.id) + + expect(another_investment.headings_voted_by_user(user2)).to_not include(new_york.id) + expect(another_investment.headings_voted_by_user(user2)).to_not include(san_franciso.id) + expect(another_investment.headings_voted_by_user(user2)).to_not include(another_heading.id) + end + end + + describe "#voted_in?" do + + let(:user) { create(:user) } + let(:investment) { create(:budget_investment) } + + it "returns true if the user has voted in this heading" do + create(:vote, votable: investment, voter: user) + + expect(investment.voted_in?(investment.heading, user)).to eq(true) + end + + it "returns false if the user has not voted in this heading" do + expect(investment.voted_in?(investment.heading, user)).to eq(false) + end + + end + describe "Order" do describe "#sort_by_confidence_score" do