diff --git a/.env-example b/.env-example index c77a6034e8..842af62c1f 100644 --- a/.env-example +++ b/.env-example @@ -105,4 +105,7 @@ SMS_GATEWAY_MB_ACCOUNT_ID= # Automatically save AH metadata to user extended data # Format : comma separated list of auhtorization handler names -# AUTO_EXPORT_AUTHORIZATIONS_DATA_TO_USER_DATA_ENABLED_FOR="authorization1,authorization2" \ No newline at end of file +# AUTO_EXPORT_AUTHORIZATIONS_DATA_TO_USER_DATA_ENABLED_FOR="authorization1,authorization2" + +# Sort participatory processes by date +SORT_PROCESSES_BY_DATE=false diff --git a/OVERLOADS.md b/OVERLOADS.md index 95d0041245..7e9b14d431 100644 --- a/OVERLOADS.md +++ b/OVERLOADS.md @@ -2,6 +2,19 @@ * `app/cells/decidim/version_cell.rb` This override the default `VersionCell` from `decidim-core`, by adding sanitization for `version_number` to prevent XSS attacks. +* `app/controllers/decidim/assemblies/assemblies_controller.rb` +This override the default `AssembliesController` from `decidim-assemblies`, by adding custom sort for assembly_participatory_processes + +* `app/helpers/decidim/assemblies/assemblies_helper.rb` +This override the default `AssembliesHelpler` from `decidim-assemblies`, by adding custom html for sorted assembly_participatory_processes + +* `app/controllers/decidim/participatory_processes/participatory_processes_controller.rb` +This override the default `ParticipatoryProcessesController` from `decidim-participatory_processes`, by adding custom sort for participatory_processes + +## Initiative form +* `lib/extends/forms/decidim/initiatives/initiative_form_extends.rb` +This adds a validation to form's description. + ## Proposal's draft (Decidim awesome overrides 0.26.7) * `app/views/decidim/proposals/collaborative_drafts/_edit_form_fields.html.erb` diff --git a/app/controllers/decidim/assemblies/assemblies_controller.rb b/app/controllers/decidim/assemblies/assemblies_controller.rb new file mode 100644 index 0000000000..6fa700f10d --- /dev/null +++ b/app/controllers/decidim/assemblies/assemblies_controller.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Decidim + module Assemblies + # A controller that holds the logic to show Assemblies in a public layout. + class AssembliesController < Decidim::Assemblies::ApplicationController + include ParticipatorySpaceContext + participatory_space_layout only: :show + include FilterResource + + helper_method :parent_assemblies, :promoted_assemblies, :stats, :assembly_participatory_processes, :current_assemblies_settings + + def index + enforce_permission_to :list, :assembly + + respond_to do |format| + format.html do + raise ActionController::RoutingError, "Not Found" if published_assemblies.none? + + render "index" + end + + format.js do + raise ActionController::RoutingError, "Not Found" if published_assemblies.none? + + render "index" + end + + format.json do + render json: published_assemblies.query.includes(:children).where(parent: nil).collect { |assembly| + { + name: assembly.title[I18n.locale.to_s], + children: assembly.children.collect do |child| + { + name: child.title[I18n.locale.to_s], + children: child.children.collect { |child_of_child| { name: child_of_child.title[I18n.locale.to_s] } } + } + end + } + } + end + end + end + + def show + enforce_permission_to :read, :assembly, assembly: current_participatory_space + end + + private + + def search_collection + Assembly.where(organization: current_organization).published.visible_for(current_user) + end + + def default_filter_params + { + with_scope: nil, + with_area: nil, + type_id_eq: nil + } + end + + def current_participatory_space + return unless params[:slug] + + @current_participatory_space ||= OrganizationAssemblies.new(current_organization).query.where(slug: params[:slug]).or( + OrganizationAssemblies.new(current_organization).query.where(id: params[:slug]) + ).first! + end + + def published_assemblies + @published_assemblies ||= OrganizationPublishedAssemblies.new(current_organization, current_user) + end + + def promoted_assemblies + @promoted_assemblies ||= published_assemblies | PromotedAssemblies.new + end + + def parent_assemblies + search.result.parent_assemblies.order(weight: :asc, promoted: :desc) + end + + def stats + @stats ||= AssemblyStatsPresenter.new(assembly: current_participatory_space) + end + + def assembly_participatory_processes + if Rails.application.secrets.dig(:decidim, :participatory_processes, :sort_by_date) == false + @assembly_participatory_processes ||= @current_participatory_space.linked_participatory_space_resources(:participatory_processes, "included_participatory_processes") + else + @assembly_participatory_processes = @current_participatory_space.linked_participatory_space_resources(:participatory_processes, "included_participatory_processes") + sorted_by_date = { + active: @assembly_participatory_processes.active_spaces.sort_by(&:end_date), + future: @assembly_participatory_processes.future_spaces.sort_by(&:start_date), + past: @assembly_participatory_processes.past_spaces.sort_by(&:end_date).reverse + } + @assembly_participatory_processes = sorted_by_date + end + end + + def current_assemblies_settings + @current_assemblies_settings ||= Decidim::AssembliesSetting.find_or_create_by(decidim_organization_id: current_organization.id) + end + end + end +end diff --git a/app/controllers/decidim/initiatives/admin/initiatives_controller.rb b/app/controllers/decidim/initiatives/admin/initiatives_controller.rb index 06fbfba3e2..2f4a2a6ec8 100644 --- a/app/controllers/decidim/initiatives/admin/initiatives_controller.rb +++ b/app/controllers/decidim/initiatives/admin/initiatives_controller.rb @@ -34,6 +34,8 @@ def edit initiative: current_initiative ) @form.attachment = form_attachment_model + # "sanitize" the translated description, if the value is a hash (for machine_translation key) we don't modify it + @form.description.transform_values! { |v| v.instance_of?(String) ? v.gsub(/on\w+=("|')/, "nothing") : v } render layout: "decidim/admin/initiative" end diff --git a/app/controllers/decidim/participatory_processes/participatory_processes_controller.rb b/app/controllers/decidim/participatory_processes/participatory_processes_controller.rb new file mode 100644 index 0000000000..e1d755b7a3 --- /dev/null +++ b/app/controllers/decidim/participatory_processes/participatory_processes_controller.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module Decidim + module ParticipatoryProcesses + # A controller that holds the logic to show ParticipatoryProcesses in a + # public layout. + class ParticipatoryProcessesController < Decidim::ParticipatoryProcesses::ApplicationController + include ParticipatorySpaceContext + participatory_space_layout only: [:show, :all_metrics] + include FilterResource + + helper_method :collection, + :promoted_collection, + :participatory_processes, + :stats, + :metrics, + :participatory_process_group, + :default_date_filter, + :related_processes, + :linked_assemblies + + def index + raise ActionController::RoutingError, "Not Found" if published_processes.none? + + enforce_permission_to :list, :process + enforce_permission_to :list, :process_group + end + + def show + enforce_permission_to :read, :process, process: current_participatory_space + end + + def all_metrics + if current_participatory_space.show_statistics + enforce_permission_to :read, :process, process: current_participatory_space + else + render status: :not_found + end + end + + private + + def search_collection + ParticipatoryProcess.where(organization: current_organization).published.visible_for(current_user).includes(:area) + end + + def default_filter_params + { + with_scope: nil, + with_area: nil, + with_type: nil, + with_date: default_date_filter + } + end + + def organization_participatory_processes + @organization_participatory_processes ||= OrganizationParticipatoryProcesses.new(current_organization).query + end + + def current_participatory_space + return unless params["slug"] + + @current_participatory_space ||= organization_participatory_processes.where(slug: params["slug"]).or( + organization_participatory_processes.where(id: params["slug"]) + ).first! + end + + def published_processes + @published_processes ||= OrganizationPublishedParticipatoryProcesses.new(current_organization, current_user) + end + + def promoted_participatory_processes + @promoted_participatory_processes ||= published_processes | PromotedParticipatoryProcesses.new + end + + def promoted_participatory_process_groups + @promoted_participatory_process_groups ||= OrganizationPromotedParticipatoryProcessGroups.new(current_organization) + end + + def promoted_collection + @promoted_collection ||= promoted_participatory_processes.query + promoted_participatory_process_groups.query + end + + def collection + @collection ||= participatory_processes + participatory_process_groups + end + + def filtered_processes + search.result + end + + def participatory_processes + @participatory_processes ||= filtered_processes.groupless.includes(attachments: :file_attachment) + return @participatory_processes if Rails.application.secrets.dig(:decidim, :participatory_processes, :sort_by_date) == false + + custom_sort(search.with_date) + end + + def participatory_process_groups + @participatory_process_groups ||= OrganizationParticipatoryProcessGroups.new(current_organization).query + .where(id: filtered_processes.grouped.group_ids) + end + + def stats + @stats ||= ParticipatoryProcessStatsPresenter.new(participatory_process: current_participatory_space) + end + + def metrics + @metrics ||= ParticipatoryProcessMetricChartsPresenter.new(participatory_process: current_participatory_space, view_context: view_context) + end + + def participatory_process_group + @participatory_process_group ||= current_participatory_space.participatory_process_group + end + + def default_date_filter + return "active" if published_processes.any?(&:active?) + return "upcoming" if published_processes.any?(&:upcoming?) + return "past" if published_processes.any?(&:past?) + + "all" + end + + def related_processes + @related_processes ||= + current_participatory_space + .linked_participatory_space_resources(:participatory_processes, "related_processes") + .published + .all + end + + def linked_assemblies + @linked_assemblies ||= current_participatory_space.linked_participatory_space_resources(:assembly, "included_participatory_processes").public_spaces + end + + def custom_sort(date) + case date + when "active" + @participatory_processes.sort_by(&:end_date) + when "past" + @participatory_processes.sort_by(&:end_date).reverse + when "upcoming" + @participatory_processes.sort_by(&:start_date) + when "all" + @participatory_processes = sort_all_processes + else + @participatory_processes + end + end + + def sort_all_processes + actives = @participatory_processes.select(&:active?).sort_by(&:end_date) + pasts = @participatory_processes.select(&:past?).sort_by(&:end_date).reverse + upcomings = @participatory_processes.select(&:upcoming?).sort_by(&:start_date) + (actives + upcomings + pasts) + end + end + end +end diff --git a/app/helpers/decidim/assemblies/assemblies_helper.rb b/app/helpers/decidim/assemblies/assemblies_helper.rb new file mode 100644 index 0000000000..4da2bb6aa9 --- /dev/null +++ b/app/helpers/decidim/assemblies/assemblies_helper.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Decidim + module Assemblies + # Helpers related to the Assemblies layout. + module AssembliesHelper + include Decidim::ResourceHelper + include Decidim::AttachmentsHelper + include Decidim::IconHelper + include Decidim::WidgetUrlsHelper + include Decidim::SanitizeHelper + include Decidim::ResourceReferenceHelper + include Decidim::FiltersHelper + include FilterAssembliesHelper + + # Public: Returns the characteristics of an assembly in a readable format like + # "title: close, no public, no transparent and is restricted to the members of the assembly" + def participatory_processes_for_assembly(assembly_participatory_processes) + if Rails.application.secrets.dig(:decidim, :participatory_processes, :sort_by_date) == true + sorted_participatory_processes_for_assembly(assembly_participatory_processes) + else + html = "" + html += %(
).html_safe + html += %(

#{t("assemblies.show.related_participatory_processes", scope: "decidim")}

).html_safe + html += %(
).html_safe + assembly_participatory_processes.each do |assembly_participatory_process| + html += render partial: "decidim/participatory_processes/participatory_process", locals: { participatory_process: assembly_participatory_process } + end + html += %(
).html_safe + html += %(
).html_safe + html.html_safe + end + end + + def sorted_participatory_processes_for_assembly(assembly_participatory_processes) + return if assembly_participatory_processes.values.all?(&:empty?) + + html = "" + html += %(
).html_safe + html += %(

#{t("assemblies.show.related_participatory_processes", scope: "decidim")} ).html_safe + + assembly_participatory_processes.each do |type, processes| + next if processes.empty? + + html += %( + #{t("assemblies.show.#{type}_assembly_participatory_processes_mini", scope: "decidim")} + (#{processes.count}) ).html_safe + end + + html += %(

).html_safe + html += %(
).html_safe + + assembly_participatory_processes.each do |type, processes| + next if processes.empty? + + html += %(
+ #{t("assemblies.show.#{type}_assembly_participatory_processes", scope: "decidim")}
).html_safe + html += %(
).html_safe + processes.each do |process| + html += render partial: "decidim/participatory_processes/participatory_process", locals: { participatory_process: process } + end + html += %(
).html_safe + end + html += %(
).html_safe + html += %(
).html_safe + html.html_safe + end + + def assembly_features(assembly) + html = "".html_safe + html += "#{translated_attribute(assembly.title)}: ".html_safe + html += t("assemblies.show.private_space", scope: "decidim").to_s.html_safe + html += ", #{t("assemblies.show.is_transparent.#{assembly.is_transparent}", scope: "decidim")}".html_safe if assembly.is_transparent? + html += " #{decidim_sanitize_editor translated_attribute(assembly.special_features)}".html_safe + html.html_safe + end + + def social_handler_links(assembly) + html = "".html_safe + if Decidim::Assembly::SOCIAL_HANDLERS.any? { |h| assembly.try("#{h}_handler").present? } + html += "
".html_safe + html += "#{t("assemblies.show.social_networks", scope: "decidim")}".html_safe + Decidim::Assembly::SOCIAL_HANDLERS.each do |handler| + handler_name = "#{handler}_handler" + next if assembly.send(handler_name).blank? + + html += link_to handler.capitalize, "https://#{handler}.com/#{assembly.send(handler_name)}", + target: "_blank", + class: "", + title: t("assemblies.show.social_networks_title", scope: "decidim") << " " << handler.capitalize.to_s, rel: "noopener" + end + html += "
".html_safe + end + + html.html_safe + end + end + end +end diff --git a/config/application.rb b/config/application.rb index 1fbb48f0e9..2ea5e51ef6 100644 --- a/config/application.rb +++ b/config/application.rb @@ -37,7 +37,6 @@ class Application < Rails::Application # Application configuration can go into files in config/initializers # -- all .rb files in that directory are automatically loaded after loading # the framework and any gems in your application. - config.to_prepare do require "extends/helpers/decidim/forms/application_helper_extends" require "extends/cells/decidim/forms/step_navigation_cell_extends" @@ -57,6 +56,7 @@ class Application < Rails::Application require "extends/controllers/decidim/newsletters_controller_extends" require "extends/commands/decidim/admin/destroy_participatory_space_private_user_extends" require "extends/controllers/decidim/proposals/proposals_controller_extends" + require "extends/forms/decidim/initiatives/initiative_form_extends" Decidim::GraphiQL::Rails.config.tap do |config| config.initial_query = "{\n deployment {\n version\n branch\n remote\n upToDate\n currentCommit\n latestCommit\n locallyModified\n }\n}".html_safe diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 8ee5d31fe6..5036104ae8 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -151,6 +151,7 @@ ignore_unused: - decidim.proposals.collaborative_drafts.new.* - decidim.admin.menu.admin_accountability - decidim.anonymous_user + - decidim.assemblies.show.* - decidim.events.initiatives.initiative_answered.* - decidim.initiatives.pages.home.highlighted_initiatives.* - activemodel.attributes.attachment.documents diff --git a/config/locales/en.yml b/config/locales/en.yml index 11e94e9d1b..477839a9f7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -30,6 +30,18 @@ en: This amendment for %{amendable_type} %{proposal_link} is in evaluation state. anonymous_user: Anonymous user + assemblies: + show: + active_assembly_participatory_processes: Active participatory processes + active_assembly_participatory_processes_mini: Active + future_assembly_participatory_processes: Future participatory processes + future_assembly_participatory_processes_mini: Future + past_assembly_participatory_processes: Past participatory processes + past_assembly_participatory_processes_mini: Past + private_space: This is a private assembly + related_participatory_processes: Related participatory processes + social_networks: Social Networks + social_networks_title: Visit assembly on authorization_handlers: osp_authorization_handler: explanation: Verify your identity by entering a unique number diff --git a/config/locales/fr.yml b/config/locales/fr.yml index d43a239a1b..a82163e17b 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -32,6 +32,18 @@ fr: Cet amendement pour le %{amendable_type} %{proposal_link} est en cours d’évaluation. anonymous_user: Utilisateur anonyme + assemblies: + show: + active_assembly_participatory_processes: Concertations actives + active_assembly_participatory_processes_mini: Actives + future_assembly_participatory_processes: Concertations futures + future_assembly_participatory_processes_mini: Futures + past_assembly_participatory_processes: Concertations passées + past_assembly_participatory_processes_mini: Passées + private_space: Ceci est une assemblée privée + related_participatory_processes: Concertations associées + social_networks: Réseaux sociaux + social_networks_title: Visiter l'assemblée sur authorization_handlers: osp_authorization_handler: explanation: Vérifier votre identité en saisissant un numéro unique diff --git a/config/secrets.yml b/config/secrets.yml index fdd8ea4d56..e9c2c8d3f8 100644 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -43,6 +43,8 @@ default: &default throttle: max_requests: <%= ENV["THROTTLING_MAX_REQUESTS"]&.to_i || 100 %> period: <%= ENV["THROTTLING_PERIOD"]&.to_i || 60 %> + participatory_processes: + sort_by_date: <%= ENV.fetch("SORT_PROCESSES_BY_DATE", "false") == "true" %> sms_gateway: service: <%= ENV.fetch("SMS_GATEWAY_SERVICE", "Decidim::Verifications::Sms::ExampleGateway") %> url: <%= ENV["SMS_GATEWAY_URL"] %> diff --git a/db/seeds.rb b/db/seeds.rb index a283560e07..1b5c54b80d 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -17,3 +17,88 @@ ENV["SEED"] = "true" end Decidim.seed! + +org = Decidim::Organization.first + +actives = [{ + slug: "slug-#{Random.rand(1..9_999)}", + weight: Random.rand(10), + start_date: 6.months.ago, + end_date: 3.months.from_now +}, { + slug: "slug-#{Random.rand(1..9_999)}", + weight: Random.rand(10), + start_date: 1.month.ago, + end_date: 1.month.from_now +}, + { + slug: "slug-#{Random.rand(1..9_999)}", + weight: Random.rand(10), + start_date: 1.year.ago, + end_date: 2.years.from_now + }] + +futures = [{ + slug: "slug-#{Random.rand(1..9_999)}", + weight: Random.rand(10), + start_date: 1.year.from_now, + end_date: 4.years.from_now +}, + { + slug: "slug-#{Random.rand(1..9_999)}", + weight: Random.rand(10), + start_date: 2.months.from_now, + end_date: 4.months.from_now + }, + { + slug: "slug-#{Random.rand(1..9_999)}", + weight: Random.rand(10), + start_date: 19.years.from_now, + end_date: 35.years.from_now + }, + { + slug: "slug-#{Random.rand(1..9_999)}", + weight: Random.rand(10), + start_date: 1.week.from_now, + end_date: 2.weeks.from_now + }] + +pasts = [{ + slug: "slug-#{Random.rand(1..9_999)}", + weight: Random.rand(10), + start_date: 1.week.ago, + end_date: 3.days.ago +}, + { + slug: "slug-#{Random.rand(1..9_999)}", + weight: Random.rand(10), + start_date: 2.months.ago, + end_date: 1.month.ago + }, + { + slug: "slug-#{Random.rand(1..9_999)}", + weight: Random.rand(10), + start_date: 19.years.ago, + end_date: 4.years.ago + }, + { + slug: "slug-#{Random.rand(1..9_999)}", + weight: Random.rand(10), + start_date: 10.years.ago, + end_date: 1.week.ago + }] + +(actives + futures + pasts).each_with_index do |process, index| + Decidim::ParticipatoryProcess.create!( + title: { fr: "Participatory process d'essai #{index} (start: #{process[:start_date].strftime("%d-%m-%Y")})" }, + subtitle: { fr: "Participatory process d'essai" }, + description: { fr: "

Participatory process d'essai

" }, + short_description: { fr: "

Participatory process d'essai

" }, + published_at: Time.current, + organization: org, + slug: process[:slug], + weight: process[:weight], + start_date: process[:start_date], + end_date: process[:end_date] + ) +end diff --git a/lib/extends/forms/decidim/initiatives/initiative_form_extends.rb b/lib/extends/forms/decidim/initiatives/initiative_form_extends.rb new file mode 100644 index 0000000000..9918c015a2 --- /dev/null +++ b/lib/extends/forms/decidim/initiatives/initiative_form_extends.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "active_support/concern" + +module NoAdminInitiativeFormExtends + extend ActiveSupport::Concern + + included do + validate :no_javascript_event_in_description + + private + + def no_javascript_event_in_description + errors.add :description, :invalid if description =~ /on\w+=/ + end + end +end + +Decidim::Initiatives::InitiativeForm.include(NoAdminInitiativeFormExtends) diff --git a/spec/controllers/assemblies_controller_spec.rb b/spec/controllers/assemblies_controller_spec.rb new file mode 100644 index 0000000000..f28fd0e2c6 --- /dev/null +++ b/spec/controllers/assemblies_controller_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Assemblies + describe AssembliesController, type: :controller do + routes { Decidim::Assemblies::Engine.routes } + + let(:organization) { create(:organization) } + + let!(:unpublished_assembly) do + create( + :assembly, + :unpublished, + organization: organization + ) + end + + let!(:published) do + create( + :assembly, + :published, + organization: organization + ) + end + + let!(:promoted) do + create( + :assembly, + :published, + :promoted, + organization: organization + ) + end + + before do + request.env["decidim.current_organization"] = organization + end + + describe "published_assemblies" do + context "when there are no published assemblies" do + before do + published.unpublish! + promoted.unpublish! + end + + it "redirects to 404" do + expect { get :index }.to raise_error(ActionController::RoutingError) + end + end + end + + describe "GET assemblies in json format" do + let!(:first_level) { create(:assembly, :published, :with_parent, parent: published, organization: organization) } + let!(:second_level) { create(:assembly, :published, :with_parent, parent: first_level, organization: organization) } + let!(:third_level) { create(:assembly, :published, :with_parent, parent: second_level, organization: organization) } + + let(:parsed_response) { JSON.parse(response.body, symbolize_names: true) } + + it "includes only published assemblies with their children (two levels)" do + get :index, format: :json + expect(parsed_response).to match_array( + [ + { + name: translated(promoted.title), + children: [] + }, + { + name: translated(published.title), + children: [ + { + name: translated(first_level.title), + children: [{ name: translated(second_level.title) }] + } + ] + } + ] + ) + end + end + + describe "promoted_assemblies" do + it "includes only promoted" do + expect(controller.helpers.promoted_assemblies).to contain_exactly(promoted) + end + end + + describe "parent_assemblies" do + let!(:child_assembly) { create(:assembly, parent: published, organization: organization) } + + it "includes only parent assemblies, with promoted listed first" do + expect(controller.helpers.parent_assemblies.first).to eq(promoted) + expect(controller.helpers.parent_assemblies.second).to eq(published) + end + end + + describe "assembly_participatory_processes" do + let!(:organization) { create(:organization) } + let!(:past_processes) do + 5.times.map do |i| + create( + :participatory_process, + :published, + organization: organization, + start_date: Time.zone.now - (i + 10).days, + end_date: Time.zone.now - (i + 5).days + ) + end + end + + let!(:active_processes) do + 5.times.map do |i| + create( + :participatory_process, + :published, + organization: organization, + start_date: Time.zone.now - (i + 5).days, + end_date: Time.zone.now + (i + 5).days + ) + end + end + + let!(:upcoming_processes) do + 5.times.map do |i| + create( + :participatory_process, + :published, + organization: organization, + start_date: Time.zone.now + (i + 5).days, + end_date: Time.zone.now + (i + 10).days + ) + end + end + + let(:participatory_processes) do + past_processes + active_processes + upcoming_processes + end + + before do + published.link_participatory_space_resources(participatory_processes, "included_participatory_processes") + current_participatory_space = published + controller.instance_variable_set(:@current_participatory_space, current_participatory_space) + end + + context "when sort_by_date variable is true" do + before do + allow(Rails.application.secrets).to receive(:dig).with(:decidim, :participatory_processes, :sort_by_date).and_return(true) + end + + it "includes only participatory processes related to the assembly, actives one by end_date then upcoming ones by start_date then past ones by end_date reversed" do + sorted_participatory_processes = { + active: participatory_processes.select(&:active?).sort_by(&:end_date), + future: participatory_processes.select(&:upcoming?).sort_by(&:start_date), + past: participatory_processes.select(&:past?).sort_by(&:end_date).reverse + } + expect(controller.helpers.assembly_participatory_processes).to eq(sorted_participatory_processes) + end + + it "includes only active participatory processes" do + expect(controller.helpers.assembly_participatory_processes[:active].all?(&:active?)).to be true + end + + it "includes only upcoming participatory processes" do + expect(controller.helpers.assembly_participatory_processes[:future].all?(&:upcoming?)).to be true + end + + it "includes only past participatory processes" do + expect(controller.helpers.assembly_participatory_processes[:past].all?(&:past?)).to be true + end + end + + context "when sort_by_date variable is false" do + before do + allow(Rails.application.secrets).to receive(:dig).with(:decidim, :participatory_processes, :sort_by_date).and_return(false) + end + + it "includes only participatory processes related to the assembly, in random order" do + allow(Rails.application.secrets).to receive(:dig).with(:decidim, :participatory_processes, :sort_by_date).and_return(false) + expect(controller.helpers.assembly_participatory_processes).to eq(published.linked_participatory_space_resources(:participatory_processes, "included_participatory_processes")) + end + end + end + + describe "GET show" do + context "when the assembly is unpublished" do + it "redirects to sign in path" do + get :show, params: { slug: unpublished_assembly.slug } + + expect(response).to redirect_to("/users/sign_in") + end + + context "with signed in user" do + let!(:user) { create(:user, :confirmed, organization: organization) } + + before do + sign_in user, scope: :user + end + + it "redirects to root path" do + get :show, params: { slug: unpublished_assembly.slug } + + expect(response).to redirect_to("/") + end + end + end + end + end + end +end diff --git a/spec/controllers/decidim/initiatives/admin/initiatives_controller_spec.rb b/spec/controllers/decidim/initiatives/admin/initiatives_controller_spec.rb new file mode 100644 index 0000000000..f76c91dd4d --- /dev/null +++ b/spec/controllers/decidim/initiatives/admin/initiatives_controller_spec.rb @@ -0,0 +1,632 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe Decidim::Initiatives::Admin::InitiativesController do + routes { Decidim::Initiatives::AdminEngine.routes } + render_views + + let(:user) { create(:user, :confirmed, :admin_terms_accepted, organization: organization) } + let(:admin_user) { create(:user, :admin, :confirmed, organization: organization) } + let(:organization) { create(:organization) } + let!(:initiative) { create(:initiative, organization: organization) } + let!(:created_initiative) { create(:initiative, :created, organization: organization) } + + before do + request.env["decidim.current_organization"] = organization + initiative.author.update(admin_terms_accepted_at: Time.current) + initiative.committee_members.approved.first.user.update(admin_terms_accepted_at: Time.current) + created_initiative.author.update(admin_terms_accepted_at: Time.current) + end + + context "when index" do + context "and Users without initiatives" do + before do + sign_in user, scope: :user + end + + it "initiative list is not allowed" do + get :index + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and anonymous users do" do + it "initiative list is not allowed" do + get :index + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and admin users" do + before do + sign_in admin_user, scope: :user + end + + it "initiative list is allowed" do + get :index + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:ok) + end + end + + context "and initiative author" do + before do + sign_in initiative.author, scope: :user + end + + it "initiative list is allowed" do + get :index + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:ok) + end + end + + describe "and promotal committee members" do + before do + sign_in initiative.committee_members.approved.first.user, scope: :user + end + + it "initiative list is allowed" do + get :index + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:ok) + end + end + end + + context "when edit" do + context "and Users without initiatives" do + before do + sign_in user, scope: :user + end + + it "are not allowed" do + get :edit, params: { slug: initiative.to_param } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and anonymous users" do + it "are not allowed" do + get :edit, params: { slug: initiative.to_param } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and admin users" do + before do + sign_in admin_user, scope: :user + end + + it "are allowed" do + get :edit, params: { slug: initiative.to_param } + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:ok) + end + + context "and initiative description contains a javascript event" do + let!(:my_initiative) { create(:initiative, organization: organization, description: { en: 'img src="invalid.jpg" onerror="alert();">' }) } + + it "modifies description value" do + get :edit, params: { slug: my_initiative.to_param } + expect(response.body).to include("nothingalert()") + end + end + end + + context "and initiative author" do + before do + sign_in initiative.author, scope: :user + end + + it "are allowed" do + get :edit, params: { slug: initiative.to_param } + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:ok) + end + end + + context "and promotal committee members" do + before do + sign_in initiative.committee_members.approved.first.user, scope: :user + end + + it "are allowed" do + get :edit, params: { slug: initiative.to_param } + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:ok) + end + end + end + + context "when update" do + let(:valid_attributes) do + attrs = attributes_for(:initiative, organization: organization) + attrs[:signature_end_date] = I18n.l(attrs[:signature_end_date], format: :decidim_short) + attrs[:signature_start_date] = I18n.l(attrs[:signature_start_date], format: :decidim_short) + attrs + end + + context "and Users without initiatives" do + before do + sign_in user, scope: :user + end + + it "are not allowed" do + put :update, + params: { + slug: initiative.to_param, + initiative: valid_attributes + } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and anonymous users do" do + it "are not allowed" do + put :update, + params: { + slug: initiative.to_param, + initiative: valid_attributes + } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and admin users" do + before do + sign_in admin_user, scope: :user + end + + it "are allowed" do + put :update, + params: { + slug: initiative.to_param, + initiative: valid_attributes + } + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:found) + end + end + + context "and initiative author" do + context "and initiative published" do + before do + sign_in initiative.author, scope: :user + end + + it "are not allowed" do + put :update, + params: { + slug: initiative.to_param, + initiative: valid_attributes + } + expect(flash[:alert]).not_to be_nil + expect(response).to have_http_status(:found) + end + end + + context "and initiative created" do + let(:initiative) { create(:initiative, :created, organization: organization) } + + before do + sign_in initiative.author, scope: :user + end + + it "are allowed" do + put :update, + params: { + slug: initiative.to_param, + initiative: valid_attributes + } + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:found) + end + end + end + + context "and promotal committee members" do + context "and initiative published" do + before do + sign_in initiative.committee_members.approved.first.user, scope: :user + end + + it "are not allowed" do + put :update, + params: { + slug: initiative.to_param, + initiative: valid_attributes + } + expect(flash[:alert]).not_to be_nil + expect(response).to have_http_status(:found) + end + end + + context "and initiative created" do + let(:initiative) { create(:initiative, :created, organization: organization) } + + before do + sign_in initiative.committee_members.approved.first.user, scope: :user + end + + it "are allowed" do + put :update, + params: { + slug: initiative.to_param, + initiative: valid_attributes + } + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:found) + end + end + end + end + + context "when GET send_to_technical_validation" do + context "and Initiative in created state" do + context "and has not enough committee members" do + before do + created_initiative.author.confirm + sign_in created_initiative.author, scope: :user + end + + it "does not pass to technical validation phase" do + created_initiative.type.update(minimum_committee_members: 4) + get :send_to_technical_validation, params: { slug: created_initiative.to_param } + + created_initiative.reload + expect(created_initiative).not_to be_validating + end + + it "does pass to technical validation phase" do + created_initiative.type.update(minimum_committee_members: 3) + get :send_to_technical_validation, params: { slug: created_initiative.to_param } + + created_initiative.reload + expect(created_initiative).to be_validating + end + end + + context "and User is not the owner of the initiative" do + let(:other_user) { create(:user, organization: organization) } + + before do + sign_in other_user, scope: :user + end + + it "Raises an error" do + get :send_to_technical_validation, params: { slug: created_initiative.to_param } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and User is the owner of the initiative. It is in created state" do + before do + created_initiative.author.confirm + sign_in created_initiative.author, scope: :user + end + + it "Passes to technical validation phase" do + get :send_to_technical_validation, params: { slug: created_initiative.to_param } + + created_initiative.reload + expect(created_initiative).to be_validating + end + end + end + + context "and Initiative in discarded state" do + let!(:discarded_initiative) { create(:initiative, :discarded, organization: organization) } + + before do + discarded_initiative.author.update(admin_terms_accepted_at: Time.current) + sign_in discarded_initiative.author, scope: :user + end + + it "Passes to technical validation phase" do + get :send_to_technical_validation, params: { slug: discarded_initiative.to_param } + + discarded_initiative.reload + expect(discarded_initiative).to be_validating + end + end + + context "and Initiative not in created or discarded state (published)" do + before do + sign_in initiative.author, scope: :user + end + + it "Raises an error" do + get :send_to_technical_validation, params: { slug: initiative.to_param } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + end + + context "when POST publish" do + let!(:initiative) { create(:initiative, :validating, organization: organization) } + + context "and Initiative owner" do + before do + sign_in initiative.author, scope: :user + end + + it "Raises an error" do + post :publish, params: { slug: initiative.to_param } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and Administrator" do + let!(:admin) { create(:user, :confirmed, :admin, organization: organization) } + + before do + sign_in admin, scope: :user + end + + it "initiative gets published" do + post :publish, params: { slug: initiative.to_param } + expect(response).to have_http_status(:found) + + initiative.reload + expect(initiative).to be_published + expect(initiative.published_at).not_to be_nil + expect(initiative.signature_start_date).not_to be_nil + expect(initiative.signature_end_date).not_to be_nil + end + end + end + + context "when DELETE unpublish" do + context "and Initiative owner" do + before do + sign_in initiative.author, scope: :user + end + + it "Raises an error" do + delete :unpublish, params: { slug: initiative.to_param } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and Administrator" do + let(:admin) { create(:user, :confirmed, :admin, organization: organization) } + + before do + sign_in admin, scope: :user + end + + it "initiative gets unpublished" do + delete :unpublish, params: { slug: initiative.to_param } + expect(response).to have_http_status(:found) + + initiative.reload + expect(initiative).not_to be_published + expect(initiative).to be_discarded + expect(initiative.published_at).to be_nil + end + end + end + + context "when DELETE discard" do + let(:initiative) { create(:initiative, :validating, organization: organization) } + + context "and Initiative owner" do + before do + sign_in initiative.author, scope: :user + end + + it "Raises an error" do + delete :discard, params: { slug: initiative.to_param } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and Administrator" do + let(:admin) { create(:user, :confirmed, :admin, organization: organization) } + + before do + sign_in admin, scope: :user + end + + it "initiative gets discarded" do + delete :discard, params: { slug: initiative.to_param } + expect(response).to have_http_status(:found) + + initiative.reload + expect(initiative).to be_discarded + expect(initiative.published_at).to be_nil + end + end + end + + context "when POST accept" do + let!(:initiative) { create(:initiative, :acceptable, signature_type: "any", organization: organization) } + + context "and Initiative owner" do + before do + sign_in initiative.author, scope: :user + end + + it "Raises an error" do + post :accept, params: { slug: initiative.to_param } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "when Administrator" do + let!(:admin) { create(:user, :confirmed, :admin, organization: organization) } + + before do + sign_in admin, scope: :user + end + + it "initiative gets published" do + post :accept, params: { slug: initiative.to_param } + expect(response).to have_http_status(:found) + + initiative.reload + expect(initiative).to be_accepted + end + end + end + + context "when DELETE reject" do + let!(:initiative) { create(:initiative, :rejectable, signature_type: "any", organization: organization) } + + context "and Initiative owner" do + before do + sign_in initiative.author, scope: :user + end + + it "Raises an error" do + delete :reject, params: { slug: initiative.to_param } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "when Administrator" do + let!(:admin) { create(:user, :confirmed, :admin, organization: organization) } + + before do + sign_in admin, scope: :user + end + + it "initiative gets rejected" do + delete :reject, params: { slug: initiative.to_param } + expect(response).to have_http_status(:found) + expect(flash[:alert]).to be_nil + + initiative.reload + expect(initiative).to be_rejected + end + end + end + + context "when GET export_votes" do + let(:initiative) { create(:initiative, organization: organization, signature_type: "any") } + + context "and author" do + before do + sign_in initiative.author, scope: :user + end + + it "is not allowed" do + get :export_votes, params: { slug: initiative.to_param, format: :csv } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and promotal committee" do + before do + sign_in initiative.committee_members.approved.first.user, scope: :user + end + + it "is not allowed" do + get :export_votes, params: { slug: initiative.to_param, format: :csv } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and admin user" do + let!(:vote) { create(:initiative_user_vote, initiative: initiative) } + + before do + sign_in admin_user, scope: :user + end + + it "is allowed" do + get :export_votes, params: { slug: initiative.to_param, format: :csv } + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:ok) + end + end + end + + context "when GET export_pdf_signatures" do + let(:initiative) { create(:initiative, :with_user_extra_fields_collection, organization: organization) } + + context "and author" do + before do + sign_in initiative.author, scope: :user + end + + it "is not allowed" do + get :export_pdf_signatures, params: { slug: initiative.to_param, format: :pdf } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and admin" do + before do + sign_in admin_user, scope: :user + end + + it "is allowed" do + get :export_pdf_signatures, params: { slug: initiative.to_param, format: :pdf } + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:ok) + end + end + end + + context "when GET export" do + context "and user" do + before do + sign_in user, scope: :user + end + + it "is not allowed" do + expect(Decidim::Initiatives::ExportInitiativesJob).not_to receive(:perform_later).with(user, "CSV", nil) + + get :export, params: { format: :csv } + expect(flash[:alert]).not_to be_empty + expect(response).to have_http_status(:found) + end + end + + context "and admin" do + before do + sign_in admin_user, scope: :user + end + + it "is allowed" do + expect(Decidim::Initiatives::ExportInitiativesJob).to receive(:perform_later).with(admin_user, organization, "csv", nil) + + get :export, params: { format: :csv } + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:found) + end + + context "when a collection of ids is passed as a parameter" do + let!(:initiatives) { create_list(:initiative, 3, organization: organization) } + let(:collection_ids) { initiatives.map(&:id) } + + it "enqueues the job" do + expect(Decidim::Initiatives::ExportInitiativesJob).to receive(:perform_later).with(admin_user, organization, "csv", collection_ids) + + get :export, params: { format: :csv, collection_ids: collection_ids } + expect(flash[:alert]).to be_nil + expect(response).to have_http_status(:found) + end + end + end + end +end diff --git a/spec/controllers/participatory_processes_controller_spec.rb b/spec/controllers/participatory_processes_controller_spec.rb index b5257c2fc1..52d34d206b 100644 --- a/spec/controllers/participatory_processes_controller_spec.rb +++ b/spec/controllers/participatory_processes_controller_spec.rb @@ -110,6 +110,113 @@ module ParticipatoryProcesses end end + describe "participatory_processes" do + context "when there are active processes" do + let!(:active_processes) do + 5.times.map do |i| + create( + :participatory_process, + :published, + organization: organization, + start_date: Time.zone.now - (i + 5).days, + end_date: Time.zone.now + (i + 5).days + ) + end + end + + context "and sort_by_date is false" do + before do + allow(Rails.application.secrets).to receive(:dig).with(:decidim, :participatory_processes, :sort_by_date).and_return(false) + end + + it "includes active processes without ordering" do + expect(controller.helpers.participatory_processes.to_a).to eq(active_processes) + end + end + + context "and sort_by_date is true" do + before do + allow(Rails.application.secrets).to receive(:dig).with(:decidim, :participatory_processes, :sort_by_date).and_return(true) + end + # search.with_date will default to "active" + + it "orders active processes by end date" do + expect(controller.helpers.participatory_processes).to eq(active_processes.sort_by(&:end_date)) + end + end + end + + context "when there are upcoming processes" do + let!(:upcoming_processes) do + 5.times.map do |i| + create( + :participatory_process, + :published, + organization: organization, + start_date: Time.zone.now + (i + 2).days, + end_date: Time.zone.now + (i + 5).days + ) + end + end + + context "and sort_by_date is false" do + before do + allow(Rails.application.secrets).to receive(:dig).with(:decidim, :participatory_processes, :sort_by_date).and_return(false) + end + + it "includes upcoming processes without ordering" do + expect(controller.helpers.participatory_processes.to_a).to eq(upcoming_processes) + end + end + + context "and sort_by_date is true" do + before do + allow(Rails.application.secrets).to receive(:dig).with(:decidim, :participatory_processes, :sort_by_date).and_return(true) + end + # search.with_date will default to "upcoming" + + it "orders upcoming processes by start_date" do + expect(controller.helpers.participatory_processes).to eq(upcoming_processes.sort_by(&:start_date)) + end + end + end + + context "when there are past processes" do + let!(:past_processes) do + 5.times.map do |i| + create( + :participatory_process, + :published, + organization: organization, + start_date: Time.zone.now - (i + 10).days, + end_date: Time.zone.now - (i + 5).days + ) + end + end + + context "and sort_by_date is false" do + before do + allow(Rails.application.secrets).to receive(:dig).with(:decidim, :participatory_processes, :sort_by_date).and_return(false) + end + + it "includes past processes without ordering" do + expect(controller.helpers.participatory_processes.to_a).to eq(past_processes) + end + end + + context "and sort_by_date is true" do + before do + allow(Rails.application.secrets).to receive(:dig).with(:decidim, :participatory_processes, :sort_by_date).and_return(true) + end + # search.with_date will default to "past" + + it "orders past processes by reverse end_date" do + expect(controller.helpers.participatory_processes).to eq(past_processes.sort_by(&:end_date).reverse) + end + end + end + end + describe "GET show" do context "when the process is unpublished" do it "redirects to sign in path" do diff --git a/spec/forms/initiative_form_spec.rb b/spec/forms/initiative_form_spec.rb new file mode 100644 index 0000000000..cc412c774e --- /dev/null +++ b/spec/forms/initiative_form_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Initiatives + describe InitiativeForm do + subject { described_class.from_params(attributes).with_context(context) } + + let(:organization) { create(:organization) } + let(:initiatives_type) { create(:initiatives_type, organization: organization) } + let(:scope) { create(:initiatives_type_scope, type: initiatives_type) } + let(:attachment_params) { nil } + + let(:title) { ::Faker::Lorem.sentence(word_count: 5) } + let(:my_description) { "" } + let(:attributes) do + { + title: title, + description: my_description, + type_id: initiatives_type.id, + scope_id: scope&.scope&.id, + signature_type: "offline", + attachment: attachment_params + }.merge(custom_signature_end_date).merge(area) + end + let(:custom_signature_end_date) { {} } + let(:area) { {} } + let(:context) do + { + current_organization: organization, + current_component: nil, + initiative_type: initiatives_type + } + end + let(:state) { "validating" } + let(:initiative) { create(:initiative, organization: organization, state: state, scoped_type: scope) } + + context "when everything is OK" do + let(:my_description) { ::Faker::Lorem.sentence(word_count: 25) } + + it { is_expected.to be_valid } + end + + context "when description contains a javascript event" do + let(:my_description) { '

description<img src=\"invalid.jpg\" onerror=\"alert();\">

' } + + it { is_expected.to be_invalid } + end + end + end +end