diff --git a/app/commands/decidim/proposals/publish_proposal.rb b/app/commands/decidim/proposals/publish_proposal.rb
new file mode 100644
index 0000000000..81f58d3779
--- /dev/null
+++ b/app/commands/decidim/proposals/publish_proposal.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+module Decidim
+ module Proposals
+ # A command with all the business logic when a user publishes a draft proposal.
+ class PublishProposal < Decidim::Command
+ include Decidim::AnonymousProposals::AnonymousBehaviorCommandsConcern
+
+ # Public: Initializes the command.
+ #
+ # proposal - The proposal to publish.
+ # current_user - The current user.
+ # override: decidim-module-anonymous_proposals/app/commands/decidim/anonymous_proposals/publish_proposal_command_overrides.rb
+ def initialize(proposal, current_user)
+ @proposal = proposal
+ @is_anonymous = allow_anonymous_proposals? && (current_user.blank? || proposal.authored_by?(anonymous_group))
+ set_current_user(current_user)
+ end
+
+ # Executes the command. Broadcasts these events:
+ #
+ # - :ok when everything is valid and the proposal is published.
+ # - :invalid if the proposal's author is not the current user.
+ #
+ # Returns nothing.
+ def call
+ return broadcast(:invalid) unless @proposal.authored_by?(@current_user)
+
+ transaction do
+ publish_proposal
+ increment_scores
+ send_notification
+ send_notification_to_participatory_space
+ send_publication_notification
+ end
+
+ broadcast(:ok, @proposal)
+ end
+
+ private
+
+ # This will be the PaperTrail version that is
+ # shown in the version control feature (1 of 1)
+ #
+ # For an attribute to appear in the new version it has to be reset
+ # and reassigned, as PaperTrail only keeps track of object CHANGES.
+ def publish_proposal
+ title = reset(:title)
+ body = reset(:body)
+
+ Decidim.traceability.perform_action!(
+ "publish",
+ @proposal,
+ @current_user,
+ visibility: "public-only"
+ ) do
+ @proposal.update title: title, body: body, published_at: Time.current
+ end
+ end
+
+ # Reset the attribute to an empty string and return the old value
+ def reset(attribute)
+ attribute_value = @proposal[attribute]
+ PaperTrail.request(enabled: false) do
+ # rubocop:disable Rails/SkipsModelValidations
+ @proposal.update_attribute attribute, ""
+ # rubocop:enable Rails/SkipsModelValidations
+ end
+ attribute_value
+ end
+
+ def send_notification
+ return if @proposal.coauthorships.empty?
+
+ Decidim::EventsManager.publish(
+ event: "decidim.events.proposals.proposal_published",
+ event_class: Decidim::Proposals::PublishProposalEvent,
+ resource: @proposal,
+ followers: coauthors_followers
+ )
+ end
+
+ def send_publication_notification
+ Decidim::EventsManager.publish(
+ event: "decidim.events.proposals.proposal_published_event",
+ event_class: Decidim::Proposals::ProposalPublishedEvent,
+ resource: @proposal,
+ affected_users: [@proposal.creator_identity],
+ extra: { force_email: true },
+ force_send: true
+ )
+ end
+
+ def send_notification_to_participatory_space
+ Decidim::EventsManager.publish(
+ event: "decidim.events.proposals.proposal_published",
+ event_class: Decidim::Proposals::PublishProposalEvent,
+ resource: @proposal,
+ followers: @proposal.participatory_space.followers - coauthors_followers,
+ extra: {
+ participatory_space: true
+ }
+ )
+ end
+
+ def coauthors_followers
+ @coauthors_followers ||= @proposal.authors.flat_map(&:followers)
+ end
+
+ def increment_scores
+ @proposal.coauthorships.find_each do |coauthorship|
+ if coauthorship.user_group
+ Decidim::Gamification.increment_score(coauthorship.user_group, :proposals)
+ else
+ Decidim::Gamification.increment_score(coauthorship.author, :proposals)
+ end
+ end
+ end
+
+ # override: decidim-module-anonymous_proposals/app/commands/decidim/anonymous_proposals/publish_proposal_command_overrides.rb
+ def component
+ @component ||= @proposal.component
+ end
+ end
+ end
+end
diff --git a/app/controllers/concerns/decidim/simple_proposal/proposals_controller_override.rb b/app/controllers/concerns/decidim/simple_proposal/proposals_controller_override.rb
index b627ce8e14..51fcfe5cb3 100644
--- a/app/controllers/concerns/decidim/simple_proposal/proposals_controller_override.rb
+++ b/app/controllers/concerns/decidim/simple_proposal/proposals_controller_override.rb
@@ -103,7 +103,6 @@ def create
end
end
end
-
# Overridden because of a core bug when the command posts the "invalid"
# signal and when rendering the form.
def update_draft
@@ -203,6 +202,7 @@ def proposal_limit_reached?(form = form_proposal_params)
def current_user_proposals(form)
Decidim::Proposals::Proposal.from_author(current_user).where(component: form.current_component).except_withdrawn
end
+
end
end
end
diff --git a/app/events/decidim/proposals/proposal_published_event.rb b/app/events/decidim/proposals/proposal_published_event.rb
new file mode 100644
index 0000000000..27b8bba9ab
--- /dev/null
+++ b/app/events/decidim/proposals/proposal_published_event.rb
@@ -0,0 +1,10 @@
+# app/events/decidim/proposals/proposal_published_event.rb
+module Decidim
+ module Proposals
+ class ProposalPublishedEvent < Decidim::Events::SimpleEvent
+ def resource_title
+ translated_attribute(resource.title)
+ end
+ end
+ end
+end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 477839a9f7..f64f878fc5 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -147,6 +147,11 @@ en:
exports:
awesome_private_proposals: Proposals with private fields
proposal_comments: Comments
+ notifications:
+ proposal_published:
+ title: "Your proposal %{proposal_title} has been published!"
+ body: "Your proposal is now live. View it here: %{proposal_link}"
+ subject: "Your proposal has been published!"
collaborative_drafts:
new:
add_file: Add file
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index a82163e17b..00052e5887 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -67,6 +67,12 @@ fr:
new:
sign_in_disabled: Vous pouvez accéder avec un compte externe
events:
+ proposals:
+ proposal_published_event:
+ email_intro: 'que vous suivez, a publié une nouvelle proposition appelée "%{resource_title}". Découvrez-le et contribuez:'
+ email_outro: Vous avez reçu cette notification car vous suivez . Si vous souhaitez vous désabonner des notifications, connectez-vous à la plateforme, puis rendez-vous dans l'onglet “Mon compte” > “Paramètres des notifications”.
+ email_subject: Nouvelle proposition "%{resource_title}" publiée
+ notification_title: La proposition %{resource_title} a été publiée.
budgets:
pending_order:
email_intro: Le vote sur le budget "%{resource_title}" n'est pas encore finalisé sur la concertation "%{participatory_space_title}".
@@ -158,6 +164,11 @@ fr:
collaborative_drafts_list: Accéder aux brouillons collaboratifs
new_proposal: Nouvelle proposition
view_proposal: Voir la proposition
+ notifications:
+ proposal_published:
+ title: "Votre proposition %{proposal_title} a bien été publiée!"
+ body: "Votre proposition est en ligne. Lien ici: %{proposal_link}"
+ subject: "Votre proposition est publiée!"
update:
error: Il y a eu une erreur lors de la mise à jour de la proposition.
success: Proposition mise à jour avec succès.
diff --git a/spec/events/decidim/proposals/proposal_published_event_spec.rb b/spec/events/decidim/proposals/proposal_published_event_spec.rb
new file mode 100644
index 0000000000..43fd5ea9db
--- /dev/null
+++ b/spec/events/decidim/proposals/proposal_published_event_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+module Decidim
+ module Proposals
+ describe ProposalPublishedEvent do
+ let(:resource) { create :extended_proposal }
+ let(:participatory_process) { create :participatory_process, organization: organization }
+ let(:proposal_component) { create(:extended_proposal_component, participatory_space: participatory_process) }
+ let(:resource_title) { decidim_sanitize_translated(resource.title) }
+ let(:event_name) { "decidim.events.proposals.proposal_published" }
+
+ include_context "when a simple event"
+
+ it_behaves_like "a simple event"
+
+ describe "resource_text" do
+ it "returns the proposal body" do
+ expect(subject.resource_text).to eq(resource.body)
+ end
+ end
+
+ describe "email_subject" do
+ context "when resource title contains apostrophes" do
+ it "is generated correctly" do
+ expect(subject.email_subject).to eq("New proposal \"#{resource_title}\" by @#{author.nickname}")
+ end
+ end
+
+ it "is generated correctly" do
+ expect(subject.email_subject).to eq("New proposal \"#{resource_title}\" by @#{author.nickname}")
+ end
+ end
+
+ describe "email_intro" do
+ it "is generated correctly" do
+ expect(subject.email_intro)
+ .to eq("#{author.name} @#{author.nickname}, who you are following, has published a new proposal called \"#{resource_title}\". Check it out and contribute:")
+ end
+ end
+
+ describe "email_outro" do
+ it "is generated correctly" do
+ expect(subject.email_outro)
+ .to eq("You have received this notification because you are following @#{author.nickname}. You can stop receiving notifications following the previous link.")
+ end
+ end
+
+ describe "notification_title" do
+ it "is generated correctly" do
+ expect(subject.notification_title)
+ .to include("The #{resource_title} proposal was published by ")
+
+ expect(subject.notification_title)
+ .to include("#{author.name} @#{author.nickname}.")
+ end
+ end
+
+ describe "translated notifications" do
+ let(:en_body) { "A nice proposal" }
+ let(:body) { { en: en_body, machine_translations: { ca: "Une belle idee" } } }
+ let(:resource) do
+ create :extended_proposal,
+ component: proposal_component,
+ title: { en: "A nice proposal", machine_translations: { ca: "Une belle idee" } },
+ body: body
+ end
+
+ let(:en_version) { subject.resource_text["en"] }
+ let(:machine_translated) { subject.resource_text["machine_translations"]["ca"] }
+ let(:translatable) { true }
+
+ it_behaves_like "a translated event"
+ end
+ end
+ end
+end
\ No newline at end of file
diff --git a/spec/shared/simple_event.rb b/spec/shared/simple_event.rb
new file mode 100644
index 0000000000..d65d354503
--- /dev/null
+++ b/spec/shared/simple_event.rb
@@ -0,0 +1,202 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+shared_context "when a simple event" do
+ include Decidim::SanitizeHelper
+ subject { event_instance }
+
+ let(:event_instance) do
+ described_class.new(
+ resource: resource,
+ event_name: event_name,
+ user: user,
+ user_role: user_role,
+ extra: extra
+ )
+ end
+
+ let(:organization) do
+ if resource.respond_to?(:organization)
+ resource.organization
+ else
+ create :organization
+ end
+ end
+ let(:user) { create :user, organization: organization }
+ let(:user_role) { :follower }
+ let(:extra) { {} }
+ let(:resource_path) { resource_locator(resource).path }
+ let(:resource_url) { resource_locator(resource).url }
+ let(:resource_title) { decidim_sanitize_translated(resource.title) }
+ # to be used when resource is a component resource, not a participatory space, in which case should be overriden
+ let(:participatory_space) { resource.participatory_space }
+ let(:participatory_space_title) { decidim_sanitize_translated(participatory_space.title) }
+ let(:participatory_space_path) { Decidim::ResourceLocatorPresenter.new(participatory_space).path }
+ let(:participatory_space_url) { Decidim::ResourceLocatorPresenter.new(participatory_space).url }
+ let(:author) do
+ if resource.respond_to?(:creator_author)
+ resource.creator_author
+ else
+ resource.author
+ end
+ end
+ let(:author_presenter) { Decidim::UserPresenter.new(author) }
+ let(:author_name) { decidim_html_escape author.name }
+ let(:author_path) { author_presenter&.profile_path.to_s }
+ let(:author_nickname) { author_presenter&.nickname.to_s }
+ let(:i18n_scope) { event_name }
+end
+
+shared_examples_for "a simple event" do |skip_space_checks|
+ describe "types" do
+ subject { described_class }
+
+ it "supports notifications" do
+ expect(subject.types).to include :notification
+ end
+
+ it "supports emails" do
+ expect(subject.types).to include :email
+ end
+ end
+
+ describe "email_subject" do
+ it "is generated correctly" do
+ expect(subject.email_subject).to be_kind_of(String)
+ expect(subject.email_subject).not_to include("translation missing")
+ expect(subject.email_subject).not_to include("script")
+ end
+ end
+
+ describe "email_intro" do
+ it "is generated correctly" do
+ expect(subject.email_intro).to be_kind_of(String)
+ expect(subject.email_intro).not_to include("translation missing")
+ end
+ end
+
+ describe "email_outro" do
+ it "is generated correctly" do
+ expect(subject.email_outro).to be_kind_of(String)
+ expect(subject.email_outro).not_to include("translation missing")
+ end
+ end
+
+ describe "email_greeting" do
+ it "is generated correctly" do
+ expect(subject.email_greeting).to be_kind_of(String)
+ expect(subject.email_greeting).not_to include("translation missing")
+ end
+ end
+
+ describe "safe_resource_text" do
+ it "is generated correctly" do
+ expect(subject.safe_resource_text).to be_kind_of(String)
+ expect(subject.safe_resource_text).to be_html_safe
+ end
+ end
+
+ describe "notification_title" do
+ it "is generated correctly" do
+ expect(subject.notification_title).to be_kind_of(String)
+ expect(subject.notification_title).not_to include("translation missing")
+ expect(subject.notification_title).not_to include("script")
+ end
+ end
+
+ describe "resource_path" do
+ it "is generated correctly" do
+ expect(subject.resource_path).to be_kind_of(String)
+ end
+ end
+
+ describe "resource_url" do
+ it "is generated correctly" do
+ expect(subject.resource_url).to be_kind_of(String)
+ expect(subject.resource_url).to start_with("http")
+ end
+ end
+
+ describe "resource_title" do
+ it "responds to the method" do
+ expect(subject).to respond_to(:resource_title)
+ end
+ end
+
+ unless skip_space_checks
+ describe "participatory_space_url" do
+ it "is generated correctly" do
+ expect(subject.participatory_space_url).to be_kind_of(String)
+ expect(subject.participatory_space_url).to start_with("http")
+ end
+ end
+
+ describe "participatory_space_title" do
+ it "is generated correctly" do
+ expect(translated(participatory_space.title)).to include("script")
+ end
+ end
+ end
+
+ describe "i18n_options" do
+ subject { super().i18n_options }
+
+ it { is_expected.to include(resource_path: satisfy(&:present?)) }
+ it { is_expected.to include(resource_title: satisfy(&:present?)) }
+ it { is_expected.to include(resource_url: start_with("http")) }
+
+ it "includes the i18n scope" do
+ if event_instance.event_has_roles?
+ expect(subject).to include(scope: "#{i18n_scope}.#{user_role}")
+ else
+ expect(subject).to include(scope: i18n_scope)
+ end
+ end
+
+ unless skip_space_checks
+ it { is_expected.to include(participatory_space_title: satisfy(&:present?)) }
+ it { is_expected.to include(participatory_space_url: start_with("http")) }
+ end
+ end
+end
+
+shared_examples_for "a simple event email" do
+ describe "email_subject" do
+ it "is generated correctly" do
+ expect(subject.email_subject).to eq(email_subject)
+ end
+
+ # it "is html safe" do
+ # # pending "Enable after #12547 is merged"
+ # expect(subject.email_subject).not_to include("script")
+ # end
+ end
+
+ describe "email_intro" do
+ it "is generated correctly" do
+ expect(subject.email_intro).to eq(email_intro)
+ end
+ end
+
+ describe "email_outro" do
+ it "is generated correctly" do
+ expect(subject.email_outro).to eq(email_outro)
+ end
+ end
+end
+
+shared_examples_for "a simple event notification" do
+ describe "notification_title" do
+ it "is generated correctly" do
+ expect(subject.notification_title)
+ .to eq(notification_title)
+ end
+ #
+ # it "is html safe" do
+ # pp subject.notification_title
+ # pending "Enable after #12547 is merged"
+ # expect(subject.notification_title).not_to include("script")
+ # end
+ end
+end
\ No newline at end of file
diff --git a/spec/shared/translated_event_examples.rb b/spec/shared/translated_event_examples.rb
new file mode 100644
index 0000000000..47a9a6b6a7
--- /dev/null
+++ b/spec/shared/translated_event_examples.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+shared_examples_for "a translated event" do
+ context "when it is not machine machine translated" do
+ let(:organization) { create(:organization, enable_machine_translations: false, machine_translation_display_priority: "original") }
+
+ it "does not perform translation" do
+ expect(subject.perform_translation?).to eq(false)
+ end
+
+ it "does not have a missing translation" do
+ expect(subject.translation_missing?).to eq(false)
+ end
+
+ it "does have content available in multiple languages" do
+ expect(subject.content_in_same_language?).to eq(false)
+ end
+
+ it "does return the original language" do
+ expect(subject.safe_resource_text).to eq(en_version)
+ end
+
+ it "does not offer an alternate translation" do
+ expect(subject.safe_resource_translated_text).to eq(en_version)
+ end
+ end
+
+ context "when is machine machine translated" do
+ let(:user) { create :user, organization: organization, locale: "ca" }
+
+ around do |example|
+ I18n.with_locale(user.locale) { example.run }
+ end
+
+ context "when priority is original" do
+ let(:organization) { create(:organization, enable_machine_translations: true, machine_translation_display_priority: "original") }
+
+ it "does perform translation" do
+ expect(subject.perform_translation?).to eq(translatable)
+ end
+
+ it "does not have a missing translation" do
+ expect(subject.translation_missing?).to eq(false)
+ end
+
+ it "does have content available in multiple languages" do
+ expect(subject.content_in_same_language?).to eq(false)
+ end
+
+ it "does return the original language" do
+ expect(subject.safe_resource_text).to eq(en_version)
+ end
+
+ it "does not offer an alternate translation" do
+ expect(subject.safe_resource_translated_text).to eq(machine_translated)
+ end
+
+ context "when translation is not available" do
+ let(:body) { { en: en_body } }
+
+ it "does perform translation" do
+ expect(subject.perform_translation?).to eq(translatable)
+ end
+
+ it "does have a missing translation" do
+ expect(subject.translation_missing?).to eq(translatable)
+ end
+
+ it "does have content available in multiple languages" do
+ expect(subject.content_in_same_language?).to eq(false)
+ end
+
+ it "does return the original language" do
+ expect(subject.safe_resource_text).to eq(en_version)
+ end
+
+ it "does not offer an alternate translation" do
+ expect(subject.safe_resource_translated_text).to eq(en_version)
+ end
+ end
+ end
+
+ context "when priority is translation" do
+ let(:organization) { create(:organization, enable_machine_translations: true, machine_translation_display_priority: "translation") }
+
+ it "does perform translation" do
+ expect(subject.perform_translation?).to eq(translatable)
+ end
+
+ it "does not have a missing translation" do
+ expect(subject.translation_missing?).to eq(false)
+ end
+
+ it "does have content available in multiple languages" do
+ expect(subject.content_in_same_language?).to eq(false)
+ end
+
+ it "does return the original language" do
+ expect(subject.safe_resource_text).to eq(en_version)
+ end
+
+ it "does not offer an alternate translation" do
+ expect(subject.safe_resource_translated_text).to eq(machine_translated)
+ end
+
+ context "when translation is not available" do
+ let(:body) { { en: en_body } }
+
+ it "does perform translation" do
+ expect(subject.perform_translation?).to eq(translatable)
+ end
+
+ it "does have a missing translation" do
+ expect(subject.translation_missing?).to eq(translatable)
+ end
+
+ it "does have content available in multiple languages" do
+ expect(subject.content_in_same_language?).to eq(false)
+ end
+
+ it "does return the original language" do
+ expect(subject.safe_resource_text).to eq(en_version)
+ end
+
+ it "does not offer an alternate translation" do
+ expect(subject.safe_resource_translated_text).to eq(en_version)
+ end
+ end
+ end
+ end
+end