diff --git a/CHANGELOG.md b/CHANGELOG.md index b73064f35..eea1b9597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Features: - Added GraphQL types for custom fields in the API - Adds parsed information about custom fields in the Proposals export - Adds parsed information bout private custom fields when admins exports private data + - Adds a maintenance menu with tools to remove old private data v0.11 ------ diff --git a/Gemfile.lock b/Gemfile.lock index 510d49f94..8e942e19c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - decidim-decidim_awesome (0.11.0) + decidim-decidim_awesome (0.11.1) decidim-admin (>= 0.28.0, < 0.29) decidim-core (>= 0.28.0, < 0.29) deface (>= 1.5) @@ -95,7 +95,7 @@ GEM brakeman (5.4.1) browser (2.7.1) builder (3.3.0) - bullet (7.1.6) + bullet (7.2.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) byebug (11.1.3) @@ -141,55 +141,55 @@ GEM date_validator (0.12.0) activemodel (>= 3) activesupport (>= 3) - decidim (0.28.1) - decidim-accountability (= 0.28.1) - decidim-admin (= 0.28.1) - decidim-api (= 0.28.1) - decidim-assemblies (= 0.28.1) - decidim-blogs (= 0.28.1) - decidim-budgets (= 0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-debates (= 0.28.1) - decidim-forms (= 0.28.1) - decidim-generators (= 0.28.1) - decidim-meetings (= 0.28.1) - decidim-pages (= 0.28.1) - decidim-participatory_processes (= 0.28.1) - decidim-proposals (= 0.28.1) - decidim-sortitions (= 0.28.1) - decidim-surveys (= 0.28.1) - decidim-system (= 0.28.1) - decidim-templates (= 0.28.1) - decidim-verifications (= 0.28.1) - decidim-accountability (0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-admin (0.28.1) + decidim (0.28.2) + decidim-accountability (= 0.28.2) + decidim-admin (= 0.28.2) + decidim-api (= 0.28.2) + decidim-assemblies (= 0.28.2) + decidim-blogs (= 0.28.2) + decidim-budgets (= 0.28.2) + decidim-comments (= 0.28.2) + decidim-core (= 0.28.2) + decidim-debates (= 0.28.2) + decidim-forms (= 0.28.2) + decidim-generators (= 0.28.2) + decidim-meetings (= 0.28.2) + decidim-pages (= 0.28.2) + decidim-participatory_processes (= 0.28.2) + decidim-proposals (= 0.28.2) + decidim-sortitions (= 0.28.2) + decidim-surveys (= 0.28.2) + decidim-system (= 0.28.2) + decidim-templates (= 0.28.2) + decidim-verifications (= 0.28.2) + decidim-accountability (0.28.2) + decidim-comments (= 0.28.2) + decidim-core (= 0.28.2) + decidim-admin (0.28.2) active_link_to (~> 1.0) - decidim-core (= 0.28.1) + decidim-core (= 0.28.2) devise (~> 4.7) devise-i18n (~> 1.2) devise_invitable (~> 2.0, >= 2.0.9) - decidim-api (0.28.1) + decidim-api (0.28.2) commonmarker (~> 0.23.0, >= 0.23.9) - decidim-core (= 0.28.1) + decidim-core (= 0.28.2) graphql (~> 2.0.0) graphql-docs (~> 3.0.1) rack-cors (~> 1.0) - decidim-assemblies (0.28.1) - decidim-core (= 0.28.1) - decidim-blogs (0.28.1) - decidim-admin (= 0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-budgets (0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-comments (0.28.1) - decidim-core (= 0.28.1) + decidim-assemblies (0.28.2) + decidim-core (= 0.28.2) + decidim-blogs (0.28.2) + decidim-admin (= 0.28.2) + decidim-comments (= 0.28.2) + decidim-core (= 0.28.2) + decidim-budgets (0.28.2) + decidim-comments (= 0.28.2) + decidim-core (= 0.28.2) + decidim-comments (0.28.2) + decidim-core (= 0.28.2) redcarpet (~> 3.5, >= 3.5.1) - decidim-core (0.28.1) + decidim-core (0.28.2) active_link_to (~> 1.0) acts_as_list (~> 1.0) batch-loader (~> 1.2) @@ -239,14 +239,14 @@ GEM valid_email2 (~> 4.0) web-push (~> 3.0) wisper (~> 2.0) - decidim-debates (0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-dev (0.28.1) + decidim-debates (0.28.2) + decidim-comments (= 0.28.2) + decidim-core (= 0.28.2) + decidim-dev (0.28.2) bullet (~> 7.0) byebug (~> 11.0) capybara (~> 3.39) - decidim (= 0.28.1) + decidim (= 0.28.2) erb_lint (~> 0.4.0) factory_bot_rails (~> 6.2) faker (~> 3.2) @@ -271,44 +271,44 @@ GEM w3c_rspec_validators (~> 0.3.0) webmock (~> 3.18) wisper-rspec (~> 1.0) - decidim-forms (0.28.1) - decidim-core (= 0.28.1) + decidim-forms (0.28.2) + decidim-core (= 0.28.2) wicked_pdf (~> 2.1) wkhtmltopdf-binary (~> 0.12) - decidim-generators (0.28.1) - decidim-core (= 0.28.1) - decidim-meetings (0.28.1) - decidim-core (= 0.28.1) - decidim-forms (= 0.28.1) + decidim-generators (0.28.2) + decidim-core (= 0.28.2) + decidim-meetings (0.28.2) + decidim-core (= 0.28.2) + decidim-forms (= 0.28.2) icalendar (~> 2.5) - decidim-pages (0.28.1) - decidim-core (= 0.28.1) - decidim-participatory_processes (0.28.1) - decidim-core (= 0.28.1) - decidim-proposals (0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) + decidim-pages (0.28.2) + decidim-core (= 0.28.2) + decidim-participatory_processes (0.28.2) + decidim-core (= 0.28.2) + decidim-proposals (0.28.2) + decidim-comments (= 0.28.2) + decidim-core (= 0.28.2) doc2text (~> 0.4.6) redcarpet (~> 3.5, >= 3.5.1) - decidim-sortitions (0.28.1) - decidim-admin (= 0.28.1) - decidim-comments (= 0.28.1) - decidim-core (= 0.28.1) - decidim-proposals (= 0.28.1) - decidim-surveys (0.28.1) - decidim-core (= 0.28.1) - decidim-forms (= 0.28.1) - decidim-system (0.28.1) + decidim-sortitions (0.28.2) + decidim-admin (= 0.28.2) + decidim-comments (= 0.28.2) + decidim-core (= 0.28.2) + decidim-proposals (= 0.28.2) + decidim-surveys (0.28.2) + decidim-core (= 0.28.2) + decidim-forms (= 0.28.2) + decidim-system (0.28.2) active_link_to (~> 1.0) - decidim-core (= 0.28.1) + decidim-core (= 0.28.2) devise (~> 4.7) devise-i18n (~> 1.2) devise_invitable (~> 2.0, >= 2.0.9) - decidim-templates (0.28.1) - decidim-core (= 0.28.1) - decidim-forms (= 0.28.1) - decidim-verifications (0.28.1) - decidim-core (= 0.28.1) + decidim-templates (0.28.2) + decidim-core (= 0.28.2) + decidim-forms (= 0.28.2) + decidim-verifications (0.28.2) + decidim-core (= 0.28.2) declarative-builder (0.1.0) declarative-option (< 0.2.0) declarative-option (0.1.0) @@ -335,7 +335,7 @@ GEM doc2text (0.4.7) nokogiri (>= 1.13.2, < 1.17.0) rubyzip (~> 2.3.0) - docile (1.4.0) + docile (1.4.1) doorkeeper (5.7.1) railties (>= 5) doorkeeper-i18n (4.0.1) @@ -358,19 +358,14 @@ GEM factory_bot_rails (6.4.3) factory_bot (~> 6.4) railties (>= 5.0.0) - faker (3.4.1) + faker (3.4.2) i18n (>= 1.8.11, < 2) faraday (2.10.0) faraday-net_http (>= 2.0, < 3.2) logger faraday-net_http (3.1.0) net-http - ffi (1.17.0-aarch64-linux-gnu) - ffi (1.17.0-arm-linux-gnu) - ffi (1.17.0-arm64-darwin) - ffi (1.17.0-x86-linux-gnu) - ffi (1.17.0-x86_64-darwin) - ffi (1.17.0-x86_64-linux-gnu) + ffi (1.17.0) file_validators (3.0.0) activemodel (>= 3.2) mime-types (>= 1.0) @@ -405,7 +400,8 @@ GEM sass (~> 3.4) hashdiff (1.1.0) hashie (5.0.0) - highline (3.0.1) + highline (3.1.0) + reline html-pipeline (2.14.3) activesupport (>= 2) nokogiri (>= 1.4) @@ -422,14 +418,15 @@ GEM rails-i18n rainbow (>= 2.2.2, < 4.0) terminal-table (>= 1.5.1) - icalendar (2.10.1) + icalendar (2.10.2) ice_cube (~> 0.16) - ice_cube (0.16.4) + ice_cube (0.17.0) image_processing (1.12.2) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) invisible_captcha (0.13.0) rails (>= 3.2.0) + io-console (0.7.2) json (2.7.2) jwt (2.8.2) base64 @@ -491,16 +488,6 @@ GEM net-smtp (0.3.4) net-protocol nio4r (2.7.3) - nokogiri (1.16.6-aarch64-linux) - racc (~> 1.4) - nokogiri (1.16.6-arm-linux) - racc (~> 1.4) - nokogiri (1.16.6-arm64-darwin) - racc (~> 1.4) - nokogiri (1.16.6-x86-linux) - racc (~> 1.4) - nokogiri (1.16.6-x86_64-darwin) - racc (~> 1.4) nokogiri (1.16.6-x86_64-linux) racc (~> 1.4) oauth (1.1.0) @@ -547,7 +534,7 @@ GEM parallel (1.25.1) parallel_tests (4.7.1) parallel - parser (3.3.3.0) + parser (3.3.4.0) ast (~> 2.4.1) racc pg (1.4.6) @@ -628,6 +615,8 @@ GEM redcarpet (3.6.0) redis (4.8.1) regexp_parser (2.9.2) + reline (0.5.9) + io-console (~> 0.5) request_store (1.5.1) rack (>= 1.4) responders (3.1.1) @@ -678,25 +667,18 @@ GEM unicode-display_width (>= 2.4.0, < 3.0) rubocop-ast (1.31.3) parser (>= 3.3.1.0) - rubocop-capybara (2.21.0) - rubocop (~> 1.41) - rubocop-factory_bot (2.26.0) + rubocop-capybara (2.18.0) rubocop (~> 1.41) rubocop-faker (1.1.0) faker (>= 2.12.0) rubocop (>= 0.82.0) - rubocop-rails (2.25.1) + rubocop-rails (2.19.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (2.31.0) - rubocop (~> 1.40) + rubocop-rspec (2.20.0) + rubocop (~> 1.33) rubocop-capybara (~> 2.17) - rubocop-factory_bot (~> 2.22) - rubocop-rspec_rails (~> 2.28) - rubocop-rspec_rails (2.29.0) - rubocop (~> 1.40) ruby-progressbar (1.13.0) ruby-vips (2.2.1) ffi (~> 1.12) @@ -711,7 +693,7 @@ GEM rb-inotify (~> 0.9, >= 0.9.7) sassc (2.4.0) ffi (~> 1.9) - selenium-webdriver (4.22.0) + selenium-webdriver (4.23.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -789,7 +771,7 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - websocket (1.2.10) + websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -803,20 +785,16 @@ GEM zeitwerk (2.6.16) PLATFORMS - aarch64-linux - arm-linux - arm64-darwin - x86-linux - x86_64-darwin x86_64-linux DEPENDENCIES bootsnap (~> 1.4) brakeman (~> 5.4) byebug (~> 11.0) - decidim (= 0.28.1) + decidim (= 0.28.2) decidim-decidim_awesome! - decidim-dev (= 0.28.1) + decidim-dev (= 0.28.2) + decidim-templates (= 0.28.2) letter_opener_web (~> 2.0) listen (~> 3.1) net-imap (~> 0.2.3) diff --git a/README.md b/README.md index 8ca90dc01..26c1f9a45 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,17 @@ This option is disable by default, must be enabled in the component's configurat ![Limiting amendments](examples/limit_amendments.png) +#### 18. Maintenance tools + +The awesome admin provides with some maintenance tools (more to come in the future); + +##### 18.1 Old private data removal + +These tools are designed to help remove old data as required by laws such as GDPR, particularly in relation to private custom fields. +This menu will show if there's any data older than 6 months (configurable) and will let admins remove it component by component. + +![Private data](examples/private_data.png) + #### To be continued... We're not done! Please check the [issues](/decidim-ice/decidim-module-decidim_awesome/issues) (and participate) to see what's on our mind diff --git a/app/controllers/concerns/decidim/decidim_awesome/admin/maintenance_context.rb b/app/controllers/concerns/decidim/decidim_awesome/admin/maintenance_context.rb new file mode 100644 index 000000000..e2fd2abb2 --- /dev/null +++ b/app/controllers/concerns/decidim/decidim_awesome/admin/maintenance_context.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Decidim + module DecidimAwesome + module Admin + module MaintenanceContext + extend ActiveSupport::Concern + + included do + layout "decidim/decidim_awesome/admin/maintenance" + helper_method :current_view, :available_views, :present_private_data + + private + + def present_private_data(model) + PrivateDataPresenter.new(model) + end + + def current_view + return params[:id] if available_views.include?(params[:id]) + + available_views.keys.first + end + + def available_views + { + "private_data" => { + title: I18n.t("private_data", scope: "decidim.decidim_awesome.admin.menu.maintenance"), + icon: "spy-line", + path: decidim_admin_decidim_awesome.maintenance_path("private_data") + }, + "checks" => { + title: I18n.t("checks", scope: "decidim.decidim_awesome.admin.menu.maintenance"), + icon: "pulse", + path: decidim_admin_decidim_awesome.checks_maintenance_index_path + } + } + end + end + end + end + end +end diff --git a/app/controllers/decidim/decidim_awesome/admin/application_controller.rb b/app/controllers/decidim/decidim_awesome/admin/application_controller.rb index 63ad2672f..f0c578e54 100644 --- a/app/controllers/decidim/decidim_awesome/admin/application_controller.rb +++ b/app/controllers/decidim/decidim_awesome/admin/application_controller.rb @@ -9,6 +9,8 @@ module Admin # Note that it inherits from `Decidim::Admin::Components::BaseController`, which # override its layout and provide all kinds of useful methods. class ApplicationController < Decidim::Admin::ApplicationController + layout "decidim/decidim_awesome/admin/application" + def permission_class_chain [::Decidim::DecidimAwesome::Admin::Permissions] + super end diff --git a/app/controllers/decidim/decidim_awesome/admin/checks_controller.rb b/app/controllers/decidim/decidim_awesome/admin/checks_controller.rb index cbd4ec32e..47f01a0f4 100644 --- a/app/controllers/decidim/decidim_awesome/admin/checks_controller.rb +++ b/app/controllers/decidim/decidim_awesome/admin/checks_controller.rb @@ -8,17 +8,17 @@ module Admin # System compatibility analyzer class ChecksController < DecidimAwesome::Admin::ApplicationController include NeedsAwesomeConfig + include MaintenanceContext + helper ConfigConstraintsHelpers helper SystemCheckerHelpers - layout "decidim/decidim_awesome/admin/application" - helper_method :head, :admin_head, :head_addons, :admin_addons def migrate_images Decidim::DecidimAwesome::MigrateLegacyImagesJob.perform_later(current_organization.id) flash[:notice] = I18n.t("image_migrations_started", scope: "decidim.decidim_awesome.admin.checks.index") - redirect_to checks_path + redirect_to checks_maintenance_index_path end private @@ -58,6 +58,10 @@ def render_template(partial) rescue ActionView::Template::Error => e flash.now[:alert] = "Partial [#{partial}] has thrown an error: #{e.message}" end + + def current_view + "checks" + end end end end diff --git a/app/controllers/decidim/decidim_awesome/admin/config_controller.rb b/app/controllers/decidim/decidim_awesome/admin/config_controller.rb index 3760dff1a..fe4dfea6f 100644 --- a/app/controllers/decidim/decidim_awesome/admin/config_controller.rb +++ b/app/controllers/decidim/decidim_awesome/admin/config_controller.rb @@ -9,8 +9,6 @@ class ConfigController < DecidimAwesome::Admin::ApplicationController include ConfigConstraintsHelpers helper ConfigConstraintsHelpers - layout "decidim/decidim_awesome/admin/application" - helper_method :constraints_for, :users_for, :config_var before_action do enforce_permission_to :edit_config, configs @@ -19,7 +17,7 @@ class ConfigController < DecidimAwesome::Admin::ApplicationController def show @form = form(ConfigForm).from_params(organization_awesome_config) - redirect_to decidim_admin_decidim_awesome.checks_path unless config_var + redirect_to decidim_admin_decidim_awesome.checks_maintenance_index_path unless config_var end def update diff --git a/app/controllers/decidim/decidim_awesome/admin/custom_redirects_controller.rb b/app/controllers/decidim/decidim_awesome/admin/custom_redirects_controller.rb index bfa23a2d5..f0856897e 100644 --- a/app/controllers/decidim/decidim_awesome/admin/custom_redirects_controller.rb +++ b/app/controllers/decidim/decidim_awesome/admin/custom_redirects_controller.rb @@ -8,8 +8,6 @@ class CustomRedirectsController < DecidimAwesome::Admin::ApplicationController include NeedsAwesomeConfig include ConfigConstraintsHelpers - layout "decidim/decidim_awesome/admin/application" - before_action do enforce_permission_to :edit_config, :menu end diff --git a/app/controllers/decidim/decidim_awesome/admin/maintenance_controller.rb b/app/controllers/decidim/decidim_awesome/admin/maintenance_controller.rb new file mode 100644 index 000000000..b85947cff --- /dev/null +++ b/app/controllers/decidim/decidim_awesome/admin/maintenance_controller.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "decidim/decidim_awesome/version" + +module Decidim + module DecidimAwesome + module Admin + # System compatibility analyzer + class MaintenanceController < DecidimAwesome::Admin::ApplicationController + include NeedsAwesomeConfig + include MaintenanceContext + include Decidim::Admin::Filterable + include ActionView::Helpers::DateHelper + + helper ConfigConstraintsHelpers + helper_method :collection, :resource, :present, :time_ago + + before_action do + enforce_permission_to :edit_config, :private_data, private_data: + end + + def show + respond_to do |format| + format.json do + render json: private_data_finder.for(params[:resources].to_s.split(",")).map { |resource| present(resource) } + end + format.all do + render :show + end + end + end + + def destroy_private_data + if private_data && private_data.total.to_i.positive? + Decidim::ActionLogger.log("destroy_private_data", current_user, resource, nil, count: private_data.total) + + Lock.new(current_organization).get!(resource) + DestroyPrivateDataJob.set(wait: 1.second).perform_later(resource) + end + redirect_to decidim_admin_decidim_awesome.maintenance_path("private_data"), + notice: I18n.t("destroying_private_data", scope: "decidim.decidim_awesome.admin.maintenance.private_data", title: present_private_data(resource).name) + end + + private + + def resource + @resource ||= Component.find_by(id: params[:resource_id]) + end + + def private_data + @private_data ||= present_private_data(resource) if resource + end + + def collection + filtered_collection + end + + def base_query + private_data_finder.query + end + + def present(resource) + present_private_data(resource) + end + + def private_data_finder + @private_data ||= PrivateDataFinder.new + end + + def time_ago + @time_ago ||= time_ago_in_words(Time.current - Decidim::DecidimAwesome.private_data_expiration_time) + end + end + end + end +end diff --git a/app/controllers/decidim/decidim_awesome/admin/menu_hacks_controller.rb b/app/controllers/decidim/decidim_awesome/admin/menu_hacks_controller.rb index 49504b19e..2437601fc 100644 --- a/app/controllers/decidim/decidim_awesome/admin/menu_hacks_controller.rb +++ b/app/controllers/decidim/decidim_awesome/admin/menu_hacks_controller.rb @@ -8,8 +8,6 @@ class MenuHacksController < DecidimAwesome::Admin::ApplicationController include NeedsAwesomeConfig include ConfigConstraintsHelpers - layout "decidim/decidim_awesome/admin/application" - helper ConfigConstraintsHelpers helper_method :current_items, :visibility_options, :target_options diff --git a/app/jobs/decidim/decidim_awesome/destroy_private_data_job.rb b/app/jobs/decidim/decidim_awesome/destroy_private_data_job.rb new file mode 100644 index 000000000..f63afefdd --- /dev/null +++ b/app/jobs/decidim/decidim_awesome/destroy_private_data_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Decidim + module DecidimAwesome + class DestroyPrivateDataJob < ApplicationJob + queue_as :default + + # Destroys private data associated with the resource + def perform(resource) + extra_fields = Decidim::DecidimAwesome::ProposalExtraField.where( + proposal: Decidim::Proposals::Proposal.where(component: resource) + ).where("private_body_updated_at < ?", DecidimAwesome.private_data_expiration_time.ago) + + extra_fields.find_each do |extra_field| + extra_field.update(private_body: nil) + end + + Lock.new(resource.organization).release!(resource) + end + end + end +end diff --git a/app/models/decidim/decidim_awesome/proposal_extra_field.rb b/app/models/decidim/decidim_awesome/proposal_extra_field.rb index 0d6fc415c..e174d12e7 100644 --- a/app/models/decidim/decidim_awesome/proposal_extra_field.rb +++ b/app/models/decidim/decidim_awesome/proposal_extra_field.rb @@ -14,12 +14,27 @@ class ProposalExtraField < ApplicationRecord encrypt_attribute :private_body, type: :string + after_initialize :store_private_body + before_save :update_private_body_updated_at + # validate not more than one extra field can be associated to a proposal # validates :proposal, uniqueness: true validate :no_more_than_one_extra_field private + def store_private_body + @initial_private_body = private_body + end + + # using private_body_changed? does not sufice as the encrypted value is always updated on saving + def update_private_body_updated_at + if private_body != @initial_private_body + self.private_body_updated_at = Time.current + @initial_private_body = private_body + end + end + def no_more_than_one_extra_field return unless ProposalExtraField.where(proposal:).where.not(id:).exists? diff --git a/app/packs/src/decidim/decidim_awesome/admin/constraint_form_events.js b/app/packs/src/decidim/decidim_awesome/admin/constraint_form_events.js index aaee8db1f..46c1dd423 100644 --- a/app/packs/src/decidim/decidim_awesome/admin/constraint_form_events.js +++ b/app/packs/src/decidim/decidim_awesome/admin/constraint_form_events.js @@ -85,7 +85,7 @@ const initializeDialog = (dialog) => { }; document.addEventListener("DOMContentLoaded", () => { - document.querySelectorAll("[data-dialog]").forEach((dialog) => { + document.querySelectorAll("[data-constraint][data-dialog]").forEach((dialog) => { initializeDialog(dialog); }); }); diff --git a/app/packs/stylesheets/decidim/decidim_awesome/awesome_admin_global.scss b/app/packs/stylesheets/decidim/decidim_awesome/awesome_admin_global.scss index 8f879e0db..e4d1aa9f3 100644 --- a/app/packs/stylesheets/decidim/decidim_awesome/awesome_admin_global.scss +++ b/app/packs/stylesheets/decidim/decidim_awesome/awesome_admin_global.scss @@ -1,3 +1,19 @@ @import "stylesheets/decidim/decidim_awesome/shared/spinner"; @import "stylesheets/decidim/decidim_awesome/admin/intergram_fixes"; @import "stylesheets/decidim/decidim_awesome/forms/custom_fields"; + +.component__show { + &_private_data { + &-grid { + @apply bg-background grid-cols-2 gap-4 my-4 p-2; + + .date_info { + @apply italic text-gray text-sm mt-4; + + a { + @apply not-italic text-secondary; + } + } + } + } +} diff --git a/app/packs/stylesheets/decidim/decidim_awesome/shared/spinner.scss b/app/packs/stylesheets/decidim/decidim_awesome/shared/spinner.scss index e7d9386b6..861e349bc 100644 --- a/app/packs/stylesheets/decidim/decidim_awesome/shared/spinner.scss +++ b/app/packs/stylesheets/decidim/decidim_awesome/shared/spinner.scss @@ -5,4 +5,22 @@ &::before { @apply content-[""] ml-[50%] md:ml-[calc(50%-0.75rem)] block w-6 h-6 rounded-full animate-spin border-4 border-l-background border-y-background border-r-secondary z-20; } + + &.alert { + &::before { + @apply border-r-alert; + } + } + + &.warning { + &::before { + @apply border-r-warning; + } + } + + &.primary { + &::before { + @apply border-r-primary; + } + } } diff --git a/app/permissions/decidim/decidim_awesome/admin/permissions.rb b/app/permissions/decidim/decidim_awesome/admin/permissions.rb index e0d446b61..30c691f8a 100644 --- a/app/permissions/decidim/decidim_awesome/admin/permissions.rb +++ b/app/permissions/decidim/decidim_awesome/admin/permissions.rb @@ -10,6 +10,7 @@ def permissions return permission_action if permission_action.scope != :admin return permission_action unless user return permission_action if user.read_attribute("admin").blank? + return permission_action unless permission_action.action == :edit_config if permission_action.subject == :admin_accountability && DecidimAwesome.admin_accountability.respond_to?(:include?) if global? @@ -17,7 +18,13 @@ def permissions else toggle_allow(DecidimAwesome.admin_accountability.include?(:participatory_space_roles)) end - elsif permission_action.action == :edit_config + elsif permission_action.subject == :private_data && config_enabled?(:proposal_private_custom_fields) + if private_data.present? + allow! if private_data.destroyable? + else + allow! + end + else toggle_allow(config_enabled?(*permission_action.subject)) end @@ -27,7 +34,11 @@ def permissions private def global? - context.fetch(:global) + context.fetch(:global, nil) + end + + def private_data + context.fetch(:private_data, nil) end end end diff --git a/app/presenters/decidim/decidim_awesome/admin_log/component_presenter_override.rb b/app/presenters/decidim/decidim_awesome/admin_log/component_presenter_override.rb new file mode 100644 index 000000000..c6a933ce1 --- /dev/null +++ b/app/presenters/decidim/decidim_awesome/admin_log/component_presenter_override.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Decidim + module DecidimAwesome + module AdminLog + module ComponentPresenterOverride + extend ActiveSupport::Concern + + included do + alias_method :decidim_original_action_string, :action_string + alias_method :decidim_original_i18n_params, :i18n_params + + def action_string + return "decidim.decidim_awesome.admin_log.component.#{action}" if action == "destroy_private_data" + + decidim_original_action_string + end + + def i18n_params + if action == "destroy_private_data" + decidim_original_i18n_params.merge({ count: action_log.extra["count"] }) + else + decidim_original_i18n_params + end + end + end + end + end + end +end diff --git a/app/presenters/decidim/decidim_awesome/private_data_presenter.rb b/app/presenters/decidim/decidim_awesome/private_data_presenter.rb new file mode 100644 index 000000000..a68d30364 --- /dev/null +++ b/app/presenters/decidim/decidim_awesome/private_data_presenter.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Decidim + module DecidimAwesome + class PrivateDataPresenter < SimpleDelegator + include Decidim::TranslatableAttributes + include ActionView::Helpers::DateHelper + include ActionView::Helpers::TagHelper + + def name + @name ||= "#{translated_attribute(participatory_space.title)} / #{translated_attribute(super)}" + end + + def path + @path ||= Decidim::EngineRouter.main_proxy(self).root_path + end + + def total + @total ||= Decidim::Proposals::Proposal.joins(:extra_fields) + .where(component: self) + .where.not(extra_fields: { private_body: nil }) + .count.to_s + end + + def last_date + @last_date ||= Decidim::Proposals::Proposal.joins(:extra_fields) + .where(component: self) + .where.not(extra_fields: { private_body: nil }) + .order(private_body_updated_at: :desc) + .first&.extra_fields&.private_body_updated_at + end + + def time_ago + I18n.t("decidim.decidim_awesome.admin.maintenance.private_data.time_ago", time: time_ago_in_words(last_date)) if last_date + end + + def destroyable? + return false unless last_date + + last_date < DecidimAwesome.private_data_expiration_time.ago + end + + def locked? + Decidim::DecidimAwesome::Lock.new(organization).locked?(__getobj__) + end + + def as_json(_options = nil) + { + id:, + name:, + path:, + total:, + last_date:, + time_ago:, + locked: locked?, + done: + } + end + + def done + return content_tag("span", "", class: "loading-spinner primary") if locked? + + return if destroyable? + return if last_date + + I18n.t("decidim.decidim_awesome.admin.maintenance.private_data.done") + end + end + end +end diff --git a/app/queries/decidim/decidim_awesome/private_data_finder.rb b/app/queries/decidim/decidim_awesome/private_data_finder.rb new file mode 100644 index 000000000..d2852f5b1 --- /dev/null +++ b/app/queries/decidim/decidim_awesome/private_data_finder.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Decidim + module DecidimAwesome + class PrivateDataFinder + def query + Component.where(id: proposals.where.not(extra_fields: { private_body: nil })) + end + + def proposals + Decidim::Proposals::Proposal.select(:decidim_component_id).joins(:extra_fields) + end + + def for(resources) + Component.where(id: proposals).where(id: resources) + end + end + end +end diff --git a/app/views/decidim/decidim_awesome/admin/checks/index.html.erb b/app/views/decidim/decidim_awesome/admin/checks/index.html.erb index 51c432944..979b75801 100644 --- a/app/views/decidim/decidim_awesome/admin/checks/index.html.erb +++ b/app/views/decidim/decidim_awesome/admin/checks/index.html.erb @@ -3,33 +3,29 @@ class="fill-success fill-alert" --> -
-

- <%= t(".title") %> -

+
+

+ "><%= t ".decidim_version", version: decidim_version %> + <%= check decidim_version_valid? %> +

-
-
-

- "><%= t ".decidim_version", version: decidim_version %> - <%= check decidim_version_valid? %> -

-
-
-

<%= t(".images_migrated") %> - <% if images_migrated? %> - <%= check true %>

- <% else %> - <%= check false %>

-
-

<%= t(".pending_image_migrations", total: pending_image_migrations).html_safe %>

- <%= link_to t(".start_image_migrations"), migrate_images_path, method: :post, class: "button" %> -
- <% end %> -
-
- + +
diff --git a/app/views/decidim/decidim_awesome/admin/config/_constraints.html.erb b/app/views/decidim/decidim_awesome/admin/config/_constraints.html.erb index 9debcc00d..d7e9bca81 100644 --- a/app/views/decidim/decidim_awesome/admin/config/_constraints.html.erb +++ b/app/views/decidim/decidim_awesome/admin/config/_constraints.html.erb @@ -30,5 +30,5 @@
-<%= decidim_modal id: "edit-modal-#{key}", class: "decidim_awesome modal", remote: true %> -<%= decidim_modal id: "new-modal-#{key}", class: "decidim_awesome modal", remote: true %> +<%= decidim_modal id: "edit-modal-#{key}", data: { constraint: key }, remote: true %> +<%= decidim_modal id: "new-modal-#{key}", data: { constraint: key }, remote: true %> diff --git a/app/views/decidim/decidim_awesome/admin/config/show.html.erb b/app/views/decidim/decidim_awesome/admin/config/show.html.erb index 56d2b4bfa..a5d3b666c 100644 --- a/app/views/decidim/decidim_awesome/admin/config/show.html.erb +++ b/app/views/decidim/decidim_awesome/admin/config/show.html.erb @@ -1,20 +1,24 @@ -
-

+
+

<%= t(".title", setting: t(config_var, scope: "decidim.decidim_awesome.admin.config.title")) %>

-
-
- <%= decidim_form_for(@form, method: :patch, url: decidim_admin_decidim_awesome.config_path(config_var), html: { class: "form-defaults awesome-edit-config" }, data: { "safe-path" => decidim_admin_decidim_awesome.config_path(config_var) }) do |f| %> -
- <%= render "form_#{config_var}", form: f, errors: defined?(errors) ? errors : nil %> -
+
+
+
+
+ <%= decidim_form_for(@form, method: :patch, url: decidim_admin_decidim_awesome.config_path(config_var), html: { class: "form-defaults awesome-edit-config" }, data: { "safe-path" => decidim_admin_decidim_awesome.config_path(config_var) }) do |f| %> +
+ <%= render "form_#{config_var}", form: f, errors: defined?(errors) ? errors : nil %> +
-
-
- <%= f.submit t(".update"), class: "button button__sm button__secondary" %> -
+
+
+ <%= f.submit t(".update"), class: "button button__sm button__secondary" %> +
+
+ <% end %>
- <% end %> +
diff --git a/app/views/decidim/decidim_awesome/admin/custom_redirects/index.html.erb b/app/views/decidim/decidim_awesome/admin/custom_redirects/index.html.erb index 0d0591d68..0c9b21515 100644 --- a/app/views/decidim/decidim_awesome/admin/custom_redirects/index.html.erb +++ b/app/views/decidim/decidim_awesome/admin/custom_redirects/index.html.erb @@ -1,10 +1,11 @@ +
+

+ <%= t(".title") %> + <%= link_to t(".new"), decidim_admin_decidim_awesome.new_custom_redirect_path, class: "button button__secondary button__xs tiny button--title text-white" %> +

+
+
-
-

- <%= t(".title") %> - <%= link_to t(".new"), decidim_admin_decidim_awesome.new_custom_redirect_path, class: "button button__secondary button__xs tiny button--title text-white" %> -

-

<%= t(".description").html_safe %>

@@ -15,7 +16,7 @@ <%= t("custom_redirect.origin", scope: "activemodel.attributes") %> <%= t("custom_redirect.destination", scope: "activemodel.attributes") %> <%= link_to(t(".check_redirections"), "#", class: "button button__secondary button__xs pull-right secondary check-custom-redirections") if current_config.present? %> - + <%= t("decidim.decidim_awesome.admin.actions") %> diff --git a/app/views/decidim/decidim_awesome/admin/maintenance/_private_data.html.erb b/app/views/decidim/decidim_awesome/admin/maintenance/_private_data.html.erb new file mode 100644 index 000000000..a510b6361 --- /dev/null +++ b/app/views/decidim/decidim_awesome/admin/maintenance/_private_data.html.erb @@ -0,0 +1,44 @@ +
+

<%= t(".help_html", time_ago:) %> +

+ +<% if collection.empty? %> +
<%= t(".no_data", time_ago:) %>
+<% else %> +
"> + + + + + + + + + + + + <% collection.each do |resource| %> + <% item = present(resource) %> + + + + + + + <% end %> + +
<%= t(".component") %><%= t(".items_count") %><%= t(".last_date") %><%= t("decidim.decidim_awesome.admin.actions") %>
<%= link_to item.name, item.path %><%= item.total %><%= item.time_ago %> + <% if item.locked? %> + "> + <% elsif item.destroyable? %> + <%= link_to destroy_private_data_maintenance_path(:private_data, resource_id: item), method: :delete, class: "button button__primary button__xs tiny", data: { confirm: t(".confirm_delete") } do %> + <%= icon "delete-bin-line" %> + <%= t(".delete") %> + <% end %> + <% else %> + "><%= icon "forbid-line" %> + <% end %> +
+
+<% end %> +<%= paginate collection, theme: "decidim" %> diff --git a/app/views/decidim/decidim_awesome/admin/maintenance/show.html.erb b/app/views/decidim/decidim_awesome/admin/maintenance/show.html.erb new file mode 100644 index 000000000..2917f688b --- /dev/null +++ b/app/views/decidim/decidim_awesome/admin/maintenance/show.html.erb @@ -0,0 +1,44 @@ +
+ <%= render current_view %> +
+ + + diff --git a/app/views/decidim/decidim_awesome/admin/menu_hacks/index.html.erb b/app/views/decidim/decidim_awesome/admin/menu_hacks/index.html.erb index ca507b552..34176e1a4 100644 --- a/app/views/decidim/decidim_awesome/admin/menu_hacks/index.html.erb +++ b/app/views/decidim/decidim_awesome/admin/menu_hacks/index.html.erb @@ -13,7 +13,7 @@ <%= t("menu.position", scope: "activemodel.attributes") %> <%= t("menu.target", scope: "activemodel.attributes") %> <%= t("menu.visibility", scope: "activemodel.attributes") %> - + <%= t("decidim.decidim_awesome.admin.actions") %> diff --git a/app/views/decidim/decidim_awesome/admin/proposals/_private_body.html.erb b/app/views/decidim/decidim_awesome/admin/proposals/_private_body.html.erb index 154b1be6f..c0bdef7ea 100644 --- a/app/views/decidim/decidim_awesome/admin/proposals/_private_body.html.erb +++ b/app/views/decidim/decidim_awesome/admin/proposals/_private_body.html.erb @@ -6,8 +6,15 @@

-
- <%= Decidim::ContentProcessor.render_without_format(render_sanitized_content(proposal, :private_body)).html_safe %> +
+
<%= Decidim::ContentProcessor.render_without_format(render_sanitized_content(proposal, :private_body)).html_safe %>
+ <% if proposal&.extra_fields&.private_body_updated_at %> +
+ <%= t("decidim.decidim_awesome.admin.proposal_custom_fields.private_data_last_update", time_ago: time_ago_in_words(proposal.extra_fields.private_body_updated_at)) if proposal.extra_fields.private_body.present? %> + <%= t("decidim.decidim_awesome.admin.proposal_custom_fields.private_data_last_remove", time_ago: time_ago_in_words(proposal.extra_fields.private_body_updated_at)) if proposal.extra_fields.private_body.blank? %> + <%= link_to t("decidim.decidim_awesome.admin.proposal_custom_fields.remove_private_data"), decidim_admin_decidim_awesome.maintenance_path(:private_data) if proposal.extra_fields.private_body_updated_at && proposal.extra_fields.private_body_updated_at < Decidim::DecidimAwesome.private_data_expiration_time.ago %> +
+ <% end %>
diff --git a/app/views/layouts/decidim/decidim_awesome/admin/_base.html.erb b/app/views/layouts/decidim/decidim_awesome/admin/_base.html.erb new file mode 100644 index 000000000..81882fdcb --- /dev/null +++ b/app/views/layouts/decidim/decidim_awesome/admin/_base.html.erb @@ -0,0 +1,12 @@ +<% add_secondary_root_menu(:awesome_admin_menu) %> + +<%= append_stylesheet_pack_tag "decidim_admin_decidim_awesome", media: "all" %> +<%= append_javascript_pack_tag "decidim_admin_decidim_awesome", defer: true %> + +<%= render "layouts/decidim/admin/application" do %> + <%= yield %> + +
<%= t("credits", scope: "decidim.decidim_awesome", version: link_to(awesome_version, "https://github.com/decidim-ice/decidim-module-decidim_awesome/"), company: link_to("PokeCode", "https://pokecode.net")).html_safe %>
+<% end %> + +<%= render partial: "layouts/decidim/decidim_awesome/awesome_config" %> diff --git a/app/views/layouts/decidim/decidim_awesome/admin/application.html.erb b/app/views/layouts/decidim/decidim_awesome/admin/application.html.erb index 81882fdcb..96b845314 100644 --- a/app/views/layouts/decidim/decidim_awesome/admin/application.html.erb +++ b/app/views/layouts/decidim/decidim_awesome/admin/application.html.erb @@ -1,12 +1,3 @@ -<% add_secondary_root_menu(:awesome_admin_menu) %> - -<%= append_stylesheet_pack_tag "decidim_admin_decidim_awesome", media: "all" %> -<%= append_javascript_pack_tag "decidim_admin_decidim_awesome", defer: true %> - -<%= render "layouts/decidim/admin/application" do %> +<%= render "layouts/decidim/decidim_awesome/admin/base" do %> <%= yield %> - -
<%= t("credits", scope: "decidim.decidim_awesome", version: link_to(awesome_version, "https://github.com/decidim-ice/decidim-module-decidim_awesome/"), company: link_to("PokeCode", "https://pokecode.net")).html_safe %>
<% end %> - -<%= render partial: "layouts/decidim/decidim_awesome/awesome_config" %> diff --git a/app/views/layouts/decidim/decidim_awesome/admin/maintenance.html.erb b/app/views/layouts/decidim/decidim_awesome/admin/maintenance.html.erb new file mode 100644 index 000000000..49fcbc93e --- /dev/null +++ b/app/views/layouts/decidim/decidim_awesome/admin/maintenance.html.erb @@ -0,0 +1,19 @@ +<%= render "layouts/decidim/decidim_awesome/admin/base" do %> +
+

+ <%= t("layouts.decidim.decidim_awesome.admin.maintenance.title", title: t(current_view, scope: "layouts.decidim.decidim_awesome.admin.maintenance.titles")) %> + + <% available_views.each do |view, parts| %> + <% next if view == current_view %> + + <%= link_to parts[:path], class: "button button__secondary button__xs tiny button--title" do %> + <%= icon parts[:icon] %> + <%= parts[:title] %> + <% end %> + <% end %> +

+
+
+ <%= yield %> +
+<% end %> diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index e7c22da87..259839142 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -12,10 +12,29 @@ data: - "<%= %x[bundle info decidim-blogs --path].chomp %>/config/locales/%{locale}.yml" ignore_unused: + - "decidim.admin.actions.manage" + - "decidim.admin.filters.admin_accountability.*" + - "decidim.admin.filters.search_placeholder.*" + - "activemodel.attributes.constraint.*" + - "activemodel.attributes.custom_redirect.*" - "decidim.components.decidim_awesome.name" + - "decidim.components.awesome_iframe.settings.*" + - "decidim.components.awesome_map.settings.*" + - "decidim.components.proposals.settings.global.*" + - "decidim.components.proposals.settings.step.*" + - "decidim.decidim_awesome.admin.config.*" + - "decidim.decidim_awesome.admin.admin_accountability.*" + - "decidim.decidim_awesome.admin.menu.*" + - "decidim.decidim_awesome.admin.menu_hacks.*" + - "decidim.decidim_awesome.admin.proposal_custom_fields.*" + - "decidim.decidim_awesome.custom_fields.*" + - "decidim.decidim_awesome.map_component.map.show.*" + - "decidim.decidim_awesome.voting.voting_cards.*" + - "decidim.decidim_awesome.content_blocks.*" + - "decidim.decidim_awesome.admin_log.*" + - "layouts.decidim.decidim_awesome.admin.maintenance.titles.*" + - "decidim.proposals.*" + - "decidim.meetings.*" ignore_missing: - decidim.participatory_processes.scopes.global - # TODO: remove when diching support for 0.26 - - decidim.proposals.collaborative_drafts.new.add_file - - decidim.proposals.collaborative_drafts.new.edit_file diff --git a/config/locales/en.yml b/config/locales/en.yml index e98499b73..ccc1b05a5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -153,6 +153,7 @@ en: za: Z-A (Reverse alphabetical) decidim_awesome: admin: + actions: Actions admin_accountability: admin_roles: admin: Super admin @@ -216,9 +217,8 @@ en: It looks that this installation needs to migrate %{total} of the old images to the new system.
You can start the process now and it will be performed in the background. start_image_migrations: "👉 Start the migration process now" - title: System compatibility checks config: - caution: 'NOTE: This feature heavily modifies some default behaviours that + caution: 'NOTE: This feature heavily modifies some default behaviors that might lead to unexpected results. Use it with caution!' constraints: add_condition: Add case @@ -358,11 +358,8 @@ en: system: Everywhere except participatory spaces title: admins: Scoped Admins - checks: System Compatibility - custom_redirects: Custom Redirections editors: Editor Hacks livechat: Live Chat - menu_hacks: Menu Tweaks proposal_custom_fields: 'Proposals Custom Fields: Public fields' proposal_private_custom_fields: 'Proposals Custom Fields: Private fields' proposals: Proposals Hacks @@ -428,12 +425,48 @@ en: update: error: Error updating redirection! %{error} success: Redirection updated successfully + maintenance: + private_data: + component: Space / Component + confirm_delete: Are you sure you want to delete all private data for this + resource? This cannot be undone. + delete: Delete all + destroying_private_data: Private data for %{title} is set to be destroyed. + Please check again in a few minutes. + done: Done + help_html: | + This tool allows you to delete all private data for a specific participatory space or component. This action is irreversible and will remove all private data from the database. +

+ What is considered private data? +
+ Private data is any information that is not publicly available and is only accessible to administrators. This, at the moment, includes private custom fields information. +

+ What happens when I delete private data? +
+ When you delete private data, all private information for the selected participatory space or component will be permanently removed from the database. A log of this action will be stored in the system for auditing purposes. +

+ What happens to public data? +
+ Public data will not be affected by this action. Only private data will be deleted. +

+ Is there a minimum time to wait before deleting private data? +
+ Yes, you cannot delete private data if the last update was less than %{time_ago} ago. You can change this configuration in the initializer file. + items_count: Total affected + last_date: Last update + no_data: No private data found older than %{time_ago} ago + not_destroyable: This resource cannot be destroyed yet + removing: Removing... + time_ago: "%{time} ago" menu: admins: Scoped Admins - checks: System Compatibility custom_redirects: Custom Redirections editors: Editor Hacks livechat: Live Chat + maintenance: + checks: System Compatibility + maintenance: Maintenance + private_data: Private data menu_hacks: Menu Tweaks proposal_custom_fields: Proposals Custom Fields proposals: Proposals Hacks @@ -483,7 +516,14 @@ en: menu: title: Public fields private_body: Private body + private_data_last_remove: This data was destroyed %{time_ago} ago. + private_data_last_update: This data was last updated %{time_ago} ago. proposal_private_custom_fields: Private fields + remove_private_data: "👉 You might want to remove it" + admin_log: + component: + destroy_private_data: "%{user_name} destroyed %{count} items of private + data for %{resource_name} in %{space_name}" amendments: modal: amendment_exists: An amendment already exists @@ -602,6 +642,12 @@ en: layouts: decidim: decidim_awesome: + admin: + maintenance: + title: 'Maintenance tools: %{title}' + titles: + checks: System Compatibility Checks + private_data: Private Data Clean-Up awesome_config: amendments: Amendments autosaved_error: LocalStorage is not supported in your browser, form cannot diff --git a/db/migrate/20240729164227_add_decidim_awesome_proposal_private_fields_date.rb b/db/migrate/20240729164227_add_decidim_awesome_proposal_private_fields_date.rb new file mode 100644 index 000000000..d8b685b2b --- /dev/null +++ b/db/migrate/20240729164227_add_decidim_awesome_proposal_private_fields_date.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddDecidimAwesomeProposalPrivateFieldsDate < ActiveRecord::Migration[6.1] + class ProposalExtraField < ApplicationRecord + self.table_name = :decidim_awesome_proposal_extra_fields + end + + def change + add_column :decidim_awesome_proposal_extra_fields, :private_body_updated_at, :datetime + + reversible do |direction| + direction.up do + execute <<~SQL.squish + UPDATE decidim_awesome_proposal_extra_fields + SET private_body_updated_at = updated_at + SQL + end + end + end +end diff --git a/examples/private_data.png b/examples/private_data.png new file mode 100644 index 000000000..db31c9f2a Binary files /dev/null and b/examples/private_data.png differ diff --git a/lib/decidim/decidim_awesome/admin_engine.rb b/lib/decidim/decidim_awesome/admin_engine.rb index 287bea764..10dffb75e 100644 --- a/lib/decidim/decidim_awesome/admin_engine.rb +++ b/lib/decidim/decidim_awesome/admin_engine.rb @@ -28,7 +28,10 @@ class AdminEngine < ::Rails::Engine post :export_admin_accountability, to: "admin_accountability#export", as: "export_admin_accountability" get :users, to: "config#users" post :rename_scope_label, to: "config#rename_scope_label" - get :checks, to: "checks#index" + resources :maintenance, only: [:show] do + delete :destroy_private_data, on: :member + get :checks, on: :collection, to: "checks#index" + end post :migrate_images, to: "checks#migrate_images" root to: "config#show" end @@ -48,20 +51,21 @@ class AdminEngine < ::Rails::Engine if first_available decidim_admin_decidim_awesome.config_path(first_available) else - decidim_admin_decidim_awesome.checks_path + decidim_admin_decidim_awesome.checks_maintenance_index_path end, icon_name: "fire", position: 7.5, active: if first_available is_active_link?(decidim_admin_decidim_awesome.config_path(first_available), :inclusive) else - is_active_link?(decidim_admin_decidim_awesome.checks_path) + is_active_link?(decidim_admin_decidim_awesome.checks_maintenance_index_path) end, if: defined?(current_user) && current_user&.read_attribute("admin") end # submenus Decidim::DecidimAwesome::Menu.register_custom_fields_submenu! Decidim::DecidimAwesome::Menu.register_menu_hacks_submenu! + Decidim::DecidimAwesome::Menu.register_maintenance_admin_menu! Decidim::DecidimAwesome::Menu.register_awesome_admin_menu! # user menu diff --git a/lib/decidim/decidim_awesome/awesome.rb b/lib/decidim/decidim_awesome/awesome.rb index d3aeb5fac..cd57fba92 100644 --- a/lib/decidim/decidim_awesome/awesome.rb +++ b/lib/decidim/decidim_awesome/awesome.rb @@ -10,6 +10,7 @@ module DecidimAwesome autoload :MenuHacker, "decidim/decidim_awesome/menu_hacker" autoload :CustomFields, "decidim/decidim_awesome/custom_fields" autoload :VotingManifest, "decidim/decidim_awesome/voting_manifest" + autoload :Lock, "decidim/decidim_awesome/lock" autoload :TranslatedCustomFieldsType, "decidim/decidim_awesome/api/types/translated_custom_fields_type" autoload :LocalizedCustomFieldsType, "decidim/decidim_awesome/api/types/localized_custom_fields_type" @@ -149,10 +150,22 @@ module DecidimAwesome config_accessor :proposal_custom_fields do {} end + + # Same as proposal_custom_fields but for generating private fields than can be read only by admins config_accessor :proposal_private_custom_fields do {} end + # How old must be the private data to be considered expired and therefore presented to the admins for deletion + config_accessor :private_data_expiration_time do + 3.months + end + + # How long must be the private data prevented from being deleted again after being scheduled for deletion + config_accessor :lock_time do + 1.minute + end + # allows to keep modifications for the main menu # can return :disabled to completly remove this feature # otherwise it should be an array (some overrides can be specified by default): diff --git a/lib/decidim/decidim_awesome/checksums.yml b/lib/decidim/decidim_awesome/checksums.yml index 919db49d6..ee4838e97 100644 --- a/lib/decidim/decidim_awesome/checksums.yml +++ b/lib/decidim/decidim_awesome/checksums.yml @@ -21,6 +21,8 @@ decidim-core: decidim-0.28: 4e4cd366a8a313079ac5fab466bd3679 /app/helpers/decidim/amendments_helper.rb: decidim-0.28: df28f9321d1bc07757746ed608274e3d + /app/presenters/decidim/admin_log/component_presenter.rb: + decidim-0.28: 77e80d527727acdf117a0c4517a69a7c decidim-proposals: /lib/decidim/proposals/proposal_serializer.rb: decidim-0.28: bbd33bdc60defb734b3fb814666bb622 diff --git a/lib/decidim/decidim_awesome/engine.rb b/lib/decidim/decidim_awesome/engine.rb index af1a7df42..09de1ca75 100644 --- a/lib/decidim/decidim_awesome/engine.rb +++ b/lib/decidim/decidim_awesome/engine.rb @@ -115,6 +115,7 @@ class Engine < ::Rails::Engine end if DecidimAwesome.enabled?(:proposal_custom_fields, :proposal_private_custom_fields, :weighted_proposal_voting) Decidim::Proposals::ProposalSerializer.include(Decidim::DecidimAwesome::Proposals::ProposalSerializerOverride) + Decidim::AdminLog::ComponentPresenter.include(Decidim::DecidimAwesome::AdminLog::ComponentPresenterOverride) end if DecidimAwesome.enabled?(:weighted_proposal_voting) @@ -272,6 +273,7 @@ class Engine < ::Rails::Engine Decidim.icons.register(name: "fire", icon: "fire-line", category: "system", description: "", engine: :decidim_awesome) Decidim.icons.register(name: "line-chart-line", icon: "line-chart-line", category: "system", description: "", engine: :decidim_awesome) Decidim.icons.register(name: "spy", icon: "spy-fill", category: "system", description: "", engine: :decidim_awesome) + Decidim.icons.register(name: "forbid-line", icon: "forbid-line", category: "system", description: "", engine: :decidim_awesome) end end end diff --git a/lib/decidim/decidim_awesome/lock.rb b/lib/decidim/decidim_awesome/lock.rb new file mode 100644 index 000000000..833d6848f --- /dev/null +++ b/lib/decidim/decidim_awesome/lock.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Decidim + module DecidimAwesome + class Lock + def initialize(organization, lock_time: DecidimAwesome.lock_time) + @organization = organization + @lock_time = lock_time + end + + attr_reader :resource, :organization, :lock_time + + def get!(resource) + @resource = resource + if config + config.update!(updated_at: Time.current) + else + AwesomeConfig.create!(var:, organization:) + end + end + + def release!(resource) + @resource = resource + config.destroy! if config + end + + def locked?(resource) + @resource = resource + return false unless config + return true if config.reload.updated_at > lock_time.ago + + config.destroy! + false + end + + def config + AwesomeConfig.find_by(var:, organization:) + end + + private + + def var + "lock-#{resource.class.name.underscore}_#{resource.id}" + end + end + end +end diff --git a/lib/decidim/decidim_awesome/menu.rb b/lib/decidim/decidim_awesome/menu.rb index e2992cf40..af286e9b7 100644 --- a/lib/decidim/decidim_awesome/menu.rb +++ b/lib/decidim/decidim_awesome/menu.rb @@ -3,139 +3,162 @@ module Decidim module DecidimAwesome class Menu - def self.register_awesome_admin_menu! - Decidim.menu :awesome_admin_menu do |menu| - menu.add_item :editors, - I18n.t("menu.editors", scope: "decidim.decidim_awesome.admin"), - decidim_admin_decidim_awesome.config_path(:editors), - position: 1, - icon_name: "editors-text", - if: menus[:editors] - - menu.add_item :proposals, - I18n.t("menu.proposals", scope: "decidim.decidim_awesome.admin"), - decidim_admin_decidim_awesome.config_path(:proposals), - position: 2, - icon_name: "documents", - if: menus[:proposals] - - menu.add_item :surveys, - I18n.t("menu.surveys", scope: "decidim.decidim_awesome.admin"), - decidim_admin_decidim_awesome.config_path(:surveys), - position: 3, - icon_name: "surveys", - if: menus[:surveys] - - menu.add_item :styles, - I18n.t("menu.styles", scope: "decidim.decidim_awesome.admin"), - decidim_admin_decidim_awesome.config_path(:styles), - position: 4, - icon_name: "brush", - if: menus[:styles] - - menu.add_item :proposal_custom_fields, - I18n.t("menu.proposal_custom_fields", scope: "decidim.decidim_awesome.admin"), - decidim_admin_decidim_awesome.config_path(:proposal_custom_fields), - position: 5, - icon_name: "layers", - if: menus[:proposal_custom_fields], - submenu: { target_menu: :custom_fields_submenu } - - menu.add_item :admins, - I18n.t("menu.admins", scope: "decidim.decidim_awesome.admin"), - decidim_admin_decidim_awesome.config_path(:admins), - position: 6, - icon_name: "group-line", - if: menus[:admins] - - menu.add_item :menu_hacks, - I18n.t("menu.menu_hacks", scope: "decidim.decidim_awesome.admin"), - decidim_admin_decidim_awesome.menu_hacks_path(menus[:menu_hacks_menu] ? :menu : :home_content_block_menu), - position: 7, - icon_name: "menu-line", - if: menus[:menu_hacks], - submenu: { target_menu: :menu_hacks_submenu } - - menu.add_item :custom_redirects, - I18n.t("menu.custom_redirects", scope: "decidim.decidim_awesome.admin"), - decidim_admin_decidim_awesome.custom_redirects_path, - position: 8, - icon_name: "external-link-line", - if: menus[:custom_redirects] - - menu.add_item :livechat, - I18n.t("menu.livechat", scope: "decidim.decidim_awesome.admin"), - decidim_admin_decidim_awesome.config_path(:livechat), - position: 9, - icon_name: "chat-1-line", - if: menus[:livechat] - - menu.add_item :checks, - I18n.t("menu.checks", scope: "decidim.decidim_awesome.admin"), - decidim_admin_decidim_awesome.checks_path, - position: 10, - icon_name: "pulse" + class << self + def register_awesome_admin_menu! + Decidim.menu :awesome_admin_menu do |menu| + menu.add_item :editors, + I18n.t("menu.editors", scope: "decidim.decidim_awesome.admin"), + decidim_admin_decidim_awesome.config_path(:editors), + position: 1, + icon_name: "editors-text", + if: menus[:editors] + + menu.add_item :proposals, + I18n.t("menu.proposals", scope: "decidim.decidim_awesome.admin"), + decidim_admin_decidim_awesome.config_path(:proposals), + position: 2, + icon_name: "documents", + if: menus[:proposals] + + menu.add_item :surveys, + I18n.t("menu.surveys", scope: "decidim.decidim_awesome.admin"), + decidim_admin_decidim_awesome.config_path(:surveys), + position: 3, + icon_name: "surveys", + if: menus[:surveys] + + menu.add_item :styles, + I18n.t("menu.styles", scope: "decidim.decidim_awesome.admin"), + decidim_admin_decidim_awesome.config_path(:styles), + position: 4, + icon_name: "brush", + if: menus[:styles] + + menu.add_item :proposal_custom_fields, + I18n.t("menu.proposal_custom_fields", scope: "decidim.decidim_awesome.admin"), + decidim_admin_decidim_awesome.config_path(:proposal_custom_fields), + position: 5, + icon_name: "layers", + if: menus[:proposal_custom_fields], + submenu: { target_menu: :custom_fields_submenu } + + menu.add_item :admins, + I18n.t("menu.admins", scope: "decidim.decidim_awesome.admin"), + decidim_admin_decidim_awesome.config_path(:admins), + position: 6, + icon_name: "group-line", + if: menus[:admins] + + menu.add_item :menu_hacks, + I18n.t("menu.menu_hacks", scope: "decidim.decidim_awesome.admin"), + decidim_admin_decidim_awesome.menu_hacks_path(menus[:menu_hacks_menu] ? :menu : :home_content_block_menu), + position: 7, + icon_name: "menu-line", + if: menus[:menu_hacks], + submenu: { target_menu: :menu_hacks_submenu } + + menu.add_item :custom_redirects, + I18n.t("menu.custom_redirects", scope: "decidim.decidim_awesome.admin"), + decidim_admin_decidim_awesome.custom_redirects_path, + position: 8, + icon_name: "external-link-line", + if: menus[:custom_redirects] + + menu.add_item :livechat, + I18n.t("menu.livechat", scope: "decidim.decidim_awesome.admin"), + decidim_admin_decidim_awesome.config_path(:livechat), + position: 9, + icon_name: "chat-1-line", + if: menus[:livechat] + + menu.add_item :maintenance, + I18n.t("maintenance", scope: "decidim.decidim_awesome.admin.menu.maintenance"), + decidim_admin_decidim_awesome.maintenance_path(:private_data), + position: 10, + icon_name: "tools-line", + submenu: { target_menu: :maintenance_submenu } + end end - end - def self.register_custom_fields_submenu! - Decidim.menu :custom_fields_submenu do |menu| - menu.add_item :proposal_custom_fields, - I18n.t("menu.title", scope: "decidim.decidim_awesome.admin.proposal_custom_fields"), - decidim_admin_decidim_awesome.config_path(:proposal_custom_fields), - position: 5.1, - if: menus[:proposal_custom_fields] - - menu.add_item :proposal_private_custom_fields, - I18n.t("proposal_private_custom_fields", scope: "decidim.decidim_awesome.admin.proposal_custom_fields"), - decidim_admin_decidim_awesome.config_path(:proposal_private_custom_fields), - position: 5.2, - if: menus[:proposal_private_custom_fields] + def register_custom_fields_submenu! + Decidim.menu :custom_fields_submenu do |menu| + menu.add_item :proposal_custom_fields, + I18n.t("menu.title", scope: "decidim.decidim_awesome.admin.proposal_custom_fields"), + decidim_admin_decidim_awesome.config_path(:proposal_custom_fields), + position: 5.1, + icon_name: "draft-line", + if: menus[:proposal_custom_fields] + + menu.add_item :proposal_private_custom_fields, + I18n.t("proposal_private_custom_fields", scope: "decidim.decidim_awesome.admin.proposal_custom_fields"), + decidim_admin_decidim_awesome.config_path(:proposal_private_custom_fields), + position: 5.2, + icon_name: "spy", + if: menus[:proposal_private_custom_fields] + end end - end - def self.register_menu_hacks_submenu! - Decidim.menu :menu_hacks_submenu do |menu| - menu.add_item :main_menu, - I18n.t("menu.title", scope: "decidim.decidim_awesome.admin.menu_hacks.index"), - decidim_admin_decidim_awesome.menu_hacks_path(:menu), - position: 7.1, - if: menus[:menu_hacks_menu] - - menu.add_item :content_block_main_menu, - I18n.t("home_content_block_menu.title", scope: "decidim.decidim_awesome.admin.menu_hacks.index"), - decidim_admin_decidim_awesome.menu_hacks_path(:home_content_block_menu), - position: 7.2, - if: menus[:menu_hacks_home_content_block_menu] + def register_menu_hacks_submenu! + Decidim.menu :menu_hacks_submenu do |menu| + menu.add_item :main_menu, + I18n.t("menu.title", scope: "decidim.decidim_awesome.admin.menu_hacks.index"), + decidim_admin_decidim_awesome.menu_hacks_path(:menu), + position: 7.1, + icon_name: "global-line", + if: menus[:menu_hacks_menu] + + menu.add_item :content_block_main_menu, + I18n.t("home_content_block_menu.title", scope: "decidim.decidim_awesome.admin.menu_hacks.index"), + decidim_admin_decidim_awesome.menu_hacks_path(:home_content_block_menu), + position: 7.2, + icon_name: "layout-masonry-line", + if: menus[:menu_hacks_home_content_block_menu] + end end - end - def self.menus - @menus ||= { - editors: config_enabled?(:allow_images_in_editors, :allow_videos_in_editors), - proposals: config_enabled?( - :allow_images_in_proposals, - :validate_title_min_length, :validate_title_max_caps_percent, - :validate_title_max_marks_together, :validate_title_start_with_caps, - :validate_body_min_length, :validate_body_max_caps_percent, - :validate_body_max_marks_together, :validate_body_start_with_caps - ), - surveys: config_enabled?(:auto_save_forms), - styles: config_enabled?(:scoped_styles), - proposal_custom_fields: config_enabled?(:proposal_custom_fields), - proposal_private_custom_fields: config_enabled?(:proposal_private_custom_fields), - admins: config_enabled?(:scoped_admins), - menu_hacks: config_enabled?(:menu, :home_content_block_menu), - menu_hacks_menu: config_enabled?(:menu), - menu_hacks_home_content_block_menu: config_enabled?(:home_content_block_menu), - custom_redirects: config_enabled?(:custom_redirects), - livechat: config_enabled?(:intergram_for_admins, :intergram_for_public) - } - end + def register_maintenance_admin_menu! + Decidim.menu :maintenance_submenu do |menu| + menu.add_item :private_data, + I18n.t("private_data", scope: "decidim.decidim_awesome.admin.menu.maintenance"), + decidim_admin_decidim_awesome.maintenance_path(:private_data), + position: 10, + icon_name: "spy-line" + + menu.add_item :checks, + I18n.t("checks", scope: "decidim.decidim_awesome.admin.menu.maintenance"), + decidim_admin_decidim_awesome.checks_maintenance_index_path, + position: 10, + icon_name: "pulse" + end + end + + def menus + @menus ||= { + editors: config_enabled?(:allow_images_in_editors, :allow_videos_in_editors), + proposals: config_enabled?( + :allow_images_in_proposals, + :validate_title_min_length, :validate_title_max_caps_percent, + :validate_title_max_marks_together, :validate_title_start_with_caps, + :validate_body_min_length, :validate_body_max_caps_percent, + :validate_body_max_marks_together, :validate_body_start_with_caps + ), + surveys: config_enabled?(:auto_save_forms), + styles: config_enabled?(:scoped_styles), + proposal_custom_fields: config_enabled?(:proposal_custom_fields), + proposal_private_custom_fields: config_enabled?(:proposal_private_custom_fields), + admins: config_enabled?(:scoped_admins), + menu_hacks: config_enabled?(:menu, :home_content_block_menu), + menu_hacks_menu: config_enabled?(:menu), + menu_hacks_home_content_block_menu: config_enabled?(:home_content_block_menu), + custom_redirects: config_enabled?(:custom_redirects), + livechat: config_enabled?(:intergram_for_admins, :intergram_for_public) + } + end - # ensure boolean value - def self.config_enabled?(*vars) - DecidimAwesome.enabled?(*vars) + # ensure boolean value + def config_enabled?(*vars) + DecidimAwesome.enabled?(*vars) + end end end end diff --git a/lib/decidim/decidim_awesome/test/shared_examples/summary_examples.rb b/lib/decidim/decidim_awesome/test/shared_examples/summary_examples.rb index 3f949d71f..eeb3be13d 100644 --- a/lib/decidim/decidim_awesome/test/shared_examples/summary_examples.rb +++ b/lib/decidim/decidim_awesome/test/shared_examples/summary_examples.rb @@ -55,6 +55,7 @@ expect(Decidim::Proposals::ProposalVotesController.included_modules).to include(Decidim::DecidimAwesome::Proposals::ProposalVotesControllerOverride) expect(Decidim::AmendmentsController.included_modules).to include(Decidim::DecidimAwesome::LimitPendingAmendments) expect(Decidim::Proposals::ProposalsController.included_modules).to include(Decidim::DecidimAwesome::Proposals::OrderableOverride) + expect(Decidim::AdminLog::ComponentPresenter.included_modules).to include(Decidim::DecidimAwesome::AdminLog::ComponentPresenterOverride) end else @@ -82,6 +83,7 @@ expect(Decidim::Proposals::ProposalVotesController.included_modules).not_to include(Decidim::DecidimAwesome::Proposals::ProposalVotesControllerOverride) expect(Decidim::AmendmentsController.included_modules).not_to include(Decidim::DecidimAwesome::LimitPendingAmendments) expect(Decidim::Proposals::ProposalsController.included_modules).not_to include(Decidim::DecidimAwesome::Proposals::OrderableOverride) + expect(Decidim::AdminLog::ComponentPresenter.included_modules).not_to include(Decidim::DecidimAwesome::AdminLog::ComponentPresenterOverride) end end end @@ -260,7 +262,7 @@ end else it "renders the compatibility checks page" do - expect(page).to have_content("System compatibility") + expect(page).to have_content("Maintenance tools: System Compatibility Checks") end it "has no admin menus" do diff --git a/spec/controllers/admin/maintenance_controller_spec.rb b/spec/controllers/admin/maintenance_controller_spec.rb new file mode 100644 index 000000000..a5658dfca --- /dev/null +++ b/spec/controllers/admin/maintenance_controller_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::DecidimAwesome + module Admin + describe MaintenanceController do + routes { Decidim::DecidimAwesome::AdminEngine.routes } + + let(:user) { create(:user, :confirmed, :admin, organization:) } + let(:organization) { create(:organization) } + let!(:component) { create(:proposal_component, organization:) } + let!(:proposal) { create(:proposal, component:) } + let!(:extra_fields) { create(:awesome_proposal_extra_fields, private_body: "private", proposal:) } + let(:params) do + { id: } + end + let(:id) { "private_data" } + let(:time_ago) { 4.months.ago } + + before do + # rubocop:disable Rails/SkipsModelValidations + extra_fields.update_column(:private_body_updated_at, time_ago) + # rubocop:enable Rails/SkipsModelValidations + request.env["decidim.current_organization"] = user.organization + sign_in user, scope: :user + end + + describe "GET #show" do + it "returns http success" do + get(:show, params:) + expect(response).to have_http_status(:success) + + expect(controller.helpers.current_view).to eq("private_data") + expect(controller.helpers.available_views.keys).to match_array(%w(private_data checks)) + end + + context "when format is json" do + it "returns json" do + get(:show, params:) + expect(response).to have_http_status(:success) + end + end + end + + describe "DELETE #destroy_private_data" do + let(:params) do + { id:, resource_id: component.id } + end + + it "returns http success" do + perform_enqueued_jobs do + delete(:destroy_private_data, params:) + end + expect(response).to have_http_status(:redirect) + expect(Decidim::DecidimAwesome::ProposalExtraField.find(extra_fields.id).private_body).to be_nil + end + + context "when private data is not present" do + let(:time_ago) { 2.months.ago } + + it "returns http success" do + perform_enqueued_jobs do + delete(:destroy_private_data, params:) + end + expect(response).to have_http_status(:redirect) + expect(Decidim::DecidimAwesome::ProposalExtraField.find(extra_fields.id).private_body).to eq("private") + end + end + + context "when no permissions" do + let(:user) { create(:user, :confirmed, organization:) } + + it "returns http success" do + perform_enqueued_jobs do + delete(:destroy_private_data, params:) + end + expect(response).to have_http_status(:redirect) + expect(Decidim::DecidimAwesome::ProposalExtraField.find(extra_fields.id).private_body).to eq("private") + end + end + end + end + end +end diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index c74b8ce07..4a05e6de6 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -16,9 +16,9 @@ expect(missing_keys).to be_empty, "#{missing_keys.inspect} are missing" end - # it "does not have unused keys" do - # expect(unused_keys).to be_empty, "#{unused_keys.inspect} are unused" - # end + it "does not have unused keys" do + expect(unused_keys).to be_empty, "#{unused_keys.inspect} are unused" + end unless ENV["SKIP_NORMALIZATION"] it "is normalized" do diff --git a/spec/jobs/destroy_private_data_job_spec.rb b/spec/jobs/destroy_private_data_job_spec.rb new file mode 100644 index 000000000..39ca6273b --- /dev/null +++ b/spec/jobs/destroy_private_data_job_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::DecidimAwesome + describe DestroyPrivateDataJob do + subject { described_class } + + let!(:organization) { create(:organization) } + let!(:user) { create(:user, organization:) } + + let!(:proposal) { create(:proposal, component:) } + let!(:proposal2) { create(:proposal, component:) } + let!(:modern_proposal) { create(:proposal, component:) } + let!(:component) { create(:proposal_component, organization:) } + let!(:extra_field) { create(:awesome_proposal_extra_fields, proposal:, private_body: "private") } + let!(:extra_field2) { create(:awesome_proposal_extra_fields, proposal: proposal2, private_body: "private") } + let!(:modern_extra_field) { create(:awesome_proposal_extra_fields, proposal: modern_proposal, private_body: "private") } + + let!(:another_component) { create(:proposal_component, organization:) } + let!(:another_proposal) { create(:proposal, component: another_component) } + let!(:another_extra_field) { create(:awesome_proposal_extra_fields, proposal: another_proposal, private_body: "private") } + + let!(:external_organization) { create(:organization) } + let!(:external_component) { create(:proposal_component, organization: external_organization) } + let!(:external_proposal) { create(:proposal, component: external_component) } + let!(:external_extra_field) { create(:awesome_proposal_extra_fields, proposal: external_proposal, private_body: "private") } + + before do + allow(::Decidim::DecidimAwesome).to receive(:private_data_expiration_time).and_return(3.months) + # rubocop:disable Rails/SkipsModelValidations + extra_field.update_column(:private_body_updated_at, 4.months.ago) + extra_field2.update_column(:private_body_updated_at, 4.months.ago) + modern_extra_field.update_column(:private_body_updated_at, 2.months.ago) + another_extra_field.update_column(:private_body_updated_at, 4.months.ago) + external_extra_field.update_column(:private_body_updated_at, 4.months.ago) + # rubocop:enable Rails/SkipsModelValidations + end + + it "cleans up the private data associated with the resource" do + subject.perform_now(component) + + expect(ProposalExtraField.find(extra_field.id).private_body).to be_nil + expect(ProposalExtraField.find(extra_field2.id).private_body).to be_nil + expect(ProposalExtraField.find(modern_extra_field.id).private_body).to eq("private") + expect(ProposalExtraField.find(another_extra_field.id).private_body).to eq("private") + expect(ProposalExtraField.find(external_extra_field.id).private_body).to eq("private") + end + + context "when there's a lock adquired" do + before do + Lock.new(organization).get!(component) + end + + it "releases the lock" do + expect(Lock.new(organization)).to be_locked(component) + + subject.perform_now(component) + + expect(ProposalExtraField.find(extra_field.id).private_body).to be_nil + expect(ProposalExtraField.find(extra_field2.id).private_body).to be_nil + expect(Lock.new(organization)).not_to be_locked(component) + end + end + end +end diff --git a/spec/lib/lock_spec.rb b/spec/lib/lock_spec.rb new file mode 100644 index 000000000..b11cac9b8 --- /dev/null +++ b/spec/lib/lock_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::DecidimAwesome + describe Lock do + let(:organization) { resource.organization } + let(:resource) { create(:dummy_component) } + let(:lock) { described_class.new(organization) } + + describe "#get!" do + it "acquires the lock" do + lock.get!(resource) + expect(lock.locked?(resource)).to be true + end + end + + describe "#release!" do + it "releases the lock" do + lock.get!(resource) + lock.release!(resource) + expect(lock.locked?(resource)).to be false + end + end + + context "when lock already exists" do + let(:another_lock) { described_class.new(organization) } + + before do + another_lock.get!(resource) + end + + describe "#get!" do + it "lock is still valid" do + lock.get!(resource) + expect(lock.locked?(resource)).to be true + end + end + + describe "#release!" do + it "releases the lock" do + lock.release!(resource) + expect(lock.locked?(resource)).to be false + expect(another_lock.locked?(resource)).to be false + end + end + end + + context "when lock is expired" do + let(:lock_time) { ::Decidim::DecidimAwesome.lock_time + 1.minute } + + before do + lock.get!(resource) + travel(lock_time) + end + + describe "#locked?" do + it "releases the lock" do + expect(lock.locked?(resource)).to be false + end + end + + context "when lock time is passed as a paramater" do + let(:lock_time) { ::Decidim::DecidimAwesome.lock_time - 1.minute } + let(:lock) { described_class.new(organization, lock_time: lock_time - 30.seconds) } + + describe "#locked?" do + it "releases the lock" do + expect(lock.locked?(resource)).to be false + end + end + end + end + end +end diff --git a/spec/lib/system_checker_spec.rb b/spec/lib/system_checker_spec.rb index 8fbe16fad..e7572b265 100644 --- a/spec/lib/system_checker_spec.rb +++ b/spec/lib/system_checker_spec.rb @@ -16,8 +16,8 @@ module Decidim::DecidimAwesome expect(subject.overrides["decidim-admin"].files.length).to eq(1) end - it "has 8 modified files in core" do - expect(subject.overrides["decidim-core"].files.length).to eq(8) + it "has 9 modified files in core" do + expect(subject.overrides["decidim-core"].files.length).to eq(9) end it "has 18 modified files in proposals" do diff --git a/spec/models/proposal_extra_field_spec.rb b/spec/models/proposal_extra_field_spec.rb index 4ede7d970..4a19abadb 100644 --- a/spec/models/proposal_extra_field_spec.rb +++ b/spec/models/proposal_extra_field_spec.rb @@ -226,6 +226,7 @@ module Decidim::DecidimAwesome describe "private_body" do it "returns nil if no private_body" do expect(extra_fields.private_body).to be_nil + expect(extra_fields.private_body_updated_at).to be_nil expect(extra_fields.attributes["private_body"]).to be_nil end @@ -244,6 +245,18 @@ module Decidim::DecidimAwesome expect(extra_fields.attributes["private_body"]["en"]).not_to start_with("
'
Something else
' } + extra_fields.save! + expect(extra_fields.private_body_updated_at).not_to eq(initial_date) + end + it "the associated proposal has a private_body" do expect(extra_fields.proposal.reload.private_body["en"]).to eq('
Something
') expect(extra_fields.proposal.private_body).to eq(extra_fields.private_body) diff --git a/spec/permissions/admin/permissions_spec.rb b/spec/permissions/admin/permissions_spec.rb index 365f41ce6..59dfbf5f5 100644 --- a/spec/permissions/admin/permissions_spec.rb +++ b/spec/permissions/admin/permissions_spec.rb @@ -10,9 +10,13 @@ module Decidim::DecidimAwesome::Admin let(:user) { create(:user, :admin, :confirmed, organization:) } let(:context) do { - current_organization: organization + current_organization: organization, + private_data:, + global: } end + let(:global) { nil } + let(:private_data) { nil } let(:feature) { :allow_images_in_editors } let(:action) do { scope: :admin, action: :edit_config, subject: feature } @@ -83,5 +87,34 @@ module Decidim::DecidimAwesome::Admin it { is_expected.to be false } end end + + context "when accessing private_data" do + let(:feature) { :private_data } + let(:status) { true } + + before do + allow(Decidim::DecidimAwesome.config).to receive(:proposal_private_custom_fields).and_return(status) + end + + it { is_expected.to be true } + + context "when proposal private fields is disabled" do + let(:status) { :disabled } + + it { is_expected.to be false } + end + + context "when private_data is present" do + let(:private_data) { double(destroyable?: true) } + + it { is_expected.to be true } + + context "when private_data is not destroyable" do + let(:private_data) { double(destroyable?: false) } + + it_behaves_like "permission is not set" + end + end + end end end diff --git a/spec/presenters/admin_log/component_presenter_spec.rb b/spec/presenters/admin_log/component_presenter_spec.rb new file mode 100644 index 000000000..30f7a87ce --- /dev/null +++ b/spec/presenters/admin_log/component_presenter_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::AdminLog + describe ComponentPresenter, type: :helper do + subject(:presenter) { described_class.new(action_log, helper) } + + let(:organization) { create(:organization) } + let(:user) { create(:user, organization:) } + let(:extra_data) { { count: 137 } } + let(:action_log) do + create( + :action_log, + user:, + action:, + resource:, + extra_data: + ) + end + let(:action) { "destroy_private_data" } + let(:resource) { create(:component, organization:) } + + before do + helper.extend(Decidim::ApplicationHelper) + helper.extend(Decidim::TranslationsHelper) + end + + describe "#present" do + subject { presenter.present } + + it "returns an empty diff" do + puts subject + expect(subject).not_to include("class=\"logs__log__diff\"") + end + + it "returns the explanation" do + expect(subject).to include("class=\"logs__log__explanation\"") + expect(subject).to include("destroyed 137 items of private data for") + end + end + end +end diff --git a/spec/presenters/private_data_presenter_spec.rb b/spec/presenters/private_data_presenter_spec.rb new file mode 100644 index 000000000..76fcf6c85 --- /dev/null +++ b/spec/presenters/private_data_presenter_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +# spec/presenters/private_data_presenter_spec.rb + +require "spec_helper" + +module Decidim::DecidimAwesome + RSpec.describe PrivateDataPresenter, type: :presenter do + let(:participatory_space) { create(:participatory_process) } + let(:component) { create(:proposal_component, participatory_space:) } + let(:proposal) { create(:proposal, component:) } + let(:proposal2) { create(:proposal, component:) } + let(:proposal3) { create(:proposal, component:) } + let(:modern_proposal) { create(:proposal, component:) } + let!(:extra_fields) { create(:awesome_proposal_extra_fields, proposal:, private_body: "private") } + let!(:extra_fields2) { create(:awesome_proposal_extra_fields, proposal: proposal2, private_body: "private") } + let!(:extra_fields3) { create(:awesome_proposal_extra_fields, proposal: proposal3, private_body: "private") } + let!(:modern_extra_fields) { create(:awesome_proposal_extra_fields, proposal: modern_proposal, private_body: nil) } + let!(:external_extra_fields) { create(:awesome_proposal_extra_fields, private_body: "private") } + let(:organization) { participatory_space.organization } + let(:presenter) { described_class.new(component) } + + before do + allow(::Decidim::DecidimAwesome).to receive(:private_data_expiration_time).and_return(3.months) + # rubocop:disable Rails/SkipsModelValidations + extra_fields.update_column(:private_body_updated_at, 4.months.ago.to_date) + extra_fields2.update_column(:private_body_updated_at, 5.months.ago.to_date) + extra_fields3.update_column(:private_body_updated_at, 6.months.ago.to_date) + modern_extra_fields.update_column(:private_body_updated_at, 2.months.ago.to_date) + external_extra_fields.update_column(:private_body_updated_at, 4.months.ago.to_date) + # rubocop:enable Rails/SkipsModelValidations + end + + describe "#name" do + it "returns the formatted name" do + expect(presenter.name).to eq("#{translated(participatory_space.title)} / #{translated(component.name)}") + end + end + + describe "#path" do + it "returns the correct path" do + expect(presenter.path).to eq("/processes/#{participatory_space.slug}/f/#{component.id}/proposals") + end + end + + describe "#total" do + it "returns the correct count of proposals with old private_data" do + expect(presenter.total).to eq("3") + end + end + + describe "#last_date" do + it "returns the correct last updated date" do + expect(presenter.last_date).to eq(4.months.ago.to_date) + end + end + + describe "#time_ago" do + it "returns the correct time ago string" do + allow(presenter).to receive(:time_ago_in_words).and_return("2 days") + expect(presenter.time_ago).to eq(I18n.t("decidim.decidim_awesome.admin.maintenance.private_data.time_ago", time: "2 days")) + end + end + + describe "#destroyable?" do + it "returns false if last_date is nil" do + allow(presenter).to receive(:last_date).and_return(nil) + expect(presenter).not_to be_destroyable + end + + it "returns true if last_date is older than expiration time" do + allow(presenter).to receive(:last_date).and_return(2.years.ago) + expect(presenter).to be_destroyable + end + end + + describe "#locked?" do + it "returns the correct locked status" do + expect(presenter).not_to be_locked + end + end + + describe "#as_json" do + it "returns the correct JSON representation" do + expected_json = { + id: component.id, + name: "#{translated(participatory_space.title)} / #{translated(component.name)}", + path: "/processes/#{participatory_space.slug}/f/#{component.id}/proposals", + total: "3", + last_date: 4.months.ago.to_date, + time_ago: "4 months ago", + locked: false, + done: nil + } + expect(presenter.as_json).to eq(expected_json) + end + end + + describe "#done" do + it "returns the loading spinner if locked" do + allow(presenter).to receive(:locked?).and_return(true) + expect(presenter.done).to eq('') + end + + it "returns nil if destroyable" do + allow(presenter).to receive(:destroyable?).and_return(true) + expect(presenter.done).to be_nil + end + + it "returns the nil if not destroyable" do + allow(presenter).to receive(:destroyable?).and_return(false) + expect(presenter.done).to be_nil + end + + it "returns the correct done message if last_date is nil" do + allow(presenter).to receive(:last_date).and_return(nil) + expect(presenter.done).to eq("Done") + end + end + end +end diff --git a/spec/system/admin/admin_edits_proposals_custom_fields_spec.rb b/spec/system/admin/admin_edits_proposals_custom_fields_spec.rb index 214439a70..85a40965b 100644 --- a/spec/system/admin/admin_edits_proposals_custom_fields_spec.rb +++ b/spec/system/admin/admin_edits_proposals_custom_fields_spec.rb @@ -169,11 +169,8 @@ end context "when answering the proposal" do - before do - find("a.action-icon--show-proposal").click - end - it "displays custom fields" do + find("a.action-icon--show-proposal").click expect(page).to have_content("Bio") within "#textarea-1476748007461" do expect(page).to have_content("I shot the sheriff") @@ -183,12 +180,64 @@ within "#text-1476748004579" do expect(page).to have_content("555-555-555") end + expect(page).to have_content("This data was last updated less than a minute ago.") + expect(page).not_to have_content("You might want to remove it") + end + + context "when private data is required to be removed" do + before do + # rubocop:disable Rails/SkipsModelValidations + proposal.extra_fields.update_column(:private_body_updated_at, 4.months.ago) + # rubocop:enable Rails/SkipsModelValidations + find("a.action-icon--show-proposal").click + end + + it "displays a warning" do + click_link_or_button "Private body" + expect(page).to have_content("Phone Number") + expect(page).to have_content("This data was last updated 4 months ago.") + expect(page).to have_content("You might want to remove it") + end + end + + context "when private data is removed" do + before do + proposal.extra_fields.update(private_body: nil) + find("a.action-icon--show-proposal").click + end + + it "shows destroyed date" do + click_link_or_button "Private body" + expect(page).not_to have_content("Phone Number") + expect(page).to have_content("This data was destroyed less than a minute ago.") + expect(page).not_to have_content("555-555-555") + expect(page).not_to have_content("You might want to remove it") + end + end + + context "when no private data, nor private data last update is present" do + before do + # rubocop:disable Rails/SkipsModelValidations + proposal.extra_fields.update_column(:private_body, nil) + proposal.extra_fields.update_column(:private_body_updated_at, nil) + # rubocop:enable Rails/SkipsModelValidations + find("a.action-icon--show-proposal").click + end + + it "does not display the private data" do + click_link_or_button "Private body" + expect(page).not_to have_content("Phone Number") + expect(page).not_to have_content("This data was last updated") + expect(page).not_to have_content("This data was last destroyed") + expect(page).not_to have_content("You might want to remove it") + end end context "when private fields are scoped to other places" do let!(:private_constraint) { create(:config_constraint, awesome_config: private_config_helper, settings: { "participatory_space_manifest" => "assemblies" }) } it "does not display private custom fields" do + find("a.action-icon--show-proposal").click expect(page).to have_content("Bio") within "#textarea-1476748007461" do expect(page).to have_content("I shot the sheriff") diff --git a/spec/system/admin/admin_manages_mainenance_spec.rb b/spec/system/admin/admin_manages_mainenance_spec.rb new file mode 100644 index 000000000..5fd1034ee --- /dev/null +++ b/spec/system/admin/admin_manages_mainenance_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Admin manages maintenance" do + let(:user) { create(:user, :confirmed, :admin, organization:) } + let(:organization) { create(:organization) } + let(:participatory_space) { create(:participatory_process, title: { "en" => "A process" }, organization:) } + let!(:component) { create(:proposal_component, name: { "en" => "Erasable" }, participatory_space:) } + let!(:another_component) { create(:proposal_component, name: { "en" => "Has mixed data" }, participatory_space:) } + let!(:modern_component) { create(:proposal_component, name: { "en" => "Has modern data" }, participatory_space:) } + let!(:missing_component) { create(:proposal_component, name: { "en" => "Missing data" }, participatory_space:) } + let!(:proposal) { create(:proposal, component:) } + let!(:proposal2) { create(:proposal, component:) } + let!(:another_proposal) { create(:proposal, component: another_component) } + let!(:modern_proposal) { create(:proposal, component: another_component) } + let!(:modern_proposal2) { create(:proposal, component: modern_component) } + let!(:missing_proposal) { create(:proposal, component: missing_component) } + let!(:extra_fields) { create(:awesome_proposal_extra_fields, private_body: "private", proposal:) } + let!(:extra_fields2) { create(:awesome_proposal_extra_fields, private_body: "private", proposal: proposal2) } + let!(:modern_extra_fields) { create(:awesome_proposal_extra_fields, private_body: "private", proposal: modern_proposal) } + let!(:modern_extra_fields2) { create(:awesome_proposal_extra_fields, private_body: "private", proposal: modern_proposal2) } + let!(:another_extra_fields) { create(:awesome_proposal_extra_fields, private_body: "private", proposal: another_proposal) } + let(:params) do + { id: } + end + let(:id) { "private_data" } + let(:time_ago) { 4.months.ago } + + before do + # rubocop:disable Rails/SkipsModelValidations + extra_fields.update_column(:private_body_updated_at, time_ago) + extra_fields2.update_column(:private_body_updated_at, time_ago) + modern_extra_fields.update_column(:private_body_updated_at, 2.months.ago) + modern_extra_fields2.update_column(:private_body_updated_at, 2.months.ago) + another_extra_fields.update_column(:private_body_updated_at, time_ago) + # rubocop:enable Rails/SkipsModelValidations + switch_to_host(organization.host) + login_as user, scope: :user + end + + context "when visiting the maintenance page" do + before do + visit decidim_admin_decidim_awesome.maintenance_path(:private_data) + end + + it "shows the private data maintenance page" do + within ".table-list thead" do + expect(page).to have_content("Space / Component") + expect(page).to have_content("Total affected") + expect(page).to have_content("Last update") + expect(page).to have_content("Actions") + end + end + + it "show to private data information" do + within ".table-list tbody" do + expect(page).to have_content(translated(component.name)) + expect(page).to have_content(translated(another_component.name)) + expect(page).to have_content(translated(modern_component.name)) + expect(page).not_to have_content(translated(missing_component.name)) + expect(page).to have_content("Delete all") + expect(page).to have_content("4 months ago") + expect(page).to have_content("2 months ago", count: 2) + expect(page).not_to have_content("Done") + expect(page).to have_css("span", class: "not-destroyable", count: 2) + end + end + + it "allows to delete all private data for a component" do + within ".table-list tbody tr[data-id=\"#{component.id}\"]" do + expect(page).to have_content(translated(component.name)) + expect(page).to have_content("4 months ago") + expect(page).not_to have_content("Done") + expect(page).to have_content("Delete all") + expect(page).not_to have_css("span", class: "not-destroyable") + end + + within ".table-list tbody tr[data-id=\"#{another_component.id}\"]" do + expect(page).to have_content(translated(another_component.name)) + expect(page).to have_content("2 months ago") + expect(page).not_to have_content("Done") + expect(page).not_to have_content("Delete all") + expect(page).to have_css("span", class: "not-destroyable") + end + + within ".table-list tbody tr[data-id=\"#{modern_component.id}\"]" do + expect(page).to have_content(translated(modern_component.name)) + expect(page).to have_content("2 months ago") + expect(page).not_to have_content("Done") + expect(page).not_to have_content("Delete all") + expect(page).to have_css("span", class: "not-destroyable") + end + + accept_confirm do + click_on "Delete all" + end + + expect(page).to have_content("Private data for A process / Erasable is set to be destroyed.") + expect(page).not_to have_content("Delete all") + expect(page).not_to have_content("Done") + expect(page).to have_css(".loading-spinner") + + Decidim::DecidimAwesome::DestroyPrivateDataJob.perform_now(component) + + expect(page).to have_content("Done") + end + + it "deletes all private data" do + perform_enqueued_jobs do + accept_confirm do + click_on "Delete all" + end + within ".table-list tbody" do + expect(page).not_to have_content("Delete all") + expect(page).not_to have_content(translated(component.name)) + end + end + end + end +end