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
[
:avatar,
:description,
:emails_disabled,
:lfs_enabled,
:name,
:path,
......
......@@ -361,6 +361,7 @@ class ProjectsController < Projects::ApplicationController
:container_registry_enabled,
:default_branch,
:description,
:emails_disabled,
:external_authorization_classification_label,
:import_url,
:issues_tracker,
......
......@@ -172,6 +172,13 @@ class Namespace < ApplicationRecord
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?
# User namespace will always default to the global setting
Gitlab.config.lfs.enabled
......
......@@ -4,6 +4,7 @@ class NotificationRecipient
include Gitlab::Utils::StrongMemoize
attr_reader :user, :type, :reason
def initialize(user, type, **opts)
unless NotificationSetting.levels.key?(type) || type == :subscription
raise ArgumentError, "invalid type: #{type.inspect}"
......@@ -30,6 +31,7 @@ class NotificationRecipient
def notifiable?
return false unless has_access?
return false if emails_disabled?
return false if own_activity?
# even users with :disabled notifications receive manual subscriptions
......@@ -109,6 +111,12 @@ class NotificationRecipient
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
return if @skip_read_ability
return @read_ability if instance_variable_defined?(:@read_ability)
......
......@@ -631,6 +631,13 @@ class Project < ApplicationRecord
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?
return namespace.lfs_enabled? if self[:lfs_enabled].nil?
......
......@@ -24,6 +24,7 @@ class EmailsOnPushService < Service
def execute(push_data)
return unless supported_events.include?(push_data[:object_kind])
return if project.emails_disabled?
EmailsOnPushWorker.perform_async(
project_id,
......
......@@ -92,6 +92,7 @@ class GroupPolicy < BasePolicy
enable :change_visibility_level
enable :set_note_created_at
enable :set_emails_disabled
end
rule { can?(:read_nested_project_resources) }.policy do
......
......@@ -162,6 +162,7 @@ class ProjectPolicy < BasePolicy
enable :set_issue_created_at
enable :set_issue_updated_at
enable :set_note_created_at
enable :set_emails_disabled
end
rule { can?(:guest_access) }.policy do
......
......@@ -46,6 +46,11 @@ module Groups
params.delete(:parent_id)
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?
return true unless changing_share_with_group_lock?
return true if can?(current_user, :change_share_with_group_lock, group)
......
......@@ -321,6 +321,9 @@ class NotificationService
end
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(
project_member.real_source_type,
project_member.project.id,
......@@ -351,8 +354,8 @@ class NotificationService
end
def decline_group_invite(group_member)
# always send this one, since it's a response to the user's own
# action
# Must always send, regardless of project/namespace configuration since it's a
# response to the user's action.
mailer.member_invite_declined_email(
group_member.real_source_type,
......@@ -410,6 +413,10 @@ class NotificationService
end
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"
return unless mailer.respond_to?(email_template)
......@@ -428,6 +435,8 @@ class NotificationService
end
def autodevops_disabled(pipeline, recipients)
return if pipeline.project.emails_disabled?
recipients.each do |recipient|
mailer.autodevops_disabled_email(pipeline, recipient).deliver_later
end
......@@ -472,10 +481,14 @@ class NotificationService
end
def repository_cleanup_success(project, user)
return if project.emails_disabled?
mailer.send(:repository_cleanup_success_email, project, user).deliver_later
end
def repository_cleanup_failure(project, user, error)
return if project.emails_disabled?
mailer.send(:repository_cleanup_failure_email, project, user, error).deliver_later
end
......
......@@ -9,6 +9,7 @@ module Projects
# rubocop: disable CodeReuse/ActiveRecord
def execute
remove_unallowed_params
validate!
ensure_wiki_exists if enabling_wiki?
......@@ -54,6 +55,10 @@ module Projects
end
end
def remove_unallowed_params
params.delete(:emails_disabled) unless can?(current_user, :set_emails_disabled, project)
end
def after_update
todos_features_changes = %w(
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
t.boolean "membership_lock", default: false
t.integer "last_ci_minutes_usage_notification_level"
t.integer "subgroup_creation_level", default: 1
t.boolean "emails_disabled"
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 ["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
t.boolean "reset_approvals_on_push", default: true
t.boolean "service_desk_enabled", default: true
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 ["created_at"], name: "index_projects_on_created_at"
t.index ["creator_id"], name: "index_projects_on_creator_id"
......
......@@ -137,6 +137,7 @@ excluded_attributes:
- :packages_enabled
- :mirror_last_update_at
- :mirror_last_successful_update_at
- :emails_disabled
namespaces:
- :runners_token
- :runners_token_encrypted
......
......@@ -16,6 +16,19 @@ FactoryBot.define do
)
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
project
type 'MockDeploymentService'
......
......@@ -853,4 +853,64 @@ describe Namespace do
it { is_expected.to be_falsy }
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
......@@ -9,6 +9,38 @@ describe NotificationRecipient do
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
before do
allow(user).to receive(:can?).and_call_original
......
......@@ -20,4 +20,24 @@ describe EmailsOnPushService do
it { is_expected.not_to validate_presence_of(:recipients) }
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
......@@ -2315,6 +2315,57 @@ describe Project do
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
let(:project) { create(:project) }
......
......@@ -86,6 +86,7 @@ describe Groups::UpdateService do
context "unauthorized visibility_level validation" do
let!(:service) { described_class.new(internal_group, user, visibility_level: 99) }
before do
internal_group.add_user(user, Gitlab::Access::MAINTAINER)
end
......@@ -96,6 +97,20 @@ describe Groups::UpdateService do
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
let!(:service) { described_class.new(internal_group, user, path: SecureRandom.hex) }
......
This diff is collapsed.
......@@ -369,9 +369,28 @@ describe Projects::UpdateService do
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
before do
enable_external_authorization_service_check
allow(::Gitlab::ExternalAuthorization)
.to receive(:access_allowed?).with(user, 'default_label', project.full_path).and_call_original
end
it 'does not save the project with an error if the service denies access' do
......@@ -402,8 +421,7 @@ describe Projects::UpdateService do
end
it 'does not check the label when it does not change' do
expect(::Gitlab::ExternalAuthorization)
.not_to receive(:access_allowed?)
expect(::Gitlab::ExternalAuthorization).to receive(:access_allowed?).once
update_project(project, user, { name: 'New name' })
end
......
......@@ -31,6 +31,10 @@ module EmailHelpers
expect(ActionMailer::Base.deliveries).to be_empty
end
def should_email_anyone
expect(ActionMailer::Base.deliveries).not_to be_empty
end
def email_recipients(kind: :to)
ActionMailer::Base.deliveries.flat_map(&kind)
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