diff --git a/app/assets/images/budget_execution_no_image.jpg b/app/assets/images/budget_execution_no_image.jpg new file mode 100644 index 000000000..7de0e0a2c Binary files /dev/null and b/app/assets/images/budget_execution_no_image.jpg differ diff --git a/app/assets/stylesheets/participation.scss b/app/assets/stylesheets/participation.scss index 112afe7d4..4bc64f9a5 100644 --- a/app/assets/stylesheets/participation.scss +++ b/app/assets/stylesheets/participation.scss @@ -1680,6 +1680,59 @@ } } +.budget-execution { + border: 1px solid $border; + overflow: hidden; + position: relative; + + a { + color: $text; + display: block; + + img { + height: $line-height * 9; + transition-duration: 0.3s; + transition-property: transform; + width: 100%; + } + + &:hover { + text-decoration: none; + + img { + transform: scale(1.05); + } + } + } + + h5 { + font-size: $base-font-size; + margin-bottom: 0; + } + + .budget-execution-info { + padding: $line-height / 2; + } + + .author { + color: $text-medium; + font-size: $small-font-size; + } + + .budget-execution-content { + min-height: $line-height * 3; + } + + .price { + color: $budget; + font-size: rem-calc(24); + } + + &:hover { + box-shadow: 0 0 12px 0 rgba(0, 0, 0, 0.2); + } +} + // 07. Proposals successful // ------------------------- diff --git a/app/controllers/budgets/executions_controller.rb b/app/controllers/budgets/executions_controller.rb new file mode 100644 index 000000000..e33f43dcd --- /dev/null +++ b/app/controllers/budgets/executions_controller.rb @@ -0,0 +1,46 @@ +module Budgets + class ExecutionsController < ApplicationController + before_action :load_budget + + load_and_authorize_resource :budget + + def show + authorize! :read_executions, @budget + @statuses = ::Budget::Investment::Status.all + + if params[:status].present? + @investments_by_heading = @budget.investments.winners + .joins(:milestones).includes(:milestones) + .select { |i| i.milestones.published.with_status + .order_by_publication_date.last + .status_id == params[:status].to_i } + .uniq + .group_by(&:heading) + else + @investments_by_heading = @budget.investments.winners + .joins(:milestones).includes(:milestones) + .distinct.group_by(&:heading) + end + + @investments_by_heading = reorder_alphabetically_with_city_heading_first.to_h + end + + private + + def load_budget + @budget = Budget.find_by(slug: params[:id]) || Budget.find_by(id: params[:id]) + end + + def reorder_alphabetically_with_city_heading_first + @investments_by_heading.sort do |a, b| + if a[0].name == 'Toda la ciudad' + -1 + elsif b[0].name == 'Toda la ciudad' + 1 + else + a[0].name <=> b[0].name + end + end + end + end +end diff --git a/app/helpers/budget_executions_helper.rb b/app/helpers/budget_executions_helper.rb new file mode 100644 index 000000000..fd8376987 --- /dev/null +++ b/app/helpers/budget_executions_helper.rb @@ -0,0 +1,9 @@ +module BudgetExecutionsHelper + + def filters_select_counts(status) + @budget.investments.winners.with_milestones.select { |i| i.milestones + .published.with_status.order_by_publication_date + .last.status_id == status rescue false }.count + end + +end diff --git a/app/models/abilities/everyone.rb b/app/models/abilities/everyone.rb index 23cfdc971..18927cc80 100644 --- a/app/models/abilities/everyone.rb +++ b/app/models/abilities/everyone.rb @@ -22,7 +22,7 @@ module Abilities can [:read], Budget can [:read], Budget::Group can [:read, :print, :json_data], Budget::Investment - can :read_results, Budget, phase: "finished" + can [:read_results, :read_executions], Budget, phase: "finished" can :new, DirectMessage can [:read, :debate, :draft_publication, :allegations, :result_publication, :proposals], Legislation::Process, published: true can [:read, :changes, :go_to_version], Legislation::DraftVersion diff --git a/app/models/budget/investment.rb b/app/models/budget/investment.rb index e531612a8..486e65052 100644 --- a/app/models/budget/investment.rb +++ b/app/models/budget/investment.rb @@ -84,6 +84,7 @@ class Budget scope :last_week, -> { where("created_at >= ?", 7.days.ago)} scope :sort_by_flags, -> { order(flags_count: :desc, updated_at: :desc) } scope :sort_by_created_at, -> { reorder(created_at: :desc) } + scope :with_milestones, -> { joins(:milestones).distinct } scope :by_budget, ->(budget) { where(budget: budget) } scope :by_group, ->(group_id) { where(group_id: group_id) } diff --git a/app/models/budget/investment/milestone.rb b/app/models/budget/investment/milestone.rb index c71516446..562be54b8 100644 --- a/app/models/budget/investment/milestone.rb +++ b/app/models/budget/investment/milestone.rb @@ -18,6 +18,8 @@ class Budget validate :description_or_status_present? scope :order_by_publication_date, -> { order(publication_date: :asc) } + scope :published, -> { where("publication_date <= ?", Date.current) } + scope :with_status, -> { where("status_id IS NOT NULL") } def self.title_max_length 80 diff --git a/app/models/site_customization/image.rb b/app/models/site_customization/image.rb index b10f3799f..f551d206f 100644 --- a/app/models/site_customization/image.rb +++ b/app/models/site_customization/image.rb @@ -4,7 +4,8 @@ class SiteCustomization::Image < ActiveRecord::Base "logo_header" => [260, 80], "social_media_icon" => [470, 246], "social_media_icon_twitter" => [246, 246], - "apple-touch-icon-200" => [200, 200] + "apple-touch-icon-200" => [200, 200], + "budget_execution_no_image" => [800, 600] } has_attached_file :image diff --git a/app/views/budgets/executions/_investments.html.erb b/app/views/budgets/executions/_investments.html.erb new file mode 100644 index 000000000..b329e1a7b --- /dev/null +++ b/app/views/budgets/executions/_investments.html.erb @@ -0,0 +1,33 @@ +<% @investments_by_heading.each do |heading, investments| %> +

+ <%= heading.name %> (<%= investments.count %>) +

+
+ <% investments.each do |investment| %> +
+
+ <%= link_to budget_investment_path(@budget, investment, anchor: "tab-milestones"), data: { 'equalizer-watch': true } do %> + <% investment.milestones.order(publication_date: :desc).limit(1).each do |milestone| %> + <% if milestone.image.present? %> + <%= image_tag milestone.image_url(:large), alt: milestone.image.title %> + <% elsif investment.image.present? %> + <%= image_tag investment.image_url(:thumb), alt: investment.image.title %> + <% else %> + <%= image_tag "budget_execution_no_image.jpg", alt: investment.title %> + <% end %> + <% end %> +
+
+
<%= investment.title %>
+ <%= investment.author.name %> +
+

+ <%= investment.formatted_price %> +

+
+ <% end %> +
+
+ <% end %> +
+<% end %> diff --git a/app/views/budgets/executions/show.html.erb b/app/views/budgets/executions/show.html.erb new file mode 100644 index 000000000..c6844c955 --- /dev/null +++ b/app/views/budgets/executions/show.html.erb @@ -0,0 +1,77 @@ +<% provide :title, t("budgets.executions.page_title", budget: @budget.name) %> +<% content_for :meta_description do %><%= @budget.description_for_phase('finished') %><% end %> +<% provide :social_media_meta_tags do %> +<%= render 'shared/social_media_meta_tags', + social_url: budget_executions_url(@budget), + social_title: @budget.name, + social_description: @budget.description_for_phase('finished') %> +<% end %> + +<% content_for :canonical do %> + <%= render 'shared/canonical', href: budget_executions_url(@budget) %> +<% end %> + +
+
+
+
+ <%= back_link_to budgets_path %> +

+ <%= t("budgets.executions.heading") %>
+ <%= @budget.name %> +

+
+
+
+
+ +
+
+ +
+
+ +
+
+

+ <%= t("budgets.executions.heading_selection_title") %> +

+ +
+ +
+ <%= form_tag(budget_executions_path(@budget), method: :get) do %> +
+ <%= label_tag t("budgets.executions.filters.label") %> + <%= select_tag :status, + options_from_collection_for_select(@statuses, + :id, lambda { |s| "#{s.name} (#{filters_select_counts(s.id)})" }, + params[:status]), + class: "js-submit-on-change", + prompt: t("budgets.executions.filters.all", + count: @budget.investments.winners.with_milestones.count) %> +
+ <% end %> + + <% if @investments_by_heading.any? %> + <%= render 'budgets/executions/investments' %> + <% else %> +
+ <%= t("budgets.executions.no_winner_investments") %> +
+ <% end %> +
+
diff --git a/app/views/budgets/index.html.erb b/app/views/budgets/index.html.erb index 729975469..4f9184477 100644 --- a/app/views/budgets/index.html.erb +++ b/app/views/budgets/index.html.erb @@ -33,7 +33,7 @@ <% if current_user %> <% if current_user.level_two_or_three_verified? %> <%= link_to t("budgets.investments.index.sidebar.create"), - new_budget_investment_path(@budget), + new_budget_investment_path(current_budget), class: "button margin-top expanded" %> <% else %>
diff --git a/app/views/budgets/results/show.html.erb b/app/views/budgets/results/show.html.erb index 63308eaca..81c5e0904 100644 --- a/app/views/budgets/results/show.html.erb +++ b/app/views/budgets/results/show.html.erb @@ -29,10 +29,10 @@
diff --git a/config/locales/en/budgets.yml b/config/locales/en/budgets.yml index 094fcbe9e..e20c9f09f 100644 --- a/config/locales/en/budgets.yml +++ b/config/locales/en/budgets.yml @@ -174,6 +174,15 @@ en: investment_proyects: List of all investment projects unfeasible_investment_proyects: List of all unfeasible investment projects not_selected_investment_proyects: List of all investment projects not selected for balloting + executions: + link: "Milestones" + page_title: "%{budget} - Milestones" + heading: "Participatory budget Milestones" + heading_selection_title: "By district" + no_winner_investments: "No winner investments in this state" + filters: + label: "Project's current state" + all: "All (%{count})" phases: errors: dates_range_invalid: "Start date can't be equal or later than End date" diff --git a/config/locales/es/budgets.yml b/config/locales/es/budgets.yml index d493d6daa..531960330 100644 --- a/config/locales/es/budgets.yml +++ b/config/locales/es/budgets.yml @@ -174,6 +174,15 @@ es: investment_proyects: Ver lista completa de proyectos de gasto unfeasible_investment_proyects: Ver lista de proyectos de gasto inviables not_selected_investment_proyects: Ver lista de proyectos de gasto no seleccionados para la votación final + executions: + link: "Seguimiento" + page_title: "%{budget} - Seguimiento de proyectos" + heading: "Seguimiento de proyectos" + heading_selection_title: "Ámbito de actuación" + no_winner_investments: "No hay proyectos de gasto ganadores en este estado" + filters: + label: "Estado actual del proyecto" + all: "Todos (%{count})" phases: errors: dates_range_invalid: "La fecha de comienzo no puede ser igual o superior a la de finalización" diff --git a/config/routes/budget.rb b/config/routes/budget.rb index b3422744f..475d5496a 100644 --- a/config/routes/budget.rb +++ b/config/routes/budget.rb @@ -15,6 +15,7 @@ resources :budgets, only: [:show, :index] do end resource :results, only: :show, controller: "budgets/results" + resource :executions, only: :show, controller: 'budgets/executions' end scope '/participatory_budget' do diff --git a/db/dev_seeds/budgets.rb b/db/dev_seeds/budgets.rb index 04b5272ff..6a44b977c 100644 --- a/db/dev_seeds/budgets.rb +++ b/db/dev_seeds/budgets.rb @@ -139,6 +139,13 @@ section "Creating Valuation Assignments" do end end +section "Creating default Investment Milestone Statuses" do + Budget::Investment::Status.create(name: I18n.t('seeds.budgets.statuses.studying_project')) + Budget::Investment::Status.create(name: I18n.t('seeds.budgets.statuses.bidding')) + Budget::Investment::Status.create(name: I18n.t('seeds.budgets.statuses.executing_project')) + Budget::Investment::Status.create(name: I18n.t('seeds.budgets.statuses.executed')) +end + section "Creating investment milestones" do Budget::Investment.find_each do |investment| milestone = Budget::Investment::Milestone.new(investment_id: investment.id, publication_date: Date.tomorrow) @@ -151,10 +158,3 @@ section "Creating investment milestones" do end end end - -section "Creating default Investment Milestone Statuses" do - Budget::Investment::Status.create(name: I18n.t('seeds.budgets.statuses.studying_project')) - Budget::Investment::Status.create(name: I18n.t('seeds.budgets.statuses.bidding')) - Budget::Investment::Status.create(name: I18n.t('seeds.budgets.statuses.executing_project')) - Budget::Investment::Status.create(name: I18n.t('seeds.budgets.statuses.executed')) -end diff --git a/spec/features/budgets/budgets_spec.rb b/spec/features/budgets/budgets_spec.rb index db0e6b806..a85df36cb 100644 --- a/spec/features/budgets/budgets_spec.rb +++ b/spec/features/budgets/budgets_spec.rb @@ -124,6 +124,15 @@ feature 'Budgets' do expect(page).to have_content "There are no budgets" end + + scenario "Accepting" do + budget.update(phase: "accepting") + login_as(create(:user, :level_two)) + + visit budgets_path + + expect(page).to have_link "Create a budget investment" + end end scenario 'Index shows only published phases' do diff --git a/spec/features/budgets/executions_spec.rb b/spec/features/budgets/executions_spec.rb new file mode 100644 index 000000000..5449c8c4b --- /dev/null +++ b/spec/features/budgets/executions_spec.rb @@ -0,0 +1,252 @@ +require 'rails_helper' + +feature 'Executions' do + + let(:budget) { create(:budget, phase: 'finished') } + let(:group) { create(:budget_group, budget: budget) } + let(:heading) { create(:budget_heading, group: group) } + + let!(:investment1) { create(:budget_investment, :winner, heading: heading) } + let!(:investment2) { create(:budget_investment, :winner, heading: heading) } + let!(:investment4) { create(:budget_investment, :winner, heading: heading) } + let!(:investment3) { create(:budget_investment, :incompatible, heading: heading) } + + scenario 'only displays investments with milestones' do + create(:budget_investment_milestone, investment: investment1) + + visit budget_path(budget) + click_link 'See results' + + expect(page).to have_link('Milestones') + + click_link 'Milestones' + + expect(page).to have_content(investment1.title) + expect(page).not_to have_content(investment2.title) + expect(page).not_to have_content(investment3.title) + expect(page).not_to have_content(investment4.title) + end + + scenario "Do not display headings with no winning investments for selected status" do + create(:budget_investment_milestone, investment: investment1) + + empty_group = create(:budget_group, budget: budget) + empty_heading = create(:budget_heading, group: empty_group, price: 1000) + + visit budget_path(budget) + click_link 'See results' + + expect(page).to have_content(heading.name) + expect(page).to have_content(empty_heading.name) + + click_link 'Milestones' + + expect(page).to have_content(heading.name) + expect(page).not_to have_content(empty_heading.name) + end + + scenario "Show message when there are no winning investments with the selected status", :js do + create(:budget_investment_status, name: I18n.t('seeds.budgets.statuses.executed')) + + visit budget_path(budget) + + click_link 'See results' + click_link 'Milestones' + + expect(page).not_to have_content('No winner investments in this state') + + select 'Executed (0)', from: 'status' + + expect(page).to have_content('No winner investments in this state') + end + + context 'Images' do + scenario 'renders milestone image if available' do + milestone1 = create(:budget_investment_milestone, investment: investment1) + create(:image, imageable: milestone1) + + visit budget_path(budget) + + click_link 'See results' + click_link 'Milestones' + + expect(page).to have_content(investment1.title) + expect(page).to have_css("img[alt='#{milestone1.image.title}']") + end + + scenario 'renders investment image if no milestone image is available' do + create(:budget_investment_milestone, investment: investment2) + create(:image, imageable: investment2) + + visit budget_path(budget) + + click_link 'See results' + click_link 'Milestones' + + expect(page).to have_content(investment2.title) + expect(page).to have_css("img[alt='#{investment2.image.title}']") + end + + scenario 'renders default image if no milestone nor investment images are available' do + create(:budget_investment_milestone, investment: investment4) + + visit budget_path(budget) + + click_link 'See results' + click_link 'Milestones' + + expect(page).to have_content(investment4.title) + expect(page).to have_css("img[alt='#{investment4.title}']") + end + + scenario "renders last milestone's image if investment has multiple milestones with images associated" do + milestone1 = create(:budget_investment_milestone, investment: investment1, + publication_date: 2.weeks.ago) + + milestone2 = create(:budget_investment_milestone, investment: investment1, + publication_date: Date.yesterday) + + create(:image, imageable: milestone1, title: 'First milestone image') + create(:image, imageable: milestone2, title: 'Second milestone image') + + visit budget_path(budget) + + click_link 'See results' + click_link 'Milestones' + + expect(page).to have_content(investment1.title) + expect(page).to have_css("img[alt='#{milestone2.image.title}']") + expect(page).not_to have_css("img[alt='#{milestone1.image.title}']") + end + end + + context 'Filters' do + + let!(:status1) { create(:budget_investment_status, name: I18n.t('seeds.budgets.statuses.studying_project')) } + let!(:status2) { create(:budget_investment_status, name: I18n.t('seeds.budgets.statuses.bidding')) } + + scenario 'Filters select with counter are shown' do + create(:budget_investment_milestone, investment: investment1, + publication_date: Date.yesterday, + status: status1) + + create(:budget_investment_milestone, investment: investment2, + publication_date: Date.yesterday, + status: status2) + + visit budget_path(budget) + + click_link 'See results' + click_link 'Milestones' + + expect(page).to have_content("All (2)") + expect(page).to have_content("#{status1.name} (1)") + expect(page).to have_content("#{status2.name} (1)") + end + + scenario 'by milestone status', :js do + create(:budget_investment_milestone, investment: investment1, status: status1) + create(:budget_investment_milestone, investment: investment2, status: status2) + create(:budget_investment_status, name: I18n.t('seeds.budgets.statuses.executing_project')) + + visit budget_path(budget) + + click_link 'See results' + click_link 'Milestones' + + expect(page).to have_content(investment1.title) + expect(page).to have_content(investment2.title) + + select 'Studying the project (1)', from: 'status' + + expect(page).to have_content(investment1.title) + expect(page).not_to have_content(investment2.title) + + select 'Bidding (1)', from: 'status' + + expect(page).to have_content(investment2.title) + expect(page).not_to have_content(investment1.title) + + select 'Executing the project (0)', from: 'status' + + expect(page).not_to have_content(investment1.title) + expect(page).not_to have_content(investment2.title) + end + + scenario 'are based on latest milestone status', :js do + create(:budget_investment_milestone, investment: investment1, + publication_date: 1.month.ago, + status: status1) + + create(:budget_investment_milestone, investment: investment1, + publication_date: Date.yesterday, + status: status2) + + visit budget_path(budget) + click_link 'See results' + click_link 'Milestones' + + select 'Studying the project (0)', from: 'status' + expect(page).not_to have_content(investment1.title) + + select 'Bidding (1)', from: 'status' + expect(page).to have_content(investment1.title) + end + + scenario 'milestones with future dates are not shown', :js do + create(:budget_investment_milestone, investment: investment1, + publication_date: Date.yesterday, + status: status1) + + create(:budget_investment_milestone, investment: investment1, + publication_date: Date.tomorrow, + status: status2) + + visit budget_path(budget) + click_link 'See results' + click_link 'Milestones' + + select 'Studying the project (1)', from: 'status' + expect(page).to have_content(investment1.title) + + select 'Bidding (0)', from: 'status' + expect(page).not_to have_content(investment1.title) + end + end + + context 'Heading Order' do + + def create_heading_with_investment_with_milestone(group:, name:) + heading = create(:budget_heading, group: group, name: name) + investment = create(:budget_investment, :winner, heading: heading) + milestone = create(:budget_investment_milestone, investment: investment) + heading + end + + scenario 'City heading is displayed first' do + heading.destroy! + other_heading1 = create_heading_with_investment_with_milestone(group: group, name: 'Other 1') + city_heading = create_heading_with_investment_with_milestone(group: group, name: 'Toda la ciudad') + other_heading2 = create_heading_with_investment_with_milestone(group: group, name: 'Other 2') + + visit budget_executions_path(budget) + + expect(page).to have_css('.budget-execution', count: 3) + expect(city_heading.name).to appear_before(other_heading1.name) + expect(city_heading.name).to appear_before(other_heading2.name) + end + + scenario 'Non-city headings are displayed in alphabetical order' do + heading.destroy! + z_heading = create_heading_with_investment_with_milestone(group: group, name: 'Zzz') + a_heading = create_heading_with_investment_with_milestone(group: group, name: 'Aaa') + m_heading = create_heading_with_investment_with_milestone(group: group, name: 'Mmm') + + visit budget_executions_path(budget) + + expect(page).to have_css('.budget-execution', count: 3) + expect(a_heading.name).to appear_before(m_heading.name) + expect(m_heading.name).to appear_before(z_heading.name) + end + end +end diff --git a/spec/models/budget/investment/milestone_spec.rb b/spec/models/budget/investment/milestone_spec.rb index 59cbe1a68..45d4e7c0b 100644 --- a/spec/models/budget/investment/milestone_spec.rb +++ b/spec/models/budget/investment/milestone_spec.rb @@ -69,4 +69,16 @@ describe Budget::Investment::Milestone do end end + describe ".published" do + it "uses the application's time zone date", :with_different_time_zone do + published_in_local_time_zone = create(:budget_investment_milestone, + publication_date: Date.today) + + published_in_application_time_zone = create(:budget_investment_milestone, + publication_date: Date.current) + + expect(Budget::Investment::Milestone.published).to include(published_in_application_time_zone) + expect(Budget::Investment::Milestone.published).not_to include(published_in_local_time_zone) + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f14ee4041..d845190ed 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -90,6 +90,17 @@ RSpec.configure do |config| travel_back end + config.before(:each, :with_different_time_zone) do + system_zone = ActiveSupport::TimeZone.new("UTC") + local_zone = ActiveSupport::TimeZone.new("Madrid") + + # Make sure the date defined by `config.time_zone` and + # the local date are different. + allow(Time).to receive(:zone).and_return(system_zone) + allow(Time).to receive(:now).and_return(Date.current.at_end_of_day.in_time_zone(local_zone)) + allow(Date).to receive(:today).and_return(Time.now.to_date) + end + # Allows RSpec to persist some state between runs in order to support # the `--only-failures` and `--next-failure` CLI options. config.example_status_persistence_file_path = "spec/examples.txt"