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