From 1a5bb05fa8cd1b607f6c898ad8a2e6d5b9167362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Tue, 21 Oct 2025 10:39:54 +0200 Subject: [PATCH 001/131] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 6cc62f36552c2ab27d76df75d5d339320f85d9a7 Merge: 31e09c73d3 82c93ce522 Author: Ivan Vergés Date: Tue Oct 21 10:37:46 2025 +0200 Merge branch 'develop' into feature/elections-census-check-before-start commit 31e09c73d35ee1721e1899f547d454278f96b6f6 Author: Anna Topalidi Date: Tue Oct 21 07:10:47 2025 +0200 fix linter commit 85f1a257e9254824dc83ff1edd4253774be43f25 Author: Anna Topalidi Date: Mon Oct 20 22:08:55 2025 +0200 Allow users to check if they can vote before the election starts commit e503bec945ba658533a6cfa9fed5f7f605cc5efd Author: Anna Topalidi Date: Fri Oct 10 15:36:16 2025 +0200 xhange controller, add tests commit 408a17494d15b41fd9783e7634b7a9485edf6184 Author: Anna Topalidi Date: Fri Oct 10 14:52:00 2025 +0200 change text commit a1ed69073915df3416c8307e65ba9772283ebe8e Author: Anna Topalidi Date: Fri Oct 10 13:55:51 2025 +0200 change test commit 242f0a66dccbcea940bde10b70f5b58859d7fccd Author: Anna Topalidi Date: Fri Oct 10 13:33:59 2025 +0200 add check before start --- .../decidim/elections/uses_census_access.rb | 39 ++++ .../decidim/elections/uses_votes_booth.rb | 28 +-- .../elections/admin/elections_controller.rb | 11 + .../elections/census_checks_controller.rb | 58 +++++ .../packs/entrypoints/decidim_elections.js | 1 + .../entrypoints/decidim_elections_admin.js | 1 + .../elections/admin/census_check_toggle.js | 44 ++++ .../elections/census_check_visibility.js | 52 +++++ .../decidim/elections/permissions.rb | 13 ++ .../decidim/elections/election_presenter.rb | 4 +- .../admin/dashboard/_status.html.erb | 12 + .../elections/census_checks/show.html.erb | 8 + .../elections/_election_aside.html.erb | 8 + decidim-elections/config/locales/en.yml | 8 + ...re_start_to_decidim_elections_elections.rb | 7 + .../lib/decidim/elections/admin_engine.rb | 1 + .../lib/decidim/elections/engine.rb | 1 + .../admin/elections_controller_spec.rb | 28 +++ .../census_checks_controller_spec.rb | 90 ++++++++ .../decidim/elections/permissions_spec.rb | 58 +++++ .../user_checks_election_census_spec.rb | 217 ++++++++++++++++++ 21 files changed, 663 insertions(+), 26 deletions(-) create mode 100644 decidim-elections/app/controllers/concerns/decidim/elections/uses_census_access.rb create mode 100644 decidim-elections/app/controllers/decidim/elections/census_checks_controller.rb create mode 100644 decidim-elections/app/packs/src/decidim/elections/admin/census_check_toggle.js create mode 100644 decidim-elections/app/packs/src/decidim/elections/census_check_visibility.js create mode 100644 decidim-elections/app/views/decidim/elections/census_checks/show.html.erb create mode 100644 decidim-elections/db/migrate/20251020130630_add_allow_census_check_before_start_to_decidim_elections_elections.rb create mode 100644 decidim-elections/spec/controllers/decidim/elections/census_checks_controller_spec.rb create mode 100644 decidim-elections/spec/system/user_checks_election_census_spec.rb diff --git a/decidim-elections/app/controllers/concerns/decidim/elections/uses_census_access.rb b/decidim-elections/app/controllers/concerns/decidim/elections/uses_census_access.rb new file mode 100644 index 0000000000000..0335cab4bd94e --- /dev/null +++ b/decidim-elections/app/controllers/concerns/decidim/elections/uses_census_access.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Decidim + module Elections + module UsesCensusAccess + extend ActiveSupport::Concern + + included do + helper_method :exit_path, :election, :session_authenticated?, :session_attributes, :voter_uid + end + + private + + def election + @election ||= Election.where(component: current_component).published.find(params[:election_id]) + end + + def session_authenticated? + @session_authenticated ||= election.census.valid_user?(election, session_attributes, current_user:) + end + + def voter_uid + @voter_uid ||= election.census.voter_uid(election, session_attributes, current_user:) + end + + def session_attributes + session[:session_attributes] ||= {} + end + + def exit_path + @exit_path ||= if allowed_to?(:read, :election, election:) + election_path(election) + else + elections_path + end + end + end + end +end diff --git a/decidim-elections/app/controllers/concerns/decidim/elections/uses_votes_booth.rb b/decidim-elections/app/controllers/concerns/decidim/elections/uses_votes_booth.rb index a32e161d4aab6..08cf7b7c030c8 100644 --- a/decidim-elections/app/controllers/concerns/decidim/elections/uses_votes_booth.rb +++ b/decidim-elections/app/controllers/concerns/decidim/elections/uses_votes_booth.rb @@ -7,8 +7,10 @@ module UsesVotesBooth extend ActiveSupport::Concern included do + include UsesCensusAccess + layout "decidim/election_booth" - helper_method :exit_path, :election, :questions, :question, :response_chosen?, :votes_buffer + helper_method :questions, :question, :response_chosen?, :votes_buffer before_action except: [:new, :create, :receipt] do next if session_authenticated? @@ -58,10 +60,6 @@ def receipt private - def election - @election ||= Election.where(component: current_component).published.find(params[:election_id]) - end - def questions @questions ||= election.questions end @@ -70,22 +68,10 @@ def question @question ||= questions.find_by(id: params[:id]) || questions.first end - def session_authenticated? - @session_authenticated ||= election.census.valid_user?(election, session_attributes, current_user:) - end - - def voter_uid - @voter_uid ||= election.census.voter_uid(election, session_attributes, current_user:) - end - def votes_buffer session[:votes_buffer] ||= {} end - def session_attributes - session[:session_attributes] ||= {} - end - def response_chosen?(response_option) response_ids = if votes_buffer.has_key?(question.id.to_s) votes_buffer[question.id.to_s] @@ -102,14 +88,6 @@ def previous_responses [question.id.to_s, question.votes.where(voter_uid: voter_uid).pluck(:response_option_id).map(&:to_s)] end end - - def exit_path - @exit_path ||= if allowed_to?(:read, :election, election:) - election_path(election) - else - elections_path - end - end end end end diff --git a/decidim-elections/app/controllers/decidim/elections/admin/elections_controller.rb b/decidim-elections/app/controllers/decidim/elections/admin/elections_controller.rb index 7140b9ee0b4de..056fbd6656f4b 100644 --- a/decidim-elections/app/controllers/decidim/elections/admin/elections_controller.rb +++ b/decidim-elections/app/controllers/decidim/elections/admin/elections_controller.rb @@ -122,6 +122,17 @@ def update_status redirect_to dashboard_election_path(election) end + def toggle_census_check + enforce_permission_to :update, :election, election: election + + value = ActiveModel::Type::Boolean.new.cast(params[:allow_census_check_before_start]) + election.update!(allow_census_check_before_start: value) + + render json: { success: true, allow_census_check_before_start: election.allow_census_check_before_start } + rescue StandardError => e + render json: { success: false, error: e.message }, status: :unprocessable_entity + end + private def per_question_waiting? diff --git a/decidim-elections/app/controllers/decidim/elections/census_checks_controller.rb b/decidim-elections/app/controllers/decidim/elections/census_checks_controller.rb new file mode 100644 index 0000000000000..b239ebb0338d5 --- /dev/null +++ b/decidim-elections/app/controllers/decidim/elections/census_checks_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Decidim + module Elections + # Allows participants to verify they belong to an election census before voting starts. + class CensusChecksController < Decidim::Elections::ApplicationController + include UsesCensusAccess + + layout "decidim/election_booth" + + before_action :redirect_if_authenticated, only: :new + before_action :ensure_session_authenticated!, only: :show + + def new + enforce_permission_to(:create, :census_check, election:) + + @form = election.census.form_instance({}, election:, current_user:) + render "decidim/elections/votes/new" + end + + def create + enforce_permission_to(:create, :census_check, election:) + + @form = election.census.form_instance(params, election:, current_user:) + if @form.valid? + session[:session_attributes] = @form.attributes + redirect_to election_census_check_path(election) + else + flash[:alert] = @form.errors.full_messages.join("
").presence || t("failed", scope: "decidim.elections.votes.check_census") + redirect_to new_election_census_check_path(election) + end + end + + def show + enforce_permission_to(:read, :census_check, election:) + end + + private + + # Allows admins to access unpublished elections for preview. + def election + @election ||= Election.where(component: current_component) + .then { |scope| current_user&.admin? ? scope : scope.published } + .find(params[:election_id]) + end + + def redirect_if_authenticated + redirect_to election_census_check_path(election) if session_authenticated? + end + + def ensure_session_authenticated! + return if session_authenticated? + + redirect_to new_election_census_check_path(election), alert: t("decidim.elections.votes.check_census.failed") + end + end + end +end diff --git a/decidim-elections/app/packs/entrypoints/decidim_elections.js b/decidim-elections/app/packs/entrypoints/decidim_elections.js index 19a5a196bd98d..36be7ef26208b 100644 --- a/decidim-elections/app/packs/entrypoints/decidim_elections.js +++ b/decidim-elections/app/packs/entrypoints/decidim_elections.js @@ -1,5 +1,6 @@ import "src/decidim/elections/waiting_room.js" import "src/decidim/elections/live_results_update.js"; +import "src/decidim/elections/census_check_visibility.js"; // Images require.context("../images", true) diff --git a/decidim-elections/app/packs/entrypoints/decidim_elections_admin.js b/decidim-elections/app/packs/entrypoints/decidim_elections_admin.js index 83f30c0f041a3..25a24b3f44c61 100644 --- a/decidim-elections/app/packs/entrypoints/decidim_elections_admin.js +++ b/decidim-elections/app/packs/entrypoints/decidim_elections_admin.js @@ -1,6 +1,7 @@ // JS import "src/decidim/elections/admin/election_form.js"; import "src/decidim/elections/admin/census_form.js"; +import "src/decidim/elections/admin/census_check_toggle.js"; import "src/decidim/elections/live_results_update.js"; // CSS diff --git a/decidim-elections/app/packs/src/decidim/elections/admin/census_check_toggle.js b/decidim-elections/app/packs/src/decidim/elections/admin/census_check_toggle.js new file mode 100644 index 0000000000000..e1a497a3a076b --- /dev/null +++ b/decidim-elections/app/packs/src/decidim/elections/admin/census_check_toggle.js @@ -0,0 +1,44 @@ +document.addEventListener("turbo:load", () => { + const checkbox = document.querySelector(".census-check-toggle"); + + if (!checkbox) { + return; + } + + checkbox.addEventListener("change", () => { + const url = checkbox.dataset.url; + const checked = checkbox.checked; + const csrfToken = document.querySelector("meta[name='csrf-token']"); + + if (!csrfToken) { + console.error("CSRF token not found. Please refresh the page."); + checkbox.checked = !checked; + return; + } + + fetch(url, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken.content + }, + body: JSON.stringify({ + // eslint-disable-next-line camelcase + allow_census_check_before_start: checked + }) + }).then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }).then((data) => { + if (!data.success) { + checkbox.checked = !checked; + console.error(`Error updating setting: ${data.error || "Unknown error"}`); + } + }).catch((error) => { + checkbox.checked = !checked; + console.error(`Error updating setting: ${error.message}`); + }); + }); +}); diff --git a/decidim-elections/app/packs/src/decidim/elections/census_check_visibility.js b/decidim-elections/app/packs/src/decidim/elections/census_check_visibility.js new file mode 100644 index 0000000000000..af4d02e7e2f7d --- /dev/null +++ b/decidim-elections/app/packs/src/decidim/elections/census_check_visibility.js @@ -0,0 +1,52 @@ +document.addEventListener("DOMContentLoaded", () => { + const censusCheckButton = document.querySelector("[data-census-check-button]"); + + if (!censusCheckButton) { + return; + } + + const url = censusCheckButton.dataset.censusCheckUrl; + + if (!url) { + return; + } + + const explanation = document.querySelector("[data-census-check-explanation]"); + + const toggleVisibility = (show) => { + censusCheckButton.classList.toggle("hidden", !show); + if (explanation) { + explanation.classList.toggle("hidden", !show); + } + }; + + const updateVisibility = async () => { + try { + const response = await fetch(url, { + method: "GET", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest" + } + }); + + if (!response.ok) { + return; + } + + const data = await response.json(); + const shouldShow = data.allow_census_check_before_start && data.census_ready && data.scheduled; + + toggleVisibility(shouldShow); + + if (data.scheduled) { + setTimeout(updateVisibility, 4000); + } + } catch (error) { + setTimeout(updateVisibility, 4000); + } + }; + + updateVisibility(); +}); diff --git a/decidim-elections/app/permissions/decidim/elections/permissions.rb b/decidim-elections/app/permissions/decidim/elections/permissions.rb index 8c5dbf5b254ec..45a57fb70dbf0 100644 --- a/decidim-elections/app/permissions/decidim/elections/permissions.rb +++ b/decidim-elections/app/permissions/decidim/elections/permissions.rb @@ -10,6 +10,7 @@ def permissions allowed_election_action? allowed_vote_action? + allowed_census_check_action? permission_action end @@ -37,6 +38,18 @@ def allowed_vote_action? allow! if election.present? && election.published? && election.ongoing? end end + + def allowed_census_check_action? + return unless permission_action.subject == :census_check + + case permission_action.action + when :create, :read + return unless election.present? && election.census_ready? + + allow! if !election.published? && user&.admin? + allow! if election.scheduled? && election.allow_census_check_before_start + end + end end end end diff --git a/decidim-elections/app/presenters/decidim/elections/election_presenter.rb b/decidim-elections/app/presenters/decidim/elections/election_presenter.rb index 74296697aeb09..dc3357a69c2ad 100644 --- a/decidim-elections/app/presenters/decidim/elections/election_presenter.rb +++ b/decidim-elections/app/presenters/decidim/elections/election_presenter.rb @@ -24,17 +24,19 @@ def title(html_escape: false, all_locales: false) end # A JSON representation of the election, including its questions and response options. - # Suitable for rendering results in real time. # Unless `admin: true` is passed, only results for questions with published results are included. def to_json(admin: false) { id: election.id, ongoing: election.ongoing?, + scheduled: election.scheduled?, status: election.status, start_date: election.start_at&.iso8601, end_date: election.end_at.iso8601, title: election.translated_attribute(title), description: election.translated_attribute(description), + allow_census_check_before_start: election.allow_census_check_before_start, + census_ready: election.census_ready?, questions: questions.map do |question| { id: question.id, diff --git a/decidim-elections/app/views/decidim/elections/admin/dashboard/_status.html.erb b/decidim-elections/app/views/decidim/elections/admin/dashboard/_status.html.erb index cc8813675720f..64aa1815ad2fc 100644 --- a/decidim-elections/app/views/decidim/elections/admin/dashboard/_status.html.erb +++ b/decidim-elections/app/views/decidim/elections/admin/dashboard/_status.html.erb @@ -16,6 +16,18 @@ <%= election_status_with_label(election) %> <%= t("decidim.elections.admin.dashboard.status.results_availability.#{election.results_availability}") %> + <% if election.scheduled? %> +
+ +
+ <% end %> diff --git a/decidim-elections/app/views/decidim/elections/census_checks/show.html.erb b/decidim-elections/app/views/decidim/elections/census_checks/show.html.erb new file mode 100644 index 0000000000000..7bdeb45e33781 --- /dev/null +++ b/decidim-elections/app/views/decidim/elections/census_checks/show.html.erb @@ -0,0 +1,8 @@ + diff --git a/decidim-elections/app/views/decidim/elections/elections/_election_aside.html.erb b/decidim-elections/app/views/decidim/elections/elections/_election_aside.html.erb index 4a52da5ed04d8..0826ccd0bb05c 100644 --- a/decidim-elections/app/views/decidim/elections/elections/_election_aside.html.erb +++ b/decidim-elections/app/views/decidim/elections/elections/_election_aside.html.erb @@ -8,6 +8,14 @@ <%= t("decidim.elections.elections.show.voted") %> <% end %> + <% elsif allowed_to? :create, :census_check, election: election %> + <%= link_to t("decidim.elections.elections.show.check_census_button"), + new_election_census_check_path(election), + class: "button button__lg button__secondary #{"hidden" if election.published? && !election.allow_census_check_before_start}", + data: election.published? ? { census_check_button: true, census_check_url: election_path(election, format: :json) } : {} %> +
" data-census-check-explanation="<%= election.published? %>"> + <%= t("decidim.elections.elections.show.check_census_explanation") %> +
<% end %> diff --git a/decidim-elections/config/locales/en.yml b/decidim-elections/config/locales/en.yml index 3f7911e476482..acd6569788199 100644 --- a/decidim-elections/config/locales/en.yml +++ b/decidim-elections/config/locales/en.yml @@ -96,6 +96,7 @@ en: start_question_button: Enable voting title: Results status: + allow_census_check_before_start: Allow users to check if they can vote before the election starts census: 'Census:' results_availability: after_end: Results available after the election ends @@ -182,6 +183,11 @@ en: update: "%{user_name} updated the %{resource_name} election in %{space_name}" question: update: "%{user_name} updated the questions of the %{resource_name} election" + census_checks: + show: + description: This means that, once the election starts, you can vote in it. + exit_button: Exit the census check + title: You have been successfully verified censuses: census_ready_html: The census data is uploaded and prepared for its use in the %{election_title} election. census_size_html: @@ -249,6 +255,8 @@ en: title: Election questions show: active_voting_until: 'Active voting until: %{end_date}' + check_census_button: Check if I can vote + check_census_explanation: This election has not started yet, but you can check if you are included in the census. vote_button: Vote voted: You have already voted. You can vote again, only your last vote will be counted. votes_count: diff --git a/decidim-elections/db/migrate/20251020130630_add_allow_census_check_before_start_to_decidim_elections_elections.rb b/decidim-elections/db/migrate/20251020130630_add_allow_census_check_before_start_to_decidim_elections_elections.rb new file mode 100644 index 0000000000000..354a9dba62227 --- /dev/null +++ b/decidim-elections/db/migrate/20251020130630_add_allow_census_check_before_start_to_decidim_elections_elections.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAllowCensusCheckBeforeStartToDecidimElectionsElections < ActiveRecord::Migration[7.0] + def change + add_column :decidim_elections_elections, :allow_census_check_before_start, :boolean, default: false, null: false + end +end diff --git a/decidim-elections/lib/decidim/elections/admin_engine.rb b/decidim-elections/lib/decidim/elections/admin_engine.rb index b1d5797ada30a..88db5ed2f3074 100644 --- a/decidim-elections/lib/decidim/elections/admin_engine.rb +++ b/decidim-elections/lib/decidim/elections/admin_engine.rb @@ -19,6 +19,7 @@ class AdminEngine < ::Rails::Engine patch :soft_delete patch :restore put :update_status + patch :toggle_census_check get "edit_questions", to: "questions#edit_questions" put "update_questions", to: "questions#update" diff --git a/decidim-elections/lib/decidim/elections/engine.rb b/decidim-elections/lib/decidim/elections/engine.rb index ad033d4e19790..8cdd378cdb1ac 100644 --- a/decidim-elections/lib/decidim/elections/engine.rb +++ b/decidim-elections/lib/decidim/elections/engine.rb @@ -7,6 +7,7 @@ class Engine < ::Rails::Engine routes do resources :elections, except: [:destroy] do + resource :census_check, only: [:new, :create, :show], controller: :census_checks resources :votes, except: [:edit, :destroy] do collection do get :confirm diff --git a/decidim-elections/spec/controllers/decidim/elections/admin/elections_controller_spec.rb b/decidim-elections/spec/controllers/decidim/elections/admin/elections_controller_spec.rb index 5606374585bee..f8fd2f3ed1dec 100644 --- a/decidim-elections/spec/controllers/decidim/elections/admin/elections_controller_spec.rb +++ b/decidim-elections/spec/controllers/decidim/elections/admin/elections_controller_spec.rb @@ -174,6 +174,34 @@ def dashboard_path(election) end end + describe "PATCH #toggle_census_check" do + it "updates the setting and returns JSON" do + expect(election.allow_census_check_before_start).to be(false) + + patch :toggle_census_check, params: { id: election.id, allow_census_check_before_start: true }, format: :json + + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to include( + "success" => true, + "allow_census_check_before_start" => true + ) + expect(election.reload.allow_census_check_before_start).to be(true) + end + + it "returns error on invalid update" do + allow(controller).to receive(:election).and_return(election) + allow(election).to receive(:update!).and_raise(StandardError, "Database error") + + patch :toggle_census_check, params: { id: election.id, allow_census_check_before_start: true }, format: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)).to include( + "success" => false, + "error" => "Database error" + ) + end + end + it_behaves_like "a soft-deletable resource", resource_name: :election, resource_path: :elections_path, diff --git a/decidim-elections/spec/controllers/decidim/elections/census_checks_controller_spec.rb b/decidim-elections/spec/controllers/decidim/elections/census_checks_controller_spec.rb new file mode 100644 index 0000000000000..531918bf2bda7 --- /dev/null +++ b/decidim-elections/spec/controllers/decidim/elections/census_checks_controller_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Elections + describe CensusChecksController do + let(:component) { create(:elections_component) } + let(:organization) { component.organization } + let(:election) { create(:election, :published, :scheduled, :with_token_csv_census, component:, allow_census_check_before_start: true) } + let(:params) { { component_id: component.id, election_id: election.id } } + let(:main_proxy) { Decidim::EngineRouter.main_proxy(component) } + let(:new_census_check_path) { main_proxy.new_election_census_check_path(election) } + let(:census_check_path) { main_proxy.election_census_check_path(election) } + let(:election_path) { main_proxy.election_path(election) } + let(:voter_data) { election.voters.first.data } + + before do + request.env["decidim.current_organization"] = organization + request.env["decidim.current_participatory_space"] = component.participatory_space + request.env["decidim.current_component"] = component + allow(controller).to receive(:current_participatory_space).and_return(component.participatory_space) + allow(controller).to receive(:current_component).and_return(component) + allow(controller).to receive(:election_census_check_path).and_return(census_check_path) + allow(controller).to receive(:new_election_census_check_path).and_return(new_census_check_path) + allow(controller).to receive(:election_path).and_return(election_path) + end + + describe "GET new" do + it "renders the census check form" do + get :new, params: params + + expect(response).to have_http_status(:ok) + expect(assigns(:form)).to be_present + end + + context "when already authenticated" do + before do + session[:session_attributes] = voter_data.slice("email", "token") + end + + it "redirects to the success page" do + get :new, params: params + + expect(response).to redirect_to(census_check_path) + end + end + end + + describe "POST create" do + it "stores the session attributes and redirects to the success page" do + post :create, params: params.merge(token_csv: voter_data.slice("email", "token")) + + expect(session[:session_attributes]).to include(voter_data.slice("email", "token").stringify_keys) + expect(response).to redirect_to(census_check_path) + end + + it "displays the form when the data is invalid" do + post :create, params: params.merge(token_csv: { email: "wrong@example.com", token: "invalid" }) + + expect(session[:session_attributes]).to be_blank + expect(response).to redirect_to(new_census_check_path) + expect(flash[:alert]).to eq(I18n.t("decidim.elections.censuses.token_csv_form.invalid")) + end + end + + describe "GET show" do + it "redirects to the form when the session is not authenticated" do + get :show, params: params + + expect(response).to redirect_to(new_census_check_path) + expect(flash[:alert]).to eq(I18n.t("decidim.elections.votes.check_census.failed")) + end + + context "when the session is authenticated" do + before do + session[:session_attributes] = voter_data.slice("email", "token") + end + + it "renders the success page" do + get :show, params: params + + expect(response).to have_http_status(:ok) + expect(subject).to render_template(:show) + end + end + end + end + end +end diff --git a/decidim-elections/spec/permissions/decidim/elections/permissions_spec.rb b/decidim-elections/spec/permissions/decidim/elections/permissions_spec.rb index 37affd1278d5c..32e72adb9c425 100644 --- a/decidim-elections/spec/permissions/decidim/elections/permissions_spec.rb +++ b/decidim-elections/spec/permissions/decidim/elections/permissions_spec.rb @@ -72,4 +72,62 @@ it { is_expected.to be true } end end + + context "when creating or reading a census_check" do + let(:action) do + { scope: :public, action: :create, subject: :census_check } + end + + it_behaves_like "permission is not set" + + context "when election is not published" do + let(:election) { create(:election, :scheduled, :with_token_csv_census, component:) } + + it_behaves_like "permission is not set" + + context "when user is admin" do + let(:user) { create(:user, :admin, organization: component.organization) } + + it { is_expected.to be true } + end + end + + context "when election is published and scheduled" do + let(:election) { create(:election, :published, :scheduled, :with_token_csv_census, component:) } + + context "when allow_census_check_before_start is false" do + before do + election.update!(allow_census_check_before_start: false) + end + + it_behaves_like "permission is not set" + end + + context "when allow_census_check_before_start is true" do + before do + election.update!(allow_census_check_before_start: true) + end + + it { is_expected.to be true } + end + end + + context "when election is published but census is not ready" do + let(:election) { create(:election, :published, :scheduled, component:, allow_census_check_before_start: true) } + + it_behaves_like "permission is not set" + end + + context "when reading census_check" do + let(:action) do + { scope: :public, action: :read, subject: :census_check } + end + + context "when election is scheduled with census ready and checkbox enabled" do + let(:election) { create(:election, :published, :scheduled, :with_token_csv_census, component:, allow_census_check_before_start: true) } + + it { is_expected.to be true } + end + end + end end diff --git a/decidim-elections/spec/system/user_checks_election_census_spec.rb b/decidim-elections/spec/system/user_checks_election_census_spec.rb new file mode 100644 index 0000000000000..26590a7f40c26 --- /dev/null +++ b/decidim-elections/spec/system/user_checks_election_census_spec.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Election census check" do + let(:component) { create(:elections_component) } + let(:organization) { component.organization } + + before do + switch_to_host(organization.host) + end + + context "when the election is scheduled" do + let(:election_path) { Decidim::EngineRouter.main_proxy(component).election_path(election) } + let(:new_census_check_path) { Decidim::EngineRouter.main_proxy(component).new_election_census_check_path(election) } + let(:census_check_path) { Decidim::EngineRouter.main_proxy(component).election_census_check_path(election) } + let(:voter_data) { election.voters.first.data } + + context "when allow_census_check_before_start is enabled" do + let!(:election) { create(:election, :published, :scheduled, :with_token_csv_census, component:, allow_census_check_before_start: true) } + + context "when user is a guest" do + it "displays the census check button" do + visit election_path + + expect(page).to have_link("Check if I can vote") + end + + it "allows the user to validate they are in the census" do + visit election_path + + click_link "Check if I can vote" + + expect(page).to have_current_path(new_census_check_path) + + fill_in "Email", with: voter_data["email"] + fill_in "Token", with: voter_data["token"] + click_button "Access" + + expect(page).to have_current_path(census_check_path) + expect(page).to have_content("You have been successfully verified") + expect(page).to have_content("This means that, once the election starts, you can vote in it.") + + click_link "Exit the census check" + + expect(page).to have_current_path(election_path) + end + end + + context "when user is logged in" do + let(:user) { create(:user, :confirmed, organization:) } + + before do + login_as user, scope: :user + end + + it "displays the census check button" do + visit election_path + + expect(page).to have_link("Check if I can vote") + end + end + + context "when user is an admin" do + let(:admin) { create(:user, :admin, :confirmed, organization:) } + + before do + login_as admin, scope: :user + end + + it "displays the census check button" do + visit election_path + + expect(page).to have_link("Check if I can vote") + end + end + end + + context "when allow_census_check_before_start is disabled" do + let!(:election) { create(:election, :published, :scheduled, :with_token_csv_census, component:, allow_census_check_before_start: false) } + + context "when user is a guest" do + it "does not display the census check button" do + visit election_path + + expect(page).to have_no_link("Check if I can vote") + end + end + + context "when user is logged in" do + let(:user) { create(:user, :confirmed, organization:) } + + before do + login_as user, scope: :user + end + + it "does not display the census check button" do + visit election_path + + expect(page).to have_no_link("Check if I can vote") + end + end + + context "when user is an admin" do + let(:admin) { create(:user, :admin, :confirmed, organization:) } + + before do + login_as admin, scope: :user + end + + it "does not display the census check button" do + visit election_path + + expect(page).to have_no_link("Check if I can vote") + end + end + end + end + + context "when the election is not published" do + let!(:election) { create(:election, :scheduled, :with_token_csv_census, component:) } + let(:election_path) { Decidim::EngineRouter.main_proxy(component).election_path(election) } + let(:new_census_check_path) { Decidim::EngineRouter.main_proxy(component).new_election_census_check_path(election) } + let(:census_check_path) { Decidim::EngineRouter.main_proxy(component).election_census_check_path(election) } + let(:voter_data) { election.voters.first.data } + let(:admin) { create(:user, :admin, :confirmed, organization:) } + + before do + login_as admin, scope: :user + end + + it "displays the census check button for admin preview" do + visit election_path + + expect(page).to have_link("Check if I can vote") + end + + it "allows the admin to preview the census check" do + visit election_path + + click_link "Check if I can vote" + + expect(page).to have_current_path(new_census_check_path) + + fill_in "Email", with: voter_data["email"] + fill_in "Token", with: voter_data["token"] + click_button "Access" + + expect(page).to have_current_path(census_check_path) + expect(page).to have_content("You have been successfully verified") + end + end + + context "when the election uses the internal users census" do + let(:authorization_handlers) { { "dummy_authorization_handler" => { "options" => { "allowed_postal_codes" => "08002" } } } } + let(:election) { create(:election, :published, :scheduled, component:, census_manifest: "internal_users", census_settings: { "authorization_handlers" => authorization_handlers }, allow_census_check_before_start: true) } + let(:election_path) { Decidim::EngineRouter.main_proxy(component).election_path(election) } + let(:new_census_check_path) { Decidim::EngineRouter.main_proxy(component).new_election_census_check_path(election) } + let(:census_check_path) { Decidim::EngineRouter.main_proxy(component).election_census_check_path(election) } + + context "with an authorized participant" do + let(:user) { create(:user, :confirmed, organization:) } + + before do + create(:authorization, user:, name: "dummy_authorization_handler", metadata: { "postal_code" => "08002" }) + login_as user, scope: :user + end + + it "confirms they will be able to vote" do + visit election_path + + click_link "Check if I can vote" + + expect(page).to have_current_path(census_check_path) + expect(page).to have_content("You have been successfully verified") + expect(page).to have_content("This means that, once the election starts, you can vote in it.") + + click_link "Exit the census check" + + expect(page).to have_current_path(election_path) + end + end + + context "with a participant without the required authorizations" do + let(:user) { create(:user, :confirmed, organization:) } + + before do + login_as user, scope: :user + end + + it "blocks the access" do + visit election_path + + click_link "Check if I can vote" + + expect(page).to have_current_path(new_census_check_path) + expect(page).to have_content("Verify your identity") + + click_button "Access" + + expect(page).to have_current_path(new_census_check_path) + expect(page).to have_content("You are not authorized to vote in this election.") + end + end + end + + context "when the election is ongoing" do + let!(:election) { create(:election, :published, :ongoing, :with_token_csv_census, component:) } + let(:election_path) { Decidim::EngineRouter.main_proxy(component).election_path(election) } + + it "does not display the census check button" do + visit election_path + + expect(page).to have_no_link("Check if I can vote") + end + end +end From 8fb0b246cbdbceb835b46dd47aa9e5570690f1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Tue, 21 Oct 2025 10:41:08 +0200 Subject: [PATCH 002/131] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 0ddc2b4df70d72348aefab4d1ed2d5f4778228a3 Merge: ad11ec8a55 82c93ce522 Author: Ivan Vergés Date: Tue Oct 21 10:40:41 2025 +0200 Merge branch 'develop' into feature/elections-display-question-description commit ad11ec8a556cf2f099c4bf213c53cf76628989c4 Author: Anna Topalidi Date: Tue Oct 21 09:43:20 2025 +0200 fix title commit 0136d42fb323d3d6a41da57dec341d68a762fcd1 Author: Anna Topalidi Date: Tue Oct 21 08:56:39 2025 +0200 fix tests commit 2523af2704702ef8f9999303a8ddd0ee501f9ed4 Author: Anna Topalidi Date: Mon Oct 20 14:44:11 2025 +0200 fix question_description commit 8dfe2c17a0543300c99a4b67cc477362a7e0f2e1 Author: Anna Topalidi Date: Thu Oct 9 11:51:33 2025 +0200 display question description --- .../decidim/elections/application_helper.rb | 12 +++++ .../decidim/elections/elections.scss | 4 ++ .../per_question_votes/show.html.erb | 5 +- .../decidim/elections/votes/show.html.erb | 5 +- .../test/per_question_vote_examples.rb | 9 ++++ .../decidim/elections/test/vote_examples.rb | 2 + .../elections/application_helper_spec.rb | 50 +++++++++++++++++++ 7 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 decidim-elections/spec/helpers/decidim/elections/application_helper_spec.rb diff --git a/decidim-elections/app/helpers/decidim/elections/application_helper.rb b/decidim-elections/app/helpers/decidim/elections/application_helper.rb index e24fdb0038b31..fb76d007ffb3c 100644 --- a/decidim-elections/app/helpers/decidim/elections/application_helper.rb +++ b/decidim-elections/app/helpers/decidim/elections/application_helper.rb @@ -43,6 +43,18 @@ def question_title(question, tag = :h3, **options) end end + def render_question_description(question) + description = translated_attribute(question.description) + return if description.blank? + + sanitized = decidim_sanitize_admin(description) + if rich_text_editor_in_public_views? + Decidim::ContentProcessor.render_without_format(sanitized).html_safe + else + Decidim::ContentProcessor.render(sanitized, "div") + end + end + def selected_response_option_id(question) session.dig(:votes_buffer, question.id.to_s, "response_option_id")&.to_i end diff --git a/decidim-elections/app/packs/stylesheets/decidim/elections/elections.scss b/decidim-elections/app/packs/stylesheets/decidim/elections/elections.scss index 37b99f1f7c7fc..fdd7bd51af2e5 100644 --- a/decidim-elections/app/packs/stylesheets/decidim/elections/elections.scss +++ b/decidim-elections/app/packs/stylesheets/decidim/elections/elections.scss @@ -89,3 +89,7 @@ } } } + +.vote_booth-question_title { + @apply h3 mb-4; +} diff --git a/decidim-elections/app/views/decidim/elections/per_question_votes/show.html.erb b/decidim-elections/app/views/decidim/elections/per_question_votes/show.html.erb index 41b72cae71ce4..0d7078b270212 100644 --- a/decidim-elections/app/views/decidim/elections/per_question_votes/show.html.erb +++ b/decidim-elections/app/views/decidim/elections/per_question_votes/show.html.erb @@ -1,5 +1,8 @@ <%= form_with url: url_for(action: :show, id: question), method: :patch, local: true do %> - <%= question_title(question, :h1, class: "h4 mb-8") %> + <%= question_title(question, :h1, class: "vote_booth-question_title") %> +
+ <%= render_question_description(question) %> +
<% question.response_options.each do |option| %> diff --git a/decidim-elections/app/views/decidim/elections/votes/show.html.erb b/decidim-elections/app/views/decidim/elections/votes/show.html.erb index 86a5a4d675333..b76cde2df6248 100644 --- a/decidim-elections/app/views/decidim/elections/votes/show.html.erb +++ b/decidim-elections/app/views/decidim/elections/votes/show.html.erb @@ -1,5 +1,8 @@ <%= form_with url: url_for(action: :show, id: question), method: :patch, local: true do %> - <%= question_title(question, :h1, class: "h4 mb-8") %> + <%= question_title(question, :h1, class: "vote_booth-question_title") %> +
+ <%= render_question_description(question) %> +
<% question.response_options.each do |option| %> diff --git a/decidim-elections/lib/decidim/elections/test/per_question_vote_examples.rb b/decidim-elections/lib/decidim/elections/test/per_question_vote_examples.rb index 5d19a71228aa0..4972a1da1469c 100644 --- a/decidim-elections/lib/decidim/elections/test/per_question_vote_examples.rb +++ b/decidim-elections/lib/decidim/elections/test/per_question_vote_examples.rb @@ -10,6 +10,7 @@ click_on "Access" expect(page).to have_current_path(election_vote_path(question1)) expect(page).to have_content(translated_attribute(question1.body)) + expect(page).to have_content(strip_tags(translated_attribute(question1.description))) choose translated_attribute(question1.response_options.first.body) click_on "Cast vote" expect(page).to have_content("Your vote has been successfully cast.") @@ -18,6 +19,7 @@ # wait for javascript to update the page sleep 2 expect(page).to have_current_path(election_vote_path(question2)) + expect(page).to have_content(strip_tags(translated_attribute(question2.description))) click_on "Cast vote" expect(page).to have_content("There was a problem casting your vote.") check translated_attribute(question2.response_options.first.body) @@ -52,6 +54,7 @@ expect(page).to have_content(translated_attribute(question1.body)) expect(page).to have_content(translated_attribute(question2.body)) click_on "Vote" + expect(page).to have_content(strip_tags(translated_attribute(question1.description))) choose translated_attribute(question1.response_options.first.body) click_on "Cast vote" expect(page).to have_current_path(waiting_election_votes_path) @@ -61,6 +64,7 @@ # wait for javascript to update the page sleep 2 expect(page).to have_current_path(election_vote_path(question2)) + expect(page).to have_content(strip_tags(translated_attribute(question2.description))) check translated_attribute(question2.response_options.first.body) click_on "Cast vote" expect(page).to have_current_path(receipt_election_votes_path) @@ -101,6 +105,7 @@ # wait for javascript to update the page sleep 2 expect(page).to have_current_path(election_vote_path(question2)) + expect(page).to have_content(strip_tags(translated_attribute(question2.description))) check translated_attribute(question2.response_options.first.body) click_on "Cast vote" expect(page).to have_current_path(receipt_election_votes_path) @@ -137,6 +142,7 @@ expect(page).to have_content(translated_attribute(question2.body)) expect(page).to have_content(translated_attribute(question3.body)) click_on "Vote" + expect(page).to have_content(strip_tags(translated_attribute(question1.description))) choose translated_attribute(question1.response_options.first.body) click_on "Cast vote" check translated_attribute(question2.response_options.first.body) @@ -144,10 +150,12 @@ expect(page).to have_current_path(waiting_election_votes_path) click_on "Edit your vote" expect(page).to have_current_path(election_vote_path(question1)) + expect(page).to have_content(strip_tags(translated_attribute(question1.description))) expect(find("input[value='#{question1.response_options.first.id}']")).to be_checked choose translated_attribute(question1.response_options.second.body) click_on "Cast vote" expect(page).to have_current_path(election_vote_path(question2)) + expect(page).to have_content(strip_tags(translated_attribute(question2.description))) expect(find("input[value='#{question2.response_options.first.id}']")).to be_checked expect(find("input[value='#{question2.response_options.second.id}']")).not_to be_checked check translated_attribute(question2.response_options.second.body) @@ -156,6 +164,7 @@ question1.update!(published_results_at: Time.current) click_on "Edit your vote" expect(page).to have_current_path(election_vote_path(question2)) + expect(page).to have_content(strip_tags(translated_attribute(question2.description))) question2.update!(published_results_at: Time.current) click_on "Cast vote" expect(page).to have_current_path(waiting_election_votes_path) diff --git a/decidim-elections/lib/decidim/elections/test/vote_examples.rb b/decidim-elections/lib/decidim/elections/test/vote_examples.rb index db2711b830587..6c7f5326444a1 100644 --- a/decidim-elections/lib/decidim/elections/test/vote_examples.rb +++ b/decidim-elections/lib/decidim/elections/test/vote_examples.rb @@ -22,10 +22,12 @@ def fill_in_votes expect(page).to have_current_path(election_vote_path(election.questions.first)) expect(page).to have_content(translated_attribute(election.questions.first.body)) + expect(page).to have_content(strip_tags(translated_attribute(election.questions.first.description))) choose translated_attribute(election.questions.first.response_options.first.body) click_on "Next" expect(page).to have_current_path(election_vote_path(election.questions.second)) expect(page).to have_content(translated_attribute(election.questions.second.body)) + expect(page).to have_content(strip_tags(translated_attribute(election.questions.second.description))) check translated_attribute(election.questions.second.response_options.first.body) check translated_attribute(election.questions.second.response_options.second.body) click_on "Next" diff --git a/decidim-elections/spec/helpers/decidim/elections/application_helper_spec.rb b/decidim-elections/spec/helpers/decidim/elections/application_helper_spec.rb new file mode 100644 index 0000000000000..50e226cef44d1 --- /dev/null +++ b/decidim-elections/spec/helpers/decidim/elections/application_helper_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module Elections + describe ApplicationHelper do + let(:organization) { create(:organization) } + + before do + allow(helper).to receive(:current_organization).and_return(organization) + allow(helper).to receive(:rich_text_editor_in_public_views?).and_return(true) + end + + describe "#question_description" do + subject(:rendered_description) { helper.render_question_description(question) } + + context "when the description is blank" do + let(:question) { build(:election_question, description: { "en" => "" }) } + + it { is_expected.to be_nil } + end + + context "when the description has plain text" do + let(:question) { create(:election_question, description: { "en" => "More info" }) } + + it { is_expected.to eq("More info") } + end + + context "when the description includes markup" do + let(:question) { build(:election_question, description: { "en" => "Intro" }) } + + it "keeps the allowed tags" do + expect(rendered_description).to eq("Intro") + end + end + + context "when the description includes images" do + let(:question) { build(:election_question, description: { "en" => '

Check this image:

Example' }) } + + it "keeps the image tags" do + expect(rendered_description).to include(" Date: Tue, 21 Oct 2025 12:32:40 +0200 Subject: [PATCH 003/131] Squashed commit of the following: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commit 328e89ce35525d8d3efdbff7a82d01ea24c578f8 Merge: a0d1ea9e44 8fb3f9237a Author: Ivan Vergés Date: Tue Oct 21 12:32:05 2025 +0200 Merge branch 'develop' into feature/elections-max-choices-limit commit 8fb3f9237a422b994cb6e03eea87c3b53896c7ee Author: Alexandru Emil Lupu Date: Tue Oct 21 12:52:21 2025 +0300 Fix ActiveRecord::AssociationTypeMismatch error in AddDemocraticQualityStaticPage (#15399) commit 3f8a04f093060e589ba6ceeb28bdc92cae1715d1 Author: Alexandru Emil Lupu Date: Tue Oct 21 11:35:30 2025 +0300 Fix expiring Cloud Storage tokens (#15005) * Fix expiring Cloud Storage tokens * Fix spellcheck issues * 📝 qlty fmt * Add public env variable * Revert qlty.toml * Revert toml * Apply suggestions from code review Co-authored-by: Andrés Pereira de Lucena * Fix liniting issues * Trigger pipeline --------- Co-authored-by: qltysh[bot] <168846912+qltysh[bot]@users.noreply.github.com> Co-authored-by: Andrés Pereira de Lucena commit a0d1ea9e44fe66ef5430372a158c2a7bf89eb5aa Merge: e1952f9fd2 e34bce9194 Author: Anna Topalidi Date: Thu Oct 9 10:11:13 2025 +0200 Merge branch 'develop' into feature/elections-max-choices-limit commit e1952f9fd2746a233f6793d818882590223c8513 Author: Anna Topalidi Date: Thu Oct 9 09:54:36 2025 +0200 fix test commit 85e53d286682d13fd2513b908b6660e59705d508 Author: Anna Topalidi Date: Wed Oct 8 17:20:28 2025 +0200 fix test commit abe20bcd76dd609be33808058d4edc368b42b567 Author: Anna Topalidi Date: Wed Oct 8 16:19:11 2025 +0200 fix linter commit cf069372f974a4e6e9e06ae8a7047f4ea3afac5e Author: Anna Topalidi Date: Tue Oct 7 15:24:39 2025 +0200 add max choices limit --- .github/actions/spelling/expect.txt | 1 + .../elections/admin/update_questions.rb | 1 + .../decidim/elections/votes_controller.rb | 8 +++ .../decidim/elections/admin/question_form.rb | 6 ++ .../decidim/elections/application_helper.rb | 6 +- .../app/models/decidim/elections/question.rb | 1 + .../packs/entrypoints/decidim_elections.js | 1 + .../packs/src/decidim/elections/elections.js | 32 +++++++++ .../admin/questions/_question.html.erb | 11 +++ .../decidim/elections/votes/show.html.erb | 8 ++- decidim-elections/config/locales/en.yml | 5 ++ ..._choices_to_decidim_elections_questions.rb | 7 ++ .../lib/decidim/elections/test/factories.rb | 10 ++- .../elections/admin/update_questions_spec.rb | 61 +++++++++++++++++ .../elections/votes_controller_spec.rb | 50 ++++++++++++++ .../elections/admin/question_form_spec.rb | 66 ++++++++++++++++++ .../models/decidim/elections/question_spec.rb | 25 +++++++ .../admin_manages_election_questions_spec.rb | 68 +++++++++++++++++++ .../spec/system/election_results_spec.rb | 8 +-- .../system/user_votes_in_an_election_spec.rb | 60 +++++++++++++++- ..._votes_in_an_per_question_election_spec.rb | 8 +-- .../generators/app_templates/storage.yml | 3 + ...eate_democratic_quality_indicators_page.rb | 6 +- ...0052_add_democratic_quality_static_page.rb | 2 +- .../decidim/participatory_processes/engine.rb | 2 +- .../decidim/participatory_processes/seeds.rb | 2 +- ...democratic_quality_indicators_page_spec.rb | 10 +-- .../pages/environment_variables.adoc | 15 ++++ 28 files changed, 456 insertions(+), 27 deletions(-) create mode 100644 decidim-elections/app/packs/src/decidim/elections/elections.js create mode 100644 decidim-elections/db/migrate/20251007113417_add_max_choices_to_decidim_elections_questions.rb diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 7d3411b72759e..d2c63e2ecb545 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -33,6 +33,7 @@ amagat amd amendables AMR +Amz andreslucena ANOT antora diff --git a/decidim-elections/app/commands/decidim/elections/admin/update_questions.rb b/decidim-elections/app/commands/decidim/elections/admin/update_questions.rb index 895cc764a3ba9..c379e669d260b 100644 --- a/decidim-elections/app/commands/decidim/elections/admin/update_questions.rb +++ b/decidim-elections/app/commands/decidim/elections/admin/update_questions.rb @@ -47,6 +47,7 @@ def update_question(question_form, index) body: question_form.body, description: question_form.description, question_type: question_form.question_type, + max_choices: question_form.max_choices, position: index ) diff --git a/decidim-elections/app/controllers/decidim/elections/votes_controller.rb b/decidim-elections/app/controllers/decidim/elections/votes_controller.rb index 41311510db060..51576fab8eb57 100644 --- a/decidim-elections/app/controllers/decidim/elections/votes_controller.rb +++ b/decidim-elections/app/controllers/decidim/elections/votes_controller.rb @@ -20,6 +20,14 @@ def show def update enforce_permission_to(:create, :vote, election:) + response_ids = Array(params.dig(:response, question.id.to_s)).compact + + if question.max_choices.present? && response_ids.size > question.max_choices + flash.now[:alert] = t("votes.question.max_choices_exceeded", scope: "decidim.elections", max: question.max_choices) + render :show + return + end + votes_buffer[question.id.to_s] = params.dig(:response, question.id.to_s) redirect_to next_vote_step_path end diff --git a/decidim-elections/app/forms/decidim/elections/admin/question_form.rb b/decidim-elections/app/forms/decidim/elections/admin/question_form.rb index 29579d7c3b02e..cb50cf84e9b02 100644 --- a/decidim-elections/app/forms/decidim/elections/admin/question_form.rb +++ b/decidim-elections/app/forms/decidim/elections/admin/question_form.rb @@ -10,6 +10,7 @@ class QuestionForm < Decidim::Form attribute :question_type, String, default: "multiple_option" attribute :response_options, Array[Decidim::Elections::Admin::ResponseOptionForm] + attribute :max_choices, Integer attribute :deleted, Boolean, default: false translatable_attribute :body, String @@ -18,6 +19,7 @@ class QuestionForm < Decidim::Form validates :body, translatable_presence: true validates :question_type, inclusion: { in: Decidim::Elections::Question.question_types }, if: :editable? validates :response_options, presence: true, if: :editable? + validates :max_choices, numericality: { only_integer: true, greater_than: 1, less_than_or_equal_to: ->(form) { form.number_of_options } }, allow_blank: true def election @election ||= context[:election] @@ -32,6 +34,10 @@ def to_param def editable? @editable ||= id.blank? || Decidim::Elections::Question.exists?(id:) end + + def number_of_options + response_options.size + end end end end diff --git a/decidim-elections/app/helpers/decidim/elections/application_helper.rb b/decidim-elections/app/helpers/decidim/elections/application_helper.rb index fb76d007ffb3c..ff228932fdf04 100644 --- a/decidim-elections/app/helpers/decidim/elections/application_helper.rb +++ b/decidim-elections/app/helpers/decidim/elections/application_helper.rb @@ -39,7 +39,11 @@ def component_name def question_title(question, tag = :h3, **options) content_tag(tag, **options) do - translated_attribute(question.body) + title = translated_attribute(question.body) + if question.max_choices.present? && question.question_type == "multiple_option" + title += " (#{t("decidim.elections.votes.question.max_choices", count: question.max_choices)})" + end + title.html_safe end end diff --git a/decidim-elections/app/models/decidim/elections/question.rb b/decidim-elections/app/models/decidim/elections/question.rb index d0ba336682ee0..6bfee4dd38532 100644 --- a/decidim-elections/app/models/decidim/elections/question.rb +++ b/decidim-elections/app/models/decidim/elections/question.rb @@ -28,6 +28,7 @@ def self.question_types end def max_votable_options + return max_choices if max_choices.present? && question_type == "multiple_option" return response_options.size if question_type == "multiple_option" 1 diff --git a/decidim-elections/app/packs/entrypoints/decidim_elections.js b/decidim-elections/app/packs/entrypoints/decidim_elections.js index 36be7ef26208b..bd596b1dff83e 100644 --- a/decidim-elections/app/packs/entrypoints/decidim_elections.js +++ b/decidim-elections/app/packs/entrypoints/decidim_elections.js @@ -1,3 +1,4 @@ +import "src/decidim/elections/elections.js" import "src/decidim/elections/waiting_room.js" import "src/decidim/elections/live_results_update.js"; import "src/decidim/elections/census_check_visibility.js"; diff --git a/decidim-elections/app/packs/src/decidim/elections/elections.js b/decidim-elections/app/packs/src/decidim/elections/elections.js new file mode 100644 index 0000000000000..50fd9d555f724 --- /dev/null +++ b/decidim-elections/app/packs/src/decidim/elections/elections.js @@ -0,0 +1,32 @@ +document.addEventListener("turbo:load", () => { + const responseContainers = document.querySelectorAll(".response[data-max-choices]"); + if (!responseContainers.length) { + return; + } + + responseContainers.forEach((container) => { + const maxChoices = parseInt(container.dataset.maxChoices, 10); + if (!maxChoices) { + return; + } + + const checkboxes = container.querySelectorAll("input[type=checkbox]"); + const alertElement = container.querySelector(".max-choices-alert"); + + const checkLimit = () => { + const checkedCount = container.querySelectorAll("input[type=checkbox]:checked").length; + + if (checkedCount > maxChoices) { + alertElement.style.display = "block"; + } else { + alertElement.style.display = "none"; + } + }; + + checkboxes.forEach((checkbox) => { + checkbox.addEventListener("change", checkLimit); + }); + + checkLimit(); + }); +}); diff --git a/decidim-elections/app/views/decidim/elections/admin/questions/_question.html.erb b/decidim-elections/app/views/decidim/elections/admin/questions/_question.html.erb index 0f433985f112d..e62bde9f60047 100644 --- a/decidim-elections/app/views/decidim/elections/admin/questions/_question.html.erb +++ b/decidim-elections/app/views/decidim/elections/admin/questions/_question.html.erb @@ -102,6 +102,17 @@
<% end %>
+ +
+ <%= + form.select( + :max_choices, + (2..question.number_of_options), + { include_blank: t("any", scope: "decidim.forms.admin.questionnaires.question") }, + disabled: !editable + ) + %> +
diff --git a/decidim-elections/app/views/decidim/elections/votes/show.html.erb b/decidim-elections/app/views/decidim/elections/votes/show.html.erb index b76cde2df6248..9463d769b0c96 100644 --- a/decidim-elections/app/views/decidim/elections/votes/show.html.erb +++ b/decidim-elections/app/views/decidim/elections/votes/show.html.erb @@ -4,12 +4,16 @@ <%= render_question_description(question) %> -
+
<% question.response_options.each do |option| %> -
diff --git a/decidim-elections/config/locales/en.yml b/decidim-elections/config/locales/en.yml index acd6569788199..fb5e3b05d5bd4 100644 --- a/decidim-elections/config/locales/en.yml +++ b/decidim-elections/config/locales/en.yml @@ -2,6 +2,8 @@ en: activemodel: attributes: + elections_question: + max_choices: Maximum number of choices token_csv: file: File remove_all: Remove all current census data @@ -311,6 +313,9 @@ en: question: back: Back cast_vote: Cast vote + max_choices: 'Max choices: %{count}' + max_choices_alert: You have selected too many options. Please deselect some to continue. + max_choices_exceeded: You cannot select more than %{max} options. Please go back and adjust your selection. next: Next receipt: description: Yo can vote again at any time while the voting period is open. Your previous vote will be overwritten by the new one. diff --git a/decidim-elections/db/migrate/20251007113417_add_max_choices_to_decidim_elections_questions.rb b/decidim-elections/db/migrate/20251007113417_add_max_choices_to_decidim_elections_questions.rb new file mode 100644 index 0000000000000..4e2d8aadcac24 --- /dev/null +++ b/decidim-elections/db/migrate/20251007113417_add_max_choices_to_decidim_elections_questions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddMaxChoicesToDecidimElectionsQuestions < ActiveRecord::Migration[7.2] + def change + add_column :decidim_elections_questions, :max_choices, :integer + end +end diff --git a/decidim-elections/lib/decidim/elections/test/factories.rb b/decidim-elections/lib/decidim/elections/test/factories.rb index 50ae7c6bd5cac..c50e57fc0fe00 100644 --- a/decidim-elections/lib/decidim/elections/test/factories.rb +++ b/decidim-elections/lib/decidim/elections/test/factories.rb @@ -69,8 +69,8 @@ end trait :with_questions do - after :create do |election, _evaluator| - create_list(:election_question, 2, :with_response_options, :voting_enabled, election:) + after :create do |election, evaluator| + create_list(:election_question, 2, :with_response_options, :voting_enabled, election:, skip_injection: evaluator.skip_injection) end end @@ -95,8 +95,12 @@ end factory :election_question, class: "Decidim::Elections::Question" do + transient do + skip_injection { false } + end + association :election - body { generate_localized_title(:question_body) } + body { generate_localized_title(:question_body, skip_injection:) } description { generate_localized_description(:question_description) } question_type { "multiple_option" } sequence(:position) { |n| n } diff --git a/decidim-elections/spec/commands/decidim/elections/admin/update_questions_spec.rb b/decidim-elections/spec/commands/decidim/elections/admin/update_questions_spec.rb index 4d0d6369ec37b..b1d35796b0490 100644 --- a/decidim-elections/spec/commands/decidim/elections/admin/update_questions_spec.rb +++ b/decidim-elections/spec/commands/decidim/elections/admin/update_questions_spec.rb @@ -190,6 +190,67 @@ module Admin end end + context "when updating max_choices" do + let(:update_max_choices_params) do + { + "questions" => [ + { + "id" => first_question.id, + "body" => first_question.body, + "description" => first_question.description, + "question_type" => "multiple_option", + "max_choices" => 2, + "response_options" => [ + { "id" => first_question_first_option.id, "body" => first_question_first_option.body }, + { "id" => first_question_second_option.id, "body" => first_question_second_option.body } + ] + } + ] + } + end + + let(:form) { Decidim::Elections::Admin::QuestionsForm.from_params(update_max_choices_params).with_context(context_params) } + let(:command) { described_class.new(form, election) } + + it "updates max_choices field" do + command.call + updated = election.reload.questions.find_by(id: first_question.id) + expect(updated.max_choices).to eq(2) + end + end + + context "when adding a new question with max_choices" do + let(:add_with_max_choices_params) do + { + "questions" => [ + { + "body" => { en: "Q with max choices" }, + "description" => { en: "Description" }, + "question_type" => "multiple_option", + "max_choices" => 3, + "response_options" => [ + { "body" => { en: "Option 1" } }, + { "body" => { en: "Option 2" } }, + { "body" => { en: "Option 3" } }, + { "body" => { en: "Option 4" } } + ] + } + ] + } + end + + let(:form) { Decidim::Elections::Admin::QuestionsForm.from_params(add_with_max_choices_params).with_context(context_params) } + let(:command) { described_class.new(form, election) } + + it "creates a new question with max_choices" do + expect { command.call } + .to change { election.reload.questions.count }.by(1) + new_question = election.reload.questions.last + expect(new_question.max_choices).to eq(3) + expect(new_question.response_options.size).to eq(4) + end + end + context "when updating, deleting, and adding at once" do let(:combo_params) do { diff --git a/decidim-elections/spec/controllers/decidim/elections/votes_controller_spec.rb b/decidim-elections/spec/controllers/decidim/elections/votes_controller_spec.rb index e340e150c9e3c..7cab91d3c4936 100644 --- a/decidim-elections/spec/controllers/decidim/elections/votes_controller_spec.rb +++ b/decidim-elections/spec/controllers/decidim/elections/votes_controller_spec.rb @@ -96,6 +96,56 @@ module Elections expect(session[:votes_buffer]).to eq({ question.id.to_s => nil, second_question.id.to_s => nil }) expect(response).to redirect_to(confirm_election_votes_path) end + + context "when question has max_choices limit" do + let!(:question_with_limit) do + create(:election_question, :voting_enabled, election:, question_type: "multiple_option", max_choices: 2) + end + let!(:option1) { create(:election_response_option, question: question_with_limit) } + let!(:option2) { create(:election_response_option, question: question_with_limit) } + let!(:option3) { create(:election_response_option, question: question_with_limit) } + + it "rejects vote when exceeding max_choices" do + patch :update, params: params.merge( + id: question_with_limit.id, + response: { + question_with_limit.id.to_s => [option1.id, option2.id, option3.id] + } + ) + + expect(response).to have_http_status(:ok) + expect(flash[:alert]).to match(/cannot select more than 2/) + expect(subject).to render_template(:show) + end + + it "accepts vote when within max_choices limit" do + session[:votes_buffer] = { question.id.to_s => nil, second_question.id.to_s => nil } + + patch :update, params: params.merge( + id: question_with_limit.id, + response: { + question_with_limit.id.to_s => [option1.id, option2.id] + } + ) + + expect(session[:votes_buffer][question_with_limit.id.to_s]).to eq([option1.id.to_s, option2.id.to_s]) + expect(response).to redirect_to(confirm_election_votes_path) + end + + it "accepts vote with less than max_choices" do + session[:votes_buffer] = { question.id.to_s => nil, second_question.id.to_s => nil } + + patch :update, params: params.merge( + id: question_with_limit.id, + response: { + question_with_limit.id.to_s => [option1.id] + } + ) + + expect(session[:votes_buffer][question_with_limit.id.to_s]).to eq([option1.id.to_s]) + expect(response).to redirect_to(confirm_election_votes_path) + end + end end end diff --git a/decidim-elections/spec/forms/decidim/elections/admin/question_form_spec.rb b/decidim-elections/spec/forms/decidim/elections/admin/question_form_spec.rb index f5a0349884b82..75e3f42235347 100644 --- a/decidim-elections/spec/forms/decidim/elections/admin/question_form_spec.rb +++ b/decidim-elections/spec/forms/decidim/elections/admin/question_form_spec.rb @@ -55,6 +55,72 @@ module Admin it { is_expected.not_to be_valid } end + describe "max_choices validation" do + let(:attributes) do + { + body_en: body_en, + description_en: description_en, + question_type: question_type, + response_options: response_options, + max_choices: max_choices + } + end + + context "when max_choices is valid" do + let(:max_choices) { 2 } + + it { is_expected.to be_valid } + end + + context "when max_choices is greater than number of options" do + let(:max_choices) { 10 } + + it { is_expected.not_to be_valid } + + it "adds an error on max_choices" do + subject.valid? + expect(subject.errors[:max_choices]).not_to be_empty + end + end + + context "when max_choices is 1" do + let(:max_choices) { 1 } + + it { is_expected.not_to be_valid } + + it "adds an error on max_choices" do + subject.valid? + expect(subject.errors[:max_choices]).not_to be_empty + end + end + + context "when max_choices is nil" do + let(:max_choices) { nil } + + it { is_expected.to be_valid } + end + + context "when max_choices is blank string" do + let(:max_choices) { "" } + + it { is_expected.to be_valid } + end + end + + describe "#number_of_options" do + it "returns the count of response options" do + expect(subject.number_of_options).to eq(2) + end + + context "when there are no response options" do + let(:response_options) { {} } + + it "returns 0" do + expect(subject.number_of_options).to eq(0) + end + end + end + it_behaves_like "form to param", default_id: "questionnaire-question-id" end end diff --git a/decidim-elections/spec/models/decidim/elections/question_spec.rb b/decidim-elections/spec/models/decidim/elections/question_spec.rb index 3f33d9493f9e4..fad27465bc732 100644 --- a/decidim-elections/spec/models/decidim/elections/question_spec.rb +++ b/decidim-elections/spec/models/decidim/elections/question_spec.rb @@ -155,6 +155,22 @@ module Elections it "returns the count of response options" do expect(subject.max_votable_options).to eq(2) end + + context "when max_choices is set" do + let(:question) { create(:election_question, :with_response_options, question_type: "multiple_option", max_choices: 1) } + + it "returns the max_choices value" do + expect(subject.max_votable_options).to eq(1) + end + end + + context "when max_choices is nil" do + let(:question) { create(:election_question, :with_response_options, question_type: "multiple_option", max_choices: nil) } + + it "returns the count of response options" do + expect(subject.max_votable_options).to eq(2) + end + end end end @@ -182,6 +198,15 @@ module Elections expect(question.safe_responses(response_ids)).to be_empty end end + + context "when max_choices is set" do + let(:question) { create(:election_question, :with_response_options, question_type: "multiple_option", max_choices: 1) } + + it "returns only max_choices number of responses" do + response_ids = question.response_options.pluck(:id) + expect(question.safe_responses(response_ids).count).to eq(1) + end + end end describe "#sibling_questions" do diff --git a/decidim-elections/spec/system/admin/admin_manages_election_questions_spec.rb b/decidim-elections/spec/system/admin/admin_manages_election_questions_spec.rb index 43ccd8a39fd7c..f7c034dc61bfe 100644 --- a/decidim-elections/spec/system/admin/admin_manages_election_questions_spec.rb +++ b/decidim-elections/spec/system/admin/admin_manages_election_questions_spec.rb @@ -128,6 +128,74 @@ end end + context "when admin user sets max_choices for multiple_option question" do + it "creates a question with max_choices" do + visit questions_edit_path + + click_on "Add question" + expand_all_questions + + within "form.edit_questions" do + within page.all(".questionnaire-question").first do + fill_in find_nested_form_field_locator("body_en"), with: "Select up to 2 options" + select "Multiple option", from: "Type" + + 3.times { click_on "Add response option" } + + page.all(".questionnaire-question-response-option").each_with_index do |option, idx| + within option do + fill_in find_nested_form_field_locator("body_en"), with: "Option #{idx + 1}" + end + end + + select "2", from: "Maximum number of choices" + end + end + + click_on "Save and continue" + + expect(page).to have_admin_callout("successfully") + + visit questions_edit_path + expand_all_questions + + expect(page).to have_css("input[value='Select up to 2 options']") + expect(election.questions.last.max_choices).to eq(2) + end + + it "updates max_choices on existing question" do + question = create(:election_question, :with_response_options, + election:, + question_type: "multiple_option", + max_choices: nil) + + visit questions_edit_path + find("#questionnaire_question_#{question.id}-button").click + + within "#accordion-questionnaire_question_#{question.id}-field" do + select "2", from: "Maximum number of choices" + end + + click_on "Save and continue" + + expect(page).to have_admin_callout("successfully") + expect(question.reload.max_choices).to eq(2) + end + + it "shows 'Any' option for max_choices when unset" do + question = create(:election_question, :with_response_options, + election:, + question_type: "multiple_option") + + visit questions_edit_path + find("#questionnaire_question_#{question.id}-button").click + + within "#accordion-questionnaire_question_#{question.id}-field" do + expect(page).to have_select("Maximum number of choices", selected: "Any") + end + end + end + private def find_nested_form_field_locator(attribute, visible: :visible) diff --git a/decidim-elections/spec/system/election_results_spec.rb b/decidim-elections/spec/system/election_results_spec.rb index a5c9adb11fb4f..0653401acf4d2 100644 --- a/decidim-elections/spec/system/election_results_spec.rb +++ b/decidim-elections/spec/system/election_results_spec.rb @@ -4,7 +4,7 @@ require "decidim/elections/test/vote_examples" describe "Dashboard" do - let(:election) { create(:election, :published, :ongoing, :real_time, :with_internal_users_census, :with_questions) } + let(:election) { create(:election, :published, :ongoing, :real_time, :with_internal_users_census, :with_questions, skip_injection: true) } let(:question1) { election.questions.first } let(:question2) { election.questions.second } let(:option11) { question1.response_options.first } @@ -70,8 +70,8 @@ def expect_vote_count(question, option, count) context "when the election is per question" do let(:election) { create(:election, :published, :ongoing, :per_question, :with_internal_users_census) } - let!(:question1) { create(:election_question, :with_response_options, :voting_enabled, election:) } - let!(:question2) { create(:election_question, :with_response_options, election:) } + let!(:question1) { create(:election_question, :with_response_options, :voting_enabled, election:, skip_injection: true) } + let!(:question2) { create(:election_question, :with_response_options, election:, skip_injection: true) } let(:option11) { question1.response_options.first } let(:option12) { question1.response_options.second } let(:option21) { question2.response_options.first } @@ -84,7 +84,7 @@ def expect_vote_count(question, option, count) end context "when all questions are enabled" do - let!(:question2) { create(:election_question, :with_response_options, :voting_enabled, election:) } + let!(:question2) { create(:election_question, :with_response_options, :voting_enabled, election:, skip_injection: true) } it_behaves_like "shows questions in an election" end diff --git a/decidim-elections/spec/system/user_votes_in_an_election_spec.rb b/decidim-elections/spec/system/user_votes_in_an_election_spec.rb index bed6fc96a6fb4..99890f16189ca 100644 --- a/decidim-elections/spec/system/user_votes_in_an_election_spec.rb +++ b/decidim-elections/spec/system/user_votes_in_an_election_spec.rb @@ -6,8 +6,8 @@ describe "Dashboard" do let(:user) { create(:user, :confirmed, organization:) } let!(:election) { create(:election, :published, :ongoing, :with_internal_users_census, census_settings:) } - let!(:question1) { create(:election_question, :with_response_options, election:, question_type: "single_option") } - let!(:question2) { create(:election_question, :with_response_options, election:, question_type: "multiple_option") } + let!(:question1) { create(:election_question, :with_response_options, skip_injection: true, election:, question_type: "single_option") } + let!(:question2) { create(:election_question, :with_response_options, skip_injection: true, election:, question_type: "multiple_option") } let(:organization) { election.organization } let(:census_settings) do { @@ -103,6 +103,62 @@ def election_vote_path(question) end end + context "when question has max_choices limit", :js do + let(:election_with_limit) { create(:election, :published, :ongoing, :with_internal_users_census, component: election.component) } + let!(:question_with_limit) { create(:election_question, election: election_with_limit, question_type: "multiple_option", max_choices: 2, body: { en: "Choose your options" }) } + let!(:option1) { create(:election_response_option, question: question_with_limit, body: { en: "Option 1" }) } + let!(:option2) { create(:election_response_option, question: question_with_limit, body: { en: "Option 2" }) } + let!(:option3) { create(:election_response_option, question: question_with_limit, body: { en: "Option 3" }) } + let(:new_election_with_limit_vote_path) { Decidim::EngineRouter.main_proxy(election.component).new_election_vote_path(election_id: election_with_limit.id) } + + before do + login_as user, scope: :user + visit new_election_with_limit_vote_path + end + + it "shows max_choices in question title" do + expect(page).to have_content("Choose your options (Max choices: 2)") + end + + it "shows alert when selecting more than max_choices" do + check "Option 1" + check "Option 2" + check "Option 3" + + expect(page).to have_css(".max-choices-alert", visible: :visible) + end + + it "hides alert when deselecting options" do + check "Option 1" + check "Option 2" + check "Option 3" + + expect(page).to have_css(".max-choices-alert", visible: :visible) + + uncheck "Option 3" + + expect(page).to have_css(".max-choices-alert", visible: :hidden) + end + + it "shows server-side validation error when exceeding limit" do + check "Option 1" + check "Option 2" + check "Option 3" + + click_on "Next" + + expect(page).to have_content("You cannot select more than 2 options") + end + + context "when question is single_option" do + let!(:single_question) { create(:election_question, election: election_with_limit, question_type: "single_option", body: { en: "Choose one option" }) } + + it "does not show max_choices for single_option questions" do + expect(page).to have_no_content("Choose one option (Max choices:") + end + end + end + context "when the election is per_question" do let(:election) { create(:election, :published, :ongoing, :with_internal_users_census, :per_question) } diff --git a/decidim-elections/spec/system/user_votes_in_an_per_question_election_spec.rb b/decidim-elections/spec/system/user_votes_in_an_per_question_election_spec.rb index 87f3717b88796..7be60f6e774b7 100644 --- a/decidim-elections/spec/system/user_votes_in_an_per_question_election_spec.rb +++ b/decidim-elections/spec/system/user_votes_in_an_per_question_election_spec.rb @@ -21,8 +21,8 @@ let(:receipt_election_votes_path) { Decidim::EngineRouter.main_proxy(election.component).receipt_election_per_question_votes_path(election_id: election.id) } let(:confirm_election_votes_path) { Decidim::EngineRouter.main_proxy(election.component).confirm_election_per_question_votes_path(election_id: election.id) } let(:new_election_normal_vote_path) { Decidim::EngineRouter.main_proxy(election.component).new_election_vote_path(election_id: election.id) } - let!(:question1) { create(:election_question, :with_response_options, :voting_enabled, question_type: "single_option", election:, position: 1) } - let!(:question2) { create(:election_question, :with_response_options, election:, position: 2) } + let!(:question1) { create(:election_question, :with_response_options, :voting_enabled, skip_injection: true, question_type: "single_option", election:, position: 1) } + let!(:question2) { create(:election_question, :with_response_options, election:, skip_injection: true, position: 2) } let(:voter_uid) { user.to_global_id.to_s } def election_vote_path(question) @@ -62,8 +62,8 @@ def election_vote_path(question) end context "when navigating through already voted questions" do - let!(:question2) { create(:election_question, :with_response_options, :voting_enabled, election:, position: 2) } - let!(:question3) { create(:election_question, :with_response_options, election:, position: 3) } + let!(:question2) { create(:election_question, :with_response_options, :voting_enabled, election:, skip_injection: true, position: 2) } + let!(:question3) { create(:election_question, :with_response_options, election:, skip_injection: true, position: 3) } before do login_as user, scope: :user diff --git a/decidim-generators/lib/decidim/generators/app_templates/storage.yml b/decidim-generators/lib/decidim/generators/app_templates/storage.yml index 2e48424431db5..e8a54fcbe971e 100644 --- a/decidim-generators/lib/decidim/generators/app_templates/storage.yml +++ b/decidim-generators/lib/decidim/generators/app_templates/storage.yml @@ -8,6 +8,7 @@ local: s3: service: S3 + public: <%= Decidim::Env.new("AWS_PUBLIC", "true").to_boolean_string %> access_key_id: <%= Decidim::Env.new("AWS_ACCESS_KEY_ID").to_s %> secret_access_key: <%= Decidim::Env.new("AWS_SECRET_ACCESS_KEY").to_s %> bucket: <%= Decidim::Env.new("AWS_BUCKET").to_s %> @@ -16,6 +17,7 @@ s3: azure: service: AzureStorage + public: <%= Decidim::Env.new("AZURE_PUBLIC", "true").to_boolean_string %> storage_account_name: <%= Decidim::Env.new("AZURE_STORAGE_ACCOUNT_NAME").to_s %> storage_access_key: <%= Decidim::Env.new("AZURE_STORAGE_ACCESS_KEY").to_s %> container: <%= Decidim::Env.new("AZURE_CONTAINER").to_s %> @@ -24,6 +26,7 @@ gcs: service: GCS project: <%= Decidim::Env.new("GCS_PROJECT").to_s %> bucket: <%= Decidim::Env.new("GCS_BUCKET").to_s %> + public: <%= Decidim::Env.new("GCS_PUBLIC", "true").to_boolean_string %> credentials: type: <%= Decidim::Env.new("GCS_TYPE", "service_account").to_s %> project_id: <%= Decidim::Env.new("GCS_PROJECT_ID").to_s %> diff --git a/decidim-participatory_processes/app/commands/decidim/participatory_processes/create_democratic_quality_indicators_page.rb b/decidim-participatory_processes/app/commands/decidim/participatory_processes/create_democratic_quality_indicators_page.rb index dd313fd825e85..96b525ce86066 100644 --- a/decidim-participatory_processes/app/commands/decidim/participatory_processes/create_democratic_quality_indicators_page.rb +++ b/decidim-participatory_processes/app/commands/decidim/participatory_processes/create_democratic_quality_indicators_page.rb @@ -5,9 +5,9 @@ module ParticipatoryProcesses class CreateDemocraticQualityIndicatorsPage < Decidim::Command # Public: Initializes the command. # - # @param organization [Decidim::Organization] a Decidim::Organization instance - def initialize(organization) - @organization = organization + # @param organization_id [Integer] an id to fetch a Decidim::Organization instance + def initialize(organization_id) + @organization = Decidim::Organization.find(organization_id) end # Executes the command that creates the required static page or returns it if it already exists. diff --git a/decidim-participatory_processes/db/migrate/20250403110052_add_democratic_quality_static_page.rb b/decidim-participatory_processes/db/migrate/20250403110052_add_democratic_quality_static_page.rb index 0b2e35ebe528a..3ca9c1db4eb87 100644 --- a/decidim-participatory_processes/db/migrate/20250403110052_add_democratic_quality_static_page.rb +++ b/decidim-participatory_processes/db/migrate/20250403110052_add_democratic_quality_static_page.rb @@ -11,7 +11,7 @@ class StaticPage < ApplicationRecord def up Organization.find_each do |organization| - Decidim::ParticipatoryProcesses::CreateDemocraticQualityIndicatorsPage.call(organization) + Decidim::ParticipatoryProcesses::CreateDemocraticQualityIndicatorsPage.call(organization.id) end end end diff --git a/decidim-participatory_processes/lib/decidim/participatory_processes/engine.rb b/decidim-participatory_processes/lib/decidim/participatory_processes/engine.rb index 9bfedac36b266..261257caf7112 100644 --- a/decidim-participatory_processes/lib/decidim/participatory_processes/engine.rb +++ b/decidim-participatory_processes/lib/decidim/participatory_processes/engine.rb @@ -110,7 +110,7 @@ class Engine < ::Rails::Engine initializer "decidim_participatory_processes.static_pages" do config.to_prepare do Decidim::EventsManager.subscribe("decidim.system.create_organization:after") do |_event_name, data| - Decidim::ParticipatoryProcesses::CreateDemocraticQualityIndicatorsPage.call(data[:organization]) + Decidim::ParticipatoryProcesses::CreateDemocraticQualityIndicatorsPage.call(data[:organization].id) end end end diff --git a/decidim-participatory_processes/lib/decidim/participatory_processes/seeds.rb b/decidim-participatory_processes/lib/decidim/participatory_processes/seeds.rb index 803e998b2efaf..7ecad9baa7773 100644 --- a/decidim-participatory_processes/lib/decidim/participatory_processes/seeds.rb +++ b/decidim-participatory_processes/lib/decidim/participatory_processes/seeds.rb @@ -7,7 +7,7 @@ module Decidim module ParticipatoryProcesses class Seeds < Decidim::Seeds def call - Decidim::ParticipatoryProcesses::CreateDemocraticQualityIndicatorsPage.call(organization) + Decidim::ParticipatoryProcesses::CreateDemocraticQualityIndicatorsPage.call(organization.id) create_content_block! diff --git a/decidim-participatory_processes/spec/commands/decidim/participatory_processes/create_democratic_quality_indicators_page_spec.rb b/decidim-participatory_processes/spec/commands/decidim/participatory_processes/create_democratic_quality_indicators_page_spec.rb index ce61a32cf8dd7..2af7f8b091606 100644 --- a/decidim-participatory_processes/spec/commands/decidim/participatory_processes/create_democratic_quality_indicators_page_spec.rb +++ b/decidim-participatory_processes/spec/commands/decidim/participatory_processes/create_democratic_quality_indicators_page_spec.rb @@ -5,7 +5,7 @@ module Decidim module ParticipatoryProcesses describe CreateDemocraticQualityIndicatorsPage do - subject { described_class.new(organization1) } + subject { described_class.new(organization1.id) } let!(:organization1) { create(:organization, create_static_pages: false) } let!(:organization2) { create(:organization, create_static_pages: false) } @@ -14,15 +14,15 @@ module ParticipatoryProcesses end it "creates the indicators page for all the organizations" do - described_class.new(organization1).call - described_class.new(organization2).call + described_class.new(organization1.id).call + described_class.new(organization2.id).call expect(organization1.static_pages.count).to eq(1) expect(organization2.static_pages.count).to eq(1) end it "sets the content with translatable title" do - described_class.new(organization1).call + described_class.new(organization1.id).call organization1.static_pages.each do |page| expect(page.title["en"]).to include(I18n.t("title", scope: "decidim.participatory_processes.static_pages.democratic_quality_indicators")) @@ -32,7 +32,7 @@ module ParticipatoryProcesses it "sets the content with each locale" do allow(Decidim).to receive(:available_locales).and_return [:en, :ca] - described_class.new(organization1).call + described_class.new(organization1.id).call organization1.static_pages.each do |page| expect(page.title["en"]).not_to be_nil expect(page.title["ca"]).not_to be_nil diff --git a/docs/modules/configure/pages/environment_variables.adoc b/docs/modules/configure/pages/environment_variables.adoc index bfedfb04f152f..bf796cdf8d132 100644 --- a/docs/modules/configure/pages/environment_variables.adoc +++ b/docs/modules/configure/pages/environment_variables.adoc @@ -170,6 +170,11 @@ Also, be sure to add the line `gem "aws-sdk-s3", require: false` in your Gemfile | |No +|*AWS_PUBLIC* +|Whether the AWS assets to be public or not. Default is true, which means that no credential strings would be attached to your AWS stored assets, making it easier for components to be cached. If you set this to `false` you may have cache issues (like changing an image and not seeing the change immediately). +|true +|No + |*AZURE_STORAGE_ACCESS_KEY* |If STORAGE_PROVIDER is set to `azure`, define here your AZURE ACCESS KEY with permissions to access the container for the application. Needs to be encoded in Base64. @@ -187,6 +192,11 @@ Also, be sure to add the line `gem "azure-storage-blob", require: false` in your | |No +|*AZURE_PUBLIC* +|Whether the Azure assets to be public or not. Default is true, which means that no credential strings would be attached to your Azure stored assets, making it easier for components to be cached. If you set this to `false` you may have cache issues (like changing an image and not seeing the change immediately). +|true +|No + |*GCS_PROJECT* |If STORAGE_PROVIDER is set to `gcs`, define here your GOOGLE CLOUD PROJECT with permissions to access the bucket for the application. @@ -251,6 +261,11 @@ Also, be sure to add the line `gem "google-cloud-storage", "~> 1.11", require: f | |No +|*GCS_PUBLIC* +|Whether the Google Cloud Service assets to be public or not. Default is true, which means that no credential strings would be attached to your CGS stored assets, making it easier for components to be cached. If you set this to `false` you may have cache issues (like changing an image and not seeing the change immediately). +|true +|No + |=== Next variables are additional services such as geolocation, etherpad and other simple integrations. From b02a2d718c39d32f7696d23b345c35fb0c2d7ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Thu, 12 Mar 2026 11:02:53 +0100 Subject: [PATCH 004/131] fix images & blobs --- .../decidim/content_parsers/blob_parser.rb | 6 ++-- .../content_renderers/blob_renderer.rb | 4 +-- .../decidim/blob_parser_spec.rb | 15 ++++++++-- .../decidim/blob_renderer_spec.rb | 30 +++++++++++++++---- 4 files changed, 42 insertions(+), 13 deletions(-) diff --git a/decidim-core/lib/decidim/content_parsers/blob_parser.rb b/decidim-core/lib/decidim/content_parsers/blob_parser.rb index 7b101c64e04b4..6f60c72582ec9 100644 --- a/decidim-core/lib/decidim/content_parsers/blob_parser.rb +++ b/decidim-core/lib/decidim/content_parsers/blob_parser.rb @@ -34,8 +34,8 @@ class BlobParser < BaseParser # Group 6: Variation key for representations /(?[\w.=-]+) )? - # Group 7: Filename - /([\w.=-]+) + # Group 7: Filename (supports apostrophes inside names but not as HTML quote delimiters) + /((?:[^\s/"<>']|'(?=[^\s/"<>']))+) }x def rewrite @@ -55,7 +55,7 @@ def replace_blobs(text) blob = if type_part == "disk" # Disk service URL - decoded = ActiveStorage.verifier.verified(key_part, purpose: :blob_key).with_indifferent_access + decoded = ActiveStorage.verifier.verified(key_part, purpose: :blob_key)&.with_indifferent_access ActiveStorage::Blob.find_by(key: decoded[:key]) if decoded else # Representation or blob diff --git a/decidim-core/lib/decidim/content_renderers/blob_renderer.rb b/decidim-core/lib/decidim/content_renderers/blob_renderer.rb index f29a933fc95dc..2a46c6621a5d2 100644 --- a/decidim-core/lib/decidim/content_renderers/blob_renderer.rb +++ b/decidim-core/lib/decidim/content_renderers/blob_renderer.rb @@ -18,8 +18,8 @@ module ContentRenderers # # @see BaseRenderer Examples of how to use a content renderer class BlobRenderer < BaseRenderer - # Matches a global id representing a Decidim::User - GLOBAL_ID_REGEX = %r{(gid://[\w-]+/ActiveStorage::Blob/\d+)(/([\w=-]+))?} + # Matches a global id representing an ActiveStorage::Blob (optionally with a variant key) + GLOBAL_ID_REGEX = %r{(gid://[\w-]+/ActiveStorage::Blob/\d+)(/([\w=-]+))?(?:[^\s"'<>]*)} # Replaces found Global IDs matching an existing blob with a URL to # that blob. The Global IDs representing an invalid ActiveStorage::Blob diff --git a/decidim-core/spec/content_parsers/decidim/blob_parser_spec.rb b/decidim-core/spec/content_parsers/decidim/blob_parser_spec.rb index 67ed5112d460b..ab57d35c70453 100644 --- a/decidim-core/spec/content_parsers/decidim/blob_parser_spec.rb +++ b/decidim-core/spec/content_parsers/decidim/blob_parser_spec.rb @@ -30,6 +30,8 @@ module Decidim let(:document_blob_proxy_path) { routes.rails_service_blob_proxy_path(document_blob.signed_id, document_blob.filename, only_path: true) } let(:document_blob_proxy_url) { routes.rails_service_blob_proxy_url(document_blob.signed_id, document_blob.filename, host: asset_host) } let(:document_blob_disk_url) { document_blob.url } + let(:document_blob_redirect_url) { routes.rails_blob_redirect_url(document_blob, host: asset_host) } + let(:missing_blob_url) { document_blob_disk_url.gsub("/disk/", "/disk/i-do-not-exist") } let(:content) do <<~HTML.squish @@ -43,7 +45,7 @@ module Decidim

#{document_blob_url}

#{document_blob_proxy_path}

#{document_blob_proxy_url}

-

#{document_blob_disk_url}

+

#{missing_blob_url}

HTML end let(:parsed_content) do @@ -58,7 +60,7 @@ module Decidim

#{document_blob.to_global_id}

#{document_blob.to_global_id}

#{document_blob.to_global_id}

-

#{document_blob.to_global_id}

+

#{missing_blob_url}

HTML end @@ -73,6 +75,15 @@ module Decidim expect(subject).to eq(parsed_content) end + context "when the image has strange characters in the filename" do + let(:image_blob) { create(:blob, :image, filename: "strange fílename @#$%<>\"'.jpg") } + let(:document_blob) { create(:blob, :document, filename: "strange fílename @#$%()\\|.pdf") } + + it "rewrites the URLs correctly" do + expect(subject).to eq(parsed_content) + end + end + context "when content is preceded by a link with an URL" do let(:parser) { described_class.new(content_with_url, context) } let(:content_with_url) do diff --git a/decidim-core/spec/content_renderers/decidim/blob_renderer_spec.rb b/decidim-core/spec/content_renderers/decidim/blob_renderer_spec.rb index ac983f3ba179b..cc98ec12b4b00 100644 --- a/decidim-core/spec/content_renderers/decidim/blob_renderer_spec.rb +++ b/decidim-core/spec/content_renderers/decidim/blob_renderer_spec.rb @@ -22,15 +22,18 @@ module Decidim let(:image_representation_path) { routes.rails_representation_path(image_variant, only_path: true) } let(:image_variant_processed_representation_path) { routes.rails_representation_path(image_variant_processed, only_path: true) } let(:image_blob_url) { routes.rails_disk_service_url(image_blob.signed_id, image_blob.filename, host: asset_host) } - let(:document_blob_url) { routes.rails_disk_service_url(document_blob.signed_id, image_blob.filename, host: asset_host) } + let(:document_blob_url) { routes.rails_disk_service_url(document_blob.signed_id, document_blob.filename, host: asset_host) } + let(:invalid_blob_url) { image_blob.to_global_id.to_s.gsub("Blob/", "Blob/123") } + let(:suffix) { "" } let(:content) do <<~HTML.squish -

Representation image

-

Representation image processed

-

Blob image

-

Link to document

-

#{document_blob.to_global_id}

+

Representation image

+

Representation image processed

+

Blob image

+

Link to document

+

#{document_blob.to_global_id}#{suffix}

+

Invalid blob

HTML end @@ -50,6 +53,9 @@ module Decidim expect(doc.at("img[alt='Blob image']").attr(:src)).to be_blob_url(image_blob) expect(doc.at("a").attr(:href)).to be_blob_url(document_blob) expect(doc.at("p.document-url").inner_html).to be_blob_url(document_blob) + # Note, the invalid blob URL is replaced with an empty string, + # This might change in the future if we decide to keep the invalid URL instead of removing it + expect(doc.at("img[alt='Invalid blob']").attr(:src)).to be_blank end end @@ -60,6 +66,18 @@ module Decidim it_behaves_like "correctly rendered blob URLs" end + + context "when there is a query string after the gid" do + let(:suffix) { "?some=strange&suffix=after" } + + it_behaves_like "correctly rendered blob URLs" + end + + context "when there is some strange suffix after the gid" do + let(:suffix) { "%something" } + + it_behaves_like "correctly rendered blob URLs" + end end end end From 59ada45ba17ddbe360805e5f4ab7b2eb24faa357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Fri, 13 Mar 2026 10:47:23 +0100 Subject: [PATCH 005/131] fix attachment updates --- .../app/cells/decidim/upload_modal/files.erb | 6 ++- .../app/cells/decidim/upload_modal_cell.rb | 11 +++- .../decidim/multiple_attachments_methods.rb | 23 ++++++-- .../decidim/proposals/admin/proposal_form.rb | 52 ++++++++++++++++++- .../system/admin/admin_edits_proposal_spec.rb | 27 ++++++++++ 5 files changed, 113 insertions(+), 6 deletions(-) diff --git a/decidim-core/app/cells/decidim/upload_modal/files.erb b/decidim-core/app/cells/decidim/upload_modal/files.erb index 651da3aacd0b2..594e8dd7717e2 100644 --- a/decidim-core/app/cells/decidim/upload_modal/files.erb +++ b/decidim-core/app/cells/decidim/upload_modal/files.erb @@ -42,7 +42,11 @@ <% end %> <% end %> <% if attachment_blob.present? %> - <%= form.hidden_field attribute, value: attachment_blob.signed_id, id: "hidden_#{attribute}_#{attachment_blob.id}" %> + <% if is_persisted_attachment %> + <%= form.hidden_field attribute, value: attachment.id, id: "hidden_#{attribute}_#{attachment.id}" %> + <% else %> + <%= form.hidden_field attribute, value: attachment_blob.signed_id, id: "hidden_#{attribute}_#{attachment_blob.id}" %> + <% end %> <% end %>
<% end %> diff --git a/decidim-core/app/cells/decidim/upload_modal_cell.rb b/decidim-core/app/cells/decidim/upload_modal_cell.rb index 055d4d1df8081..03a9af51e1578 100644 --- a/decidim-core/app/cells/decidim/upload_modal_cell.rb +++ b/decidim-core/app/cells/decidim/upload_modal_cell.rb @@ -128,7 +128,16 @@ def attachments @attachments = begin attachments = options[:attachments] || form.object.send(attribute) attachments = Array(attachments).compact_blank - attachments.map { |attachment| attachment.is_a?(String) ? ActiveStorage::Blob.find_signed(attachment) : attachment } + attachments.map do |attachment| + case attachment + when String + ActiveStorage::Blob.find_signed(attachment) + when Integer + Decidim::Attachment.find_by(id: attachment) + else + attachment + end + end.compact end end diff --git a/decidim-core/app/commands/decidim/multiple_attachments_methods.rb b/decidim-core/app/commands/decidim/multiple_attachments_methods.rb index 1dfe2e4c40949..248d604e0d7e3 100644 --- a/decidim-core/app/commands/decidim/multiple_attachments_methods.rb +++ b/decidim-core/app/commands/decidim/multiple_attachments_methods.rb @@ -41,8 +41,9 @@ def attachments_invalid? def create_attachments(first_weight: 0) weight = first_weight - # Add the weights first to the old document - @form.documents.each do |document| + # Add the weights first to the old documents + document_ids = keep_ids + Decidim::Attachment.where(id: document_ids).each do |document| document.update!(weight:) weight += 1 end @@ -59,7 +60,7 @@ def document_cleanup!(include_all_attachments: false) documents = include_all_attachments ? documents_attached_to.attachments.with_attached_file : documents_attached_to.documents documents.each do |document| - document.destroy! if @form.documents.map(&:id).exclude? document.id + document.destroy! unless keep_ids.include?(document.id) end documents_attached_to.reload @@ -98,5 +99,21 @@ def content_type_for(attachment) def blob(signed_id) ActiveStorage::Blob.find_signed(signed_id) end + + def keep_ids + documents_array = Array(@form.documents) + documents_array.map do |doc| + case doc + when Decidim::Attachment + doc.id + when Integer + doc + when String + doc.match?(/\A\d+\z/) ? doc.to_i : nil + when Hash + (doc[:id] || doc["id"]).to_i + end + end.compact + end end end diff --git a/decidim-proposals/app/forms/decidim/proposals/admin/proposal_form.rb b/decidim-proposals/app/forms/decidim/proposals/admin/proposal_form.rb index 35fbfaaaf589a..f4d392a9d53f6 100644 --- a/decidim-proposals/app/forms/decidim/proposals/admin/proposal_form.rb +++ b/decidim-proposals/app/forms/decidim/proposals/admin/proposal_form.rb @@ -27,12 +27,62 @@ def map_model(model) self.title = presenter.title(all_locales: title.is_a?(Hash)) self.body = presenter.editor_body(all_locales: body.is_a?(Hash)) - self.documents = model.attachments + self.documents = model.attachments.ids + self.add_documents = model.attachments.map { |att| { id: att.id, title: att.title } } + end + + def documents=(value) + case value + when String + super(parse_string_documents(value)) + when Integer + super([value]) + else + super + end + end + + def documents + result = super + + if should_use_add_documents?(result) + extract_ids_from_add_documents + else + result.is_a?(Array) ? result : [] + end end def notify_missing_attachment_if_errored errors.add(:add_documents, :needs_to_be_reattached) if errors.any? && add_documents.present? end + + private + + def should_use_add_documents?(result) + (result.blank? || result.is_a?(String)) && add_documents.present? + end + + def extract_ids_from_add_documents + add_documents + .select { |doc| doc.is_a?(Hash) && (doc[:id].present? || doc["id"].present?) } + .map { |doc| (doc[:id] || doc["id"]).to_i } + end + + def parse_string_documents(value) + return [] if value.blank? + + parse_document_ids(value) + end + + def parse_document_ids(value) + ids = begin + Array(JSON.parse(value)) + rescue JSON::ParserError + value.split(",").map(&:strip) + end + + ids.map(&:to_i).reject(&:zero?) + end end end end diff --git a/decidim-proposals/spec/system/admin/admin_edits_proposal_spec.rb b/decidim-proposals/spec/system/admin/admin_edits_proposal_spec.rb index 0560038a3c702..7a3f7a2a10d9c 100644 --- a/decidim-proposals/spec/system/admin/admin_edits_proposal_spec.rb +++ b/decidim-proposals/spec/system/admin/admin_edits_proposal_spec.rb @@ -162,6 +162,33 @@ expect(page).to have_no_content("city.jpeg") end + + it "can edit a proposal with an attachment" do + visit_component_admin + within "tr[data-id='#{proposal.id}']" do + find("button[data-controller='dropdown']").click + click_on "Edit proposal" + end + + expect(page).to have_content("Update proposal") + expect(page).to have_field("proposal_title_en") + expect(page.html).to include(document.file.blob.filename.to_s) + + fill_in_i18n :proposal_title, "#proposal-title-tabs", en: "Updated proposal title with attachments" + click_on "Update" + + expect(page).to have_content("Proposal successfully updated.") + + visit_component_admin + within "tr[data-id='#{proposal.id}']" do + find("button[data-controller='dropdown']").click + click_on "Edit proposal" + end + + expect(page).to have_field("proposal_title_en", with: "Updated proposal title with attachments") + click_on "Edit attachments" + expect(page).to have_content(document.file.blob.filename.to_s) + end end end From 92a329aaa3ab0dc06f3cae8219b0d2d9f8999045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Fri, 13 Mar 2026 10:49:34 +0100 Subject: [PATCH 006/131] prevent infinite loops --- .../find_and_update_descendants_job.rb | 7 +++-- .../jobs/decidim/update_search_indexes_job.rb | 4 +-- decidim-core/lib/decidim/searchable.rb | 8 ++--- .../find_and_update_descendants_job_spec.rb | 8 +++++ decidim-core/spec/lib/searchable_spec.rb | 4 +-- decidim-meetings/spec/models/meeting_spec.rb | 30 +++++++++++++++++++ 6 files changed, 51 insertions(+), 10 deletions(-) diff --git a/decidim-core/app/jobs/decidim/find_and_update_descendants_job.rb b/decidim-core/app/jobs/decidim/find_and_update_descendants_job.rb index 21d3cbc635ab8..c8b0637a2bb62 100644 --- a/decidim-core/app/jobs/decidim/find_and_update_descendants_job.rb +++ b/decidim-core/app/jobs/decidim/find_and_update_descendants_job.rb @@ -4,8 +4,11 @@ module Decidim # Update search indexes for each descendants of a given element class FindAndUpdateDescendantsJob < ApplicationJob queue_as :default + MAX_DEPTH = 5 + + def perform(element, current_depth = 0) + return if current_depth >= MAX_DEPTH - def perform(element) descendants_collector = components_for(element) descendants_collector << element.comments.to_a if element.respond_to?(:comments) @@ -14,7 +17,7 @@ def perform(element) descendants_collector.each do |descendants| next if descendants.blank? - Decidim::UpdateSearchIndexesJob.perform_later(descendants) + Decidim::UpdateSearchIndexesJob.perform_later(descendants, current_depth + 1) end end diff --git a/decidim-core/app/jobs/decidim/update_search_indexes_job.rb b/decidim-core/app/jobs/decidim/update_search_indexes_job.rb index 5c8904ccc3706..575096e8f3bb6 100644 --- a/decidim-core/app/jobs/decidim/update_search_indexes_job.rb +++ b/decidim-core/app/jobs/decidim/update_search_indexes_job.rb @@ -4,8 +4,8 @@ module Decidim class UpdateSearchIndexesJob < ApplicationJob queue_as :default - def perform(elements) - elements.each { |element| element.try(:try_update_index_for_search_resource) } + def perform(elements, current_depth = 0) + elements.each { |element| element.try(:try_update_index_for_search_resource, current_depth) } end end end diff --git a/decidim-core/lib/decidim/searchable.rb b/decidim-core/lib/decidim/searchable.rb index 4f93222f8c5a8..9ad46b191f57d 100644 --- a/decidim-core/lib/decidim/searchable.rb +++ b/decidim-core/lib/decidim/searchable.rb @@ -101,7 +101,7 @@ def add_to_index_as_search_resource # Public: after_update callback to update index information of the model. # - def try_update_index_for_search_resource + def try_update_index_for_search_resource(current_depth = 0) return unless self.class.searchable_resource?(self) org = self.class.search_resource_fields_mapper.retrieve_organization(self) @@ -124,13 +124,13 @@ def try_update_index_for_search_resource searchables_in_org.destroy_all end - find_and_update_descendants + find_and_update_descendants(current_depth) end private - def find_and_update_descendants - Decidim::FindAndUpdateDescendantsJob.perform_later(self) + def find_and_update_descendants(current_depth = 0) + Decidim::FindAndUpdateDescendantsJob.perform_later(self, current_depth) end def contents_to_searchable_resource_attributes(fields, locale) diff --git a/decidim-core/spec/jobs/decidim/find_and_update_descendants_job_spec.rb b/decidim-core/spec/jobs/decidim/find_and_update_descendants_job_spec.rb index 21181a90fb265..65a3d2c03af17 100644 --- a/decidim-core/spec/jobs/decidim/find_and_update_descendants_job_spec.rb +++ b/decidim-core/spec/jobs/decidim/find_and_update_descendants_job_spec.rb @@ -39,6 +39,14 @@ end.to have_enqueued_job(Decidim::UpdateSearchIndexesJob).exactly(:twice) end + context "when recursion reaches max depth" do + it "does not update search indexes" do + expect do + Decidim::FindAndUpdateDescendantsJob.perform_now(participatory_process, described_class::MAX_DEPTH) + end.not_to have_enqueued_job(Decidim::UpdateSearchIndexesJob) + end + end + context "when participatory process has no descendants" do let(:proposal_component) { nil } let(:post_component) { nil } diff --git a/decidim-core/spec/lib/searchable_spec.rb b/decidim-core/spec/lib/searchable_spec.rb index fa824b2f6b134..988dd21d5744b 100644 --- a/decidim-core/spec/lib/searchable_spec.rb +++ b/decidim-core/spec/lib/searchable_spec.rb @@ -70,7 +70,7 @@ module Decidim context "when searchable does not have component" do it "enqueues the job when participatory process is updated" do - expect(Decidim::FindAndUpdateDescendantsJob).to receive(:perform_later).with(participatory_process) + expect(Decidim::FindAndUpdateDescendantsJob).to receive(:perform_later).with(participatory_process, 0) participatory_process.update!(published_at: nil) end @@ -81,7 +81,7 @@ module Decidim let!(:resource) { create(:proposal, :official, component: proposal_component) } it "enqueues the job when participatory process is updated" do - expect(Decidim::FindAndUpdateDescendantsJob).to receive(:perform_later).with(participatory_process) + expect(Decidim::FindAndUpdateDescendantsJob).to receive(:perform_later).with(participatory_process, 0) participatory_process.update!(published_at: nil) end diff --git a/decidim-meetings/spec/models/meeting_spec.rb b/decidim-meetings/spec/models/meeting_spec.rb index c1ded521a2f03..9e5bc811f39bc 100644 --- a/decidim-meetings/spec/models/meeting_spec.rb +++ b/decidim-meetings/spec/models/meeting_spec.rb @@ -478,5 +478,35 @@ module Decidim::Meetings end end end + + describe "search index updates with linked meetings" do + let(:organization) { create(:organization, available_locales: [:en]) } + let(:space_a) { create(:participatory_process, organization:) } + let(:space_b) { create(:participatory_process, organization:) } + let(:component_a) { create(:meeting_component, participatory_space: space_a) } + let(:component_b) { create(:meeting_component, participatory_space: space_b) } + let!(:meeting_a) { create(:meeting, :published, component: component_a, title: { en: "Meeting A" }) } + let!(:meeting_b) { create(:meeting, :published, component: component_b, title: { en: "Meeting B" }) } + + before do + create(:meeting_link, meeting: meeting_a, component: component_b) + create(:meeting_link, meeting: meeting_b, component: component_a) + end + + it "does not enqueue descendants indexing indefinitely" do + clear_enqueued_jobs + clear_performed_jobs + + perform_enqueued_jobs(only: [Decidim::FindAndUpdateDescendantsJob, Decidim::UpdateSearchIndexesJob]) do + meeting_a.update!(title: { en: "Updated meeting A" }) + end + + find_jobs = performed_jobs.count { |job| job[:job] == Decidim::FindAndUpdateDescendantsJob } + update_jobs = performed_jobs.count { |job| job[:job] == Decidim::UpdateSearchIndexesJob } + + expect(find_jobs).to be <= Decidim::FindAndUpdateDescendantsJob::MAX_DEPTH + 1 + expect(update_jobs).to be <= Decidim::FindAndUpdateDescendantsJob::MAX_DEPTH + end + end end end From 2b233ef928893fde1fbe994f119085a98ea17a4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Fri, 13 Mar 2026 10:55:38 +0100 Subject: [PATCH 007/131] ensure absolute URLs in emails --- .../decidim/blogs/create_post_event_spec.rb | 28 ++++++++ .../app/helpers/decidim/newsletters_helper.rb | 25 +------- .../app/helpers/decidim/sanitize_helper.rb | 26 ++++++++ .../app/mailers/decidim/application_mailer.rb | 5 +- .../event_received.html.erb | 6 +- .../decidim/newsletters_helper_spec.rb | 22 +------ .../helpers/decidim/sanitize_helper_spec.rb | 64 +++++++++++++++++++ 7 files changed, 131 insertions(+), 45 deletions(-) diff --git a/decidim-blogs/spec/events/decidim/blogs/create_post_event_spec.rb b/decidim-blogs/spec/events/decidim/blogs/create_post_event_spec.rb index 4f4f9d40f03ae..0a7b376bbe7e3 100644 --- a/decidim-blogs/spec/events/decidim/blogs/create_post_event_spec.rb +++ b/decidim-blogs/spec/events/decidim/blogs/create_post_event_spec.rb @@ -21,4 +21,32 @@ expect(subject.resource_text).to eq translated(resource.body) end end + + describe "email rendering with images" do + let(:body_with_image) do + { + en: '

Check: image

', + ca: '

Mira: image

', + es: '

Mira: image

' + } + end + let(:resource) { create(:post, title: generate_localized_title(:blog_title), body: body_with_image) } + let(:organization) { resource.component.organization } + + it "includes transformed image URLs in notification email body" do + mail = Decidim::NotificationMailer.event_received( + event_name, + "Decidim::Blogs::CreatePostEvent", + resource, + user, + :follower, + {} + ) + + root_url = Decidim::EngineRouter.new("decidim", {}).root_url(host: organization.host)[0..-2] + expected_img = %(image) + + expect(mail.body.encoded).to include(expected_img) + end + end end diff --git a/decidim-core/app/helpers/decidim/newsletters_helper.rb b/decidim-core/app/helpers/decidim/newsletters_helper.rb index 8f28c48c8038f..f750c5eb4e77d 100644 --- a/decidim-core/app/helpers/decidim/newsletters_helper.rb +++ b/decidim-core/app/helpers/decidim/newsletters_helper.rb @@ -3,6 +3,8 @@ module Decidim # Helper that provides methods to render links with utm codes, and replaced name module NewslettersHelper + include Decidim::SanitizeHelper + # If the newsletter body there are some links and the Decidim.track_newsletter_links = true # it will be replaced with the utm_codes method described below. # for example transform "https://es.lipsum.com/" to "https://es.lipsum.com/?utm_source=localhost&utm_campaign=newsletter_11" @@ -19,7 +21,7 @@ def parse_interpolations(content, user = nil, id = nil) content = interpret_name(content, user) content = track_newsletter_links(content, id, host) - transform_image_urls(content, host) + decidim_transform_image_urls(content, host) end # this method is used to generate the root link on mail with the utm_codes @@ -67,27 +69,6 @@ def interpret_name(content, user) content.gsub("%{name}", user.name) end - # Find each img HTML tag with relative path in src attribute - # For each URL, prepends the decidim.root_url - # If host is not defined it returns full content - # - # @param content [String] - the string to convert - # @param host [String] - the Decidim::Organization host to replace - # - # @return [String] - the content converted - # - def transform_image_urls(content, host) - return content if host.blank? - - content.scan(/src\s*=\s*"([^"]*)"/).each do |src| - root_url = decidim.root_url(host:)[0..-2] - src_replaced = "#{root_url}#{src.first}" - content = content.gsub(/src\s*=\s*"([^"]*#{src.first})"/, %(src="#{src_replaced}")) - end - - content - end - # Add tracking query params to each links # # @param content [String] - the string to convert diff --git a/decidim-core/app/helpers/decidim/sanitize_helper.rb b/decidim-core/app/helpers/decidim/sanitize_helper.rb index 4da95d82b675b..d19487fda38d5 100644 --- a/decidim-core/app/helpers/decidim/sanitize_helper.rb +++ b/decidim-core/app/helpers/decidim/sanitize_helper.rb @@ -139,5 +139,31 @@ def render_sanitized_content(resource, method, presenter_class: nil) decidim_sanitize_editor(content) end + + # Transforms relative image URLs in HTML content to absolute URLs using the provided host. + # This is used in emails (newsletters and notifications) to ensure images display correctly + # in email clients. + # + # @param content [String] - HTML content with img tags + # @param host [String] - the Decidim::Organization host to use for the root URL + # + # @return [String] - the content with transformed image URLs + def decidim_transform_image_urls(content, host) + return content if host.blank? || content.blank? + + root_url = Decidim::EngineRouter.new("decidim", {}).root_url(host:).chomp("/") + + content.gsub(/src\s*=\s*(['"])([^'"]*)\1/) do + quote = Regexp.last_match(1) + src_value = Regexp.last_match(2) + + if src_value.blank? || src_value.start_with?("http://", "https://", "data:", "//", "cid:") + %(src=#{quote}#{src_value}#{quote}) + else + normalized_src = src_value.start_with?("/") ? src_value : "/#{src_value}" + %(src=#{quote}#{root_url}#{normalized_src}#{quote}) + end + end + end end end diff --git a/decidim-core/app/mailers/decidim/application_mailer.rb b/decidim-core/app/mailers/decidim/application_mailer.rb index c7efba313f1a7..d726cf43263e7 100644 --- a/decidim-core/app/mailers/decidim/application_mailer.rb +++ b/decidim-core/app/mailers/decidim/application_mailer.rb @@ -8,7 +8,10 @@ class ApplicationMailer < ActionMailer::Base include MultitenantAssetHost include Decidim::SanitizeHelper include Decidim::OrganizationHelper - helper_method :organization_name, :decidim_escape_translated, :decidim_sanitize_translated, :translated_attribute, :decidim_sanitize, :decidim_sanitize_newsletter + + helper Decidim::SanitizeHelper + helper_method :organization_name, :current_locale, :decidim_escape_translated, :decidim_sanitize_translated, :translated_attribute, :decidim_sanitize, + :decidim_sanitize_newsletter after_action :set_smtp after_action :set_from diff --git a/decidim-core/app/views/decidim/notification_mailer/event_received.html.erb b/decidim-core/app/views/decidim/notification_mailer/event_received.html.erb index ff9711f0a157e..5c84104b212a5 100644 --- a/decidim-core/app/views/decidim/notification_mailer/event_received.html.erb +++ b/decidim-core/app/views/decidim/notification_mailer/event_received.html.erb @@ -15,7 +15,7 @@

- <%= @event_instance.safe_resource_text %> + <%= decidim_transform_image_urls(@event_instance.safe_resource_text, @organization.host).html_safe %>

<% end %> @@ -28,7 +28,7 @@

<%= t(".translated_text") %>

- <%= @event_instance.safe_resource_translated_text %> + <%= decidim_transform_image_urls(@event_instance.safe_resource_translated_text, @organization.host).html_safe %>

<% end %> @@ -40,7 +40,7 @@
- <%= link_to @event_instance.button_text, @event_instance.button_url, target: :blank %> + <%= link_to decidim_sanitize(@event_instance.button_text, strip_tags: true), @event_instance.button_url, target: :blank %>
diff --git a/decidim-core/spec/helpers/decidim/newsletters_helper_spec.rb b/decidim-core/spec/helpers/decidim/newsletters_helper_spec.rb index 2e07dd2833b11..e7a7343a62b4d 100644 --- a/decidim-core/spec/helpers/decidim/newsletters_helper_spec.rb +++ b/decidim-core/spec/helpers/decidim/newsletters_helper_spec.rb @@ -31,8 +31,9 @@ module Decidim end it "transforms image URLs with the host" do - expect(subject).to include('Hello,

") } end end - - describe "#transform_image_urls" do - subject { helper.send(:transform_image_urls, text, organization.host) } - - it "transforms image URLs with the host" do - expect(subject).to include('', strip_tags: false)).not_to include("onerror") end end + + context "when decidim_transform_image_urls is invoked" do + let(:host) { "example.org" } + + let(:user_input) do + %{(

Hello, %{name}

+ Link + image + Link + second image)} + end + + subject { helper.send(:decidim_transform_image_urls, user_input, host) } + + it "transforms image URLs with the host" do + root_url = Decidim::EngineRouter.new("decidim", {}).root_url(host:)[0..-2] + expect(subject).to include(%(relativeabsolute) + end + + it "transforms only the relative URL" do + root_url = Decidim::EngineRouter.new("decidim", {}).root_url(host:)[0..-2] + + expect(subject).to include(%(relative)) + expect(subject).to include(%(absolute)) + end + end + + context "when src uses data/protocol-relative/cid URLs" do + let(:user_input) do + %(data +protocol-relative +cid) + end + + it "keeps them unchanged" do + expect(subject).to include(%(src="data:image/png;base64,AAAA")) + expect(subject).to include(%(src="//cdn.example.org/image.jpg")) + expect(subject).to include(%(src="cid:logo@example.org")) + end + end + + context "when src attribute is single-quoted" do + let(:user_input) { "relative" } + + it "transforms the URL preserving single quotes" do + root_url = Decidim::EngineRouter.new("decidim", {}).root_url(host:).chomp("/") + expect(subject).to include(%(relative)) + end + end + + context "when host is not present" do + subject { helper.send(:decidim_transform_image_urls, user_input, nil) } + + it "returns the full content" do + expect(subject).to eq user_input + end + end + end end end end From b4bd20a77876e807fa5a26ac19d49394131e97c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Mon, 16 Mar 2026 19:42:36 +0100 Subject: [PATCH 008/131] show images in questions --- .../app/views/decidim/elections/elections/_questions.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/decidim-elections/app/views/decidim/elections/elections/_questions.html.erb b/decidim-elections/app/views/decidim/elections/elections/_questions.html.erb index 9cbd2fea2b1fa..bfdf74daf862c 100644 --- a/decidim-elections/app/views/decidim/elections/elections/_questions.html.erb +++ b/decidim-elections/app/views/decidim/elections/elections/_questions.html.erb @@ -12,7 +12,7 @@