Commit 3489dc3d authored by Brett Walker's avatar Brett Walker Committed by Douglas Barbosa Alexandre

Allow disabling group/project email notifications

- Adds UI to configure in group and project settings
- Removes notification configuration for users when
disabled at group or project level
parent 23754943
...@@ -176,6 +176,7 @@ class GroupsController < Groups::ApplicationController ...@@ -176,6 +176,7 @@ class GroupsController < Groups::ApplicationController
[ [
:avatar, :avatar,
:description, :description,
:emails_disabled,
:lfs_enabled, :lfs_enabled,
:name, :name,
:path, :path,
......
...@@ -361,6 +361,7 @@ class ProjectsController < Projects::ApplicationController ...@@ -361,6 +361,7 @@ class ProjectsController < Projects::ApplicationController
:container_registry_enabled, :container_registry_enabled,
:default_branch, :default_branch,
:description, :description,
:emails_disabled,
:external_authorization_classification_label, :external_authorization_classification_label,
:import_url, :import_url,
:issues_tracker, :issues_tracker,
......
...@@ -172,6 +172,13 @@ class Namespace < ApplicationRecord ...@@ -172,6 +172,13 @@ class Namespace < ApplicationRecord
end end
end end
# any ancestor can disable emails for all descendants
def emails_disabled?
strong_memoize(:emails_disabled) do
Feature.enabled?(:emails_disabled, self, default_enabled: true) && self_and_ancestors.where(emails_disabled: true).exists?
end
end
def lfs_enabled? def lfs_enabled?
# User namespace will always default to the global setting # User namespace will always default to the global setting
Gitlab.config.lfs.enabled Gitlab.config.lfs.enabled
......
...@@ -4,6 +4,7 @@ class NotificationRecipient ...@@ -4,6 +4,7 @@ class NotificationRecipient
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
attr_reader :user, :type, :reason attr_reader :user, :type, :reason
def initialize(user, type, **opts) def initialize(user, type, **opts)
unless NotificationSetting.levels.key?(type) || type == :subscription unless NotificationSetting.levels.key?(type) || type == :subscription
raise ArgumentError, "invalid type: #{type.inspect}" raise ArgumentError, "invalid type: #{type.inspect}"
...@@ -30,6 +31,7 @@ class NotificationRecipient ...@@ -30,6 +31,7 @@ class NotificationRecipient
def notifiable? def notifiable?
return false unless has_access? return false unless has_access?
return false if emails_disabled?
return false if own_activity? return false if own_activity?
# even users with :disabled notifications receive manual subscriptions # even users with :disabled notifications receive manual subscriptions
...@@ -109,6 +111,12 @@ class NotificationRecipient ...@@ -109,6 +111,12 @@ class NotificationRecipient
private private
# They are disabled if the project or group has disallowed it.
# No need to check the group if there is already a project
def emails_disabled?
@project ? @project.emails_disabled? : @group&.emails_disabled?
end
def read_ability def read_ability
return if @skip_read_ability return if @skip_read_ability
return @read_ability if instance_variable_defined?(:@read_ability) return @read_ability if instance_variable_defined?(:@read_ability)
......
...@@ -631,6 +631,13 @@ class Project < ApplicationRecord ...@@ -631,6 +631,13 @@ class Project < ApplicationRecord
alias_method :ancestors, :ancestors_upto alias_method :ancestors, :ancestors_upto
def emails_disabled?
strong_memoize(:emails_disabled) do
# disabling in the namespace overrides the project setting
Feature.enabled?(:emails_disabled, self, default_enabled: true) && (super || namespace.emails_disabled?)
end
end
def lfs_enabled? def lfs_enabled?
return namespace.lfs_enabled? if self[:lfs_enabled].nil? return namespace.lfs_enabled? if self[:lfs_enabled].nil?
......
...@@ -24,6 +24,7 @@ class EmailsOnPushService < Service ...@@ -24,6 +24,7 @@ class EmailsOnPushService < Service
def execute(push_data) def execute(push_data)
return unless supported_events.include?(push_data[:object_kind]) return unless supported_events.include?(push_data[:object_kind])
return if project.emails_disabled?
EmailsOnPushWorker.perform_async( EmailsOnPushWorker.perform_async(
project_id, project_id,
......
...@@ -92,6 +92,7 @@ class GroupPolicy < BasePolicy ...@@ -92,6 +92,7 @@ class GroupPolicy < BasePolicy
enable :change_visibility_level enable :change_visibility_level
enable :set_note_created_at enable :set_note_created_at
enable :set_emails_disabled
end end
rule { can?(:read_nested_project_resources) }.policy do rule { can?(:read_nested_project_resources) }.policy do
......
...@@ -162,6 +162,7 @@ class ProjectPolicy < BasePolicy ...@@ -162,6 +162,7 @@ class ProjectPolicy < BasePolicy
enable :set_issue_created_at enable :set_issue_created_at
enable :set_issue_updated_at enable :set_issue_updated_at
enable :set_note_created_at enable :set_note_created_at
enable :set_emails_disabled
end end
rule { can?(:guest_access) }.policy do rule { can?(:guest_access) }.policy do
......
...@@ -46,6 +46,11 @@ module Groups ...@@ -46,6 +46,11 @@ module Groups
params.delete(:parent_id) params.delete(:parent_id)
end end
# overridden in EE
def remove_unallowed_params
params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, group)
end
def valid_share_with_group_lock_change? def valid_share_with_group_lock_change?
return true unless changing_share_with_group_lock? return true unless changing_share_with_group_lock?
return true if can?(current_user, :change_share_with_group_lock, group) return true if can?(current_user, :change_share_with_group_lock, group)
......
...@@ -321,6 +321,9 @@ class NotificationService ...@@ -321,6 +321,9 @@ class NotificationService
end end
def decline_project_invite(project_member) def decline_project_invite(project_member)
# Must always send, regardless of project/namespace configuration since it's a
# response to the user's action.
mailer.member_invite_declined_email( mailer.member_invite_declined_email(
project_member.real_source_type, project_member.real_source_type,
project_member.project.id, project_member.project.id,
...@@ -351,8 +354,8 @@ class NotificationService ...@@ -351,8 +354,8 @@ class NotificationService
end end
def decline_group_invite(group_member) def decline_group_invite(group_member)
# always send this one, since it's a response to the user's own # Must always send, regardless of project/namespace configuration since it's a
# action # response to the user's action.
mailer.member_invite_declined_email( mailer.member_invite_declined_email(
group_member.real_source_type, group_member.real_source_type,
...@@ -410,6 +413,10 @@ class NotificationService ...@@ -410,6 +413,10 @@ class NotificationService
end end
def pipeline_finished(pipeline, recipients = nil) def pipeline_finished(pipeline, recipients = nil)
# Must always check project configuration since recipients could be a list of emails
# from the PipelinesEmailService integration.
return if pipeline.project.emails_disabled?
email_template = "pipeline_#{pipeline.status}_email" email_template = "pipeline_#{pipeline.status}_email"
return unless mailer.respond_to?(email_template) return unless mailer.respond_to?(email_template)
...@@ -428,6 +435,8 @@ class NotificationService ...@@ -428,6 +435,8 @@ class NotificationService
end end
def autodevops_disabled(pipeline, recipients) def autodevops_disabled(pipeline, recipients)
return if pipeline.project.emails_disabled?
recipients.each do |recipient| recipients.each do |recipient|
mailer.autodevops_disabled_email(pipeline, recipient).deliver_later mailer.autodevops_disabled_email(pipeline, recipient).deliver_later
end end
...@@ -472,10 +481,14 @@ class NotificationService ...@@ -472,10 +481,14 @@ class NotificationService
end end
def repository_cleanup_success(project, user) def repository_cleanup_success(project, user)
return if project.emails_disabled?
mailer.send(:repository_cleanup_success_email, project, user).deliver_later mailer.send(:repository_cleanup_success_email, project, user).deliver_later
end end
def repository_cleanup_failure(project, user, error) def repository_cleanup_failure(project, user, error)
return if project.emails_disabled?
mailer.send(:repository_cleanup_failure_email, project, user, error).deliver_later mailer.send(:repository_cleanup_failure_email, project, user, error).deliver_later
end end
......
...@@ -9,6 +9,7 @@ module Projects ...@@ -9,6 +9,7 @@ module Projects
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def execute def execute
remove_unallowed_params
validate! validate!
ensure_wiki_exists if enabling_wiki? ensure_wiki_exists if enabling_wiki?
...@@ -54,6 +55,10 @@ module Projects ...@@ -54,6 +55,10 @@ module Projects
end end
end end
def remove_unallowed_params
params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, project)
end
def after_update def after_update
todos_features_changes = %w( todos_features_changes = %w(
issues_access_level issues_access_level
......
---
title: Allow email notifications to be disabled for all members of a group or project
merge_request: 30755
author: Dustin Spicuzza
type: added
# frozen_string_literal: true
class AddProjectEmailsDisabled < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
add_column :projects, :emails_disabled, :boolean
end
end
# frozen_string_literal: true
class AddGroupEmailsDisabled < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
add_column :namespaces, :emails_disabled, :boolean
end
end
...@@ -2175,6 +2175,7 @@ ActiveRecord::Schema.define(version: 2019_08_12_070645) do ...@@ -2175,6 +2175,7 @@ ActiveRecord::Schema.define(version: 2019_08_12_070645) do
t.boolean "membership_lock", default: false t.boolean "membership_lock", default: false
t.integer "last_ci_minutes_usage_notification_level" t.integer "last_ci_minutes_usage_notification_level"
t.integer "subgroup_creation_level", default: 1 t.integer "subgroup_creation_level", default: 1
t.boolean "emails_disabled"
t.index ["created_at"], name: "index_namespaces_on_created_at" t.index ["created_at"], name: "index_namespaces_on_created_at"
t.index ["custom_project_templates_group_id", "type"], name: "index_namespaces_on_custom_project_templates_group_id_and_type", where: "(custom_project_templates_group_id IS NOT NULL)" t.index ["custom_project_templates_group_id", "type"], name: "index_namespaces_on_custom_project_templates_group_id_and_type", where: "(custom_project_templates_group_id IS NOT NULL)"
t.index ["file_template_project_id"], name: "index_namespaces_on_file_template_project_id" t.index ["file_template_project_id"], name: "index_namespaces_on_file_template_project_id"
...@@ -2745,6 +2746,7 @@ ActiveRecord::Schema.define(version: 2019_08_12_070645) do ...@@ -2745,6 +2746,7 @@ ActiveRecord::Schema.define(version: 2019_08_12_070645) do
t.boolean "reset_approvals_on_push", default: true t.boolean "reset_approvals_on_push", default: true
t.boolean "service_desk_enabled", default: true t.boolean "service_desk_enabled", default: true
t.integer "approvals_before_merge", default: 0, null: false t.integer "approvals_before_merge", default: 0, null: false
t.boolean "emails_disabled"
t.index ["archived", "pending_delete", "merge_requests_require_code_owner_approval"], name: "projects_requiring_code_owner_approval", where: "((pending_delete = false) AND (archived = false) AND (merge_requests_require_code_owner_approval = true))" t.index ["archived", "pending_delete", "merge_requests_require_code_owner_approval"], name: "projects_requiring_code_owner_approval", where: "((pending_delete = false) AND (archived = false) AND (merge_requests_require_code_owner_approval = true))"
t.index ["created_at"], name: "index_projects_on_created_at" t.index ["created_at"], name: "index_projects_on_created_at"
t.index ["creator_id"], name: "index_projects_on_creator_id" t.index ["creator_id"], name: "index_projects_on_creator_id"
......
...@@ -137,6 +137,7 @@ excluded_attributes: ...@@ -137,6 +137,7 @@ excluded_attributes:
- :packages_enabled - :packages_enabled
- :mirror_last_update_at - :mirror_last_update_at
- :mirror_last_successful_update_at - :mirror_last_successful_update_at
- :emails_disabled
namespaces: namespaces:
- :runners_token - :runners_token
- :runners_token_encrypted - :runners_token_encrypted
......
...@@ -16,6 +16,19 @@ FactoryBot.define do ...@@ -16,6 +16,19 @@ FactoryBot.define do
) )
end end
factory :emails_on_push_service do
project
type 'EmailsOnPushService'
active true
push_events true
tag_push_events true
properties(
recipients: 'test@example.com',
disable_diffs: true,
send_from_committer_email: true
)
end
factory :mock_deployment_service do factory :mock_deployment_service do
project project
type 'MockDeploymentService' type 'MockDeploymentService'
......
...@@ -853,4 +853,64 @@ describe Namespace do ...@@ -853,4 +853,64 @@ describe Namespace do
it { is_expected.to be_falsy } it { is_expected.to be_falsy }
end end
end end
describe '#emails_disabled?' do
context 'when not a subgroup' do
it 'returns false' do
group = create(:group, emails_disabled: false)
expect(group.emails_disabled?).to be_falsey
end
it 'returns true' do
group = create(:group, emails_disabled: true)
expect(group.emails_disabled?).to be_truthy
end
end
context 'when a subgroup' do
let(:grandparent) { create(:group) }
let(:parent) { create(:group, parent: grandparent) }
let(:group) { create(:group, parent: parent) }
it 'returns false' do
expect(group.emails_disabled?).to be_falsey
end
context 'when ancestor emails are disabled' do
it 'returns true' do
grandparent.update_attribute(:emails_disabled, true)
expect(group.emails_disabled?).to be_truthy
end
end
end
context 'when :emails_disabled feature flag is off' do
before do
stub_feature_flags(emails_disabled: false)
end
context 'when not a subgroup' do
it 'returns false' do
group = create(:group, emails_disabled: true)
expect(group.emails_disabled?).to be_falsey
end
end
context 'when a subgroup and ancestor emails are disabled' do
let(:grandparent) { create(:group) }
let(:parent) { create(:group, parent: grandparent) }
let(:group) { create(:group, parent: parent) }
it 'returns false' do
grandparent.update_attribute(:emails_disabled, true)
expect(group.emails_disabled?).to be_falsey
end
end
end
end
end end
...@@ -9,6 +9,38 @@ describe NotificationRecipient do ...@@ -9,6 +9,38 @@ describe NotificationRecipient do
subject(:recipient) { described_class.new(user, :watch, target: target, project: project) } subject(:recipient) { described_class.new(user, :watch, target: target, project: project) }
describe '#notifiable?' do
let(:recipient) { described_class.new(user, :mention, target: target, project: project) }
context 'when emails are disabled' do
it 'returns false if group disabled' do
expect(project.namespace).to receive(:emails_disabled?).and_return(true)
expect(recipient).to receive(:emails_disabled?).and_call_original
expect(recipient.notifiable?).to eq false
end
it 'returns false if project disabled' do
expect(project).to receive(:emails_disabled?).and_return(true)
expect(recipient).to receive(:emails_disabled?).and_call_original
expect(recipient.notifiable?).to eq false
end
end
context 'when emails are enabled' do
it 'returns true if group enabled' do
expect(project.namespace).to receive(:emails_disabled?).and_return(false)
expect(recipient).to receive(:emails_disabled?).and_call_original
expect(recipient.notifiable?).to eq true
end
it 'returns true if project enabled' do
expect(project).to receive(:emails_disabled?).and_return(false)
expect(recipient).to receive(:emails_disabled?).and_call_original
expect(recipient.notifiable?).to eq true
end
end
end
describe '#has_access?' do describe '#has_access?' do
before do before do
allow(user).to receive(:can?).and_call_original allow(user).to receive(:can?).and_call_original
......
...@@ -20,4 +20,24 @@ describe EmailsOnPushService do ...@@ -20,4 +20,24 @@ describe EmailsOnPushService do
it { is_expected.not_to validate_presence_of(:recipients) } it { is_expected.not_to validate_presence_of(:recipients) }
end end
end end
context 'project emails' do
let(:push_data) { { object_kind: 'push' } }
let(:project) { create(:project, :repository) }
let(:service) { create(:emails_on_push_service, project: project) }
it 'does not send emails when disabled' do
expect(project).to receive(:emails_disabled?).and_return(true)
expect(EmailsOnPushWorker).not_to receive(:perform_async)
service.execute(push_data)
end
it 'does send emails when enabled' do
expect(project).to receive(:emails_disabled?).and_return(false)
expect(EmailsOnPushWorker).to receive(:perform_async)
service.execute(push_data)
end
end
end end
...@@ -2315,6 +2315,57 @@ describe Project do ...@@ -2315,6 +2315,57 @@ describe Project do
end end
end end
describe '#emails_disabled?' do
let(:project) { create(:project, emails_disabled: false) }
context 'emails disabled in group' do
it 'returns true' do
allow(project.namespace).to receive(:emails_disabled?) { true }
expect(project.emails_disabled?).to be_truthy
end
end
context 'emails enabled in group' do
before do
allow(project.namespace).to receive(:emails_disabled?) { false }
end
it 'returns false' do
expect(project.emails_disabled?).to be_falsey
end
it 'returns true' do
project.update_attribute(:emails_disabled, true)
expect(project.emails_disabled?).to be_truthy
end
end
context 'when :emails_disabled feature flag is off' do
before do
stub_feature_flags(emails_disabled: false)
end
context 'emails disabled in group' do
it 'returns false' do
allow(project.namespace).to receive(:emails_disabled?) { true }
expect(project.emails_disabled?).to be_falsey
end
end
context 'emails enabled in group' do
it 'returns false' do
allow(project.namespace).to receive(:emails_disabled?) { false }
project.update_attribute(:emails_disabled, true)
expect(project.emails_disabled?).to be_falsey
end
end
end
end
describe '#lfs_enabled?' do describe '#lfs_enabled?' do
let(:project) { create(:project) } let(:project) { create(:project) }
......
...@@ -86,6 +86,7 @@ describe Groups::UpdateService do ...@@ -86,6 +86,7 @@ describe Groups::UpdateService do
context "unauthorized visibility_level validation" do context "unauthorized visibility_level validation" do
let!(:service) { described_class.new(internal_group, user, visibility_level: 99) } let!(:service) { described_class.new(internal_group, user, visibility_level: 99) }
before do before do
internal_group.add_user(user, Gitlab::Access::MAINTAINER) internal_group.add_user(user, Gitlab::Access::MAINTAINER)
end end
...@@ -96,6 +97,20 @@ describe Groups::UpdateService do ...@@ -96,6 +97,20 @@ describe Groups::UpdateService do
end end
end end
context 'when updating #emails_disabled' do
let(:service) { described_class.new(internal_group, user, emails_disabled: true) }
it 'updates the attribute' do
internal_group.add_user(user, Gitlab::Access::OWNER)
expect { service.execute }.to change { internal_group.emails_disabled }.to(true)
end
it 'does not update when not group owner' do
expect { service.execute }.not_to change { internal_group.emails_disabled }
end
end
context 'rename group' do context 'rename group' do
let!(:service) { described_class.new(internal_group, user, path: SecureRandom.hex) } let!(:service) { described_class.new(internal_group, user, path: SecureRandom.hex) }
......
This diff is collapsed.
...@@ -369,9 +369,28 @@ describe Projects::UpdateService do ...@@ -369,9 +369,28 @@ describe Projects::UpdateService do
end end
end end
context 'when updating #emails_disabled' do
it 'updates the attribute for the project owner' do
expect { update_project(project, user, emails_disabled: true) }
.to change { project.emails_disabled }
.to(true)
end
it 'does not update when not project owner' do
maintainer = create(:user)
project.add_user(maintainer, :maintainer)
expect { update_project(project, maintainer, emails_disabled: true) }
.not_to change { project.emails_disabled }
end
end
context 'with external authorization enabled' do context 'with external authorization enabled' do
before do before do
enable_external_authorization_service_check enable_external_authorization_service_check
allow(::Gitlab::ExternalAuthorization)
.to receive(:access_allowed?).with(user, 'default_label', project.full_path).and_call_original
end end
it 'does not save the project with an error if the service denies access' do it 'does not save the project with an error if the service denies access' do
...@@ -402,8 +421,7 @@ describe Projects::UpdateService do ...@@ -402,8 +421,7 @@ describe Projects::UpdateService do
end end
it 'does not check the label when it does not change' do it 'does not check the label when it does not change' do
expect(::Gitlab::ExternalAuthorization) expect(::Gitlab::ExternalAuthorization).to receive(:access_allowed?).once
.not_to receive(:access_allowed?)
update_project(project, user, { name: 'New name' }) update_project(project, user, { name: 'New name' })
end end
......
...@@ -31,6 +31,10 @@ module EmailHelpers ...@@ -31,6 +31,10 @@ module EmailHelpers
expect(ActionMailer::Base.deliveries).to be_empty expect(ActionMailer::Base.deliveries).to be_empty
end end
def should_email_anyone
expect(ActionMailer::Base.deliveries).not_to be_empty
end
def email_recipients(kind: :to) def email_recipients(kind: :to)
ActionMailer::Base.deliveries.flat_map(&kind) ActionMailer::Base.deliveries.flat_map(&kind)
end end
......
# frozen_string_literal: true
# Note that we actually update the attribute on the target_project/group, rather than
# using `allow`. This is because there are some specs where, based on how the notification
# is done, using an `allow` doesn't change the correct object.
shared_examples 'project emails are disabled' do
let(:target_project) { notification_target.is_a?(Project) ? notification_target : notification_target.project }
before do
reset_delivered_emails!
target_project.clear_memoization(:emails_disabled)
end
it 'sends no emails with project emails disabled' do
target_project.update_attribute(:emails_disabled, true)
notification_trigger
should_not_email_anyone
end
it 'sends emails to someone' do
target_project.update_attribute(:emails_disabled, false)
notification_trigger
should_email_anyone
end
end
shared_examples 'group emails are disabled' do
let(:target_group) { notification_target.is_a?(Group) ? notification_target : notification_target.project.group }
before do
reset_delivered_emails!
target_group.clear_memoization(:emails_disabled)
end
it 'sends no emails with group emails disabled' do
target_group.update_attribute(:emails_disabled, true)
notification_trigger
should_not_email_anyone
end
it 'sends emails to someone' do
target_group.update_attribute(:emails_disabled, false)
notification_trigger
should_email_anyone
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment