diff --git a/.rubocop_rails.yml b/.rubocop_rails.yml index 6e191ba6..b5e753dd 100644 --- a/.rubocop_rails.yml +++ b/.rubocop_rails.yml @@ -89,6 +89,8 @@ Rails/SkipsModelValidations: Enabled: true Exclude: - db/migrate/*.rb + - lib/extends/models/decidim/decidim_awesome/proposal_extra_field_extends.rb + - spec/lib/tasks/decidim_app/set_decrypted_private_body_task_spec.rb Rails/Validation: Include: @@ -103,4 +105,4 @@ Rails/BulkChangeTable: RSpec/MultipleMemoizedHelpers: Exclude: - - spec/**/** \ No newline at end of file + - spec/**/** diff --git a/Makefile b/Makefile index 0d4ec45c..641e9e66 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ up: build @make setup-database build: - docker build . -f Dockerfile.local -t decidim-app:latest + docker build . -f Dockerfile.local -t decidim-lyon:latest # Stops containers and remove volumes teardown: @@ -51,4 +51,4 @@ external: rebuild: docker compose -f docker-compose.local.yml down docker volume rm decidim-app_shared-volume || true - @make up \ No newline at end of file + @make up diff --git a/config/application.rb b/config/application.rb index 4bdee961..b3faeeab 100644 --- a/config/application.rb +++ b/config/application.rb @@ -52,6 +52,7 @@ class Application < Rails::Application require "extends/commands/decidim/meetings/admin/create_meeting_extends" require "extends/commands/decidim/meetings/update_meeting_extends" require "extends/commands/decidim/meetings/create_meeting_extends" + require "extends/models/decidim/decidim_awesome/proposal_extra_field_extends" Decidim::GraphiQL::Rails.config.tap do |config| config.initial_query = "{\n deployment {\n version\n branch\n remote\n upToDate\n currentCommit\n latestCommit\n locallyModified\n }\n}".html_safe diff --git a/db/migrate/20241008065657_add_decrypted_private_body_to_proposal_extra_field.rb b/db/migrate/20241008065657_add_decrypted_private_body_to_proposal_extra_field.rb new file mode 100644 index 00000000..0b7f1d20 --- /dev/null +++ b/db/migrate/20241008065657_add_decrypted_private_body_to_proposal_extra_field.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddDecryptedPrivateBodyToProposalExtraField < 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, :decrypted_private_body, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 2b628980..3517d5c5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_08_19_170611) do +ActiveRecord::Schema.define(version: 2024_10_08_065657) do # These are extensions that must be enabled in order to support this database enable_extension "ltree" @@ -335,6 +335,7 @@ t.string "private_body" t.string "decidim_proposal_type", null: false t.datetime "private_body_updated_at" + t.string "decrypted_private_body" t.index ["decidim_proposal_id", "decidim_proposal_type"], name: "index_decidim_awesome_proposal_extra_fields_on_decidim_proposal" end diff --git a/lib/extends/models/decidim/decidim_awesome/proposal_extra_field_extends.rb b/lib/extends/models/decidim/decidim_awesome/proposal_extra_field_extends.rb new file mode 100644 index 00000000..68bc5715 --- /dev/null +++ b/lib/extends/models/decidim/decidim_awesome/proposal_extra_field_extends.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "active_support/concern" +module ProposalExtraFieldExtends + extend ActiveSupport::Concern + + included do + after_save :update_decrypted_body + + private + + def update_decrypted_body + update_columns(decrypted_private_body: private_body.to_s) if private_body.present? + end + end +end + +Decidim::DecidimAwesome::ProposalExtraField.include(ProposalExtraFieldExtends) diff --git a/lib/tasks/set_decrypted_private_body.rake b/lib/tasks/set_decrypted_private_body.rake new file mode 100644 index 00000000..d387083f --- /dev/null +++ b/lib/tasks/set_decrypted_private_body.rake @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +namespace :decidim do + desc "Set decrypted_private_body to existing extra fields" + task set_decrypted_private_body: :environment do + extra_fields = Decidim::DecidimAwesome::ProposalExtraField.where(decrypted_private_body: nil).where.not(private_body: nil) + if extra_fields.any? + p "Extra fields to update: #{extra_fields.size}" + count = 0 + extra_fields.find_each do |extra_field| + extra_field.update(decrypted_private_body: extra_field.private_body.to_s) + count += 1 if extra_field.decrypted_private_body_previous_change.present? + end + p "Extra fields updated: #{count}" + end + end +end diff --git a/spec/lib/tasks/decidim_app/set_decrypted_private_body_task_spec.rb b/spec/lib/tasks/decidim_app/set_decrypted_private_body_task_spec.rb new file mode 100644 index 00000000..0352c1e6 --- /dev/null +++ b/spec/lib/tasks/decidim_app/set_decrypted_private_body_task_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "rake decidim:set_decrypted_private_body", type: :task do + let(:task) { Rake::Task["decidim:set_decrypted_private_body"] } + let!(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: create(:extended_proposal)) } + + before do + extra_fields.private_body = { "en" => '
Something
' } + extra_fields.save! + end + + it "preloads the Rails environment" do + expect(task.prerequisites).to include "environment" + end + + it "prints nothing if no extra_fields to update" do + expect { task.execute }.not_to output("\"Extra fields to update: 1\"\n\"Extra fields updated: 1\"\n").to_stdout + end + + it "sets the decrypted body correctly" do + # we need an empty decrypted_private_body to test if the task will update it well + extra_fields.update_columns(decrypted_private_body: nil) + expect(extra_fields.decrypted_private_body).to be_nil + expect { task.execute }.to output("\"Extra fields to update: 1\"\n\"Extra fields updated: 1\"\n").to_stdout + expect(extra_fields.reload.decrypted_private_body).to eq('{"en"=>"
Something
"}') + end +end diff --git a/spec/models/proposal_extra_field_spec.rb b/spec/models/proposal_extra_field_spec.rb new file mode 100644 index 00000000..1223979e --- /dev/null +++ b/spec/models/proposal_extra_field_spec.rb @@ -0,0 +1,298 @@ +# frozen_string_literal: true + +require "spec_helper" + +module Decidim::DecidimAwesome + describe ProposalExtraField do + subject { extra_fields } + + let(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: create(:extended_proposal)) } + let(:proposal) { create(:extended_proposal) } + + it { is_expected.to be_valid } + + it "has a proposal associated" do + expect(extra_fields.proposal).to be_a(Decidim::Proposals::Proposal) + end + + it "cannot associate more than one extra field to a proposal" do + extra_fields + expect do + another_proposal = create(:proposal, component: extra_fields.proposal.component) + create(:awesome_proposal_extra_fields, proposal: another_proposal) + end.to change(Decidim::DecidimAwesome::ProposalExtraField, :count).by(1) + expect { create(:awesome_proposal_extra_fields, proposal: extra_fields.proposal) }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "the associated proposal has the same extra_fields" do + expect(extra_fields.proposal.reload.extra_fields).to eq(extra_fields) + end + + describe "weight_count" do + let!(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: proposal) } + let!(:vote_weights) do + proposal + [ + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 1), + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 2), + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3) + ] + end + + it "returns the weight count for a weight" do + expect(proposal.reload.weight_count(1)).to eq(1) + expect(proposal.weight_count(2)).to eq(1) + expect(proposal.weight_count(3)).to eq(1) + end + + context "when a vote is added" do + before do + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 5) + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3) + end + + it "returns the weight count for a weight" do + expect(proposal.reload.weight_count(1)).to eq(1) + expect(proposal.weight_count(2)).to eq(1) + expect(proposal.weight_count(3)).to eq(2) + expect(proposal.weight_count(4)).to eq(0) + expect(proposal.weight_count(5)).to eq(1) + end + end + + context "when extra_fields does not exist" do + let(:extra_fields) { nil } + let(:vote_weights) { nil } + + it "returns 0" do + expect(proposal.reload.weight_count(1)).to eq(0) + expect(proposal.weight_count(2)).to eq(0) + expect(proposal.weight_count(3)).to eq(0) + expect(proposal.weight_count(100)).to eq(0) + end + + context "when a vote is added" do + before do + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 5) + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3) + end + + it "returns the weight count for a weight" do + expect(proposal.reload.weight_count(1)).to eq(0) + expect(proposal.weight_count(2)).to eq(0) + expect(proposal.weight_count(3)).to eq(1) + expect(proposal.weight_count(4)).to eq(0) + expect(proposal.weight_count(5)).to eq(1) + end + end + end + end + + context "when proposal is destroyed" do + let!(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: proposal) } + + it "destroys the proposal weight" do + expect { proposal.destroy }.to change(Decidim::DecidimAwesome::ProposalExtraField, :count).by(-1) + end + end + + context "when proposal weight is destroyed" do + let!(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: proposal) } + + it "does not destroy the proposal" do + expect { extra_fields.destroy }.not_to change(Decidim::Proposals::ProposalVote, :count) + end + end + + context "when vote weight is" do + describe "created" do + it "increments the weight cache" do + expect { create(:proposal_vote, proposal: proposal) }.to change { proposal.votes.count }.by(1) + expect { create(:awesome_vote_weight, vote: proposal.votes.first, weight: 3) }.to change(Decidim::DecidimAwesome::ProposalExtraField, :count).by(1) + expect(proposal.reload.extra_fields.vote_weight_totals).to eq({ "3" => 1 }) + expect(proposal.extra_fields.weight_total).to eq(3) + end + + context "when cache already exists" do + let(:another_proposal) { create(:proposal, component: proposal.component) } + let!(:extra_fields) { create(:awesome_proposal_extra_fields, :with_votes, proposal: proposal) } + let!(:another_extra_fields) { create(:awesome_proposal_extra_fields, :with_votes, proposal: another_proposal) } + + it "has weights and votes" do + expect(extra_fields.reload.vote_weight_totals).to eq({ "1" => 1, "2" => 1, "3" => 1, "4" => 1, "5" => 1 }) + expect(extra_fields.weight_total).to eq(15) + end + + it "increments the weight cache" do + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 1) + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3) + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3) + expect(extra_fields.reload.vote_weight_totals).to eq({ "1" => 2, "2" => 1, "3" => 3, "4" => 1, "5" => 1 }) + expect(extra_fields.weight_total).to eq(22) + end + end + + context "when cache does not exist yet" do + let(:extra_fields) { proposal.reload.extra_fields } + + it "has no weights and votes" do + expect(extra_fields).to be_nil + end + + it "increments the weight cache" do + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 1) + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3) + create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 3) + expect(extra_fields.vote_weight_totals).to eq({ "1" => 1, "3" => 2 }) + expect(extra_fields.weight_total).to eq(7) + end + end + end + + # this is an unlikely scenario where voting removes and creates new vote weights, just in case... + describe "updated" do + let!(:vote_weight1) { create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 1) } + let!(:vote_weight2) { create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 2) } + let(:extra_fields) { proposal.reload.extra_fields } + + it "increments the weight cache" do + vote_weight1.weight = 3 + vote_weight1.save + expect(extra_fields.vote_weight_totals).to eq({ "2" => 1, "3" => 1 }) + expect(extra_fields.weight_total).to eq(5) + end + + it "decreases the weight cache" do + vote_weight2.weight = 1 + vote_weight2.save + expect(extra_fields.vote_weight_totals).to eq({ "1" => 2 }) + expect(extra_fields.weight_total).to eq(2) + end + end + + describe "destroyed" do + let!(:vote_weight1) { create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 1) } + let!(:vote_weight2) { create(:awesome_vote_weight, vote: create(:proposal_vote, proposal: proposal), weight: 2) } + let(:extra_fields) { proposal.reload.extra_fields } + + it "decreases the weight cache" do + vote_weight1.destroy + expect(extra_fields.vote_weight_totals).to eq({ "2" => 1 }) + expect(extra_fields.weight_total).to eq(2) + end + end + end + + describe "all_vote_weights" do + let!(:extra_fields) { create(:awesome_proposal_extra_fields, proposal: proposal) } + let!(:another_extra_fields) { create(:awesome_proposal_extra_fields, proposal: another_proposal) } + let!(:unrelated_another_extra_fields) { create(:awesome_proposal_extra_fields, :with_votes, proposal: create(:extended_proposal)) } + let(:another_proposal) { create(:proposal, component: proposal.component) } + let!(:votes) do + vote = create(:proposal_vote, proposal: proposal, author: create(:user, organization: proposal.organization)) + create(:awesome_vote_weight, vote: vote, weight: 1) + end + let!(:other_votes) do + vote = create(:proposal_vote, proposal: another_proposal, author: create(:user, organization: proposal.organization)) + create(:awesome_vote_weight, vote: vote, weight: 2) + end + + it "returns all vote weights for a component" do + expect(proposal.reload.all_vote_weights).to contain_exactly(1, 2) + expect(another_proposal.reload.all_vote_weights).to contain_exactly(1, 2) + expect(proposal.vote_weights).to eq({ "1" => 1, "2" => 0 }) + expect(another_proposal.vote_weights).to eq({ "1" => 0, "2" => 1 }) + end + + context "when wrong cache exists" do + before do + # rubocop:disable Rails/SkipsModelValidations: + # we don't want to trigger the active record hooks + extra_fields.update_columns(vote_weight_totals: { "3" => 1, "4" => 1 }) + # rubocop:enable Rails/SkipsModelValidations: + end + + it "returns all vote weights for a component" do + expect(proposal.reload.extra_fields.vote_weight_totals).to eq({ "3" => 1, "4" => 1 }) + expect(proposal.vote_weights).to eq({ "1" => 0, "2" => 0 }) + proposal.update_vote_weights! + expect(proposal.vote_weights).to eq({ "1" => 1, "2" => 0 }) + expect(another_proposal.reload.vote_weights).to eq({ "1" => 0, "2" => 1 }) + expect(proposal.extra_fields.vote_weight_totals).to eq({ "1" => 1 }) + expect(another_proposal.extra_fields.vote_weight_totals).to eq({ "2" => 1 }) + end + end + end + + 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 + + it "the associated proposal has a private_body" do + expect(extra_fields.proposal.private_body).to be_nil + end + + context "when private body is set" do + before do + extra_fields.private_body = { "en" => '
Something
' } + extra_fields.save! + end + + it "sets the private body" do + expect(extra_fields.private_body["en"]).to eq('
Something
') + 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) + end + + it "sets the decrypted private body" do + expect(extra_fields.decrypted_private_body).to eq('{"en"=>"
Something
"}') + end + end + + context "when setting the private body from the proposal" do + before do + proposal.private_body = { "en" => '
Something
' } + end + + it "sets the private body" do + expect(proposal.private_body["en"]).to eq('
Something
') + end + end + + context "when saving the private body from the proposal" do + before do + proposal.private_body = { "en" => '
Something
' } + proposal.save! + end + + it "sets the private body" do + expect(proposal.extra_fields.private_body["en"]).to eq('
Something
') + expect(proposal.extra_fields.attributes["private_body"]["en"]).not_to start_with("
"
Something
"}') + end + end + end + end +end