Feature/member notifications (#8958)

* spec with correctly scoped links

* move db check into own file - fix deprecation

* basic spec for member creation service

* use constants for all notifications

* send an OP notification after member has been created

* send an OP notification after member has been updated

* mails on group member added

Depending on whether the membership existed before or not, an updated or
a created notification is send. This is done asynchronously.

* move all mail sender background jobs into namespace

* wip

* wip

* correct handling group member notifications

* add setting enable/disable mail sending on member alterations

* use services in members controller

* move Notifiable to OpenProject

* remove member after save hooks

* cleanup/testing/linting

* render member mails in receiver locale

* remove add_member! method

* use mailer layout for all mailers

* Update app/services/groups/cleanup_inherited_roles_service.rb

Co-authored-by: Oliver Günther <mail@oliverguenther.de>

* use around callback to avoid prepending

* handle nil params

Co-authored-by: Oliver Günther <mail@oliverguenther.de>
pull/9172/head
ulferts 4 years ago committed by GitHub
parent 3f3e831f6a
commit 9fa5599392
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      .rubocop.yml
  2. 2
      app/cells/members/row_cell.rb
  3. 14
      app/contracts/admin_only_contract.rb
  4. 2
      app/controllers/admin/settings/mail_notifications_settings_controller.rb
  5. 103
      app/controllers/groups_controller.rb
  6. 109
      app/controllers/members_controller.rb
  7. 2
      app/helpers/settings_helper.rb
  8. 4
      app/mailers/base_mailer.rb
  9. 94
      app/mailers/member_mailer.rb
  10. 36
      app/models/group.rb
  11. 109
      app/models/group/destroy.rb
  12. 142
      app/models/member.rb
  13. 61
      app/models/member_role.rb
  14. 12
      app/models/project.rb
  15. 78
      app/models/watcher_notification_mailer.rb
  16. 97
      app/services/groups/add_users_service.rb
  17. 97
      app/services/groups/cleanup_inherited_roles_service.rb
  18. 84
      app/services/groups/concerns/membership_manipulation.rb
  19. 9
      app/services/groups/delete_service.rb
  20. 128
      app/services/groups/update_roles_service.rb
  21. 33
      app/services/groups/update_service.rb
  22. 70
      app/services/members/cleanup_service.rb
  23. 49
      app/services/members/concerns/cleaned_up.rb
  24. 17
      app/services/members/create_service.rb
  25. 28
      app/services/members/delete_service.rb
  26. 21
      app/services/members/set_attributes_service.rb
  27. 21
      app/services/members/update_service.rb
  28. 2
      app/services/notifications/journal_notification_service.rb
  29. 2
      app/services/notifications/journal_wp_mail_service.rb
  30. 36
      app/services/projects/copy/members_dependent_service.rb
  31. 0
      app/views/layouts/mailer.html.erb
  32. 0
      app/views/layouts/mailer.text.erb
  33. 77
      app/views/layouts/project_mailer.html.erb
  34. 35
      app/views/layouts/user_mailer.text.erb
  35. 10
      app/views/member_mailer/added_project.html.erb
  36. 6
      app/views/member_mailer/added_project.text.erb
  37. 10
      app/views/member_mailer/updated_global.html.erb
  38. 6
      app/views/member_mailer/updated_global.text.erb
  39. 10
      app/views/member_mailer/updated_project.html.erb
  40. 6
      app/views/member_mailer/updated_project.text.erb
  41. 29
      app/workers/mails/deliver_job.rb
  42. 2
      app/workers/mails/invitation_job.rb
  43. 2
      app/workers/mails/mailer_job.rb
  44. 50
      app/workers/mails/member_created_job.rb
  45. 90
      app/workers/mails/member_job.rb
  46. 37
      app/workers/mails/member_updated_job.rb
  47. 8
      app/workers/mails/watcher_added_job.rb
  48. 78
      app/workers/mails/watcher_job.rb
  49. 17
      app/workers/mails/watcher_removed_job.rb
  50. 2
      app/workers/mails/work_package_job.rb
  51. 31
      app/workers/notifications/group_member_altered_job.rb
  52. 2
      app/workers/notifications/journal_completed_job.rb
  53. 11
      app/workers/principals/delete_job.rb
  54. 23
      config/application.rb
  55. 32
      config/initializers/03-db_check.rb
  56. 26
      config/initializers/subscribe_listeners.rb
  57. 4
      config/initializers/user_invitation.rb
  58. 21
      config/locales/en.yml
  59. 2
      config/settings.yml
  60. 13
      db/migrate/20200625133727_fix_inherited_group_member_roles.rb
  61. 5
      docs/api/apiv3/endpoints/groups.apib
  62. 3
      lib/api/v3/groups/groups_api.rb
  63. 1
      lib/open_project.rb
  64. 13
      lib/open_project/events.rb
  65. 34
      lib/open_project/notifiable.rb
  66. 2
      lib/plugins/acts_as_journalized/lib/acts/journalized/save_hooks.rb
  67. 2
      lib/services/create_watcher.rb
  68. 2
      lib/services/remove_watcher.rb
  69. 7
      modules/budgets/spec/features/budgets/add_budget_spec.rb
  70. 10
      modules/budgets/spec/features/budgets/update_budget_spec.rb
  71. 2
      modules/costs/app/services/time_entries/create_service.rb
  72. 9
      modules/costs/spec/factories/cost_entry_factory.rb
  73. 3
      modules/costs/spec/features/members_hourly_rates_spec.rb
  74. 4
      modules/costs/spec/models/work_package_spec.rb
  75. 4
      modules/dashboards/spec/features/project_details_spec.rb
  76. 2
      modules/documents/lib/open_project/documents/engine.rb
  77. 4
      modules/github_integration/spec/lib/open_project/github_integration/hook_handler_integration_spec.rb
  78. 13
      modules/ldap_groups/app/models/ldap_groups/synchronized_group.rb
  79. 39
      modules/meeting/spec/models/meeting_spec.rb
  80. 6
      modules/reporting/spec/features/update_cost_report_spec.rb
  81. 2
      modules/webhooks/lib/open_project/webhooks/event_resources/time_entry.rb
  82. 12
      spec/controllers/groups_controller_spec.rb
  83. 9
      spec/factories/principal_factory.rb
  84. 9
      spec/factories/project_factory.rb
  85. 34
      spec/features/groups/group_memberships_spec.rb
  86. 19
      spec/features/groups/membership_spec.rb
  87. 7
      spec/features/members/invitation_spec.rb
  88. 9
      spec/features/members/membership_spec.rb
  89. 109
      spec/features/members/pagination_spec.rb
  90. 27
      spec/features/members/roles_spec.rb
  91. 30
      spec/features/principals/shared_memberships_examples.rb
  92. 7
      spec/features/work_packages/table/queries/filter_spec.rb
  93. 32
      spec/features/work_packages/table/queries/me_filter_spec.rb
  94. 16
      spec/lib/open_project/notifiable_spec.rb
  95. 157
      spec/mailers/member_mailer_spec.rb
  96. 17
      spec/models/group_performance_spec.rb
  97. 36
      spec/models/group_spec.rb
  98. 2
      spec/models/mail_handler_spec.rb
  99. 134
      spec/models/member_spec.rb
  100. 10
      spec/models/queries/work_packages/filter/assignee_or_group_filter_spec.rb
  101. Some files were not shown because too many files have changed in this diff Show More

@ -102,6 +102,13 @@ Naming/PredicateName:
- is_
# There are valid cases in which to use methods like:
# * update_all
# * touch_all
Rails/SkipsModelValidations:
Enabled: false
Style/Alias:
Enabled: false

@ -39,7 +39,7 @@ module Members
end
def roles
label = h member.roles.sort.collect(&:name).join(', ')
label = h member.roles.uniq.sort.collect(&:name).join(', ')
if principal&.admin?
label << tag(:br)

@ -26,15 +26,13 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
module Groups
class AddUsersContract < ::ModelContract
include RequiresAdminGuard
# A contract that only checks whether the current user is an admin
class AdminOnlyContract < ::ModelContract
include RequiresAdminGuard
protected
protected
# No need to validate the whole of the group when we only want to ensure that the user is an admin.
def validate_model?
false
end
def validate_model?
false
end
end

@ -36,7 +36,7 @@ module Admin::Settings
def show
@deliveries = ActionMailer::Base.perform_deliveries
@notifiables = Redmine::Notifiable.all
@notifiables = OpenProject::Notifiable.all
respond_to :html
end

@ -35,7 +35,7 @@ class GroupsController < ApplicationController
helper_method :gon
before_action :require_admin, except: %i[show]
before_action :find_group, only: %i[destroy show create_memberships destroy_membership
before_action :find_group, only: %i[destroy update show create_memberships destroy_membership
edit_membership add_users]
# GET /groups
@ -82,16 +82,20 @@ class GroupsController < ApplicationController
# POST /groups
# POST /groups.xml
def create
@group = Group.new permitted_params.group
service_call = Groups::CreateService
.new(user: current_user)
.call(permitted_params.group)
@group = service_call.result
respond_to do |format|
if @group.save
if service_call.success?
flash[:notice] = I18n.t(:notice_successful_create)
format.html { redirect_to(groups_path) }
format.xml { render xml: @group, status: :created, location: @group }
else
format.html { render action: :new }
format.xml { render xml: @group.errors, status: :unprocessable_entity }
format.xml { render xml: service_call.errors, status: :unprocessable_entity }
end
end
end
@ -99,16 +103,18 @@ class GroupsController < ApplicationController
# PUT /groups/1
# PUT /groups/1.xml
def update
@group = Group.includes(:users).find(params[:id])
service_call = Groups::UpdateService
.new(user: current_user, model: @group)
.call(permitted_params.group)
respond_to do |format|
if @group.update(permitted_params.group)
if service_call.success?
flash[:notice] = I18n.t(:notice_successful_update)
format.html { redirect_to(groups_path) }
format.xml { head :ok }
else
format.html { render action: 'edit' }
format.xml { render xml: @group.errors, status: :unprocessable_entity }
format.xml { render xml: service_call.errors, status: :unprocessable_entity }
end
end
end
@ -116,7 +122,9 @@ class GroupsController < ApplicationController
# DELETE /groups/1
# DELETE /groups/1.xml
def destroy
::Principals::DeleteJob.perform_later(@group)
Groups::DeleteService
.new(user: current_user, model: @group)
.call
respond_to do |format|
format.html do
@ -128,54 +136,49 @@ class GroupsController < ApplicationController
end
def add_users
call = @group
.add_members!(User.where(id: params[:user_ids]).pluck(:id))
if call.success?
flash[:notice] = I18n.t(:notice_successful_update)
else
call.apply_flash_message!(flash)
end
service_call = Groups::UpdateService
.new(user: current_user, model: @group)
.call(user_ids: @group.user_ids + Array(params[:user_ids]).map(&:to_i))
redirect_to controller: '/groups', action: 'edit', id: @group, tab: 'users'
respond_users_altered(service_call)
end
def remove_user
@group = Group.includes(:group_users).find(params[:id])
@group.group_users.destroy(GroupUser.find_by(user_id: params[:user_id], group_id: @group.id))
I18n.t :notice_successful_update
redirect_to controller: '/groups', action: 'edit', id: @group, tab: 'users'
service_call = Groups::UpdateService
.new(user: current_user, model: @group)
.call(user_ids: @group.user_ids - Array(params[:user_id]).map(&:to_i))
respond_users_altered(service_call)
end
def create_memberships
membership_params = permitted_params.group_membership[:new_membership]
service_call = Members::CreateService
.new(user: current_user)
.call(membership_params.merge(principal: @group))
respond_membership_altered(service_call)
end
def edit_membership
membership_params = permitted_params.group_membership
membership_id = membership_params[:membership_id]
if membership_id.present?
key = :membership
@membership = Member.find(membership_id)
else
key = :new_membership
@membership = Member.new(principal: @group)
end
@membership = Member.find(membership_params[:membership_id])
service = ::Members::EditMembershipService.new(@membership, save: true, current_user: current_user)
result = service.call(attributes: membership_params[key])
service_call = Members::UpdateService
.new(model: @membership, user: current_user)
.call(membership_params[:membership])
if result.success?
flash[:notice] = I18n.t :notice_successful_update
else
flash[:error] = result.errors.full_messages.join("\n")
end
redirect_to controller: '/groups', action: 'edit', id: @group, tab: 'memberships'
respond_membership_altered(service_call)
end
alias :edit_membership :create_memberships
def destroy_membership
membership_params = permitted_params.group_membership
Member.find(membership_params[:membership_id]).destroy
Members::DeleteService
.new(model: Member.find(params[:membership_id]), user: current_user)
.call
flash[:notice] = I18n.t :notice_successful_delete
redirect_to controller: '/groups', action: 'edit', id: @group, tab: 'memberships'
@ -211,4 +214,24 @@ class GroupsController < ApplicationController
def show_local_breadcrumb
true
end
def respond_membership_altered(service_call)
if service_call.success?
flash[:notice] = I18n.t :notice_successful_update
else
flash[:error] = service_call.errors.full_messages.join("\n")
end
redirect_to controller: '/groups', action: 'edit', id: @group, tab: 'memberships'
end
def respond_users_altered(service_call)
if service_call.success?
flash[:notice] = I18n.t(:notice_successful_update)
else
service_call.apply_flash_message!(flash)
end
redirect_to controller: '/groups', action: 'edit', id: @group, tab: 'users'
end
end

@ -46,21 +46,14 @@ class MembersController < ApplicationController
end
def create
if params[:member]
members = new_members_from_params(params[:member])
@project.members << members
end
service_call = create_members
if no_create_errors?(members)
flash[:notice] = members_added_notice members
if service_call.success?
display_success(members_added_notice(service_call.all_results))
redirect_to project_members_path(project_id: @project, status: 'all')
else
if members.present? && params[:member]
@member = members.first
else
flash[:error] = t(:error_check_user_and_role)
end
display_error(service_call)
set_index_data!
@ -71,13 +64,14 @@ class MembersController < ApplicationController
end
def update
member = update_member_from_params
service_call = Members::UpdateService
.new(user: current_user, model: @member)
.call(permitted_params.member)
if member.save
flash[:notice] = I18n.t(:notice_successful_update)
if service_call.success?
display_success(I18n.t(:notice_successful_update))
else
# only possible message is about choosing at least one role
flash[:error] = member.errors.full_messages.first
display_error(service_call)
end
redirect_to project_members_path(project_id: @project,
@ -86,16 +80,12 @@ class MembersController < ApplicationController
end
def destroy
if @member.deletable?
if @member.disposable?
flash.notice = I18n.t(:notice_member_deleted, user: @member.principal.name)
service_call = Members::DeleteService
.new(user: current_user, model: @member)
.call
@member.principal.destroy
else
flash.notice = I18n.t(:notice_member_removed, user: @member.principal.name)
@member.destroy
end
if service_call.success?
display_success(I18n.t(:notice_member_removed, user: @member.principal.name))
end
redirect_to project_members_path(project_id: @project)
@ -180,35 +170,33 @@ class MembersController < ApplicationController
@members_query = Members::UserFilterCell.query(filters)
end
def new_members_from_params(member_params)
roles = roles_for_new_members(member_params)
def create_members
overall_result = nil
if roles.present?
user_ids = user_ids_for_new_members(member_params)
members = user_ids.map { |user_id| new_member user_id }
# In edge cases, the user might choose a group together with a member which is also part of a group added
# at the same time. If the group is added before the user, a :taken error is produced. To avoid this, we
# get the user to be added first.
members = sort_by_groups_last(members)
with_new_member_params do |member_params|
service_call = Members::CreateService
.new(user: current_user)
.call(member_params)
# most likely wrong user input, use a dummy member for error handling
if !members.present? && roles.present?
members << new_member(nil)
if overall_result
overall_result.merge!(service_call)
else
overall_result = service_call
end
members
else
# Pick a user that exists but can't be chosen.
# We only want the missing role error message.
dummy = new_member User.anonymous.id
[dummy]
end
overall_result
end
def new_member(user_id)
Member.new(permitted_params.member).tap do |member|
member.user_id = user_id if user_id
def with_new_member_params
user_ids = user_ids_for_new_members(params[:member])
group_ids = Group.where(id: user_ids).pluck(:id)
user_ids.sort_by! { |id| group_ids.include?(id) ? 1 : -1 }
user_ids.each do |id|
yield permitted_params.member.merge(user_id: id, project: @project)
end
end
@ -216,10 +204,6 @@ class MembersController < ApplicationController
invite_new_users possibly_seperated_ids_for_entity(member_params, :user)
end
def roles_for_new_members(member_params)
Role.where(id: possibly_seperated_ids_for_entity(member_params, :role))
end
def invite_new_users(user_ids)
user_ids.map do |id|
if id.to_i == 0 && id.present? # we've got an email - invite that user
@ -270,19 +254,6 @@ class MembersController < ApplicationController
end
end
def update_member_from_params
# this way, mass assignment is considered and all updates happen in one transaction (autosave)
attrs = permitted_params.member.dup
attrs.merge! permitted_params.membership.dup if params[:membership].present?
if attrs.include? :role_ids
role_ids = attrs.delete(:role_ids).map(&:to_i).select { |i| i > 0 }
@member.assign_roles(role_ids)
end
@member.assign_attributes(attrs)
@member
end
def members_added_notice(members)
if members.size == 1
I18n.t(:notice_member_added, name: members.first.name)
@ -300,4 +271,12 @@ class MembersController < ApplicationController
members.sort_by { |m| group_ids.include?(m.user_id) ? 1 : -1 }
end
def display_error(service_call)
flash[:error] = service_call.errors.full_messages.compact.join(', ')
end
def display_success(message)
flash[:notice] = message
end
end

@ -175,7 +175,7 @@ module SettingsHelper
setting_label(setting, options) + wrap_field_outer(options, &block)
end
# Renders a notification field for a Redmine::Notifiable option
# Renders a notification field for an OpenProject::Notifiable option
def notification_field(notifiable, options = {})
content_tag(:label, class: 'form--label-with-check-box' + (notifiable.parent.present? ? ' parent' : '')) do
styled_check_box_tag('settings[notified_events][]',

@ -29,6 +29,8 @@
#++
class BaseMailer < ActionMailer::Base
layout 'mailer'
helper :application, # for format_text
:work_packages, # for css classes
:custom_fields # for show_value
@ -36,7 +38,7 @@ class BaseMailer < ActionMailer::Base
include OpenProject::LocaleHelper
# Send all delayed mails with the following job
self.delivery_job = ::MailerJob
self.delivery_job = ::Mails::MailerJob
# wrap in a lambda to allow changing at run-time
default from: Proc.new { Setting.mail_from }

@ -0,0 +1,94 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
# Sends mails for updates to memberships. There can be three cases we have to cover:
# * user is added to a project
# * existing project membership is altered
# * global roles are altered
#
# There is no creation of a global membership as far as the user is concerned. Hence, all
# global cases can be covered by one method.
#
# The mailer does not fan out in case a group is provided. The individual members of a group
# need to be mailed to individually.
class MemberMailer < BaseMailer
def added_project(current_user, member)
alter_project(current_user,
member,
in_member_locale(member) { I18n.t(:'mail_member_added_project.subject', project: member.project.name) })
end
def updated_project(current_user, member)
alter_project(current_user,
member,
in_member_locale(member) { I18n.t(:'mail_member_updated_project.subject', project: member.project.name) })
end
def updated_global(current_user, member)
send_mail(current_user,
member,
in_member_locale(member) { I18n.t(:'mail_member_updated_global.subject') })
end
private
def alter_project(current_user, member, subject)
send_mail(current_user,
member,
subject) do
open_project_headers Project: member.project.identifier
@project = member.project
end
end
def send_mail(current_user, member, subject)
in_member_locale(member) do
User.execute_as(current_user) do
message_id member, current_user
@roles = member.roles
@principal = member.principal
yield if block_given?
mail to: member.principal.mail,
subject: subject
end
end
end
def in_member_locale(member, &block)
raise ArgumentError unless member.principal.is_a?(User)
with_locale_for(member.principal, &block)
end
end

@ -33,8 +33,7 @@ class Group < Principal
has_many :group_users,
autosave: true,
dependent: :destroy,
after_remove: :user_removed
dependent: :destroy
has_many :users,
through: :group_users,
@ -59,45 +58,12 @@ class Group < Principal
:create_preference,
:create_preference!
include Destroy
scopes :visible
def to_s
lastname
end
def user_removed(group_user)
user = group_user.user
member_roles = MemberRole
.includes(member: :member_roles)
.where(inherited_from: members.joins(:member_roles).select('member_roles.id'))
.where(members: { user_id: user.id })
project_ids = member_roles.map { |mr| mr.member.project_id }
member_roles.each do |member_role|
member_role.member.remove_member_role_and_destroy_member_if_last(member_role,
prune_watchers: false)
end
Watcher.prune(user: user, project_id: project_ids)
end
# adds group members
# meaning users that are members of the group
def add_members!(users)
user_ids = Array(users).map { |user_or_id| user_or_id.is_a?(Integer) ? user_or_id : user_or_id.id }
::Groups::AddUsersService
.new(self, current_user: User.current)
.call(ids: user_ids)
.tap do |result|
raise "Failed to add to group #{result.message}" if result.failure?
end
end
private
def uniqueness_of_name

@ -1,109 +0,0 @@
module Group::Destroy
extend ActiveSupport::Concern
included do
before_destroy :destroy_members
end
##
# Instead of firing of separate queries for each and every Member and MemberRole
# instance upon group deletion this implementation does most of the deletion
# in a hand full of aggregate queries.
#
# Instead of doing
#
# Member:
# before_destroy :remove_from_category_assignments
# after_destroy :unwatch_from_permission_change
# after_destroy :destroy_notification
#
# MemberRole:
# after_destroy :remove_role_from_group_users
#
# for every row all relevant roles are deleted within 5 mass delete queries
# + 1 query for each member instance for each group itself + number of watchers
# among the users in the deleted group.
#
# Example:
#
# Given: 150 projects and 1 group with 20 users which is member in every project
#
# That makes 150 * 20 3000 Member rows and also 3000 MemberRole rows.
#
# Without this patch this would result in at least 4 queries for each member
# (the callbacks mentioned above + the deletion of the member) and 2 queries
# for each MemberRole (callback mentioned above + deletion of the actual MemberRole).
# Altogether that makes:
#
# num_queries_pre_patch = 3000 * 4 + 3000 * 2 + W = 18000 + W
#
# Where W is the number of watchers among the users in the destroyed group.
# The actual number is actually even higher as for the callbacks a bunch of read queries
# (loading the project, the user, etc.) are triggered, too.
#
# With this patch the number of queries is reduced to the 5 + 1 for each group member
# as explained above, making it:
#
# num_queries_post_patch = 5 + 150 + W = 155 + W
#
def destroy_members
MemberRole.transaction do
members = Member.table_name
member_roles = MemberRole.table_name
# Store all project/user combinations for later watcher pruning
# See: Member#unwatch_from_permission_change
user_id_and_project_id = Member
.joins(
"INNER JOIN #{member_roles} umr
ON #{members}.id = umr.member_id
INNER JOIN #{member_roles} gmr
ON umr.inherited_from = gmr.id
INNER JOIN #{members} gm
ON gm.id = gmr.member_id AND gm.user_id = #{id}"
)
.distinct
.pluck(:user_id, :project_id)
user_ids, project_ids = user_id_and_project_id.each_with_object([[], []]) do |element, array|
array.first << element.first
array.last << element.last
end
users = User.find(user_ids)
# Delete all MemberRoles created through this group for each user within it.
MemberRole
.joins("INNER JOIN #{member_roles} b on #{member_roles}.inherited_from = b.id")
.joins("INNER JOIN #{members} on #{members}.id = b.member_id")
.where("#{members}.user_id" => id) # group ID
.delete_all
# Delete all MemberRoles associating this group itself with a project.
MemberRole
.joins("INNER JOIN #{members} on #{members}.id = #{member_roles}.member_id")
.where("#{members}.user_id" => id)
.delete_all
Watcher.prune(user: users, project_id: project_ids)
# Destroy member instances for this group itself to trigger
# member destroyed notifications.
Member
.where(user_id: id)
.destroy_all
# Remove category based auto assignments for this member.
# See: Member#remove_from_category_assignments
Category
.joins("INNER JOIN #{members}
ON #{members}.project_id = categories.project_id
AND #{members}.user_id = categories.assigned_to_id")
.where("#{members}.user_id" => id)
.update_all "assigned_to_id = NULL"
self.users.delete_all # remove all users from this group
reload # so associated member instances are not destroyed again
end
end
end

@ -43,13 +43,6 @@ class Member < ApplicationRecord
validate :validate_presence_of_role
validate :validate_presence_of_principal
before_destroy :remove_from_category_assignments
after_destroy :unwatch_from_permission_change,
if: ->(member) { member.prune_watchers_on_destruction != false }
after_save :save_notification
after_destroy :destroy_notification
scopes :assignable,
:global,
:not_locked,
@ -64,53 +57,9 @@ class Member < ApplicationRecord
name
end
# Set the roles for this member to the given roles_or_role_ids.
# Inherited roles are left untouched.
def assign_roles(roles_or_role_ids)
do_assign_roles(roles_or_role_ids, false)
end
alias :base_role_ids= :role_ids=
deprecated_alias :user, :principal
deprecated_alias :user=, :principal=
# Set the roles for this member to the given roles_or_role_ids, immediately
# save the changes and destroy the member in case no role is left.
# Inherited roles are left untouched.
def assign_and_save_roles_and_destroy_member_if_none_left(roles_or_role_ids)
do_assign_roles(roles_or_role_ids, true)
end
alias_method :role_ids=, :assign_and_save_roles_and_destroy_member_if_none_left
# Add a role to the membership
# Does not save the changes, the member must be saved afterwards for the role to be added.
def add_role(role_or_role_id, inherited_from_id = nil)
do_add_role(role_or_role_id, inherited_from_id, false)
end
# Add a role and save the change to the database
def add_and_save_role(role_or_role_id, inherited_from_id = nil)
do_add_role(role_or_role_id, inherited_from_id, true)
end
# Mark one of the member's roles for destruction
#
# Make sure to get the MemberRole instance from the member's association, otherwise the actual
# destruction on save doesn't work.
def mark_member_role_for_destruction(member_role)
do_remove_member_role(member_role, false)
end
# Remove a role from a member
# Destroys the member itself when no role is left afterwards
#
# Make sure to get the MemberRole instance from the member's association, otherwise the
# destruction of the member, when the last MemberRole is destroyed, might not work.
def remove_member_role_and_destroy_member_if_last(member_role, prune_watchers: true)
do_remove_member_role(member_role, true, prune_watchers: prune_watchers)
end
def <=>(other)
a = roles.min
b = other.roles.min
@ -129,16 +78,6 @@ class Member < ApplicationRecord
end
end
# remove category based auto assignments for this member
#
# Note: This logic is duplicated for mass deletion in `app/models/group/destroy.rb`.
# Accordingly it has to be changed there too should this bit change at all.
def remove_from_category_assignments
Category
.where(project_id: project_id, assigned_to_id: user_id)
.update_all(assigned_to_id: nil)
end
##
# Returns true if this user can be deleted as they have no other memberships
# and haven't been activated yet. Only applies if the member is actually a user
@ -151,12 +90,6 @@ class Member < ApplicationRecord
attr_accessor :prune_watchers_on_destruction
def destroy_if_no_roles_left!
destroy if member_roles.empty? || member_roles.all? do |member_role|
member_role.marked_for_destruction? || member_role.destroyed?
end
end
def validate_presence_of_role
if (member_roles.empty? && roles.empty?) ||
member_roles.all? do |member_role|
@ -171,83 +104,8 @@ class Member < ApplicationRecord
errors.add :base, :principal_blank if principal.blank?
end
def do_add_role(role_or_role_id, inherited_from_id, save_immediately)
id = role_or_role_id.is_a?(Role) ? role_or_role_id.id : role_or_role_id
if save_immediately
member_roles << MemberRole.new.tap do |member_role|
member_role.role_id = id
member_role.inherited_from = inherited_from_id
end
else
member_roles.build.tap do |member_role|
member_role.role_id = id
member_role.inherited_from = inherited_from_id
end
end
end
# Set save_and_possibly_destroy to true to immediately save changes and destroy
# when no roles are left.
def do_assign_roles(roles_or_role_ids, save_and_possibly_destroy)
# ensure we have integer ids
ids = roles_or_role_ids.map { |r| r.is_a?(Role) ? r.id : r.to_i }
# Keep inherited roles
ids += member_roles.reject { |mr| mr.inherited_from.nil? }.map(&:role_id)
new_role_ids = ids - role_ids
# Add new roles
# Do this before destroying them, otherwise the Member is destroyed due to not having any
# Roles assigned via MemberRoles.
new_role_ids.each { |id| do_add_role(id, nil, save_and_possibly_destroy) }
# Remove roles (Rails' #role_ids= will not trigger MemberRole#on_destroy)
member_roles_to_destroy = member_roles.reject { |mr| ids.include?(mr.role_id) }
member_roles_to_destroy.each { |mr| do_remove_member_role(mr, save_and_possibly_destroy) }
end
def do_remove_member_role(member_role, destroy, prune_watchers: true)
self.prune_watchers_on_destruction = prune_watchers
# because we later on check whether all member_roles have been destroyed
# (at least when we do destroy it) we have to work on the member_role
# instance existing in the member_roles association. Otherwise, while
# representing the same db entry, the instances could be different and the
# wrong instance might have the destroyed flag.
to_destroy = member_roles.detect { |mr| mr.id == member_role.id }
if destroy
to_destroy.destroy_for_member
destroy_if_no_roles_left!
else
to_destroy.mark_for_destruction
end
unwatch_from_permission_change if prune_watchers
self.prune_watchers_on_destruction = true
end
private
# Unwatch things that the user is no longer allowed to view inside project
#
# Note: This logic is duplicated for mass deletion in `app/models/group/destroy.rb`.
# Accordingly it has to be changed there too should this bit change at all.
def unwatch_from_permission_change
if principal
Watcher.prune(user: principal, project_id: project_id)
end
end
def save_notification
::OpenProject::Notifications.send(:member_updated, member: self)
end
def destroy_notification
::OpenProject::Notifications.send(:member_removed, member: self)
end
def user?
principal.is_a?(User)
end

@ -32,9 +32,6 @@ class MemberRole < ApplicationRecord
belongs_to :member, touch: true
belongs_to :role
after_create :add_role_to_group_users
after_destroy :remove_role_from_group_users
validates_presence_of :role
validate :validate_project_member_role
@ -42,65 +39,7 @@ class MemberRole < ApplicationRecord
errors.add :role_id, :invalid if role && !role.member?
end
# Add alias, so Member can still destroy MemberRoles
# Don't call this from anywhere else, use remove_member_role on Member.
alias :destroy_for_member :destroy
# You shouldn't call this, only ActiveRecord itself is allowed to do this
# when destroying a Member. Use Member.remove_member_role to remove a role from a member.
#
# You may remove this once we have a layer above persistence that handles business logic
# and prevents or at least discourages working on persistence objects from controllers
# or unrelated business logic.
def destroy(*args)
if caller[2] =~ /has_many_association\.rb:[0-9]+:in `delete_records'/
super
else
raise 'MemberRole.destroy called from method other than HasManyAssociation.delete_records' +
"\n on #{inspect}\n from #{caller.first} / #{caller[6]}"
end
end
def inherited?
!inherited_from.nil?
end
private
def add_role_to_group_users
if member && member.principal.is_a?(Group)
member.principal.users.each do |user|
user_member = Member.find_by(project_id: member.project_id, user_id: user.id)
if user_member.nil?
user_member = Member.new.tap do |m|
m.project_id = member.project_id
m.user_id = user.id
end
user_member.add_role(role, id)
user_member.save
else
user_member.add_and_save_role(role, id)
end
end
end
end
def remove_role_from_group_users
inherited_roles_by_member = MemberRole
.where(inherited_from: id)
.includes(member: %i[principal member_roles])
.group_by(&:member)
inherited_roles_by_member.each do |member, member_roles|
member_roles.each do |mr|
member.remove_member_role_and_destroy_member_if_last(mr, prune_watchers: false)
end
end
users = inherited_roles_by_member.keys.map(&:principal)
Watcher.prune(user: users, project_id: member.project_id) unless users.empty?
end
end

@ -158,18 +158,6 @@ class Project < ApplicationRecord
visible.like(query)
end
def add_member(user, roles)
members.build.tap do |m|
m.principal = user
m.roles = Array(roles)
end
end
def add_member!(user, roles)
add_member(user, roles)
save
end
# Returns all projects the user is allowed to see.
#
# Employs the :view_project permission to perform the

@ -1,78 +0,0 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
class WatcherNotificationMailer
class << self
def handle_watcher(watcher, watcher_changer)
# We only handle this watcher setting if associated user wants to be notified
# about it.
return unless notify_about_watcher_changed?(watcher, watcher_changer)
unless other_jobs_queued?(watcher.watchable)
perform_notification_job(watcher, watcher_changer)
end
end
private
def perform_notification_job(_watcher, _watcher_changer)
raise NotImplementedError, 'Subclass has to implement #notification_job'
end
# HACK: TODO this needs generalization as well as performance improvements
# We need to make sure no work package created or updated job is queued to avoid sending two
# mails in short succession.
def other_jobs_queued?(work_package)
Delayed::Job.where('handler LIKE ?',
"%NotificationJob%journal_id: #{work_package.journals.last.id}%").exists?
end
def notify_about_watcher_changed?(watcher, watcher_changer)
return false if notify_about_self_watching?(watcher, watcher_changer)
case watcher.user.mail_notification
when 'only_my_events'
true
when 'selected'
watching_selected_includes_project?(watcher)
else
watcher.user.notify_about?(watcher.watchable)
end
end
def notify_about_self_watching?(watcher, watcher_changer)
watcher.user == watcher_changer && !watcher.user.pref.self_notified?
end
def watching_selected_includes_project?(watcher)
watcher.user.notified_projects_ids.include?(watcher.watchable.project_id)
end
end
end

@ -30,43 +30,24 @@
module Groups
class AddUsersService < ::BaseServices::BaseContracted
attr_reader :group
include Groups::Concerns::MembershipManipulation
def initialize(group, current_user:)
@group = group
def initialize(group, current_user:, contract_class: AdminOnlyContract)
self.model = group
super user: current_user,
contract_class: Groups::AddUsersContract
end
def after_validate(params, _call)
::Group.transaction do
add_to_user_and_projects(params[:ids])
end
end
def model
group
contract_class: contract_class
end
private
##
# Add users as the same members to the projects
# the group is a member of
def add_to_user_and_projects(user_ids)
exec_query!(user_ids)
ServiceResult.new success: true, result: group
rescue StandardError => e
Rails.logger.error { "Failed to add users to group #{group.id}: #{e} #{e.message}" }
ServiceResult.new(success: false, message: I18n.t(:notice_internal_server_error, app_title: Setting.app_title))
end
def exec_query!(user_ids)
def modify_members_and_roles(params)
sql_query = ::OpenProject::SqlSanitization
.sanitize add_to_user_and_projects_cte, group_id: group.id, user_ids: user_ids
.sanitize add_to_user_and_projects_cte,
group_id: model.id,
user_ids: params[:ids]
::Group.connection.exec_query(sql_query)
execute_query(sql_query)
end
def add_to_user_and_projects_cte
@ -75,6 +56,9 @@ module Groups
WITH found_users AS (
SELECT id as user_id FROM #{User.table_name} WHERE id IN (:user_ids)
),
timestamp AS (
SELECT CURRENT_TIMESTAMP as time
),
-- select existing memberships of the group
group_memberships AS (
SELECT project_id, user_id FROM #{Member.table_name} WHERE user_id = :group_id
@ -96,25 +80,56 @@ module Groups
SELECT :group_id as group_id, user_id FROM found_users
ON CONFLICT DO NOTHING
),
-- find members that already exist
existing_members AS (
SELECT members.id, found_users.user_id, members.project_id
FROM members, found_users, group_memberships
WHERE members.user_id = found_users.user_id
AND members.project_id = group_memberships.project_id
AND members.id IS NOT NULL
),
-- insert the group user into members
new_members AS (
INSERT INTO #{Member.table_name} (project_id, user_id)
SELECT group_memberships.project_id, found_users.user_id
INSERT INTO #{Member.table_name} (project_id, user_id, updated_at, created_at)
SELECT group_memberships.project_id, found_users.user_id, (SELECT time from timestamp), (SELECT time from timestamp)
FROM found_users, group_memberships
-- We need to return all members for the given group memberships
-- even if they already exist as members (e.g., added individually) to ensure we add all roles
-- to mark that we reset the created_at date since replacing the member
ON CONFLICT(project_id, user_id) DO UPDATE SET updated_at = CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM existing_members WHERE existing_members.user_id = found_users.user_id AND existing_members.project_id = group_memberships.project_id)
ON CONFLICT(project_id, user_id) DO NOTHING
RETURNING id, user_id, project_id
)
),
-- copy the member roles of the group
INSERT INTO #{MemberRole.table_name} (member_id, role_id, inherited_from)
SELECT new_members.id, group_roles.role_id, group_roles.member_role_id
FROM group_roles
JOIN new_members ON group_roles.project_id = new_members.project_id
-- Ignore if the role was already inserted by us
ON CONFLICT DO NOTHING
add_roles AS (
INSERT INTO #{MemberRole.table_name} (member_id, role_id, inherited_from)
SELECT members.id, group_roles.role_id, group_roles.member_role_id
FROM group_roles
JOIN
(SELECT * FROM new_members UNION SELECT * from existing_members) members ON group_roles.project_id = members.project_id
-- Ignore if the role was already inserted by us
ON CONFLICT DO NOTHING
RETURNING id, member_id, role_id
),
-- get the ids of members where roles have been added the member did not have before
members_with_added_roles AS (
SELECT DISTINCT add_roles.member_id
FROM add_roles
WHERE NOT EXISTS
(SELECT 1 FROM #{MemberRole.table_name}
WHERE #{MemberRole.table_name}.member_id = add_roles.member_id
AND #{MemberRole.table_name}.role_id = add_roles.role_id
AND #{MemberRole.table_name}.id != add_roles.id)
),
touch_existing_members AS (
UPDATE members SET updated_AT = CURRENT_TIMESTAMP
WHERE id IN (SELECT id from existing_members)
AND id IN (SELECT member_id from members_with_added_roles)
)
SELECT member_id from members_with_added_roles
SQL
end
def touch_updated(member_ids)
# do nothing in this case as we already touch while updating
end
end
end

@ -0,0 +1,97 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
# Deletes the roles granted to users by being part of a group.
# Will only delete the roles that are no longer granted so the group's membership needs to be deleted first.
# In case the user has roles independent of the group (not inherited) they are kept.
#
# This service is not specific to a certain group membership being removed. Rather, it will remove
# all MemberRole associations and in turn their Member associations if no matching inherited_from is found.
module Groups
class CleanupInheritedRolesService < ::BaseServices::BaseContracted
include Groups::Concerns::MembershipManipulation
def initialize(group, current_user:, contract_class: AdminOnlyContract)
self.model = group
super user: current_user,
contract_class: contract_class
end
private
def modify_members_and_roles(params)
affected_member_ids = execute_query(remove_member_roles_sql(params[:member_role_ids]))
members_to_remove = members_to_remove(affected_member_ids)
remove_members(members_to_remove)
affected_member_ids - members_to_remove.map(&:id)
end
def remove_member_roles_sql(member_role_ids)
if member_role_ids.present?
sql_query = <<~SQL
DELETE FROM #{MemberRole.table_name}
WHERE id IN (:member_role_ids)
RETURNING member_roles.member_id
SQL
::OpenProject::SqlSanitization
.sanitize sql_query,
member_role_ids: member_role_ids
else
<<~SQL
DELETE FROM #{MemberRole.table_name}
USING #{MemberRole.table_name} user_member_roles
WHERE
user_member_roles.inherited_from IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM #{MemberRole.table_name} group_member_roles WHERE group_member_roles.id = user_member_roles.inherited_from)
AND user_member_roles.id = member_roles.id
RETURNING member_roles.member_id
SQL
end
end
def members_to_remove(member_ids)
Member
.where(id: member_ids)
.where.not(id: MemberRole.select(:member_id).distinct)
.to_a
end
def remove_members(members)
members.each do |member|
Members::DeleteService
.new(model: member, user: user, contract_class: EmptyContract)
.call
end
end
end
end

@ -0,0 +1,84 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
module Groups::Concerns
module MembershipManipulation
extend ActiveSupport::Concern
def after_validate(params, _call)
params ||= {}
with_error_handled do
::Group.transaction do
exec_query!(params, params.delete(:send_notifications) { true })
end
end
end
private
def with_error_handled
yield
ServiceResult.new success: true, result: model
rescue StandardError => e
Rails.logger.error { "Failed to modify members and associated roles of group #{model.id}: #{e} #{e.message}" }
ServiceResult.new(success: false,
message: I18n.t(:notice_internal_server_error, app_title: Setting.app_title))
end
def exec_query!(params, send_notifications)
affected_member_ids = modify_members_and_roles(params)
touch_updated(affected_member_ids)
send_notifications(affected_member_ids) if affected_member_ids.any? && send_notifications
end
def modify_members_and_roles(_params)
raise NotImplementedError
end
def execute_query(query)
::Group
.connection
.exec_query(query)
.rows
.flatten
end
def touch_updated(member_ids)
Member
.where(id: member_ids)
.touch_all
end
def send_notifications(member_ids)
Notifications::GroupMemberAlteredJob.perform_later(member_ids)
end
end
end

@ -26,4 +26,11 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Groups::DeleteService < ::BaseServices::Delete; end
class Groups::DeleteService < ::BaseServices::Delete
protected
def destroy(group)
::Principals::DeleteJob.perform_later(group)
true
end
end

@ -0,0 +1,128 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
# Updates the roles of a membership assigned to the group.
module Groups
class UpdateRolesService < ::BaseServices::BaseContracted
include Groups::Concerns::MembershipManipulation
def initialize(group, current_user:, contract_class: AdminOnlyContract)
self.model = group
super user: current_user,
contract_class: contract_class
end
private
def modify_members_and_roles(params)
member = params.fetch(:member)
sql_query = ::OpenProject::SqlSanitization
.sanitize update_roles_cte,
group_id: model.id,
member_id: member.id,
project_id: member.project_id,
role_ids: member.role_ids
execute_query(sql_query)
end
# rubocop:disable Metrics/AbcSize
def update_roles_cte
<<~SQL
WITH
-- select all users of the group
group_users AS (
SELECT user_id
FROM #{GroupUser.table_name}
WHERE group_id = :group_id
),
-- select all members of the users of the group
user_members AS (
SELECT id
FROM #{Member.table_name}
WHERE user_id IN (SELECT user_id FROM group_users)
AND project_id = :project_id
),
-- select all member roles the group has for the member
group_member_roles AS (
SELECT member_roles.role_id AS role_id,
member_roles.id
FROM #{MemberRole.table_name} member_roles
WHERE member_roles.member_id = :member_id
),
-- delete all roles assigned to users that group no longer has but keep those that the user
-- has independently of the group (not inherited)
remove_roles AS (
DELETE FROM #{MemberRole.table_name}
USING #{MemberRole.table_name} user_member_roles
JOIN user_members ON user_members.id = user_member_roles.member_id
LEFT JOIN group_member_roles ON user_member_roles.role_id = group_member_roles.role_id
WHERE user_member_roles.inherited_from IS NOT NULL AND group_member_roles.role_id IS NULL
AND member_roles.id = user_member_roles.id
RETURNING #{MemberRole.table_name}.id, #{MemberRole.table_name}.member_id, #{MemberRole.table_name}.role_id
),
-- add all roles to the user memberships
add_roles AS (
INSERT INTO #{MemberRole.table_name} (member_id, role_id, inherited_from)
SELECT user_members.id, group_member_roles.role_id, group_member_roles.id
FROM group_member_roles, user_members
-- Ignore if role was already assigned
ON CONFLICT DO NOTHING
RETURNING member_id, role_id, id
),
-- get all the member_roles that are duplicates of removed ones
members_with_removed_roles AS (
SELECT DISTINCT remove_roles.member_id
FROM remove_roles
WHERE NOT EXISTS
(SELECT 1 FROM #{MemberRole.table_name}
WHERE #{MemberRole.table_name}.member_id = remove_roles.member_id
AND #{MemberRole.table_name}.role_id = remove_roles.role_id
AND #{MemberRole.table_name}.id != remove_roles.id)
),
-- get only the ids of members where roles have been added the member did not have before
members_with_added_roles AS (
SELECT DISTINCT add_roles.member_id
FROM add_roles
WHERE NOT EXISTS
(SELECT 1 FROM #{MemberRole.table_name}
WHERE #{MemberRole.table_name}.member_id = add_roles.member_id
AND #{MemberRole.table_name}.role_id = add_roles.role_id
AND #{MemberRole.table_name}.id != add_roles.id)
)
SELECT member_id from members_with_removed_roles
UNION SELECT member_id from members_with_added_roles
SQL
end
# rubocop:enable Metrics/AbcSize
end
end

@ -31,6 +31,20 @@
class Groups::UpdateService < ::BaseServices::Update
protected
def persist(call)
removed_users = call.result.group_users.select(&:marked_for_destruction?).map(&:user)
member_roles = member_roles_to_prune(removed_users)
project_ids = member_roles.pluck(:project_id)
member_role_ids = member_roles.pluck(:id)
call = super
remove_member_roles(member_role_ids)
cleanup_members(removed_users, project_ids)
call
end
def after_perform(call)
new_user_ids = call.result.group_users.select(&:saved_changes?).map(&:user_id)
@ -44,4 +58,23 @@ class Groups::UpdateService < ::BaseServices::Update
call
end
def remove_member_roles(member_role_ids)
::Groups::CleanupInheritedRolesService
.new(model, current_user: user)
.call(member_role_ids: member_role_ids)
end
def member_roles_to_prune(users)
MemberRole
.includes(member: :member_roles)
.where(inherited_from: model.members.joins(:member_roles).select('member_roles.id'))
.where(members: { user_id: users.map(&:id) })
end
def cleanup_members(users, project_ids)
Members::CleanupService
.new(users, project_ids)
.call
end
end

@ -0,0 +1,70 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
module Members
class CleanupService < ::BaseServices::BaseCallable
def initialize(users, projects)
self.users = users
self.projects = Array(projects)
super()
end
protected
def perform(_params = {})
prune_watchers
unassign_categories
ServiceResult.new(success: true)
end
attr_accessor :users,
:projects
def prune_watchers
Watcher.prune(user: users, project_id: project_ids)
end
def unassign_categories
Category
.where(assigned_to_id: users)
.where(project_id: project_ids)
.where.not(assigned_to_id: Member.assignable.of(projects).select(:user_id))
.update_all(assigned_to_id: nil)
end
def project_ids
projects.first.is_a?(Project) ? projects.map(&:id) : projects
end
def members_table
Member.table_name
end
end
end

@ -0,0 +1,49 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
module Members::Concerns::CleanedUp
extend ActiveSupport::Concern
included do
around_call :cleanup_members
protected
def cleanup_members
service_call = yield
return unless service_call.success?
member = service_call.result
Members::CleanupService
.new(member.principal, member.project_id)
.call
end
end
end

@ -28,4 +28,19 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Members::CreateService < ::BaseServices::Create; end
class Members::CreateService < ::BaseServices::Create
def after_perform(service_call)
member = service_call.result
if member.principal.is_a?(Group)
Groups::AddUsersService
.new(member.principal, current_user: user, contract_class: EmptyContract)
.call(ids: member.principal.user_ids, send_notifications: false)
end
OpenProject::Notifications.send(OpenProject::Events::MEMBER_CREATED,
member: member)
service_call
end
end

@ -26,4 +26,30 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Members::DeleteService < ::BaseServices::Delete; end
class Members::DeleteService < ::BaseServices::Delete
include Members::Concerns::CleanedUp
protected
def after_perform(service_call)
super(service_call).tap do |call|
member = call.result
cleanup_for_group(member)
send_notification(member)
end
end
def send_notification(member)
::OpenProject::Notifications.send(OpenProject::Events::MEMBER_DESTROYED,
member: member)
end
def cleanup_for_group(member)
return unless member.principal.is_a?(Group)
Groups::CleanupInheritedRolesService
.new(member.principal, current_user: user, contract_class: EmptyContract)
.call
end
end

@ -44,8 +44,27 @@ module Members
role_ids = params
.delete(:role_ids)
.select(&:present?)
.map(&:to_i)
model.assign_roles(role_ids)
existing_ids = model.member_roles.map(&:role_id)
mark_roles_for_destruction(existing_ids - role_ids)
build_roles(role_ids - existing_ids)
end
def mark_roles_for_destruction(role_ids)
role_ids.each do |role_id|
model
.member_roles
.detect { |mr| mr.inherited_from.nil? && mr.role_id == role_id }
&.mark_for_destruction
end
end
def build_roles(role_ids)
role_ids.each do |role_id|
model.member_roles.build(role_id: role_id)
end
end
end
end

@ -28,4 +28,23 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Members::UpdateService < ::BaseServices::Update; end
class Members::UpdateService < ::BaseServices::Update
include Members::Concerns::CleanedUp
protected
def after_perform(service_call)
super.tap do |call|
member = call.result
if member.principal.is_a?(Group)
Groups::UpdateRolesService
.new(member.principal, current_user: user, contract_class: EmptyContract)
.call(member: member, send_notifications: true)
else
OpenProject::Notifications.send(OpenProject::Events::MEMBER_UPDATED,
member: member)
end
end
end
end

@ -37,7 +37,7 @@ class Notifications::JournalNotificationService
private
def enqueue_notification(journal, send_mails)
NotifyJournalCompletedJob
Notifications::JournalCompletedJob
.set(wait_until: delivery_time)
.perform_later(journal.id, send_mails)
end

@ -52,7 +52,7 @@ class Notifications::JournalWpMailService
author = User.find_by(id: journal.user_id) || DeletedUser.first
notification_receivers(journal.journable, journal).each do |recipient|
DeliverWorkPackageNotificationJob.perform_later(journal.id, recipient.id, author.id)
Mails::WorkPackageJob.perform_later(journal.id, recipient.id, author.id)
end
end

@ -41,23 +41,27 @@ module Projects::Copy
protected
def copy_dependency(*)
# Copy users first, then groups to handle members with inherited and given roles
members_to_copy = []
members_to_copy += source.memberships.select { |m| m.principal.is_a?(User) }
members_to_copy += source.memberships.reject { |m| m.principal.is_a?(User) }
members_to_copy.each do |member|
# only copy non inherited roles
# inherited roles will be added when copying the group membership
role_ids = member.member_roles.reject(&:inherited?).map(&:role_id)
next if role_ids.empty?
attributes = member
.attributes.dup.except('id', 'project_id', 'created_at')
.merge(role_ids: role_ids)
target.memberships.create attributes
# Copy users and placeholder users first,
# then groups to handle members with inherited and given roles
source.memberships.sort_by { |m| m.principal.is_a?(Group) ? 1 : 0 }.each do |member|
create_membership(member)
end
end
def create_membership(member)
# only copy non inherited roles
# inherited roles will be added when copying the group membership
role_ids = member.member_roles.reject(&:inherited?).map(&:role_id)
return if role_ids.empty?
attributes = member
.attributes.dup.except('id', 'project_id', 'created_at', 'updated_at')
.merge(role_ids: role_ids, project: target)
Members::CreateService
.new(user: User.current, contract_class: EmptyContract)
.call(attributes)
end
end
end

@ -1,77 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<html>
<head>
<style>
:root {
color-scheme: light dark;
supported-color-schemes: light dark;
}
body {
font-family: Verdana, sans-serif;
font-size: 0.8em;
color: #484848;
background: #FFFFFF;
}
@media (prefers-color-scheme: dark) {
body {
color: #CCC !important;
background: #222222 !important;
}
}
h1, h2, h3 { font-family: "Trebuchet MS", Verdana, sans-serif; margin: 0px; }
h1 { font-size: 1.2em; }
h2, h3 { font-size: 1.1em; }
a, a:link, a:visited { color: #2A5685;}
a:hover, a:active { color: #c61a1a; }
a.op-uc-link_permalink { display: none; }
hr {
width: 100%;
height: 1px;
background: #ccc;
border: 0;
}
.footer {
font-size: 0.8em;
font-style: italic;
}
</style>
</head>
<body>
<span class="header"><%= OpenProject::TextFormatting::Renderer.format_text(Setting.localized_emails_header) %></span>
<%= call_hook(:view_layouts_mailer_html_before_content, self.assigns) %>
<%= yield %>
<%= call_hook(:view_layouts_mailer_html_after_content, self.assigns) %>
<hr />
<span class="footer"><%= OpenProject::TextFormatting::Renderer.format_text(Setting.localized_emails_footer) %></span>
</body>
</html>

@ -1,35 +0,0 @@
<%#-- copyright
OpenProject is an open source project management software.
Copyright (C) 2012-2021 the OpenProject GmbH
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License version 3.
OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
Copyright (C) 2006-2013 Jean-Philippe Lang
Copyright (C) 2010-2013 the ChiliProject Team
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
See docs/COPYRIGHT.rdoc for more details.
++#%>
<%= Setting.localized_emails_header %>
<%= call_hook(:view_layouts_mailer_plain_before_content, self.assigns) %>
<%= yield %>
<%= call_hook(:view_layouts_mailer_plain_after_content, self.assigns) %>
--
<%= Setting.localized_emails_footer %>

@ -0,0 +1,10 @@
<%= I18n.t(:'mail_member_added_project.body.added_by', project: @project.name, user: User.current.name) %>
<%= I18n.t(:'mail_member_added_project.body.roles') %>
<ul>
<% @roles.each do |role| %>
<li>
<%= role.name %>
</li>
<% end %>
</ul>

@ -0,0 +1,6 @@
<%= I18n.t(:'mail_member_added_project.body.added_by', project: h(@project.name), user: h(User.current.name)) %>
<%= I18n.t(:'mail_member_added_project.body.roles') %>
<% @roles.each do |role| %>
* <%= h(role.name) %>
<% end %>

@ -0,0 +1,10 @@
<%= I18n.t(:'mail_member_updated_global.body.added_by', user: User.current.name) %>
<%= I18n.t(:'mail_member_updated_global.body.roles') %>
<ul>
<% @roles.each do |role| %>
<li>
<%= role.name %>
</li>
<% end %>
</ul>

@ -0,0 +1,6 @@
<%= I18n.t(:'mail_member_updated_global.body.added_by', user: User.current.name) %>
<%= I18n.t(:'mail_member_updated_global.body.roles') %>
<% @roles.each do |role| %>
* <%= h(role.name) %>
<% end %>

@ -0,0 +1,10 @@
<%= I18n.t(:'mail_member_updated_project.body.added_by', project: @project.name, user: User.current.name) %>
<%= I18n.t(:'mail_member_updated_project.body.roles') %>
<ul>
<% @roles.each do |role| %>
<li>
<%= role.name %>
</li>
<% end %>
</ul>

@ -0,0 +1,6 @@
<%= I18n.t(:'mail_member_updated_project.body.added_by', project: @project.name, user: User.current.name) %>
<%= I18n.t(:'mail_member_updated_project.body.roles') %>
<% @roles.each do |role| %>
* <%= h(role.name) %>
<% end %>

@ -28,30 +28,35 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class DeliverNotificationJob < ApplicationJob
class Mails::DeliverJob < ApplicationJob
queue_with_priority :notification
def perform(recipient_id, sender_id)
@recipient_id = recipient_id
@sender_id = sender_id
# nothing to do if recipient was deleted in the meantime
return unless recipient
return if abort?
mail = User.execute_as(recipient) { build_mail }
if mail
mail.deliver_now
end
mail&.deliver_now
end
private
def abort?
# nothing to do if recipient was deleted in the meantime
recipient.nil?
end
# To be implemented by subclasses.
# Actual recipient and sender User objects are passed (always non-nil).
# Returns a Mail::Message, or nil if no message should be sent.
# rubocop:disable Lint/UnusedMethodArgument
def render_mail(recipient:, sender:)
raise 'SubclassResponsibility'
end
# rubocop:enable Lint/UnusedMethodArgument
def build_mail
render_mail(recipient: recipient, sender: sender)
@ -63,10 +68,18 @@ class DeliverNotificationJob < ApplicationJob
end
def recipient
@recipient ||= User.find_by(id: @recipient_id)
@recipient ||= if @recipient_id.is_a?(User)
@recipient_id
else
User.find_by(id: @recipient_id)
end
end
def sender
@sender ||= User.find_by(id: @sender_id) || DeletedUser.first
@sender ||= if @sender_id.is_a?(User)
@sender_id
else
User.find_by(id: @sender_id) || DeletedUser.first
end
end
end

@ -28,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class DeliverInvitationJob < ApplicationJob
class Mails::InvitationJob < ApplicationJob
queue_with_priority :high
def perform(token)

@ -39,7 +39,7 @@
# as opposed to using `ActiveJob::QueueAdapters::DelayedJobAdapter::JobWrapper`.
# We want it to run in an `ApplicationJob` because of the shared setup required
# such as reloading the mailer configuration and resetting the request store.
class MailerJob < ApplicationJob
class Mails::MailerJob < ApplicationJob
queue_as { ActionMailer::Base.deliver_later_queue_name }
# Retry mailing jobs three times with exponential backoff

@ -0,0 +1,50 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
class Mails::MemberCreatedJob < Mails::MemberJob
private
alias_method :send_for_project_user, :send_added_project
def send_for_group_user(current_user, user_member, group_member)
if new_roles_added?(user_member, group_member)
send_updated_project(current_user, user_member)
elsif all_roles_added?(user_member, group_member)
send_added_project(current_user, user_member)
end
end
def new_roles_added?(user_member, group_member)
(group_member.member_roles.map(&:id) - user_member.member_roles.map(&:inherited_from)).length <
group_member.member_roles.length && user_member.member_roles.any? { |mr| mr.inherited_from.nil? }
end
def all_roles_added?(user_member, group_member)
(user_member.member_roles.map(&:inherited_from) - group_member.member_roles.map(&:id)).empty?
end
end

@ -0,0 +1,90 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
class Mails::MemberJob < ApplicationJob
queue_with_priority :notification
def perform(current_user:,
member:)
if member.project.nil?
send_updated_global(current_user, member)
elsif member.principal.is_a?(Group)
every_group_user_member(member) do |user_member|
send_for_group_user(current_user, user_member, member)
end
elsif member.principal.is_a?(User)
send_for_project_user(current_user, member)
end
end
private
def send_for_group_user(_current_user, _member, _group)
raise NotImplementedError, "subclass responsibility"
end
def send_for_project_user(_current_user, _member)
raise NotImplementedError, "subclass responsibility"
end
def send_updated_global(current_user, member)
return if sending_disabled?(:updated)
MemberMailer
.updated_global(current_user, member)
.deliver_now
end
def send_added_project(current_user, member)
return if sending_disabled?(:added)
MemberMailer
.added_project(current_user, member)
.deliver_now
end
def send_updated_project(current_user, member)
return if sending_disabled?(:updated)
MemberMailer
.updated_project(current_user, member)
.deliver_now
end
def every_group_user_member(member, &block)
Member
.of(member.project)
.where(principal: member.principal.users)
.includes(:project, :principal, :roles, :member_roles)
.each(&block)
end
def sending_disabled?(setting)
!Setting.notified_events.include?("membership_#{setting}")
end
end

@ -0,0 +1,37 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
class Mails::MemberUpdatedJob < Mails::MemberJob
private
alias_method :send_for_project_user, :send_updated_project
def send_for_group_user(current_user, member, _group)
send_updated_project(current_user, member)
end
end

@ -28,10 +28,10 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class DeliverWatcherAddedNotificationJob < DeliverWatcherNotificationJob
def render_mail(recipient:, sender:)
return unless watcher
class Mails::WatcherAddedJob < Mails::WatcherJob
private
UserMailer.work_package_watcher_changed(watcher.watchable, recipient, sender, 'added')
def action
'added'
end
end

@ -0,0 +1,78 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
class Mails::WatcherJob < Mails::DeliverJob
def perform(watcher, watcher_changer)
self.watcher = watcher
super(watcher.user, watcher_changer)
end
def render_mail(recipient:, sender:)
UserMailer
.work_package_watcher_changed(watcher.watchable,
recipient,
sender,
action)
end
private
attr_accessor :watcher
def abort?
super || !notify_about_watcher_changed?
end
def notify_about_watcher_changed?
return false if notify_about_self_watching?
case watcher.user.mail_notification
when 'only_my_events'
true
when 'selected'
watching_selected_includes_project?
else
watcher.user.notify_about?(watcher.watchable)
end
end
def notify_about_self_watching?
watcher.user == sender && !sender.pref.self_notified?
end
def watching_selected_includes_project?
watcher.user.notified_projects_ids.include?(watcher.watchable.project_id)
end
def action
raise NotImplementedError, 'subclass responsibility'
end
end

@ -28,13 +28,16 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class WatcherAddedNotificationMailer < WatcherNotificationMailer
class << self
private
class Mails::WatcherRemovedJob < Mails::WatcherJob
def perform(watcher_attributes, watcher_changer)
watcher = Watcher.new(watcher_attributes)
def perform_notification_job(watcher, watcher_changer)
DeliverWatcherAddedNotificationJob
.perform_later(watcher.id, watcher.user.id, watcher_changer.id)
end
super(watcher, watcher_changer)
end
private
def action
'removed'
end
end

@ -28,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class DeliverWorkPackageNotificationJob < DeliverNotificationJob
class Mails::WorkPackageJob < Mails::DeliverJob
queue_with_priority :notification
def perform(journal_id, recipient_id, author_id)

@ -28,20 +28,33 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class DeliverWatcherNotificationJob < DeliverNotificationJob
def perform(watcher_id, recipient_id, watcher_changer_id)
@watcher_id = watcher_id
class Notifications::GroupMemberAlteredJob < ApplicationJob
queue_with_priority :notification
super(recipient_id, watcher_changer_id)
def perform(members_ids)
each_member(members_ids) do |member|
OpenProject::Notifications.send(event_type(member),
member: member)
end
end
def render_mail(recipient:, sender:) # rubocop:disable Lint/UnusedMethodArgument
raise NotImplementedError, 'Subclass has to implement #render_mail'
private
def event_type(member)
if matching_timestamps?(member)
OpenProject::Events::MEMBER_CREATED
else
OpenProject::Events::MEMBER_UPDATED
end
end
private
def matching_timestamps?(member)
member.updated_at == member.created_at
end
def watcher
@watcher ||= Watcher.find_by(id: @watcher_id)
def each_member(members_ids, &block)
Member
.where(id: members_ids)
.each(&block)
end
end

@ -28,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class NotifyJournalCompletedJob < ApplicationJob
class Notifications::JournalCompletedJob < ApplicationJob
queue_with_priority :notification
def perform(journal_id, send_mails)

@ -36,6 +36,7 @@ class Principals::DeleteJob < ApplicationJob
delete_associated(principal)
replace_references(principal)
update_cost_queries(principal)
remove_members(principal)
principal.destroy
end
@ -61,7 +62,6 @@ class Principals::DeleteJob < ApplicationJob
CostQuery.where(user_id: principal.id, is_public: false).delete_all
end
# rubocop:disable Rails/SkipsModelValidations
def update_cost_queries(principal)
CostQuery.in_batches.each_record do |query|
serialized = query.serialized
@ -73,7 +73,6 @@ class Principals::DeleteJob < ApplicationJob
CostQuery.where(id: query.id).update_all(serialized: serialized)
end
end
# rubocop:enable Rails/SkipsModelValidations
def remove_cost_query_values(name, options, principal)
options[:values].delete(principal.id.to_s) if %w[UserId AuthorId AssignedToId ResponsibleId].include?(name)
@ -82,4 +81,12 @@ class Principals::DeleteJob < ApplicationJob
[name, options]
end
end
def remove_members(principal)
principal.members.each do |member|
Members::DeleteService
.new(user: User.current, contract_class: EmptyContract, model: member)
.call
end
end
end

@ -60,29 +60,6 @@ end
require_relative '../lib/open_project/configuration'
env = ENV['RAILS_ENV'] || 'production'
db_config = ActiveRecord::Base.configurations[env] || {}
db_adapter = db_config['adapter']
if db_adapter&.start_with? 'mysql'
warn <<~ERROR
======= INCOMPATIBLE DATABASE DETECTED =======
Your database is set up for use with a MySQL or MySQL-compatible variant.
This installation of OpenProject no longer supports these variants.
The following guides provide extensive documentation for migrating
your installation to a PostgreSQL database:
https://www.openproject.org/migration-guides/
This process is mostly automated so you can continue using your
OpenProject installation within a few minutes!
==============================================
ERROR
Kernel.exit 1
end
module OpenProject
class Application < Rails::Application
# Settings in config/environments/* take precedence over those specified here.

@ -1,5 +1,3 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
@ -28,17 +26,27 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class DeliverWatcherRemovedNotificationJob < DeliverWatcherNotificationJob
# As watcher is already destroyed we need to pass a hash
def perform(watcher_attributes, recipient_id, watcher_remover_id)
@watcher = Watcher.new(watcher_attributes)
env = ENV['RAILS_ENV'] || 'production'
if (db_config = ActiveRecord::Base.configurations.configs_for(env_name: env)[0]) &&
db_config.configuration_hash['adapter']&.start_with?('mysql')
warn <<~ERROR
======= INCOMPATIBLE DATABASE DETECTED =======
Your database is set up for use with a MySQL or MySQL-compatible variant.
This installation of OpenProject no longer supports these variants.
The following guides provide extensive documentation for migrating
your installation to a PostgreSQL database:
https://www.openproject.org/migration-guides/
super(watcher.id, recipient_id, watcher_remover_id)
end
This process is mostly automated so you can continue using your
OpenProject installation within a few minutes!
def render_mail(recipient:, sender:)
return unless watcher
==============================================
ERROR
UserMailer.work_package_watcher_changed(watcher.watchable, recipient, sender, 'removed')
end
# rubocop:disable Rails:Exit
Kernel.exit 1
# rubocop:enable Rails:Exit
end

@ -28,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
OpenProject::Notifications.subscribe('journal_created') do |payload|
OpenProject::Notifications.subscribe(OpenProject::Events::JOURNAL_CREATED) do |payload|
Notifications::JournalNotificationService.call(payload[:journal], payload[:send_notification])
end
@ -40,10 +40,26 @@ OpenProject::Notifications.subscribe(OpenProject::Events::AGGREGATED_WIKI_JOURNA
Notifications::JournalWikiMailService.call(payload[:journal], payload[:send_mail])
end
OpenProject::Notifications.subscribe('watcher_added') do |payload|
WatcherAddedNotificationMailer.handle_watcher(payload[:watcher], payload[:watcher_setter])
OpenProject::Notifications.subscribe(OpenProject::Events::WATCHER_ADDED) do |payload|
Mails::WatcherAddedJob
.perform_later(payload[:watcher],
payload[:watcher_setter])
end
OpenProject::Notifications.subscribe('watcher_removed') do |payload|
WatcherRemovedNotificationMailer.handle_watcher(payload[:watcher], payload[:watcher_remover])
OpenProject::Notifications.subscribe(OpenProject::Events::WATCHER_REMOVED) do |payload|
Mails::WatcherRemovedJob
.perform_later(payload[:watcher].attributes,
payload[:watcher_remover])
end
OpenProject::Notifications.subscribe(OpenProject::Events::MEMBER_CREATED) do |payload|
Mails::MemberCreatedJob
.perform_later(current_user: User.current,
member: payload[:member])
end
OpenProject::Notifications.subscribe(OpenProject::Events::MEMBER_UPDATED) do |payload|
Mails::MemberUpdatedJob
.perform_later(current_user: User.current,
member: payload[:member])
end

@ -2,9 +2,9 @@
# The default behaviour is to send the user a sign-up mail
# when they were invited.
OpenProject::Notifications.subscribe UserInvitation::Events.user_invited do |token|
DeliverInvitationJob.perform_later(token)
Mails::InvitationJob.perform_later(token)
end
OpenProject::Notifications.subscribe UserInvitation::Events.user_reinvited do |token|
DeliverInvitationJob.perform_later(token)
Mails::InvitationJob.perform_later(token)
end

@ -1539,6 +1539,8 @@ en:
label_member_new: "New member"
label_member_all_admin: "(All roles due to admin status)"
label_member_plural: "Members"
lable_membership_added: 'Member added'
lable_membership_updated: 'Member updated'
label_menu_item_name: "Name of menu item"
label_message: "Message"
label_message_last: "Last message"
@ -1887,6 +1889,24 @@ en:
mail_subject_wiki_content_added: "'%{id}' wiki page has been added"
mail_subject_wiki_content_updated: "'%{id}' wiki page has been updated"
mail_member_added_project:
subject: "%{project} - You have been added as a member"
body:
added_by: "%{user} added you as a member to the project '%{project}'."
roles: "You have the following roles:"
mail_member_updated_project:
subject: "%{project} - Your roles have been updated"
body:
added_by: "%{user} updated the roles you have in the project '%{project}'."
roles: "You now have the following roles:"
mail_member_updated_global:
subject: "Your global permissions have been updated"
body:
added_by: "%{user} updated the roles you have globally."
roles: "You now have the following roles:"
mail_user_activation_limit_reached:
subject: User activation limit reached
message: |
@ -1897,6 +1917,7 @@ en:
a: "Upgrade your payment plan ([here](upgrade_url))" # here turned into a link
b: "Lock or delete an existing user ([here](users_url))" # here turned into a link
more_actions: "More functions"
noscript_description: "You need to activate JavaScript in order to use OpenProject!"

@ -244,6 +244,8 @@ notified_events:
- message_posted
- wiki_content_added
- wiki_content_updated
- membership_added
- membership_updated
mail_handler_body_delimiters:
default: ''
mail_handler_ignore_filenames:

@ -5,13 +5,14 @@ class FixInheritedGroupMemberRoles < ActiveRecord::Migration[6.0]
# For all group memberships, recreate the member_roles for all users
# which will auto-create members for the users if necessary
MemberRole
.joins(member: [:principal])
.includes(member: %i[principal member_roles])
.where("#{Principal.table_name}.type" => 'Group')
.find_each do |member_role|
Member
.includes(%i[principal])
.where(users: { type: 'Group' })
.find_each do |member|
# Recreate member_roles for all group members
member_role.send :add_role_to_group_users
Groups::UpdateRolesService
.new(member.principal, current_user: SystemUser.first, contract_class: EmptyContract)
.call(member: member, send_notifications: false)
end
end

@ -269,10 +269,13 @@ Deletes the group.
+ Parameters
+ id (required, integer, `1`) ... Group id
+ Response 204 (application/hal+json)
+ Response 202 (application/hal+json)
Returned if the group was successfully deleted
Note that the response body is empty as of now. In future versions of the API a body
*might* be returned, indicating the progress of deletion.
+ Body
+ Response 403 (application/hal+json)

@ -54,7 +54,8 @@ module API
.new(model: Group)
.mount
delete &::API::V3::Utilities::Endpoints::Delete
.new(model: Group)
.new(model: Group,
success_status: 202)
.mount
end
end

@ -38,7 +38,6 @@ require 'open_project/custom_styles/design'
require 'redmine/hook'
require 'open_project/hooks'
require 'redmine/plugin'
require 'redmine/notifiable'
require 'csv'

@ -39,10 +39,21 @@ module OpenProject
module Events
AGGREGATED_WORK_PACKAGE_JOURNAL_READY = "aggregated_work_package_journal_ready".freeze
AGGREGATED_WIKI_JOURNAL_READY = "aggregated_wiki_journal_ready".freeze
NEW_TIME_ENTRY_CREATED = "new_time_entry_created".freeze
JOURNAL_CREATED = 'journal_created'.freeze
MEMBER_CREATED = 'member_created'.freeze
MEMBER_UPDATED = 'member_updated'.freeze
# Called like this for historic reasons, should be called 'member_destroyed'
MEMBER_DESTROYED = 'member_removed'.freeze
TIME_ENTRY_CREATED = "time_entry_created".freeze
PROJECT_CREATED = "project_created".freeze
PROJECT_UPDATED = "project_updated".freeze
PROJECT_RENAMED = "project_renamed".freeze
WATCHER_ADDED = 'watcher_added'.freeze
WATCHER_REMOVED = 'watcher_removed'.freeze
end
end

@ -28,7 +28,23 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
module Redmine
module OpenProject
NOTIFIABLE = [
%w(work_package_added),
%w(work_package_updated),
%w(work_package_note_added work_package_updated),
%w(status_updated work_package_updated),
%w(work_package_priority_updated work_package_updated),
%w(news_added),
%w(news_comment_added),
%w(file_added),
%w(message_posted),
%w(wiki_content_added),
%w(wiki_content_updated),
%w(membership_added),
%w(membership_updated)
].freeze
Notifiable = Struct.new(:name, :parent) do
def to_s
name
@ -36,19 +52,9 @@ module Redmine
# TODO: Plugin API for adding a new notification?
def self.all
notifications = []
notifications << Notifiable.new('work_package_added')
notifications << Notifiable.new('work_package_updated')
notifications << Notifiable.new('work_package_note_added', 'work_package_updated')
notifications << Notifiable.new('status_updated', 'work_package_updated')
notifications << Notifiable.new('work_package_priority_updated', 'work_package_updated')
notifications << Notifiable.new('news_added')
notifications << Notifiable.new('news_comment_added')
notifications << Notifiable.new('file_added')
notifications << Notifiable.new('message_posted')
notifications << Notifiable.new('wiki_content_added')
notifications << Notifiable.new('wiki_content_updated')
notifications
OpenProject::NOTIFIABLE.map do |event_strings|
Notifiable.new(*event_strings)
end
end
end
end

@ -66,7 +66,7 @@ module Acts::Journalized
.call(notes: @journal_notes)
if create_call.success? && create_call.result
OpenProject::Notifications.send('journal_created',
OpenProject::Notifications.send(OpenProject::Events::JOURNAL_CREATED,
journal: create_call.result,
send_notification: Journal::NotificationConfiguration.active?)
end

@ -40,7 +40,7 @@ class Services::CreateWatcher
elsif @watcher.valid?
@work_package.watchers << @watcher
success.(created: true)
OpenProject::Notifications.send('watcher_added',
OpenProject::Notifications.send(OpenProject::Events::WATCHER_ADDED,
watcher: @watcher,
watcher_setter: User.current)
else

@ -38,7 +38,7 @@ class Services::RemoveWatcher
if watcher.present?
@work_package.watcher_users.delete(@user)
success.call
OpenProject::Notifications.send('watcher_removed',
OpenProject::Notifications.send(OpenProject::Events::WATCHER_REMOVED,
watcher: watcher,
watcher_remover: User.current)
else

@ -29,8 +29,9 @@
require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb')
describe 'adding a new budget', type: :feature, js: true do
let(:project) { FactoryBot.create :project_with_types }
let(:project) { FactoryBot.create :project_with_types, members: project_members }
let(:user) { FactoryBot.create :admin }
let(:project_members) { {} }
before do
login_as user
@ -96,9 +97,9 @@ describe 'adding a new budget', type: :feature, js: true do
let(:new_budget_page) { Pages::NewBudget.new project.identifier }
let(:budget_page) { Pages::EditBudget.new Budget.last }
before do
project.add_member! user, FactoryBot.create(:role)
let(:project_members) { { user => FactoryBot.create(:role) } }
before do
FactoryBot.create :cost_rate, cost_type: cost_type, rate: 50.0
FactoryBot.create :default_hourly_rate, user: user, rate: 25.0
end

@ -29,7 +29,11 @@
require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper.rb')
describe 'updating a budget', type: :feature, js: true do
let(:project) { FactoryBot.create :project_with_types, enabled_module_names: %i[budgets costs] }
let(:project) do
FactoryBot.create :project_with_types,
enabled_module_names: %i[budgets costs],
members: { user => FactoryBot.create(:role) }
end
let(:user) { FactoryBot.create :admin }
let(:budget) { FactoryBot.create :budget, author: user, project: project }
@ -45,8 +49,6 @@ describe 'updating a budget', type: :feature, js: true do
let(:budget_page) { Pages::EditBudget.new budget.id }
before do
project.add_member! user, FactoryBot.create(:role)
FactoryBot.create :cost_rate, cost_type: cost_type, rate: 50.0
FactoryBot.create :default_hourly_rate, user: user, rate: 25.0
end
@ -94,8 +96,6 @@ describe 'updating a budget', type: :feature, js: true do
let(:budget_page) { Pages::EditBudget.new budget.id }
before do
project.add_member! user, FactoryBot.create(:role)
FactoryBot.create :cost_rate, cost_type: cost_type, rate: 50.0
FactoryBot.create :default_hourly_rate, user: user, rate: 25.0

@ -31,7 +31,7 @@
class TimeEntries::CreateService < ::BaseServices::Create
def after_perform(call)
OpenProject::Notifications.send(
OpenProject::Events::NEW_TIME_ENTRY_CREATED,
OpenProject::Events::TIME_ENTRY_CREATED,
time_entry: call.result
)

@ -38,7 +38,14 @@ FactoryBot.define do
before(:create) do |ce|
ce.work_package.project = ce.project
ce.project.add_member!(ce.user, [FactoryBot.create(:role)]) unless ce.project.users.include?(ce.user)
unless ce.project.users.include?(ce.user)
Members::CreateService
.new(user: nil, contract_class: EmptyContract)
.call(principal: ce.user,
project: ce.project,
roles: [FactoryBot.create(:role)])
end
end
end
end

@ -32,8 +32,7 @@ describe 'hourly rates on a member', type: :feature, js: true do
let(:project) { FactoryBot.build :project }
let(:user) do
FactoryBot.create :admin,
member_in_project: project,
member_through_role: [FactoryBot.create(:role)]
member_in_project: project
end
let(:member) { Member.find_by(project: project, principal: user) }

@ -32,9 +32,7 @@ describe WorkPackage, type: :model do
let(:user) { FactoryBot.create(:admin) }
let(:role) { FactoryBot.create(:role) }
let(:project) do
project = FactoryBot.create(:project_with_types)
project.add_member!(user, role)
project
FactoryBot.create(:project_with_types, members: { user => role })
end
let(:project2) { FactoryBot.create(:project_with_types, types: project.types) }

@ -43,9 +43,7 @@ describe 'Project details widget on dashboard', type: :feature, js: true do
let(:system_version) { FactoryBot.create(:version, sharing: 'system') }
let!(:project) do
FactoryBot.create(:project).tap do |p|
p.add_member(other_user, [role])
FactoryBot.create(:project, members: { other_user => role }).tap do |p|
p.send(:"custom_field_#{int_cf.id}=", 5)
p.send(:"custom_field_#{bool_cf.id}=", true)
p.send(:"custom_field_#{version_cf.id}=", system_version)

@ -50,7 +50,7 @@ module OpenProject::Documents
}, require: :loggedin
end
Redmine::Notifiable.all << Redmine::Notifiable.new('document_added')
OpenProject::Notifiable.all << OpenProject::Notifiable.new('document_added')
Redmine::Search.register :documents
end

@ -53,9 +53,7 @@ describe OpenProject::GithubIntegration::HookHandler do
permissions: %i[view_work_packages add_work_package_notes])
end
let(:project) do
FactoryBot.create(:project).tap do |p|
p.add_member(user, role).save
end
FactoryBot.create(:project, members: { user => role })
end
let(:work_packages) { FactoryBot.create_list :work_package, 4, project: project }

@ -37,7 +37,7 @@ module LdapGroups
::LdapGroups::Membership.insert_all memberships
# add users to users collection of internal group
group.add_members! new_users
add_members_to_group(new_users)
end
end
@ -67,9 +67,10 @@ module LdapGroups
private
def user_id(user)
if user.is_a? Integer
case user
when Integer
user
elsif user.is_a? User
when User
user.id
else
raise ArgumentError, "Expected User or User ID (Integer) but got #{user}"
@ -79,5 +80,11 @@ module LdapGroups
def remove_all_members
remove_members! User.find(users.pluck(:user_id))
end
def add_members_to_group(new_users)
Groups::UpdateService
.new(user: User.current, model: group)
.call(user_ids: group.user_ids + new_users.map { |user| user_id(user) })
end
end
end

@ -29,11 +29,7 @@
require File.dirname(__FILE__) + '/../spec_helper'
describe Meeting, type: :model do
it { is_expected.to belong_to :project }
it { is_expected.to belong_to :author }
it { is_expected.to validate_presence_of :title }
let(:project) { FactoryBot.create(:project) }
let(:project) { FactoryBot.create(:project, members: project_members) }
let(:user1) { FactoryBot.create(:user) }
let(:user2) { FactoryBot.create(:user) }
let(:meeting) { FactoryBot.create(:meeting, project: project, author: user1) }
@ -41,9 +37,14 @@ describe Meeting, type: :model do
meeting.create_agenda text: 'Meeting Agenda text'
meeting.reload_agenda # avoiding stale object errors
end
let(:project_members) { {} }
let(:role) { FactoryBot.create(:role, permissions: [:view_meetings]) }
it { is_expected.to belong_to :project }
it { is_expected.to belong_to :author }
it { is_expected.to validate_presence_of :title }
before do
@m = FactoryBot.build :meeting, title: 'dingens'
end
@ -110,10 +111,7 @@ describe Meeting, type: :model do
describe 'all_changeable_participants' do
describe 'WITH a user having the view_meetings permission' do
before do
project.add_member user1, [role]
project.save!
end
let(:project_members) { { user1 => role } }
it 'should contain the user' do
expect(meeting.all_changeable_participants).to eq([user1])
@ -122,14 +120,7 @@ describe Meeting, type: :model do
describe 'WITH a user not having the view_meetings permission' do
let(:role2) { FactoryBot.create(:role, permissions: []) }
before do
# adding both users so that the author is valid
project.add_member user1, [role]
project.add_member user2, [role2]
project.save!
end
let(:project_members) { { user1 => role, user2 => role2 } }
it 'should not contain the user' do
expect(meeting.all_changeable_participants.include?(user2)).to be_falsey
@ -149,12 +140,9 @@ describe Meeting, type: :model do
end
describe 'participants and author as watchers' do
before do
project.add_member user1, [role]
project.add_member user2, [role]
project.save!
let(:project_members) { { user1 => role, user2 => role } }
before do
meeting.participants.build(user: user2)
meeting.save!
end
@ -206,12 +194,9 @@ describe Meeting, type: :model do
end
describe 'Copied meetings' do
before do
project.add_member user1, [role]
project.add_member user2, [role]
project.save!
let(:project_members) { { user1 => role, user2 => role } }
before do
meeting.start_date = '2013-03-27'
meeting.start_time_hour = '15:35'
meeting.participants.build(user: user2)

@ -30,11 +30,9 @@ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper.rb')
require_relative 'support/pages/cost_report_page'
describe "updating a cost report's cost type", type: :feature, js: true do
let(:project) { FactoryBot.create :project_with_types }
let(:project) { FactoryBot.create :project_with_types, members: { user => FactoryBot.create(:role) } }
let(:user) do
FactoryBot.create(:admin).tap do |user|
project.add_member! user, FactoryBot.create(:role)
end
FactoryBot.create(:admin)
end
let(:cost_type) do

@ -5,7 +5,7 @@ module OpenProject::Webhooks::EventResources
class << self
def notification_names
[
OpenProject::Events::NEW_TIME_ENTRY_CREATED
OpenProject::Events::TIME_ENTRY_CREATED
]
end

@ -30,7 +30,8 @@
require 'spec_helper'
describe GroupsController, type: :controller do
let(:group) { FactoryBot.create :group }
let(:group) { FactoryBot.create :group, members: group_members }
let(:group_members) { [] }
before do
login_as current_user
@ -81,9 +82,9 @@ describe GroupsController, type: :controller do
end
it 'should destroy' do
delete :destroy, params: { id: group.id }
perform_enqueued_jobs
perform_enqueued_jobs do
delete :destroy, params: { id: group.id }
end
expect { group.reload }.to raise_error ActiveRecord::RecordNotFound
@ -103,10 +104,9 @@ describe GroupsController, type: :controller do
context 'with a group member' do
let(:user1) { FactoryBot.create :user }
let(:user2) { FactoryBot.create :user }
let(:group_members) { [user1] }
it 'should add users' do
group.add_members! user1
post :add_users, params: { id: group.id, user_ids: [user2.id] }
expect(group.reload.users.count).to eq 2
end

@ -46,13 +46,16 @@ FactoryBot.define do
callback(:after_build) do |principal, evaluator| # this is also done after :create
(projects = evaluator.member_in_projects || [])
projects << evaluator.member_in_project if evaluator.member_in_project
if !projects.empty?
if projects.any?
role = evaluator.member_through_role || FactoryBot.build(:role,
permissions: evaluator.member_with_permissions || %i[
view_work_packages edit_work_packages
])
projects.each do |project|
project.add_member! principal, role if project
projects.compact.each do |project|
FactoryBot.create(:member,
project: project,
principal: principal,
roles: Array(role))
end
end
end

@ -33,6 +33,7 @@ FactoryBot.define do
transient do
no_types { false }
disable_modules { [] }
members { [] }
end
sequence(:name) { |n| "My Project No. #{n}" }
@ -52,6 +53,14 @@ FactoryBot.define do
end
end
callback(:after_create) do |project, evaluator|
evaluator.members.each do |user, roles|
Members::CreateService
.new(user: nil, contract_class: EmptyContract)
.call(principal: user, project: project, roles: Array(roles))
end
end
factory :public_project do
public { true } # Remark: public defaults to true
end

@ -30,22 +30,26 @@ require 'spec_helper'
feature 'group memberships through groups page', type: :feature, js: true do
shared_let(:admin) { FactoryBot.create :admin }
let!(:project) { FactoryBot.create :project, name: 'Project 1', identifier: 'project1' }
let!(:project) do
FactoryBot.create :project, name: 'Project 1', identifier: 'project1', members: project_members
end
let!(:peter) { FactoryBot.create :user, firstname: 'Peter', lastname: 'Pan' }
let!(:hannibal) { FactoryBot.create :user, firstname: 'Hannibal', lastname: 'Smith' }
let(:group) { FactoryBot.create :group, lastname: 'A-Team' }
let(:group) do
FactoryBot.create(:group, lastname: 'A-Team', members: group_members)
end
let!(:manager) { FactoryBot.create :role, name: 'Manager' }
let!(:developer) { FactoryBot.create :role, name: 'Developer' }
let(:members_page) { Pages::Members.new project.identifier }
let(:group_page) { Pages::Groups.new.group(group.id) }
let(:group_members) { [peter] }
let(:project_members) { {} }
before do
allow(User).to receive(:current).and_return admin
group.add_members! peter
end
scenario 'adding a user to a group adds the user to the project as well' do
@ -68,10 +72,8 @@ feature 'group memberships through groups page', type: :feature, js: true do
end
context 'given a group with members in a project' do
before do
group.add_members! hannibal
project.add_member! group, [manager]
end
let(:group_members) { [peter, hannibal] }
let(:project_members) { { group => [manager] } }
scenario 'removing a user from the group removes them from the project too' do
members_page.visit!
@ -99,17 +101,15 @@ feature 'group memberships through groups page', type: :feature, js: true do
end
describe 'with the group in two projects' do
let!(:project2) { FactoryBot.create :project, name: 'Project 2', identifier: 'project2' }
let!(:project2) do
FactoryBot.create :project,
name: 'Project 2',
identifier: 'project2',
members: project_members
end
let(:members_page1) { Pages::Members.new project.identifier }
let(:members_page2) { Pages::Members.new project2.identifier }
before do
project.add_member! peter, [manager]
project2.add_member! peter, [manager]
project.add_member! group, [developer]
project2.add_member! group, [developer]
end
let(:project_members) { { peter => manager, group => developer } }
it 'can add a new user to the group with correct member roles (Regression #33659)' do
members_page1.visit!

@ -30,7 +30,7 @@ require 'spec_helper'
feature 'group memberships through project members page', type: :feature do
shared_let(:admin) { FactoryBot.create :admin }
let(:project) { FactoryBot.create :project, name: 'Project 1', identifier: 'project1' }
let(:project) { FactoryBot.create :project, name: 'Project 1', identifier: 'project1', members: project_member }
let(:alice) { FactoryBot.create :user, firstname: 'Alice', lastname: 'Wonderland' }
let(:bob) { FactoryBot.create :user, firstname: 'Bob', lastname: 'Bobbit' }
@ -41,6 +41,7 @@ feature 'group memberships through project members page', type: :feature do
let(:members_page) { Pages::Members.new project.identifier }
let(:groups_page) { Pages::Groups.new }
let(:project_member) { {} }
before do
FactoryBot.create :member, user: bob, project: project, roles: [alpha]
@ -48,9 +49,7 @@ feature 'group memberships through project members page', type: :feature do
context 'given a group with members' do
let!(:group) { FactoryBot.create :group, lastname: 'group1', members: alice }
before do
allow(User).to receive(:current).and_return bob
end
current_user { bob }
scenario 'adding group1 as a member with the beta role', js: true do
members_page.visit!
@ -61,9 +60,7 @@ feature 'group memberships through project members page', type: :feature do
end
context 'which has has been added to a project' do
before do
project.add_member! group, [beta]
end
let(:project_member) { { group => beta } }
context 'with the members having no roles of their own' do
scenario 'removing the group removes its members too' do
@ -82,7 +79,7 @@ feature 'group memberships through project members page', type: :feature do
before do
project.members
.select { |m| m.user_id == alice.id }
.each { |m| m.add_and_save_role alpha }
.each { |m| m.roles << alpha }
end
scenario 'removing the group leaves the user without their group roles' do
@ -102,11 +99,11 @@ feature 'group memberships through project members page', type: :feature do
end
context 'given an empty group in a project' do
let(:project_member) { { group => beta } }
current_user { admin }
before do
alice # create alice
project.add_member! group, [beta]
allow(User).to receive(:current).and_return admin
end
scenario 'adding members to that group adds them to the project too', js: true do

@ -30,8 +30,9 @@ require 'spec_helper'
feature 'invite user via email', type: :feature, js: true do
shared_let(:admin) { FactoryBot.create :admin }
let!(:project) { FactoryBot.create :project, name: 'Project 1', identifier: 'project1' }
let!(:project) { FactoryBot.create :project, name: 'Project 1', identifier: 'project1', members: project_members }
let!(:developer) { FactoryBot.create :role, name: 'Developer' }
let(:project_members) { {} }
let(:members_page) { Pages::Members.new project.identifier }
@ -91,9 +92,7 @@ feature 'invite user via email', type: :feature, js: true do
end
context 'who is already a member' do
before do
project.add_member! user, [developer]
end
let(:project_members) { { user => developer } }
shared_examples 'no user to invite is found' do
scenario 'no matches found' do

@ -60,12 +60,7 @@ feature 'Administrating memberships via the project settings', type: :feature, j
lastname: "<script>alert('h4x');</script>"
end
let!(:group) do
FactoryBot.create(:group, lastname: 'A-Team').tap do |group|
User.execute_as User.admin.first do
group.add_members! peter
group.add_members! hannibal
end
end
FactoryBot.create(:group, lastname: 'A-Team', members: [peter, hannibal])
end
let!(:manager) { FactoryBot.create :role, name: 'Manager', permissions: [:manage_members] }
@ -137,7 +132,7 @@ feature 'Administrating memberships via the project settings', type: :feature, j
SeleniumHubWaiter.wait
members_page.remove_user! 'Hannibal Smith'
expect(page).to have_text 'Hannibal Smith has been removed from the project and deleted.'
expect(page).to have_text 'Removed Hannibal Smith from project'
expect(page).to have_text 'There are currently no members part of this project.'
end

@ -30,63 +30,82 @@ require 'spec_helper'
feature 'members pagination', type: :feature, js: true do
shared_let(:admin) { FactoryBot.create :admin }
let!(:project) { FactoryBot.create :project, name: 'Project 1', identifier: 'project1' }
let(:project) do
FactoryBot.create :project,
name: 'Project 1',
identifier: 'project1',
members: project_members
end
let(:project_members) {
{
bob => manager,
alice => developer
}
}
let!(:peter) { FactoryBot.create :user, firstname: 'Peter', lastname: 'Pan' }
let!(:bob) { FactoryBot.create :user, firstname: 'Bob', lastname: 'Bobbit' }
let!(:alice) { FactoryBot.create :user, firstname: 'Alice', lastname: 'Alison' }
let(:bob) { FactoryBot.create :user, firstname: 'Bob', lastname: 'Bobbit' }
let(:alice) { FactoryBot.create :user, firstname: 'Alice', lastname: 'Alison' }
let!(:manager) { FactoryBot.create :role, name: 'Manager' }
let!(:developer) { FactoryBot.create :role, name: 'Developer' }
let(:manager) { FactoryBot.create :role, name: 'Manager' }
let(:developer) { FactoryBot.create :role, name: 'Developer' }
let(:members_page) { Pages::Members.new project.identifier }
before do
allow(User).to receive(:current).and_return admin
project.add_member! bob, [manager]
project.add_member! alice, [developer]
end
current_user { admin }
scenario 'paginating after adding a member' do
members_page.set_items_per_page! 2
context 'when adding a member' do
it 'paginates' do
members_page.set_items_per_page! 2
members_page.visit!
SeleniumHubWaiter.wait
expect(members_page).to have_user 'Alice Alison' # members are sorted by last name desc
members_page.add_user! 'Peter Pan', as: 'Manager'
members_page.visit!
SeleniumHubWaiter.wait
expect(members_page).to have_user 'Alice Alison' # members are sorted by last name desc
members_page.add_user! 'Peter Pan', as: 'Manager'
SeleniumHubWaiter.wait
members_page.go_to_page! 2
expect(members_page).to have_user 'Peter Pan'
SeleniumHubWaiter.wait
members_page.go_to_page! 2
expect(members_page).to have_user 'Peter Pan'
end
end
scenario 'Paginating after removing a member' do
project.add_member! peter, [manager]
members_page.set_items_per_page! 1
members_page.visit!
SeleniumHubWaiter.wait
members_page.remove_user! 'Alice Alison'
expect(members_page).to have_user 'Bob Bobbit'
SeleniumHubWaiter.wait
members_page.go_to_page! 2
expect(members_page).to have_user 'Peter Pan'
context 'when removing a member' do
let(:project_members) {
{
bob => manager,
alice => developer,
peter => manager
}
}
it 'paginates' do
members_page.set_items_per_page! 1
members_page.visit!
SeleniumHubWaiter.wait
members_page.remove_user! 'Alice Alison'
expect(members_page).to have_user 'Bob Bobbit'
SeleniumHubWaiter.wait
members_page.go_to_page! 2
expect(members_page).to have_user 'Peter Pan'
end
end
scenario 'Paginating after updating a member' do
members_page.set_items_per_page! 1
members_page.visit!
SeleniumHubWaiter.wait
members_page.go_to_page! 2
members_page.edit_user! 'Bob Bobbit', add_roles: ['Developer']
expect(page).to have_text 'Successful update'
expect(members_page).to have_user 'Bob Bobbit', roles: ['Developer', 'Manager']
SeleniumHubWaiter.wait
members_page.go_to_page! 1
expect(members_page).to have_user 'Alice Alison'
context 'when updating a member' do
it 'paginates' do
members_page.set_items_per_page! 1
members_page.visit!
SeleniumHubWaiter.wait
members_page.go_to_page! 2
members_page.edit_user! 'Bob Bobbit', add_roles: ['Developer']
expect(page).to have_text 'Successful update'
expect(members_page).to have_user 'Bob Bobbit', roles: ['Developer', 'Manager']
SeleniumHubWaiter.wait
members_page.go_to_page! 1
expect(members_page).to have_user 'Alice Alison'
end
end
end

@ -28,24 +28,29 @@
require 'spec_helper'
feature 'members pagination', type: :feature, js: true do
describe 'members pagination', type: :feature, js: true do
shared_let(:admin) { FactoryBot.create :admin }
let!(:project) { FactoryBot.create :project, name: 'Project 1', identifier: 'project1' }
let(:project) do
FactoryBot.create :project,
name: 'Project 1',
identifier: 'project1',
members: {
alice => beta,
bob => alpha
}
end
let!(:bob) { FactoryBot.create :user, firstname: 'Bob', lastname: 'Bobbit' }
let!(:alice) { FactoryBot.create :user, firstname: 'Alice', lastname: 'Alison' }
let(:bob) { FactoryBot.create :user, firstname: 'Bob', lastname: 'Bobbit' }
let(:alice) { FactoryBot.create :user, firstname: 'Alice', lastname: 'Alison' }
let!(:alpha) { FactoryBot.create :role, name: 'alpha' }
let!(:beta) { FactoryBot.create :role, name: 'beta' }
let(:alpha) { FactoryBot.create :role, name: 'alpha' }
let(:beta) { FactoryBot.create :role, name: 'beta' }
let(:members_page) { Pages::Members.new project.identifier }
before do
allow(User).to receive(:current).and_return admin
project.add_member! alice, [beta]
project.add_member! bob, [alpha]
current_user { admin }
before do
members_page.visit!
end

@ -1,5 +1,9 @@
shared_context 'principal membership management context' do
shared_let(:project) { FactoryBot.create :project, name: 'Project 1', identifier: 'project1' }
shared_let(:project) do
FactoryBot.create :project,
name: 'Project 1',
identifier: 'project1'
end
shared_let(:project2) { FactoryBot.create :project, name: 'Project 2', identifier: 'project2' }
shared_let(:manager) { FactoryBot.create :role, name: 'Manager', permissions: %i[view_members manage_members] }
@ -43,16 +47,23 @@ end
shared_examples 'global user principal membership management flows' do |permission|
context 'as global user' do
shared_let(:global_user) { FactoryBot.create :user, global_permission: permission }
shared_let(:project_members) { { global_user => manager } }
current_user { global_user }
context 'when the user is member in the projects' do
it_behaves_like 'principal membership management flows' do
before do
project.add_member global_user, [manager]
project.save!
project2.add_member global_user, [manager]
project2.save!
Members::CreateService
.new(user: nil, contract_class: EmptyContract)
.call(principal: global_user,
project: project,
roles: [manager])
Members::CreateService
.new(user: nil, contract_class: EmptyContract)
.call(principal: global_user,
project: project2,
roles: [manager])
end
end
end
@ -67,8 +78,11 @@ shared_examples 'global user principal membership management flows' do |permissi
end
it 'does not show the membership' do
project.add_member principal, [developer]
project.save!
Members::CreateService
.new(user: nil, contract_class: EmptyContract)
.call(principal: principal,
project: project,
roles: [developer])
principal_page.visit!
principal_page.open_projects_tab!

@ -31,15 +31,12 @@ require 'spec_helper'
describe 'filter work packages', js: true do
let(:user) { FactoryBot.create :admin }
let(:watcher) { FactoryBot.create :user }
let(:project) { FactoryBot.create :project }
let(:project) { FactoryBot.create :project, members: { watcher => role } }
let(:role) { FactoryBot.create :existing_role, permissions: [:view_work_packages] }
let(:wp_table) { ::Pages::WorkPackagesTable.new(project) }
let(:filters) { ::Components::WorkPackages::Filters.new }
before do
project.add_member! watcher, role
login_as(user)
end
current_user { user }
context 'by watchers' do
let(:work_package_with_watcher) do

@ -31,24 +31,31 @@ require 'spec_helper'
describe 'filter me value', js: true do
let(:status) { FactoryBot.create :default_status }
let!(:priority) { FactoryBot.create :default_priority }
let(:project) { FactoryBot.create :project, public: true }
let(:project) do
FactoryBot.create :project,
public: true,
members: project_members
end
let(:role) { FactoryBot.create :existing_role, permissions: [:view_work_packages] }
let(:admin) { FactoryBot.create :admin }
let(:user) { FactoryBot.create :user }
let(:wp_table) { ::Pages::WorkPackagesTable.new(project) }
let(:filters) { ::Components::WorkPackages::Filters.new }
before do
login_as admin
project.add_member! admin, role
project.add_member! user, role
let(:project_members) do
{
admin => role,
user => role
}
end
let!(:role_anonymous) { FactoryBot.create(:anonymous_role, permissions: [:view_work_packages]) }
describe 'assignee' do
let(:wp_admin) { FactoryBot.create :work_package, status: status, project: project, assigned_to: admin }
let(:wp_user) { FactoryBot.create :work_package, status: status, project: project, assigned_to: user }
context 'as anonymous', with_settings: { login_required?: false } do
current_user { User.anonymous }
let(:assignee_query) do
query = FactoryBot.create(:query,
name: 'Assignee Query',
@ -69,11 +76,11 @@ describe 'filter me value', js: true do
end
context 'logged in' do
current_user { admin }
before do
wp_admin
wp_user
login_as(admin)
end
it 'shows the one work package filtering for myself' do
@ -132,7 +139,9 @@ describe 'filter me value', js: true do
let(:project) do
FactoryBot.create(:project,
types: [type_task],
work_package_custom_fields: [custom_field])
public: true,
work_package_custom_fields: [custom_field],
members: project_members)
end
let(:cf_accessor) { "cf_#{custom_field.id}" }
@ -163,6 +172,7 @@ describe 'filter me value', js: true do
query
end
current_user { User.anonymous }
it 'shows an error visiting a query with a me value' do
wp_table.visit_query assignee_query
@ -172,11 +182,11 @@ describe 'filter me value', js: true do
end
context 'logged in' do
current_user { admin }
before do
wp_admin
wp_user
login_as(admin)
end
it 'shows the one work package filtering for myself' do

@ -27,15 +27,17 @@
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'legacy_spec_helper'
require 'spec_helper'
describe Redmine::Notifiable do
it 'should all' do
assert_equal 11, Redmine::Notifiable.all.length
describe OpenProject::Notifiable do
describe '#all' do
it 'matches expected list' do
expected = %w(work_package_added work_package_updated work_package_note_added
status_updated work_package_priority_updated news_added news_comment_added
file_added message_posted wiki_content_added wiki_content_updated membership_added membership_updated)
%w(work_package_added work_package_updated work_package_note_added status_updated work_package_priority_updated news_added
news_comment_added file_added message_posted wiki_content_added wiki_content_updated).each do |notifiable|
assert Redmine::Notifiable.all.map(&:name).include?(notifiable), "missing #{notifiable}"
expect(described_class.all.map(&:name))
.to match_array(expected)
end
end
end

@ -0,0 +1,157 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2021 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe MemberMailer, type: :mailer do
let(:current_user) { FactoryBot.build_stubbed(:user) }
let(:member) do
FactoryBot.build_stubbed(:member,
principal: principal,
project: project,
roles: roles)
end
let(:principal) { FactoryBot.build_stubbed(:user) }
let(:project) { FactoryBot.build_stubbed(:project) }
let(:roles) { [FactoryBot.build_stubbed(:role), FactoryBot.build_stubbed(:role)] }
shared_examples_for 'has a subject' do |key|
it "has a subject" do
if project
expect(subject.subject)
.to eql I18n.t(key, project: project.name)
else
expect(subject.subject)
.to eql I18n.t(key)
end
end
end
shared_examples_for 'fails for a group' do
let(:principal) { FactoryBot.build_stubbed(:group) }
it 'raises an argument error' do
# Calling .to in order to have the mail rendered
expect { subject.to }
.to raise_error ArgumentError
end
end
shared_examples_for "sends a mail to the member's principal" do
let(:principal) { FactoryBot.build_stubbed(:group) }
it 'raises an argument error' do
# Calling .to in order to have the mail rendered
expect { subject.to }
.to raise_error ArgumentError
end
end
shared_examples_for 'sets the expected message_id header' do
it 'sets the expected message_id header' do
expect(subject['Message-ID'].value)
.to eql "<openproject.member-#{current_user.id}-#{member.id}.#{member.created_at.strftime('%Y%m%d%H%M%S')}@example.net>"
end
end
shared_examples_for 'sets the expected openproject header' do
it 'sets the expected openproject header' do
expect(subject['X-OpenProject-Project'].value)
.to eql project.identifier
end
end
shared_examples_for 'has the expected body' do
let(:body) { subject.body.parts.detect { |part| part['Content-Type'].value == 'text/html' }.body.to_s }
it 'has the expected header' do
expect(body)
.to have_text(expected_header)
end
it 'highlights the roles received' do
expected = <<~MSG
<ul>
<li> #{roles.first.name} </li>
<li> #{roles.last.name} </li>
</ul>
MSG
expect(body)
.to be_html_eql(expected)
.at_path('body/ul')
end
end
describe '#added_project' do
subject { MemberMailer.added_project(current_user, member) }
it_behaves_like "sends a mail to the member's principal"
it_behaves_like 'has a subject', :'mail_member_added_project.subject'
it_behaves_like 'sets the expected message_id header'
it_behaves_like 'sets the expected openproject header'
it_behaves_like 'has the expected body' do
let(:expected_header) do
"#{current_user.name} added you as a member to the project '#{project.name}'."
end
end
it_behaves_like 'fails for a group'
end
describe '#updated_project' do
subject { MemberMailer.updated_project(current_user, member) }
it_behaves_like "sends a mail to the member's principal"
it_behaves_like 'has a subject', :'mail_member_updated_project.subject'
it_behaves_like 'sets the expected message_id header'
it_behaves_like 'sets the expected openproject header'
it_behaves_like 'has the expected body' do
let(:expected_header) do
"#{current_user.name} updated the roles you have in the project '#{project.name}'."
end
end
it_behaves_like 'fails for a group'
end
describe '#updated_global' do
let(:project) { nil }
subject { MemberMailer.updated_global(current_user, member) }
it_behaves_like "sends a mail to the member's principal"
it_behaves_like 'has a subject', :'mail_member_updated_global.subject'
it_behaves_like 'sets the expected message_id header'
it_behaves_like 'has the expected body' do
let(:expected_header) do
"#{current_user.name} updated the roles you have globally."
end
end
it_behaves_like 'fails for a group'
end
end

@ -72,13 +72,17 @@ describe Group, type: :model do
let(:deleted_user) { DeletedUser.first }
before do
expect(::OpenProject::Notifications)
.to receive(:send).with(:member_removed, any_args)
.exactly(projects.size).times
allow(::OpenProject::Notifications)
.to receive(:send)
puts "Destroying group ..."
start = Time.now.to_i
Principals::DeleteJob.perform_now group
Groups::DeleteService
.new(user: nil, contract_class: EmptyContract, model: group)
.call
perform_enqueued_jobs
@seconds = Time.now.to_i - start
puts "Destroyed group in #{@seconds} seconds"
@ -87,6 +91,11 @@ describe Group, type: :model do
end
it 'should reassign the work package to nobody and clean up the journals' do
expect(::OpenProject::Notifications)
.to have_received(:send)
.with(OpenProject::Events::MEMBER_DESTROYED, any_args)
.exactly(projects.size).times
work_packages.each do |wp|
wp.reload

@ -95,42 +95,6 @@ describe Group, type: :model do
end
end
describe 'from legacy specs' do
let!(:roles) { FactoryBot.create_list :role, 2 }
let!(:role_ids) { roles.map(&:id).sort }
let!(:member) { FactoryBot.create :member, project: project, principal: group, role_ids: role_ids }
let!(:group) { FactoryBot.create(:group, members: user) }
it 'should roles removed when removing group membership' do
expect(user).to be_member_of project
Principals::DeleteJob.perform_now group
user.reload
project.reload
expect(user).not_to be_member_of project
end
it 'should roles updated' do
group = FactoryBot.create :group, members: user
member = FactoryBot.build :member
roles = FactoryBot.create_list :role, 2
role_ids = roles.map(&:id)
member.attributes = { principal: group, role_ids: role_ids }
member.save!
member.role_ids = [role_ids.first]
expect(user.reload.roles_for_project(member.project).map(&:id).sort).to eq([role_ids.first])
member.role_ids = role_ids
expect(user.reload.roles_for_project(member.project).map(&:id).sort).to eq(role_ids)
member.role_ids = [role_ids.last]
expect(user.reload.roles_for_project(member.project).map(&:id).sort).to eq([role_ids.last])
member.role_ids = [role_ids.first]
expect(user.reload.roles_for_project(member.project).map(&:id).sort).to eq([role_ids.first])
end
end
describe '#create' do
describe 'group with empty group name' do
let(:group) { FactoryBot.build(:group, lastname: '') }

@ -38,7 +38,7 @@ describe MailHandler, type: :model do
let(:priority_low) { FactoryBot.create(:priority_low, is_default: true) }
before do
allow(Setting).to receive(:notified_events).and_return(Redmine::Notifiable.all.map(&:name))
allow(Setting).to receive(:notified_events).and_return(OpenProject::Notifiable.all.map(&:name))
# we need both of these run first so the anonymous user is created and
# there is a default work package priority to save any work packages
priority_low

@ -32,7 +32,6 @@ describe Member, type: :model do
let(:user) { FactoryBot.create(:user) }
let(:role) { FactoryBot.create(:role) }
let(:project) { FactoryBot.create(:project) }
let(:second_role) { FactoryBot.create(:role) }
let(:member) { FactoryBot.create(:member, user: user, roles: [role]) }
let(:new_member) { FactoryBot.build(:member, user: user, roles: [role], project: project) }
@ -63,137 +62,4 @@ describe Member, type: :model do
end
end
end
describe '#add_role' do
before do
member.add_role(second_role)
member.save!
member.reload
end
context(:roles) do
it { expect(member.roles).to include role }
it { expect(member.roles).to include second_role }
end
end
describe '#add_and_save_role' do
before do
member.add_and_save_role(second_role)
member.reload
end
context(:roles) do
it { expect(member.roles).to include role }
it { expect(member.roles).to include second_role }
end
end
describe '#assign_roles' do
describe 'when replacing an existing role' do
before do
member.assign_roles([second_role])
member.save!
member.reload
end
context 'roles' do
it { expect(member.roles).not_to include role }
it { expect(member.roles).to include second_role }
end
end
describe 'when assigning empty list of roles' do
before do
member.assign_roles([])
member.save
end
context(:roles) { it { expect(member.roles).to include role } }
context(:errors) { it { expect(member.errors.map { |_k, v| v }).to include 'need to be assigned.' } }
end
end
describe '#assign_and_save_roles_and_destroy_member_if_none_left' do
describe 'when replacing an existing role' do
before do
member.assign_and_save_roles_and_destroy_member_if_none_left([second_role])
member.save!
member.reload
end
context 'roles' do
it { expect(member.roles).not_to include role }
it { expect(member.roles).to include second_role }
end
end
context 'when assigning an empty list of roles' do
before do
member.assign_and_save_roles_and_destroy_member_if_none_left([])
end
it('member should be destroyed') { expect(member.destroyed?).to eq(true) }
context(:roles) { it { expect(member.roles.reload).to be_empty } }
end
end
describe '#mark_member_role_for_destruction' do
context 'after saving the member' do
before do
# Add a second role, since we can't remove the last one
member.add_and_save_role(second_role)
member.reload
# Use member_roles(true) to make sure that all member roles are loaded,
# otherwise ActiveRecord doesn't notice mark_for_destruction.
member_role = member.member_roles.reload.first
member.mark_member_role_for_destruction(member_role)
member.save!
member.reload
end
context(:roles) { it { expect(member.roles.length).to eq(1) } }
context(:member_roles) { it { expect(member.member_roles.length).to eq(1) } }
end
context 'before saving the member when removing the last role' do
before do
member_role = member.member_roles.reload.first
member.mark_member_role_for_destruction(member_role)
end
context(:roles) { it { expect(member.roles).not_to be_empty } }
context(:member_roles) { it { expect(member.member_roles).not_to be_empty } }
context(:member) { it { expect(member).not_to be_valid } }
end
end
describe '#remove_member_role_and_destroy_member_if_last' do
context 'when a member role remains' do
before do
# Add second role, so we can check it remains
#
# Order is important here to ensure we destroy the existing
# member_role and not the one added by adding second_role.
member_role = member.member_roles.reload.first
member.add_and_save_role(second_role)
member.remove_member_role_and_destroy_member_if_last(member_role)
end
it('member should not be destroyed') { expect(member.destroyed?).to eq(false) }
context(:roles) do
it { expect(member.roles.reload).to eq [second_role] }
end
end
context 'when removing the last member role' do
before do
member_role = member.member_roles.reload.first
member.remove_member_role_and_destroy_member_if_last(member_role)
end
it('member should be destroyed') { expect(member.destroyed?).to eq(true) }
context(:roles) { it { expect(member.roles.reload).to be_empty } }
end
end
end

@ -42,7 +42,8 @@ describe Queries::WorkPackages::Filter::AssigneeOrGroupFilter, type: :model do
describe 'where filter results' do
let(:work_package) { FactoryBot.create(:work_package, assigned_to: assignee) }
let(:assignee) { FactoryBot.create(:user) }
let(:group) { FactoryBot.create(:group) }
let(:group) { FactoryBot.create(:group, members: group_members) }
let(:group_members) { [] }
subject { WorkPackage.where(instance.where) }
@ -97,12 +98,7 @@ describe Queries::WorkPackages::Filter::AssigneeOrGroupFilter, type: :model do
context 'for a group value with a group member being assignee' do
let(:values) { [group.id.to_s] }
before do
User.system.run_given do
group.add_members!(assignee)
end
end
let(:group_members) { [assignee] }
it 'returns the work package' do
is_expected

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save