diff --git a/app/controllers/decidim/stratified_sortitions/admin/samples_controller.rb b/app/controllers/decidim/stratified_sortitions/admin/samples_controller.rb index 8492d83..5e8baff 100644 --- a/app/controllers/decidim/stratified_sortitions/admin/samples_controller.rb +++ b/app/controllers/decidim/stratified_sortitions/admin/samples_controller.rb @@ -30,6 +30,7 @@ def create Decidim::StratifiedSortitions::Admin::ImportSample.call(@form, stratified_sortition, current_user) do on(:ok) do + Decidim.traceability.perform_action!("import_sample", stratified_sortition, current_user, visibility: "all") flash[:notice] = I18n.t("sample_imports.create.success", scope: "decidim.stratified_sortitions.admin") redirect_to upload_sample_stratified_sortition_path(stratified_sortition) end @@ -46,6 +47,7 @@ def remove_multiple Decidim::StratifiedSortitions::Admin::RemoveUploadedSamples.call(stratified_sortition) do on(:ok) do + Decidim.traceability.perform_action!("remove_samples", stratified_sortition, current_user, visibility: "all") flash[:notice] = I18n.t("sample_imports.remove_uploaded_samples.success", scope: "decidim.stratified_sortitions.admin") redirect_to upload_sample_stratified_sortition_path(stratified_sortition) end diff --git a/app/controllers/decidim/stratified_sortitions/admin/stratified_sortitions_controller.rb b/app/controllers/decidim/stratified_sortitions/admin/stratified_sortitions_controller.rb index a224f81..27cf5c7 100644 --- a/app/controllers/decidim/stratified_sortitions/admin/stratified_sortitions_controller.rb +++ b/app/controllers/decidim/stratified_sortitions/admin/stratified_sortitions_controller.rb @@ -142,6 +142,7 @@ def execute_stratified_sortition @result = FairSortitionService.new(stratified_sortition).call if @result.success? stratified_sortition.update!(status: "executed") + Decidim.traceability.perform_action!("execute", stratified_sortition, current_user, visibility: "all") flash[:notice] = I18n.t("stratified_sortitions.execute.success", scope: "decidim.stratified_sortitions.admin") else flash[:error] = @result.error @@ -185,10 +186,24 @@ def export_results SortitionResultsExportJob.perform_later(current_user, stratified_sortition, format) + Decidim.traceability.perform_action!("export_results", stratified_sortition, current_user, visibility: "all") flash[:notice] = I18n.t("decidim.admin.exports.notice") redirect_to execute_stratified_sortition_path(stratified_sortition) end + def log_view_participants + portfolio = stratified_sortition.panel_portfolio + + unless portfolio&.sampled? + head :unprocessable_entity + return + end + + Decidim.traceability.perform_action!("view_participants", stratified_sortition, current_user, visibility: "all") + + head :ok + end + private def collection diff --git a/app/models/decidim/stratified_sortitions/stratified_sortition.rb b/app/models/decidim/stratified_sortitions/stratified_sortition.rb index 7885792..6e58ed7 100644 --- a/app/models/decidim/stratified_sortitions/stratified_sortition.rb +++ b/app/models/decidim/stratified_sortitions/stratified_sortition.rb @@ -16,6 +16,10 @@ class StratifiedSortition < ApplicationRecord component_manifest_name "stratified_sortitions" + def self.log_presenter_class_for(_log) + Decidim::StratifiedSortitions::AdminLog::StratifiedSortitionPresenter + end + has_many :strata, class_name: "Decidim::StratifiedSortitions::Stratum", foreign_key: "decidim_stratified_sortition_id", dependent: :destroy has_many :sample_imports, class_name: "Decidim::StratifiedSortitions::SampleImport", dependent: :destroy has_many :sample_participants, class_name: "Decidim::StratifiedSortitions::SampleParticipant", foreign_key: "decidim_stratified_sortition_id", dependent: :destroy diff --git a/app/packs/src/decidim/stratified_sortitions/results_tabs.js b/app/packs/src/decidim/stratified_sortitions/results_tabs.js index 6fe4013..dea263e 100644 --- a/app/packs/src/decidim/stratified_sortitions/results_tabs.js +++ b/app/packs/src/decidim/stratified_sortitions/results_tabs.js @@ -33,6 +33,21 @@ document.addEventListener("DOMContentLoaded", () => { confirmBtn.addEventListener("click", () => { activateTab("participants-section"); + // Log the view_participants action + const logUrl = confirmBtn.dataset.logUrl; + const csrfToken = confirmBtn.dataset.authenticityToken; + if (logUrl && csrfToken) { + fetch(logUrl, { + method: "POST", + headers: { + "X-CSRF-Token": csrfToken, + "Content-Type": "application/json", + }, + }).catch(() => { + // Silently ignore log errors + }); + } + // Close the modal const modal = document.getElementById("confirm-participants-modal"); if (modal) { diff --git a/app/presenters/decidim/stratified_sortitions/admin_log/stratified_sortition_presenter.rb b/app/presenters/decidim/stratified_sortitions/admin_log/stratified_sortition_presenter.rb new file mode 100644 index 0000000..b98e10b --- /dev/null +++ b/app/presenters/decidim/stratified_sortitions/admin_log/stratified_sortition_presenter.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Decidim + module StratifiedSortitions + module AdminLog + # This class holds the logic to present a `Decidim::StratifiedSortitions::StratifiedSortition` + # for the `AdminLog` log. + # + # Usage should be automatic and you should not need to call this class + # directly, but here is an example: + # + # action_log = Decidim::ActionLog.last + # view_helpers # => this comes from the views + # StratifiedSortitionPresenter.new(action_log, view_helpers).present + class StratifiedSortitionPresenter < Decidim::Log::BasePresenter + private + + def action_string + case action + when "create", "update", "delete", "duplicate", + "execute", "export_results", "view_participants", + "import_sample", "remove_samples" + "decidim.stratified_sortitions.admin_log.stratified_sortition.#{action}" + else + super + end + end + + def i18n_labels_scope + "activemodel.attributes.stratified_sortition" + end + end + end + end +end diff --git a/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_participants_modal.html.erb b/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_participants_modal.html.erb index 357c553..1ff4bce 100644 --- a/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_participants_modal.html.erb +++ b/app/views/decidim/stratified_sortitions/admin/stratified_sortitions/_confirm_participants_modal.html.erb @@ -11,7 +11,9 @@ - diff --git a/config/locales/ca.yml b/config/locales/ca.yml index 4db0ed9..616dc09 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -278,6 +278,17 @@ ca: count: one: 1 sorteig estratificat other: "%{count} sortejos estratificats" + admin_log: + stratified_sortition: + create: "%{user_name} ha creat el sorteig estratificat %{resource_name} a %{space_name}" + update: "%{user_name} ha actualitzat el sorteig estratificat %{resource_name} a %{space_name}" + delete: "%{user_name} ha eliminat el sorteig estratificat %{resource_name} a %{space_name}" + duplicate: "%{user_name} ha duplicat el sorteig estratificat %{resource_name}" + import_sample: "%{user_name} ha importat una mostra al sorteig estratificat %{resource_name} a %{space_name}" + remove_samples: "%{user_name} ha eliminat els registres importats del sorteig estratificat %{resource_name} a %{space_name}" + execute: "%{user_name} ha executat el sorteig estratificat %{resource_name} a %{space_name}" + export_results: "%{user_name} ha exportat els resultats del sorteig estratificat %{resource_name} a %{space_name}" + view_participants: "%{user_name} ha consultat el llistat de participants seleccionades del sorteig estratificat %{resource_name} a %{space_name}" errors: panel_portfolio: already_sampled: "La cartera ja ha estat mostrejada" diff --git a/config/locales/en.yml b/config/locales/en.yml index 9bf46fe..8c18faa 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -278,6 +278,17 @@ en: count: one: 1 stratified sortition other: "%{count} stratified sortitions" + admin_log: + stratified_sortition: + create: "%{user_name} created the stratified sortition %{resource_name} on %{space_name}" + update: "%{user_name} updated the stratified sortition %{resource_name} on %{space_name}" + delete: "%{user_name} deleted the stratified sortition %{resource_name} on %{space_name}" + duplicate: "%{user_name} duplicated the stratified sortition %{resource_name}" + import_sample: "%{user_name} imported a sample to the stratified sortition %{resource_name} on %{space_name}" + remove_samples: "%{user_name} removed the imported records from the stratified sortition %{resource_name} on %{space_name}" + execute: "%{user_name} executed the stratified sortition %{resource_name} on %{space_name}" + export_results: "%{user_name} exported the results of the stratified sortition %{resource_name} on %{space_name}" + view_participants: "%{user_name} viewed the selected participants list of the stratified sortition %{resource_name} on %{space_name}" errors: panel_portfolio: already_sampled: "Portfolio already sampled" diff --git a/config/locales/es.yml b/config/locales/es.yml index 7346130..ea2964f 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -278,6 +278,17 @@ es: count: one: 1 sorteo estratificado other: "%{count} sorteos estratificados" + admin_log: + stratified_sortition: + create: "%{user_name} ha creado el sorteo estratificado %{resource_name} en %{space_name}" + update: "%{user_name} ha actualizado el sorteo estratificado %{resource_name} en %{space_name}" + delete: "%{user_name} ha eliminado el sorteo estratificado %{resource_name} en %{space_name}" + duplicate: "%{user_name} ha duplicado el sorteo estratificado %{resource_name}" + import_sample: "%{user_name} ha importado una muestra en el sorteo estratificado %{resource_name} en %{space_name}" + remove_samples: "%{user_name} ha eliminado los registros importados del sorteo estratificado %{resource_name} en %{space_name}" + execute: "%{user_name} ha ejecutado el sorteo estratificado %{resource_name} en %{space_name}" + export_results: "%{user_name} ha exportado los resultados del sorteo estratificado %{resource_name} en %{space_name}" + view_participants: "%{user_name} ha consultado el listado de participantes seleccionadas del sorteo estratificado %{resource_name} en %{space_name}" errors: panel_portfolio: already_sampled: "La cartera ya ha sido muestreada" diff --git a/lib/decidim/stratified_sortitions/admin_engine.rb b/lib/decidim/stratified_sortitions/admin_engine.rb index b79570c..899673d 100644 --- a/lib/decidim/stratified_sortitions/admin_engine.rb +++ b/lib/decidim/stratified_sortitions/admin_engine.rb @@ -22,6 +22,7 @@ class AdminEngine < ::Rails::Engine post :execute_stratified_sortition, on: :member post :export_results, on: :member post :export_charts_pdf, on: :member + post :log_view_participants, on: :member end resources :samples, only: [:show, :create] do diff --git a/spec/controllers/decidim/sortitions/admin/samples_controller_spec.rb b/spec/controllers/decidim/sortitions/admin/samples_controller_spec.rb new file mode 100644 index 0000000..7d22cda --- /dev/null +++ b/spec/controllers/decidim/sortitions/admin/samples_controller_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim + module StratifiedSortitions + module Admin + describe SamplesController do + routes { Decidim::StratifiedSortitions::AdminEngine.routes } + + let(:component) { stratified_sortition.component } + let(:stratified_sortition) { create(:stratified_sortition) } + let(:user) { create(:user, :confirmed, :admin, organization: component.organization) } + + let!(:stratum) do + create(:stratum, stratified_sortition:, kind: "value", name: { en: "Gender" }) + end + let!(:substratum) do + create(:substratum, stratum:, name: { en: "Man" }, value: "M", max_quota_percentage: "50") + end + + before do + request.env["decidim.current_organization"] = component.organization + request.env["decidim.current_component"] = component + sign_in user, scope: :user + end + + describe "create (import_sample)" do + let(:params) do + { + id: stratified_sortition.id, + } + end + + before do + allow_any_instance_of(Decidim::StratifiedSortitions::Admin::ImportSample) + .to receive(:call) { |instance| instance.send(:broadcast, :ok) } + end + + it "redirects to the upload_sample page" do + post(:create, params:) + expect(response).to redirect_to(upload_sample_stratified_sortition_path(stratified_sortition)) + end + + it "traces the import_sample action" do + expect { post(:create, params:) } + .to change(Decidim::ActionLog, :count).by(1) + expect(Decidim::ActionLog.last.action).to eq("import_sample") + end + end + + describe "remove_multiple (remove_samples)" do + let(:params) do + { + id: stratified_sortition.id, + } + end + + let(:sample_import) { create(:sample_import, stratified_sortition:) } + let!(:participant) do + create(:sample_participant, + decidim_stratified_sortition: stratified_sortition, + decidim_stratified_sortitions_sample_import: sample_import) + end + + it "redirects to the upload_sample page" do + delete(:remove_multiple, params:) + expect(response).to redirect_to(upload_sample_stratified_sortition_path(stratified_sortition)) + end + + it "traces the remove_samples action" do + expect { delete(:remove_multiple, params:) } + .to change(Decidim::ActionLog, :count).by(1) + expect(Decidim::ActionLog.last.action).to eq("remove_samples") + end + + it "removes the sample participants" do + expect { delete(:remove_multiple, params:) } + .to change(Decidim::StratifiedSortitions::SampleParticipant, :count).by(-1) + end + end + end + end + end +end diff --git a/spec/controllers/decidim/sortitions/admin/stratified_sortitions_controller_spec.rb b/spec/controllers/decidim/sortitions/admin/stratified_sortitions_controller_spec.rb index e3c06da..3630f06 100644 --- a/spec/controllers/decidim/sortitions/admin/stratified_sortitions_controller_spec.rb +++ b/spec/controllers/decidim/sortitions/admin/stratified_sortitions_controller_spec.rb @@ -363,6 +363,114 @@ module Admin expect(Decidim::StratifiedSortitions::Admin::SortitionResultsExportJob) .to have_been_enqueued.with(anything, anything, "csv") end + + it "traces the export_results action" do + expect { post(:export_results, params:) } + .to change(Decidim::ActionLog, :count).by(1) + expect(Decidim::ActionLog.last.action).to eq("export_results") + end + end + end + + describe "execute_stratified_sortition" do + let(:params) do + { + participatory_process_slug: component.participatory_space.slug, + id: stratified_sortition.id, + } + end + + let(:fair_service_result) { double("result", success?: true, error: nil) } + let(:fair_service) { double("fair_sortition_service", call: fair_service_result) } + + before do + allow(FairSortitionService).to receive(:new).and_return(fair_service) + end + + context "when the sortition executes successfully" do + it "redirects to the execute page" do + post(:execute_stratified_sortition, params:) + expect(response).to redirect_to(execute_stratified_sortition_path(stratified_sortition)) + end + + it "sets a notice flash message" do + post(:execute_stratified_sortition, params:) + expect(flash[:notice]).to be_present + end + + it "traces the execute action" do + expect { post(:execute_stratified_sortition, params:) } + .to change(Decidim::ActionLog, :count).by(1) + expect(Decidim::ActionLog.last.action).to eq("execute") + end + end + + context "when the sortition fails" do + let(:fair_service_result) { double("result", success?: false, error: "Something went wrong") } + + it "redirects to the execute page" do + post(:execute_stratified_sortition, params:) + expect(response).to redirect_to(execute_stratified_sortition_path(stratified_sortition)) + end + + it "sets an error flash message" do + post(:execute_stratified_sortition, params:) + expect(flash[:error]).to be_present + end + + it "does not trace the execute action" do + expect { post(:execute_stratified_sortition, params:) } + .not_to change(Decidim::ActionLog, :count) + end + end + end + + describe "log_view_participants" do + let(:params) do + { + participatory_process_slug: component.participatory_space.slug, + id: stratified_sortition.id, + } + end + + context "when the portfolio is not sampled" do + it "returns unprocessable_entity" do + post(:log_view_participants, params:) + expect(response).to have_http_status(:unprocessable_entity) + end + + it "does not trace the action" do + expect { post(:log_view_participants, params:) } + .not_to change(Decidim::ActionLog, :count) + end + end + + context "when the portfolio is sampled" do + let(:sample_import) { create(:sample_import, stratified_sortition:) } + let!(:participant) do + create(:sample_participant, + decidim_stratified_sortition: stratified_sortition, + decidim_stratified_sortitions_sample_import: sample_import) + end + let!(:portfolio) do + create(:panel_portfolio, + :sampled, + stratified_sortition:, + panels: [[participant.id]], + probabilities: [1.0], + selection_probabilities: { participant.id => 1.0 }) + end + + it "returns ok" do + post(:log_view_participants, params:) + expect(response).to have_http_status(:ok) + end + + it "traces the view_participants action" do + expect { post(:log_view_participants, params:) } + .to change(Decidim::ActionLog, :count).by(1) + expect(Decidim::ActionLog.last.action).to eq("view_participants") + end end end