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