diff --git a/app/models/user_merge_data.rb b/app/models/user_merge_data.rb new file mode 100644 index 00000000000..798a8475aa5 --- /dev/null +++ b/app/models/user_merge_data.rb @@ -0,0 +1,33 @@ +# +# Copyright (C) 2016 Instructure, Inc. +# +# This file is part of Canvas. +# +# Canvas is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, version 3 of the License. +# +# Canvas 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 Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# +class UserMergeData < ActiveRecord::Base + belongs_to :user + belongs_to :from_user, class_name: 'User' + has_many :user_merge_data_records + + strong_params + + def add_more_data(objects) + objects.each do |o| + r = self.user_merge_data_records.new(context: o, previous_user_id: o.user_id) + r.previous_workflow_state = o.workflow_state if o.class.columns_hash.key?('workflow_state') + r.save! + end + end + +end diff --git a/app/models/user_merge_data_record.rb b/app/models/user_merge_data_record.rb new file mode 100644 index 00000000000..a0c61cbc106 --- /dev/null +++ b/app/models/user_merge_data_record.rb @@ -0,0 +1,24 @@ +# +# Copyright (C) 2016 Instructure, Inc. +# +# This file is part of Canvas. +# +# Canvas is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, version 3 of the License. +# +# Canvas 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 Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# +class UserMergeDataRecord < ActiveRecord::Base + belongs_to :previous_user, class_name: 'User' + belongs_to :user_merge_data + belongs_to :context, polymorphic: [:account_user, :enrollment, :pseudonym] + + strong_params +end diff --git a/db/migrate/20160222035553_create_user_merge_data.rb b/db/migrate/20160222035553_create_user_merge_data.rb new file mode 100644 index 00000000000..c44aa661a76 --- /dev/null +++ b/db/migrate/20160222035553_create_user_merge_data.rb @@ -0,0 +1,29 @@ +class CreateUserMergeData < ActiveRecord::Migration + tag :predeploy + + def change + create_table :user_merge_data do |t| + t.integer :user_id, limit: 8, null: false + t.integer :from_user_id, limit: 8, null: false + t.timestamps null: false + t.string :workflow_state, null: false, default: 'active' + end + + create_table :user_merge_data_records do |t| + t.integer :user_merge_data_id, limit: 8, null: false + t.integer :context_id, limit: 8, null: false + t.integer :previous_user_id, limit: 8, null: false + t.string :context_type, null: false + t.string :previous_workflow_state + end + + add_index :user_merge_data, :user_id + add_index :user_merge_data, :from_user_id + add_index :user_merge_data_records, :user_merge_data_id + add_index :user_merge_data_records, [:context_id, :context_type, :user_merge_data_id, :previous_user_id], + unique: true, name: "index_user_merge_data_records_on_context_id_and_context_type" + + add_foreign_key :user_merge_data, :users + add_foreign_key :user_merge_data_records, :user_merge_data, column: :user_merge_data_id + end +end diff --git a/lib/user_merge.rb b/lib/user_merge.rb index 7b0a88a8781..4e02030b5c3 100644 --- a/lib/user_merge.rb +++ b/lib/user_merge.rb @@ -13,6 +13,9 @@ class UserMerge def into(target_user) return unless target_user return if target_user == from_user + user_merge_data = target_user.shard.activate do + UserMergeData.create!(user: target_user, from_user: from_user) + end if target_user.avatar_state == :none && from_user.avatar_state != :none [:avatar_image_source, :avatar_image_url, :avatar_image_updated_at, :avatar_state].each do |attr| @@ -96,11 +99,13 @@ class UserMerge destroy_conflicting_module_progressions(@from_user, target_user) - move_enrollments(@from_user, target_user) + move_enrollments(target_user, user_merge_data) Shard.with_each_shard(from_user.associated_shards + from_user.associated_shards(:weak) + from_user.associated_shards(:shadow)) do - max_position = Pseudonym.where(:user_id => target_user).order(:position).last.try(:position) || 0 - Pseudonym.where(:user_id => from_user).update_all(["user_id=?, position=position+?", target_user, max_position]) + max_position = Pseudonym.where(user_id: target_user).order(:position).last.try(:position) || 0 + pseudonyms_to_move = Pseudonym.where(user_id: from_user) + user_merge_data.add_more_data(pseudonyms_to_move) + pseudonyms_to_move.update_all(["user_id=?, position=position+?", target_user, max_position]) target_user.communication_channels.email.unretired.each do |cc| Rails.cache.delete([cc.path, 'invited_enrollments2'].cache_key) @@ -165,9 +170,12 @@ class UserMerge Rails.logger.error "migrating discussions failed: #{e}" end + account_users = AccountUser.where(user_id: from_user) + user_merge_data.add_more_data(account_users) + account_users.update_all(user_id: target_user) + updates = {} - ['account_users', 'access_tokens', 'asset_user_accesses', - 'attachments', + ['access_tokens', 'asset_user_accesses', 'attachments', 'calendar_events', 'collaborations', 'context_module_progressions', 'group_memberships', 'page_comments', @@ -291,31 +299,46 @@ class UserMerge END, sis_batch_id DESC, updated_at DESC").first end - def move_enrollments(from_user, target_user) + def handle_conflicts(column, target_user, user_merge_data) + users = [from_user, target_user] + conflict_scope(column).where(column => users).find_each do |e| + + scope = enrollment_conflicts(e, column, users) + keeper = enrollment_keeper(scope) + + # delete all conflicts from target user + to_delete = scope.where("id<>?", keeper).where(column => target_user) + user_merge_data.add_more_data(to_delete) + to_delete.delete_all + + # mark all conflicts on from_user as deleted so they will be left + to_delete = scope.active.where("id<>?", keeper).where(column => from_user) + user_merge_data.add_more_data(to_delete) + to_delete.destroy_all + end + end + + def remove_self_observers(target_user, user_merge_data) + # prevent observing self by marking them as deleted + to_delete = Enrollment.active.where("type = 'ObserverEnrollment' AND + (associated_user_id = :target_user AND user_id = :from_user OR + associated_user_id = :from_user AND user_id = :target_user)", + {target_user: target_user, from_user: from_user}) + user_merge_data.add_more_data(to_delete) + to_delete.destroy_all + end + + def move_enrollments(target_user, user_merge_data) [:associated_user_id, :user_id].each do |column| - users = [from_user, target_user] Shard.with_each_shard(from_user.associated_shards) do Enrollment.transaction do - conflict_scope(column).where(column => users).find_each do |e| - - scope = enrollment_conflicts(e, column, users) - keeper = enrollment_keeper(scope) - - # delete all conflicts from target user - scope.where("id<>?", keeper).where(column => target_user).delete_all - - # mark all conflicts on from_user as deleted so they will be left - scope.active.where("id<>?", keeper).where(column => from_user).destroy_all - end - - # prevent observing self by marking them as deleted - Enrollment.active.where("type = 'ObserverEnrollment' AND - (associated_user_id = :target_user AND user_id = :from_user OR - associated_user_id = :from_user AND user_id = :target_user)", - {target_user: target_user, from_user: from_user}).destroy_all + handle_conflicts(column, target_user, user_merge_data) + remove_self_observers(target_user, user_merge_data) # move all the enrollments that are not marked as deleted to the target user - Enrollment.active.where(column => from_user).update_all(column => target_user) + to_move = Enrollment.active.where(column => from_user) + user_merge_data.add_more_data(to_move) + to_move.update_all(column => target_user) end end end diff --git a/spec/lib/user_merge_spec.rb b/spec/lib/user_merge_spec.rb index 48e37fd4a9a..c15cb8d473a 100644 --- a/spec/lib/user_merge_spec.rb +++ b/spec/lib/user_merge_spec.rb @@ -17,14 +17,29 @@ describe UserMerge do end it "should move pseudonyms to the new user" do - user2.pseudonyms.create!(:unique_id => 'sam@yahoo.com') + pseudonym = user2.pseudonyms.create!(unique_id: 'sam@yahoo.com') + pseudonym2 = user2.pseudonyms.create!(unique_id: 'sam2@yahoo.com') UserMerge.from(user2).into(user1) + merge_data = UserMergeData.where(user_id: user1).first + expect(merge_data.from_user_id).to eq user2.id + expect(merge_data.user_merge_data_records.pluck(:context_id).sort).to eq [pseudonym.id, pseudonym2.id].sort user2.reload expect(user2.pseudonyms).to be_empty user1.reload expect(user1.pseudonyms.map(&:unique_id)).to be_include('sam@yahoo.com') end + it "should move admins to the new user" do + account1 = account_model + admin = account1.account_users.create(user: user2) + UserMerge.from(user2).into(user1) + merge_data = UserMergeData.where(user_id: user1).first + expect(merge_data.from_user_id).to eq user2.id + expect(merge_data.user_merge_data_records.where(context_type: 'AccountUser').first.context_id).to eq admin.id + user1.reload + expect(user1.account_users.first.id).to eq admin.id + end + it "should use avatar information from merged user if none exists" do user2.avatar_image = {'type' => 'external', 'url' => 'https://example.com/image.png'} user2.save! @@ -219,9 +234,14 @@ describe UserMerge do enrollment4 = course1.enroll_teacher(user1) UserMerge.from(user1).into(user2) + merge_data = UserMergeData.where(user_id: user2).first + expect(merge_data.user_merge_data_records.pluck(:context_id).sort). + to eq [enrollment1.id, enrollment3.id, enrollment4.id].sort enrollment1.reload expect(enrollment1.user).to eq user1 expect(enrollment1).to be_deleted + merge_data_record = merge_data.user_merge_data_records.where(context_id: enrollment1).first + expect(merge_data_record.previous_workflow_state).to eq 'invited' enrollment2.reload expect(enrollment2).to be_active expect(enrollment2.user).to eq user2 @@ -271,6 +291,9 @@ describe UserMerge do enrollment2 = course1.enroll_user(user2, 'ObserverEnrollment', enrollment_state: 'active', associated_user_id: user1.id) UserMerge.from(user1).into(user2) + merge_data = UserMergeData.where(user_id: user2).first + o = merge_data.user_merge_data_records.where(context_id: enrollment2).first + expect(o.previous_workflow_state).to eq 'active' expect(enrollment1.reload.user).to eql user2 expect(enrollment2.reload.workflow_state).to eql 'deleted' end