decidim-proposals 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/decidim/proposals/social_share.css.scss +5 -0
  3. data/app/commands/decidim/proposals/admin/answer_proposal.rb +43 -0
  4. data/app/commands/decidim/proposals/create_proposal.rb +1 -0
  5. data/app/controllers/decidim/proposals/admin/proposal_answers_controller.rb +37 -0
  6. data/app/controllers/decidim/proposals/admin/proposals_controller.rb +1 -0
  7. data/app/controllers/decidim/proposals/proposal_votes_controller.rb +18 -7
  8. data/app/controllers/decidim/proposals/proposals_controller.rb +8 -7
  9. data/app/forms/decidim/proposals/admin/proposal_answer_form.rb +18 -0
  10. data/app/forms/decidim/proposals/proposal_form.rb +1 -0
  11. data/app/helpers/decidim/proposals/application_helper.rb +17 -0
  12. data/app/helpers/decidim/proposals/proposal_votes_helper.rb +19 -0
  13. data/app/models/decidim/proposals/abilities/current_user.rb +59 -0
  14. data/app/models/decidim/proposals/proposal.rb +30 -33
  15. data/app/models/decidim/proposals/proposal_vote.rb +2 -0
  16. data/app/services/decidim/proposals/proposal_search.rb +11 -0
  17. data/app/views/decidim/proposals/admin/proposal_answers/edit.html.erb +15 -0
  18. data/app/views/decidim/proposals/admin/proposals/index.html.erb +8 -0
  19. data/app/views/decidim/proposals/proposal_votes/update_buttons_and_counters.js.erb +20 -0
  20. data/app/views/decidim/proposals/proposals/_filters.html.erb +3 -1
  21. data/app/views/decidim/proposals/proposals/_filters_small_view.html.erb +18 -0
  22. data/app/views/decidim/proposals/proposals/_linked_proposals.html.erb +27 -0
  23. data/app/views/decidim/proposals/proposals/_proposal.html.erb +3 -2
  24. data/app/views/decidim/proposals/proposals/_proposal_badge.html.erb +3 -0
  25. data/app/views/decidim/proposals/proposals/_remaining_votes_count.html.erb +1 -0
  26. data/app/views/decidim/proposals/proposals/_share.html.erb +1 -1
  27. data/app/views/decidim/proposals/proposals/_vote_button.html.erb +16 -4
  28. data/app/views/decidim/proposals/proposals/_votes_limit.html.erb +23 -0
  29. data/app/views/decidim/proposals/proposals/index.html.erb +13 -3
  30. data/app/views/decidim/proposals/proposals/new.html.erb +6 -0
  31. data/app/views/decidim/proposals/proposals/show.html.erb +15 -2
  32. data/config/i18n-tasks.yml +3 -0
  33. data/config/locales/ca.yml +54 -1
  34. data/config/locales/en.yml +54 -0
  35. data/config/locales/es.yml +54 -1
  36. data/db/migrate/20170120151202_add_user_group_id_to_proposals.rb +5 -0
  37. data/db/migrate/20170131092413_add_answers_to_proposals.rb +7 -0
  38. data/lib/decidim/proposals/admin_engine.rb +3 -1
  39. data/lib/decidim/proposals/engine.rb +7 -1
  40. data/lib/decidim/proposals/feature.rb +28 -1
  41. data/lib/decidim/proposals/test/factories.rb +83 -0
  42. metadata +64 -9
  43. data/app/views/decidim/proposals/proposal_votes/create.js.erb +0 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: f981594ab6257e9cf721a9ffd76368d8c86c96f8
4
- data.tar.gz: e857387adb235b90d3f758f57565712fccca1334
3
+ metadata.gz: 72d9615abcde37945383d87bb3f7c74126d66b3c
4
+ data.tar.gz: 588e35fae074a55307aab6789d6d3af25db87d9a
5
5
  SHA512:
6
- metadata.gz: 6ed1c5ea32849f63efe358f438fc878b833528030d6d12d0669953e5419866c8a24a5c1f9dfb2c56f76e6f854a19487920c4bc0354f8db5853bfdc48aa7c9717
7
- data.tar.gz: efb15ffb3ad5e9fbfea35fa780a0966e9b5d44899c7c8dde74dd1ce418bea634b268b2a9e7faf5cbfd349b067139add3f128dabd06cbdfca3428aedb3ffbd134
6
+ metadata.gz: a883d5c8a999da8fb3f0bda1811678905858d1693f69b846b975b66f2c25cbdbf0bfa7c671ceb991519f2b68972ae2cef71bd08be7a21f03cda07a5dfbe6cc9e
7
+ data.tar.gz: d0882dff25bb3c47491036de7988d4e1fd392e6d1806e49a287ebcd4cf02e9e70a6a64b829e07102e16607ea31363ba17afb1dbed0968450df66e02ba2033330
@@ -1,6 +1,11 @@
1
1
  /*= require social-share-button */
2
2
  $size: 45px;
3
3
 
4
+ .share-link:hover {
5
+ text-decoration: underline;
6
+ cursor: pointer;
7
+ }
8
+
4
9
  .social-share-button {
5
10
  display: inline-block;
6
11
  vertical-align: top;
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Proposals
4
+ module Admin
5
+ # A command with all the business logic when an admin answers a proposal.
6
+ class AnswerProposal < Rectify::Command
7
+ # Public: Initializes the command.
8
+ #
9
+ # form - A form object with the params.
10
+ # proposal - The proposal to write the answer for.
11
+ def initialize(form, proposal)
12
+ @form = form
13
+ @proposal = proposal
14
+ end
15
+
16
+ # Executes the command. Broadcasts these events:
17
+ #
18
+ # - :ok when everything is valid.
19
+ # - :invalid if the form wasn't valid and we couldn't proceed.
20
+ #
21
+ # Returns nothing.
22
+ def call
23
+ return broadcast(:invalid) if form.invalid?
24
+
25
+ answer_proposal
26
+ broadcast(:ok)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :form, :proposal
32
+
33
+ def answer_proposal
34
+ proposal.update_attributes!(
35
+ state: @form.state,
36
+ answer: @form.answer,
37
+ answered_at: Time.current
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -36,6 +36,7 @@ module Decidim
36
36
  category: form.category,
37
37
  scope: form.scope,
38
38
  author: @current_user,
39
+ decidim_user_group_id: form.user_group_id,
39
40
  feature: form.feature
40
41
  )
41
42
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Proposals
4
+ module Admin
5
+ # This controller allows admins to answer proposals in a participatory process.
6
+ class ProposalAnswersController < Admin::ApplicationController
7
+ helper_method :proposal
8
+
9
+ def edit
10
+ @form = form(Admin::ProposalAnswerForm).from_model(proposal)
11
+ end
12
+
13
+ def update
14
+ @form = form(Admin::ProposalAnswerForm).from_params(params)
15
+
16
+ Admin::AnswerProposal.call(@form, proposal) do
17
+ on(:ok) do
18
+ flash[:notice] = I18n.t("proposals.answer.success", scope: "decidim.proposals.admin")
19
+ redirect_to proposals_path
20
+ end
21
+
22
+ on(:invalid) do
23
+ flash.now[:alert] = I18n.t("proposals.answer.invalid", scope: "decidim.proposals.admin")
24
+ render action: "edit"
25
+ end
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def proposal
32
+ @proposals ||= Proposal.where(feature: current_feature).find(params[:id])
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -4,6 +4,7 @@ module Decidim
4
4
  module Admin
5
5
  # This controller allows admins to manage proposals in a participatory process.
6
6
  class ProposalsController < Admin::ApplicationController
7
+ helper Proposals::ApplicationHelper
7
8
  helper_method :proposals
8
9
 
9
10
  def new
@@ -4,21 +4,32 @@ module Decidim
4
4
  module Proposals
5
5
  # Exposes the proposal vote resource so users can vote proposals.
6
6
  class ProposalVotesController < Decidim::Proposals::ApplicationController
7
+ include ProposalVotesHelper
8
+
9
+ helper_method :proposal
10
+
7
11
  before_action :authenticate_user!
8
- before_action :check_current_settings!
9
12
 
10
13
  def create
11
- @proposal = Proposal.where(feature: current_feature).find(params[:proposal_id])
12
- @proposal.votes.create!(author: current_user)
14
+ authorize! :vote, proposal
15
+
16
+ proposal.votes.create!(author: current_user)
17
+ @from_proposals_list = params[:from_proposals_list] == "true"
18
+ render :update_buttons_and_counters
19
+ end
20
+
21
+ def destroy
22
+ authorize! :unvote, proposal
23
+
24
+ proposal.votes.where(author: current_user).delete_all
13
25
  @from_proposals_list = params[:from_proposals_list] == "true"
26
+ render :update_buttons_and_counters
14
27
  end
15
28
 
16
29
  private
17
30
 
18
- # The vote buttons should not be visible if the setting is not enabled.
19
- # This ensure the votes cannot be created using a POST request directly.
20
- def check_current_settings!
21
- raise "This setting is not enabled for this step" unless current_settings.votes_enabled?
31
+ def proposal
32
+ @proposal ||= Proposal.where(feature: current_feature).find(params[:proposal_id])
22
33
  end
23
34
  end
24
35
  end
@@ -18,14 +18,21 @@ module Decidim
18
18
  .results
19
19
  .includes(:author)
20
20
  .includes(votes: [:author])
21
+ .page(params[:page])
22
+ .per(12)
23
+
21
24
  @random_seed = search.random_seed
22
25
  end
23
26
 
24
27
  def new
28
+ authorize! :create, Proposal
29
+
25
30
  @form = form(ProposalForm).from_params({})
26
31
  end
27
32
 
28
33
  def create
34
+ authorize! :create, Proposal
35
+
29
36
  @form = form(ProposalForm).from_params(params)
30
37
 
31
38
  CreateProposal.call(@form, current_user) do
@@ -47,19 +54,13 @@ module Decidim
47
54
  ProposalSearch
48
55
  end
49
56
 
50
- def default_search_params
51
- {
52
- page: params[:page],
53
- per_page: 12
54
- }
55
- end
56
-
57
57
  def default_filter_params
58
58
  {
59
59
  search_text: "",
60
60
  origin: "all",
61
61
  activity: "",
62
62
  category_id: "",
63
+ state: "all",
63
64
  random_seed: params[:random_seed]
64
65
  }
65
66
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Proposals
4
+ module Admin
5
+ # A form object to be used when admin users want to answer a proposal.
6
+ class ProposalAnswerForm < Decidim::Form
7
+ include TranslatableAttributes
8
+ mimic :proposal_answer
9
+
10
+ translatable_attribute :answer, String
11
+ attribute :state, String
12
+
13
+ validates :state, presence: true, inclusion: { in: %w(accepted rejected) }
14
+ validates :answer, translatable_presence: true, if: ->(form) { form.state == "rejected" }
15
+ end
16
+ end
17
+ end
18
+ end
@@ -9,6 +9,7 @@ module Decidim
9
9
  attribute :body, String
10
10
  attribute :category_id, Integer
11
11
  attribute :scope_id, Integer
12
+ attribute :user_group_id, Integer
12
13
 
13
14
  validates :title, :body, presence: true
14
15
  validates :category, presence: true, if: ->(form) { form.category_id.present? }
@@ -7,6 +7,23 @@ module Decidim
7
7
  include Decidim::Comments::CommentsHelper
8
8
  include PaginateHelper
9
9
  include ProposalVotesHelper
10
+
11
+ # Public: The state of a proposal in a way a human can understand.
12
+ #
13
+ # state - The String state of the proposal.
14
+ #
15
+ # Returns a String.
16
+ def humanize_proposal_state(state)
17
+ value = if state == "accepted"
18
+ "accepted"
19
+ elsif state == "rejected"
20
+ "rejected"
21
+ else
22
+ "not_answered"
23
+ end
24
+
25
+ I18n.t(value, scope: "decidim.proposals.answers")
26
+ end
10
27
  end
11
28
  end
12
29
  end
@@ -22,6 +22,25 @@ module Decidim
22
22
  return "small" if from_proposals_list
23
23
  "expanded button--sc"
24
24
  end
25
+
26
+ # Check if the vote limit is enabled for the current feature
27
+ #
28
+ # Returns true if the vote limit is enabled
29
+ def vote_limit_enabled?
30
+ current_user && feature_settings.vote_limit.present? && feature_settings.vote_limit.positive?
31
+ end
32
+
33
+ # Return the remaining votes for a user if the current feature has a vote limit
34
+ #
35
+ # user - A User object
36
+ #
37
+ # Returns a number with the remaining votes for that user
38
+ def remaining_votes_count_for(user)
39
+ return 0 unless vote_limit_enabled?
40
+ proposals = Proposal.where(feature: current_feature)
41
+ votes_count = ProposalVote.where(author: user, proposal: proposals).size
42
+ feature_settings.vote_limit - votes_count
43
+ end
25
44
  end
26
45
  end
27
46
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+ module Decidim
3
+ module Proposals
4
+ module Abilities
5
+ # Defines the abilities related to proposals for a logged in user.
6
+ # Intended to be used with `cancancan`.
7
+ class CurrentUser
8
+ include CanCan::Ability
9
+
10
+ attr_reader :user, :context
11
+
12
+ def initialize(user, context)
13
+ return unless user
14
+
15
+ @user = user
16
+ @context = context
17
+
18
+ can :vote, Proposal do |_proposal|
19
+ voting_enabled? && remaining_votes.positive?
20
+ end
21
+
22
+ can :unvote, Proposal do |_proposal|
23
+ voting_enabled? && vote_limit_enabled?
24
+ end
25
+
26
+ can :create, Proposal if current_settings.try(:creation_enabled?)
27
+ end
28
+
29
+ private
30
+
31
+ def vote_limit_enabled?
32
+ return unless feature_settings
33
+ feature_settings.vote_limit.present? && feature_settings.vote_limit.positive?
34
+ end
35
+
36
+ def remaining_votes
37
+ return 1 unless vote_limit_enabled?
38
+
39
+ proposals = Proposal.where(feature: context.fetch(:current_feature))
40
+ votes_count = ProposalVote.where(author: user, proposal: proposals).size
41
+ feature_settings.vote_limit - votes_count
42
+ end
43
+
44
+ def voting_enabled?
45
+ return unless current_settings
46
+ current_settings.votes_enabled? && !current_settings.votes_blocked?
47
+ end
48
+
49
+ def current_settings
50
+ context.fetch(:current_settings, nil)
51
+ end
52
+
53
+ def feature_settings
54
+ context.fetch(:feature_settings, nil)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -3,58 +3,55 @@ module Decidim
3
3
  module Proposals
4
4
  # The data store for a Proposal in the Decidim::Proposals component.
5
5
  class Proposal < Proposals::ApplicationRecord
6
- belongs_to :feature, foreign_key: "decidim_feature_id", class_name: Decidim::Feature
7
- belongs_to :author, foreign_key: "decidim_author_id", class_name: Decidim::User
8
- belongs_to :category, foreign_key: "decidim_category_id", class_name: Decidim::Category
9
- belongs_to :scope, foreign_key: "decidim_scope_id", class_name: Decidim::Scope
10
- has_one :organization, through: :feature
6
+ include Decidim::Resourceable
7
+ include Decidim::Authorable
8
+ include Decidim::HasFeature
9
+ include Decidim::HasScope
10
+ include Decidim::HasCategory
11
+
12
+ feature_manifest_name "proposals"
13
+
11
14
  has_many :votes, foreign_key: "decidim_proposal_id", class_name: ProposalVote, dependent: :destroy
12
15
 
13
- validates :title, :feature, :body, presence: true
14
- validate :category_belongs_to_feature
15
- validate :scope_belongs_to_organization
16
- validate :author_belongs_to_organization
16
+ validates :title, :body, presence: true
17
+
18
+ scope :accepted, -> { where(state: "accepted") }
19
+ scope :rejected, -> { where(state: "rejected") }
17
20
 
18
21
  def author_name
19
- author&.name || I18n.t("decidim.proposals.models.proposal.fields.official_proposal")
22
+ user_group&.name || author&.name || I18n.t("decidim.proposals.models.proposal.fields.official_proposal")
20
23
  end
21
24
 
22
25
  def author_avatar_url
23
26
  author&.avatar&.url || ActionController::Base.helpers.asset_path("decidim/default-avatar.svg")
24
27
  end
25
28
 
26
- # Public: Canpeople comment on this proposal?
29
+ # Public: Check if the user has voted the proposal.
27
30
  #
28
- # Until we have a way to store options fore features and its resources we
29
- # assume all proposals can be commented.
30
- #
31
- # Returns Boolean
32
- def commentable?
33
- true
34
- end
35
-
36
- # Public: Check if the user has voted the proposal
37
- #
38
- # Returns Boolean
31
+ # Returns Boolean.
39
32
  def voted_by?(user)
40
33
  votes.any? { |vote| vote.author == user }
41
34
  end
42
35
 
43
- private
44
-
45
- def category_belongs_to_feature
46
- return unless category
47
- errors.add(:category, :invalid) unless feature.categories.where(id: category.id).exists?
36
+ # Public: Checks if the organization has given an answer for the proposal.
37
+ #
38
+ # Returns Boolean.
39
+ def answered?
40
+ answered_at.present?
48
41
  end
49
42
 
50
- def scope_belongs_to_organization
51
- return unless scope
52
- errors.add(:scope, :invalid) unless feature.scopes.where(id: scope.id).exists?
43
+ # Public: Checks if the organization has accepted a proposal.
44
+ #
45
+ # Returns Boolean.
46
+ def accepted?
47
+ state == "accepted"
53
48
  end
54
49
 
55
- def author_belongs_to_organization
56
- return unless author
57
- errors.add(:author, :invalid) unless Decidim::User.where(decidim_organization_id: feature.organization.id, id: author.id).exists?
50
+ # Public: Checks if the organization has rejected a proposal.
51
+ #
52
+ # Returns Boolean.
53
+ def rejected?
54
+ state == "rejected"
58
55
  end
59
56
  end
60
57
  end
@@ -6,6 +6,7 @@ module Decidim
6
6
  belongs_to :proposal, foreign_key: "decidim_proposal_id", class_name: Decidim::Proposals::Proposal, counter_cache: true
7
7
  belongs_to :author, foreign_key: "decidim_author_id", class_name: Decidim::User
8
8
 
9
+ validates :proposal, :author, presence: true
9
10
  validates :proposal, uniqueness: { scope: :author }
10
11
  validate :author_and_proposal_same_organization
11
12
 
@@ -13,6 +14,7 @@ module Decidim
13
14
 
14
15
  # Private: check if the proposal and the author have the same organization
15
16
  def author_and_proposal_same_organization
17
+ return if !proposal || !author
16
18
  errors.add(:proposal, :invalid) unless author.organization == proposal.organization
17
19
  end
18
20
  end