Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Record delegated votes #67

Merged
merged 12 commits into from
Nov 5, 2020
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,33 @@ TWILIO_AUTH_TOKEN # Token from your Twilio account
SMS_SENDER # Twilio's phone number. You need to purchase one there with SMS capability.
```

### Track delegated votes and unvotes

Votes and revocations done on behalf of other members are tracked through the
`versions` table using `PaperTrail`. This enables fetching a log of actions
involving a particular delegation or consultation for auditing purposes. This
keeps out regular votes and unvotes.

When performing votes and unvotes of delegations you'll see things like the
following in your `versions` table:

```sql
id | item_type | item_id | event | whodunnit | decidim_action_delegator_delegation_id
------+------------------------------+---------+---------+-----------+----------------------------------------
2019 | Decidim::Consultations::Vote | 143 | destroy | 1 | 22
2018 | Decidim::Consultations::Vote | 143 | create | 1 | 22
2017 | Decidim::Consultations::Vote | 142 | create | 1 | 23
2016 | Decidim::Consultations::Vote | 138 | destroy | 1 | 23
```

Note that the `item_type` is `Decidim::Consultations::Vote` and `whoddunit`
refers to a `Decidim::User` record. This enables joining `versions` and
`decidim_users` tables although this doesn't follow Decidim's convention of
using gids, such as `gid://decidim/Decidim::User/1`.

You can use `Decidim::ActionDelegato::DelegatedVotes::Versions` query object for
that matter.

## Contributing

See [Decidim](https://github.com/decidim/decidim).
Expand Down
28 changes: 28 additions & 0 deletions app/commands/decidim/action_delegator/vote_delegation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# frozen_string_literal: true

module Decidim
module ActionDelegator
class VoteDelegation
def initialize(form)
@context = form.context
@response = form.response
end

def call
PaperTrail.request.controller_info = { decidim_action_delegator_delegation_id: context.delegation.id }
WhodunnitVote.new(build_vote, context.current_user)
end

private

attr_reader :context, :response

def build_vote
context.current_question.votes.build(
author: context.delegation.granter,
response: response
)
end
end
end
end
13 changes: 13 additions & 0 deletions app/models/decidim/action_delegator/unversioned_vote.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true

module Decidim
module ActionDelegator
class UnversionedVote < SimpleDelegator
def save
PaperTrail.request(enabled: false) do
super
end
end
end
end
end
22 changes: 22 additions & 0 deletions app/models/decidim/action_delegator/whodunnit_vote.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Decidim
module ActionDelegator
class WhodunnitVote < DelegateClass(Decidim::Consultations::Vote)
def initialize(vote, user)
@user = user
super(vote)
end

def save
PaperTrail.request(whodunnit: user.id) do
super
end
end

private

attr_reader :user
end
end
end
15 changes: 10 additions & 5 deletions app/overrides/commands/decidim/consultations/vote_question.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@
private

def build_vote
author = delegation ? delegation.granter : form.context.current_user
form.context.current_question.votes.build(
author: author,
response: form.response
)
if delegation
form.context.delegation = delegation
Decidim::ActionDelegator::VoteDelegation.new(form).call
else
vote = form.context.current_question.votes.build(
author: form.context.current_user,
response: form.response
)
Decidim::ActionDelegator::UnversionedVote.new(vote)
end
end

def delegation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,29 @@
Decidim::Consultations::QuestionVotesController.class_eval do
def destroy
enforce_permission_to_unvote
Decidim::Consultations::UnvoteQuestion.call(current_question, delegation.present? ? delegation.granter : current_user) do
on(:ok) do
current_question.reload
render :update_vote_button

user = delegation.blank? ? current_user : delegation.granter

PaperTrail.request(enabled: delegation.present?, whodunnit: current_user.id) do
Decidim::Consultations::UnvoteQuestion.call(current_question, user) do
on(:ok) do
current_question.reload
render :update_vote_button
end
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs serious refactoring extracting UnvoteDelegation to start with. We also should give it a crack at extracting the DelegationVotesController

end
end
end

private

def info_for_paper_trail
if delegation.present?
{ decidim_action_delegator_delegation_id: delegation.id }
else
{}
end
end

def delegation
@delegation ||= Decidim::ActionDelegator::Delegation.find_by(id: params[:decidim_consultations_delegation_id])
end
Expand Down
5 changes: 5 additions & 0 deletions app/overrides/models/decidim/consultations/vote.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

Decidim::Consultations::Vote.class_eval do
has_paper_trail
end
31 changes: 31 additions & 0 deletions app/queries/decidim/action_delegator/delegated_votes_versions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

module Decidim
module ActionDelegator
# Returns all PaperTrail versions of a consultation's delegated votes for auditing purposes.
# It is intended to be used to easily fetch this data when a judge ask us so.
class DelegatedVotesVersions
def initialize(consultation)
@consultation = consultation
end

def query
statement = <<-SQL.strip_heredoc
SELECT *
FROM versions
INNER JOIN decidim_action_delegator_delegations
ON decidim_action_delegator_delegations.id = versions.decidim_action_delegator_delegation_id
INNER JOIN decidim_action_delegator_settings
ON decidim_action_delegator_settings.id = decidim_action_delegator_delegations.decidim_action_delegator_setting_id
WHERE decidim_action_delegator_settings.decidim_consultation_id = #{consultation.id}
SQL

ActiveRecord::Base.connection.execute(statement).to_a
end

private

attr_reader :consultation
end
end
end
8 changes: 8 additions & 0 deletions db/migrate/20201030164808_add_delegation_id_to_versions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true

class AddDelegationIdToVersions < ActiveRecord::Migration[5.2]
def change
add_column :versions, :decidim_action_delegator_delegation_id, :integer, null: true, default: nil
sauloperez marked this conversation as resolved.
Show resolved Hide resolved
add_index :versions, :decidim_action_delegator_delegation_id
end
end
sauloperez marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 2 additions & 2 deletions lib/decidim/action_delegator/test/factories.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

FactoryBot.define do
factory :delegation, class: "Decidim::ActionDelegator::Delegation" do
granter factory: :user
grantee factory: :user
setting
granter { association :user, organization: setting.consultation.organization }
grantee { association :user, organization: setting.consultation.organization }
end

factory :setting, class: "Decidim::ActionDelegator::Setting" do
Expand Down
51 changes: 51 additions & 0 deletions spec/commands/decidim/action_delegator/vote_delegation_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

require "spec_helper"

describe Decidim::ActionDelegator::VoteDelegation do
subject { described_class.new(form) }

let(:organization) { build(:organization) }
let(:consultation) { build(:consultation, :active, organization: organization) }
let(:setting) { build(:setting, consultation: consultation) }
let(:delegation) { build(:delegation, setting: setting) }
let(:question) { build(:question, :published, consultation: consultation) }
let(:response) { build(:response, question: question) }

let(:context) { double(:context, delegation: delegation, current_question: question, current_user: delegation.grantee) }
let(:form) { instance_double(Decidim::Consultations::VoteForm, context: context, response: response) }

describe "#call" do
it "builds a vote with the granter as author" do
vote = subject.call
expect(vote.author).to eq(delegation.granter)
end

it "builds a vote with the response taken from the form" do
vote = subject.call
expect(vote.response).to eq(form.response)
end

it "builds a valid vote" do
vote = subject.call
expect(vote).to be_valid
end

it "tracks who performed the vote", versioning: true do
vote = subject.call
vote.save

expect(vote.versions.last.whodunnit).to eq(context.current_user.id.to_s)
end

it "tracks the delegation the vote is related to", versioning: true do
delegation.save
question.save

vote = subject.call
vote.save

expect(vote.versions.last.decidim_action_delegator_delegation_id).to eq(delegation.id)
end
end
end
15 changes: 15 additions & 0 deletions spec/commands/decidim/consultations/vote_question_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ module Consultations
end.to change(response, :votes_count).by(1)
end

describe "originator", versioning: true do
it "does not track who was responsible for the action" do
expect { subject.call }
.not_to change(PaperTrail::Version.where(item_type: "Decidim::Consultations::Vote"), :count)
end
end

context "when there is a delegation available" do
let(:setting) { create(:setting, consultation: consultation) }
let(:granter) { create(:user, organization: organization) }
Expand All @@ -72,6 +79,14 @@ module Consultations
subject.call
end.to change(Vote.where(author: delegation.granter), :count).by(1)
end

describe "originator", versioning: true do
it "tracks who was responsible for the action" do
subject.call
vote = Vote.last
expect(vote.paper_trail.originator).to eq(delegation.grantee.id.to_s)
end
end
end
end

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# frozen_string_literal: true

require "spec_helper"

module Decidim
module Consultations
describe QuestionVotesController, type: :controller do
routes { Decidim::Consultations::Engine.routes }

let(:organization) { create :organization }
let(:user) { create(:user, :confirmed, organization: organization) }

before do
request.env["decidim.current_organization"] = organization
sign_in user
end

describe "#destroy" do
let(:consultation) { create(:consultation, organization: organization) }
let(:question) { create(:question, consultation: consultation) }
let(:setting) { create(:setting, consultation: consultation) }

context "when a delegation is specified", versioning: true do
let(:delegation) { create(:delegation, setting: setting, grantee: user) }
let!(:vote) { create(:vote, author: delegation.granter, question: question) }

it "destroys the vote" do
delete :destroy, params: { question_slug: question.slug, decidim_consultations_delegation_id: delegation.id }, format: :js
expect(response).to render_template(:update_vote_button)
end

it "creates a new version" do
expect do
delete :destroy, params: { question_slug: question.slug, decidim_consultations_delegation_id: delegation.id }, format: :js
end.to change(PaperTrail::Version, :count)
end

it "tracks who performed the unvote" do
delete :destroy, params: { question_slug: question.slug, decidim_consultations_delegation_id: delegation.id }, format: :js
version = vote.versions.last
expect(version.whodunnit).to eq(user.id.to_s)
end

it "tracks the delegation the unvote is related to" do
delete :destroy, params: { question_slug: question.slug, decidim_consultations_delegation_id: delegation.id }, format: :js
version = vote.versions.last
expect(version.decidim_action_delegator_delegation_id).to eq(delegation.id)
end
end

context "when no delegation is specified", versioning: true do
before { create(:vote, author: user, question: question) }

it "destroys the vote" do
delete :destroy, params: { question_slug: question.slug }, format: :js
expect(response).to render_template(:update_vote_button)
end

it "does not create a new version" do
expect { delete :destroy, params: { question_slug: question.slug }, format: :js }
.not_to change(PaperTrail::Version, :count)
end
end
end
end
end
end
1 change: 1 addition & 0 deletions spec/lib/overrides_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ module Decidim::ActionDelegator

# monkeypatches
"/app/commands/decidim/consultations/vote_question.rb" => "8d89031039a1ba2972437d13687a72b5",
"/app/models/decidim/consultations/vote.rb" => "c06286e3f7366d3a017bf69f1c9e3eef",
"/app/controllers/decidim/consultations/question_votes_controller.rb" => "69bf764e99dfcdae138613adbed28b84",
"/app/forms/decidim/consultations/vote_form.rb" => "d2b69f479b61b32faf3b108da310081a"
}
Expand Down
19 changes: 19 additions & 0 deletions spec/models/decidim/action_delegator/unversioned_vote_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

require "spec_helper"

module Decidim
module ActionDelegator
describe UnversionedVote do
subject(:unversioned_vote) { described_class.new(vote) }

let(:vote) { build(:vote) }

it "disables PaperTrail", versioning: true do
subject.save

expect(unversioned_vote.versions).to be_empty
end
end
end
end
Loading