handle user preferences and name

also use consistent users and just get them from
the class instead of passing them around

fixes CORE-271
fixes CORE-272

test plan
 - merge a user with prefs
 - they should come through

Change-Id: I1debabbffed1dba8b1c215aa676a51a5b5dc4c53
Reviewed-on: https://gerrit.instructure.com/187281
Tested-by: Jenkins
Reviewed-by: Brent Burgoyne <bburgoyne@instructure.com>
QA-Review: Brent Burgoyne <bburgoyne@instructure.com>
Product-Review: Rob Orton <rob@instructure.com>
This commit is contained in:
Rob Orton 2019-03-27 21:07:38 -06:00
parent 8f1058183a
commit 3ff025385d
9 changed files with 437 additions and 293 deletions

View File

@ -81,230 +81,255 @@ class SplitUsers
context_id: 'courses.id'}.freeze
].freeze
attr_accessor :source_user, :restored_user, :merge_data
def initialize(source_user, merge_data)
@source_user = source_user
@merge_data = merge_data
@restored_user = nil
end
def self.split_db_users(user, merge_data = nil)
if merge_data
users = split_users(user, merge_data)
users = new(user, merge_data).split_users
else
users = []
UserMergeData.active.splitable.where(user_id: user).shard(user).find_each do |data|
splitters = split_users(user, data)
splitters = new(user, data).split_users
users = splitters | users
end
end
users
end
class << self
private
def split_users(user, merge_data)
# user is the active user that was the destination of the user merge
user.shard.activate do
ActiveRecord::Base.transaction do
records = merge_data.user_merge_data_records
old_user = User.find(merge_data.from_user_id)
old_user, pseudonyms = restore_old_user(old_user, records)
records = check_and_update_local_ids(old_user, records) if merge_data.from_user_id > Shard::IDS_PER_SHARD
records = records.preload(:context)
restore_merge_items(old_user)
move_records_to_old_user(user, old_user, records, pseudonyms)
# update account associations for each split out user
users = [old_user, user]
User.update_account_associations(users, all_shards: (old_user.shard != user.shard))
merge_data.destroy
User.where(id: users).touch_all
users
end
end
end
def restore_merge_items(old_user)
Shard.with_each_shard(old_user.associated_shards + old_user.associated_shards(:weak) + old_user.associated_shards(:shadow)) do
UserPastLtiIds.where(user: old_user, user_lti_id: old_user.lti_id).delete_all
end
end
def check_and_update_local_ids(old_user, records)
if records.where("previous_user_id<?", Shard::IDS_PER_SHARD).where(previous_user_id: old_user.local_id).exists?
records.where(previous_user_id: old_user.local_id).update_all(previous_user_id: old_user.global_id)
end
records.reload
end
# source_user is the destination user of the user merge
# user is the old user that is being restored
def move_records_to_old_user(source_user, user, records, pseudonyms)
fix_communication_channels(source_user, user, records.where(context_type: 'CommunicationChannel'))
move_user_observers(source_user, user, records.where(context_type: ['UserObserver', 'UserObservationLink'], previous_user_id: user))
move_attachments(source_user, user, records.where(context_type: 'Attachment'))
enrollment_ids = records.where(context_type: 'Enrollment', previous_user_id: user).pluck(:context_id)
Shard.partition_by_shard(enrollment_ids) do |enrollments|
restore_enrollments(enrollments, source_user, user)
end
Shard.partition_by_shard(pseudonyms) do |pseudonyms|
move_new_enrollments(enrollment_ids, pseudonyms, source_user, user)
end
handle_submissions(source_user, user, records)
account_users_ids = records.where(context_type: 'AccountUser').pluck(:context_id)
AccountUser.where(id: account_users_ids).update_all(user_id: user.id)
restore_workflow_states_from_records(records)
end
def restore_enrollments(enrollments, source_user, user)
enrollments = Enrollment.where(id: enrollments).where.not(user: user)
move_enrollments(enrollments, source_user, user)
end
def move_new_enrollments(enrollment_ids, pseudonyms, source_user, user)
new_enrollments = Enrollment.where.not(id: enrollment_ids, user: user).
where(sis_pseudonym_id: pseudonyms).shard(pseudonyms.first.shard)
move_enrollments(new_enrollments, source_user, user)
end
def move_enrollments(enrollments, source_user, user)
enrollments_to_update = filter_enrollments(enrollments, user)
Enrollment.where(id: enrollments_to_update).update_all(user_id: user.id, updated_at: Time.now.utc)
courses = enrollments_to_update.map(&:course_id)
transfer_enrollment_data(source_user, user, Course.where(id: courses))
move_submissions(source_user, user, enrollments_to_update)
end
def filter_enrollments(enrollments, user)
enrollments.reject do |e|
# skip conflicting enrollments
Enrollment.where(user_id: user,
course_section_id: e.course_section_id,
type: e.type,
role_id: e.role_id).where.not(id: e).shard(e.shard).exists?
end
end
# source_user is the destination user of the user merge
# user is the old user that is being restored
def fix_communication_channels(source_user, user, cc_records)
if source_user.shard != user.shard
source_user.shard.activate do
# remove communication channels that didn't exist prior to the merge
ccs = CommunicationChannel.where(id: cc_records.where(previous_workflow_state: 'non_existent').pluck(:context_id))
DelayedMessage.where(communication_channel_id: ccs).delete_all
NotificationPolicy.where(communication_channel: ccs).delete_all
ccs.delete_all
end
end
# move moved communication channels back
max_position = user.communication_channels.last.try(:position) || 0
scope = source_user.communication_channels.where(id: cc_records.where(previous_user_id: user).pluck(:context_id))
scope.update_all(["user_id=?, position=position+?", user.id, max_position]) unless scope.empty?
cc_records.where.not(previous_workflow_state: 'non existent').each do |cr|
CommunicationChannel.where(id: cr.context_id).update_all(workflow_state: cr.previous_workflow_state)
end
end
def move_user_observers(source_user, user, records)
# skip when the user observer is between the two users. Just undlete the record
not_obs = UserObservationLink.where(user_id: [source_user, user], observer_id: [source_user, user])
obs = UserObservationLink.where(id: records.pluck(:context_id)).where.not(id: not_obs)
source_user.as_student_observation_links.where(id: obs).update_all(user_id: user.id)
source_user.as_observer_observation_links.where(id: obs).update_all(observer_id: user.id)
end
def move_attachments(source_user, user, records)
attachments = source_user.attachments.where(id: records.pluck(:context_id))
Attachment.migrate_attachments(source_user, user, attachments)
end
def restore_old_user(user, records)
pseudonyms_ids = records.where(context_type: 'Pseudonym').pluck(:context_id)
pseudonyms = Pseudonym.where(id: pseudonyms_ids)
user ||= User.create!(name: pseudonyms.first.unique_id)
user.workflow_state = 'registered'
user.save!
move_pseudonyms_to_user(pseudonyms, user)
return user, pseudonyms
end
def move_pseudonyms_to_user(pseudonyms, target_user)
pseudonyms.each do |pseudonym|
pseudonym.update_attribute(:user_id, target_user.id)
end
end
def transfer_enrollment_data(source_user, target_user, courses)
# use a partition proc so that we only run on the actual course shard, not all
# shards associated with the course
Shard.partition_by_shard(courses, ->(course) { course.shard }) do |shard_course|
source_user_id = source_user.id
target_user_id = target_user.id
ENROLLMENT_DATA_UPDATES.each do |update|
relation = update[:table].classify.constantize.all
relation = relation.instance_exec(&update[:scope]) if update[:scope]
relation.
where((update[:context_id] || :context_id) => shard_course,
(update[:foreign_key] || :user_id) => source_user_id).
update_all((update[:foreign_key] || :user_id) => target_user_id)
end
end
end
# source_user is the destination user of the user merge
# user is the old user that is being restored
# enrollments are enrollments that have been created since the merge event,
# but for a pseudonym that was moved back to the old user.
# Also work that has happened since the merge event should moved if the
# enrollment is moved.
def move_submissions(source_user, user, enrollments)
# there should be no conflicts here because this is only called for
# enrollments that were updated which already excluded conflicts, but we
# will add the scope to protect against a FK violation.
source_user.submissions.where(assignment_id: Assignment.where(context_id: enrollments.map(&:course_id))).
where.not(assignment_id: user.all_submissions.select(:assignment_id)).
update_all(user_id: user.id)
source_user.quiz_submissions.where(quiz_id: Quizzes::Quiz.where(context_id: enrollments.map(&:course_id))).
where.not(quiz_id: user.quiz_submissions.select(:quiz_id)).
update_all(user_id: user.id)
end
def handle_submissions(source_user, user, records)
[[:submissions, 'fk_rails_8d85741475'],
[:'quizzes/quiz_submissions', 'fk_rails_04850db4b4']].each do |table, foreign_key|
model = table.to_s.classify.constantize
ids_by_shard = records.where(context_type: model.to_s, previous_user_id: user).pluck(:context_id).group_by{|id| Shard.shard_for(id)}
other_ids_by_shard = records.where(context_type: model.to_s, previous_user_id: source_user).pluck(:context_id).group_by{|id| Shard.shard_for(id)}
(ids_by_shard.keys + other_ids_by_shard.keys).uniq.each do |shard|
ids = ids_by_shard[shard] || []
other_ids = other_ids_by_shard[shard] || []
shard.activate do
model.transaction do
# there is a unique index on assignment_id and user_id or quiz_id
# and user_id. Unique indexes are checked after every row during
# an update statement to get around this and to allow us to swap
# we are setting the user_id to the negative user_id and then back
# to the user_id after the conflicting rows have been updated.
model.connection.execute("SET CONSTRAINTS #{model.connection.quote_table_name(foreign_key)} DEFERRED")
model.where(id: ids).update_all(user_id: -user.id)
model.where(id: other_ids).update_all(user_id: source_user.id)
model.where(id: ids).update_all(user_id: user.id)
end
Enrollment.send_later(:recompute_due_dates_and_scores, source_user.id)
Enrollment.send_later(:recompute_due_dates_and_scores, user.id)
end
end
end
end
def restore_workflow_states_from_records(records)
records.each do |r|
c = r.context
next unless c && c.class.columns_hash.key?('workflow_state')
c.workflow_state = r.previous_workflow_state unless c.class == Attachment
c.file_state = r.previous_workflow_state if c.class == Attachment
c.save! if c.changed? && c.valid?
def split_users
source_user.shard.activate do
ActiveRecord::Base.transaction do
@restored_user = User.find(merge_data.from_user_id)
records = merge_data.records
pseudonyms = restore_users
records = check_and_update_local_ids(records) if merge_data.from_user_id > Shard::IDS_PER_SHARD
records = records.preload(:context)
restore_merge_items
move_records_to_old_user(records, pseudonyms)
# update account associations for each split out user
users = [restored_user, source_user]
User.update_account_associations(users, all_shards: (restored_user.shard != source_user.shard))
merge_data.destroy
User.where(id: users).touch_all
users
end
end
end
private
def restore_merge_items
Shard.with_each_shard(restored_user.associated_shards + restored_user.associated_shards(:weak) + restored_user.associated_shards(:shadow)) do
UserPastLtiIds.where(user: restored_user, user_lti_id: restored_user.lti_id).delete_all
end
end
def check_and_update_local_ids(records)
if records.where("previous_user_id<?", Shard::IDS_PER_SHARD).where(previous_user_id: restored_user.local_id).exists?
records.where(previous_user_id: restored_user.local_id).update_all(previous_user_id: restored_user.global_id)
end
records.reload
end
def move_records_to_old_user(records, pseudonyms)
fix_communication_channels(records.where(context_type: 'CommunicationChannel'))
move_user_observers(records.where(context_type: ['UserObserver', 'UserObservationLink'], previous_user_id: restored_user))
move_attachments(records.where(context_type: 'Attachment'))
enrollment_ids = records.where(context_type: 'Enrollment', previous_user_id: restored_user).pluck(:context_id)
Shard.partition_by_shard(enrollment_ids) do |enrollments|
restore_enrollments(enrollments)
end
Shard.partition_by_shard(pseudonyms) do |pseudonyms|
move_new_enrollments(enrollment_ids, pseudonyms)
end
handle_submissions(records)
account_users_ids = records.where(context_type: 'AccountUser').pluck(:context_id)
AccountUser.where(id: account_users_ids).update_all(user_id: restored_user.id)
restore_workflow_states_from_records(records)
end
def restore_enrollments(enrollments)
enrollments = Enrollment.where(id: enrollments).where.not(user: restored_user)
move_enrollments(enrollments)
end
def move_new_enrollments(enrollment_ids, pseudonyms)
new_enrollments = Enrollment.where.not(id: enrollment_ids, user: restored_user).
where(sis_pseudonym_id: pseudonyms).shard(pseudonyms.first.shard)
move_enrollments(new_enrollments)
end
def move_enrollments(enrollments)
enrollments_to_update = filter_enrollments(enrollments)
Enrollment.where(id: enrollments_to_update).update_all(user_id: restored_user.id, updated_at: Time.now.utc)
courses = enrollments_to_update.map(&:course_id)
transfer_enrollment_data(Course.where(id: courses))
move_submissions(enrollments_to_update)
end
def filter_enrollments(enrollments)
enrollments.reject do |e|
# skip conflicting enrollments
Enrollment.where(user_id: restored_user,
course_section_id: e.course_section_id,
type: e.type,
role_id: e.role_id).where.not(id: e).shard(e.shard).exists?
end
end
def fix_communication_channels(cc_records)
if source_user.shard != restored_user.shard
source_user.shard.activate do
# remove communication channels that didn't exist prior to the merge
ccs = CommunicationChannel.where(id: cc_records.where(previous_workflow_state: 'non_existent').pluck(:context_id))
DelayedMessage.where(communication_channel_id: ccs).delete_all
NotificationPolicy.where(communication_channel: ccs).delete_all
ccs.delete_all
end
end
# move moved communication channels back
max_position = restored_user.communication_channels.last.try(:position) || 0
scope = source_user.communication_channels.where(id: cc_records.where(previous_user_id: restored_user).pluck(:context_id))
scope.update_all(["user_id=?, position=position+?", restored_user.id, max_position]) unless scope.empty?
cc_records.where.not(previous_workflow_state: 'non existent').each do |cr|
CommunicationChannel.where(id: cr.context_id).update_all(workflow_state: cr.previous_workflow_state)
end
end
def move_user_observers(records)
# skip when the user observer is between the two users. Just undlete the record
not_obs = UserObservationLink.where(user_id: [source_user, restored_user], observer_id: [source_user, restored_user])
obs = UserObservationLink.where(id: records.pluck(:context_id)).where.not(id: not_obs)
source_user.as_student_observation_links.where(id: obs).update_all(user_id: restored_user.id)
source_user.as_observer_observation_links.where(id: obs).update_all(observer_id: restored_user.id)
end
def move_attachments(records)
attachments = source_user.attachments.where(id: records.pluck(:context_id))
Attachment.migrate_attachments(source_user, restored_user, attachments)
end
def restore_users
restore_source_user
pseudonyms_ids = merge_data.records.where(context_type: 'Pseudonym').pluck(:context_id)
pseudonyms = Pseudonym.where(id: pseudonyms_ids)
# the where.not needs to be used incase that user is actually deleted.
name = merge_data.items.where.not(user_id: source_user).where(item_type: 'user_name').take&.item || pseudonyms.first.unique_id
prefs = merge_data.items.where.not(user_id: source_user).where(item_type: 'user_preferences').take&.item
@restored_user ||= User.new
@restored_user.name = name
@restored_user.preferences = prefs
@restored_user.workflow_state = 'registered'
shard = Shard.shard_for(merge_data.from_user_id)
shard ||= source_user.shard
@restored_user.shard = shard if @restored_user.new_record?
@restored_user.save!
move_pseudonyms_to_user(pseudonyms)
pseudonyms
end
def restore_source_user
[:avatar_image_source, :avatar_image_url, :avatar_image_updated_at, :avatar_state].each do |attr|
avatar_item = merge_data.items.where.not(user_id: source_user).where(item_type: attr).take&.item
# we only move avatar items if there were no avatar on the source_user,
# so now we only restore it if they match what was on the from_user.
source_user[attr] = avatar_item if source_user[attr] == avatar_item
end
source_user.name = merge_data.items.where(user_id: source_user, item_type: 'user_name').take&.item
# we will leave the merged preferences on the user, most of them are for a
# specific context that will not be there, but it will keep new
# preferences except for terms_of_use.
source_user.preferences[:accepted_terms] = merge_data.items.
where(user_id: source_user).where(item_type: 'user_preferences').take&.item&.dig(:accepted_terms)
source_user.preferences = {} if source_user.preferences == {accepted_terms: nil}
source_user.save! if source_user.changed?
end
def move_pseudonyms_to_user(pseudonyms)
pseudonyms.each do |pseudonym|
pseudonym.update_attribute(:user_id, restored_user.id)
end
end
def transfer_enrollment_data(courses)
# use a partition proc so that we only run on the actual course shard, not all
# shards associated with the course
Shard.partition_by_shard(courses, ->(course) {course.shard}) do |shard_course|
source_user_id = source_user.id
target_user_id = restored_user.id
ENROLLMENT_DATA_UPDATES.each do |update|
relation = update[:table].classify.constantize.all
relation = relation.instance_exec(&update[:scope]) if update[:scope]
relation.
where((update[:context_id] || :context_id) => shard_course,
(update[:foreign_key] || :user_id) => source_user_id).
update_all((update[:foreign_key] || :user_id) => target_user_id)
end
end
end
# enrollments are enrollments that have been created since the merge event,
# but for a pseudonym that was moved back to the old user.
# Also work that has happened since the merge event should moved if the
# enrollment is moved.
def move_submissions(enrollments)
# there should be no conflicts here because this is only called for
# enrollments that were updated which already excluded conflicts, but we
# will add the scope to protect against a FK violation.
source_user.submissions.where(assignment_id: Assignment.where(context_id: enrollments.map(&:course_id))).
where.not(assignment_id: restored_user.all_submissions.select(:assignment_id)).
update_all(user_id: restored_user.id)
source_user.quiz_submissions.where(quiz_id: Quizzes::Quiz.where(context_id: enrollments.map(&:course_id))).
where.not(quiz_id: restored_user.quiz_submissions.select(:quiz_id)).
update_all(user_id: restored_user.id)
end
def handle_submissions(records)
[[:submissions, 'fk_rails_8d85741475'],
[:'quizzes/quiz_submissions', 'fk_rails_04850db4b4']].each do |table, foreign_key|
model = table.to_s.classify.constantize
ids_by_shard = records.where(context_type: model.to_s, previous_user_id: restored_user).pluck(:context_id).group_by {|id| Shard.shard_for(id)}
other_ids_by_shard = records.where(context_type: model.to_s, previous_user_id: source_user).pluck(:context_id).group_by {|id| Shard.shard_for(id)}
(ids_by_shard.keys + other_ids_by_shard.keys).uniq.each do |shard|
ids = ids_by_shard[shard] || []
other_ids = other_ids_by_shard[shard] || []
shard.activate do
model.transaction do
# there is a unique index on assignment_id and user_id or quiz_id
# and user_id. Unique indexes are checked after every row during
# an update statement to get around this and to allow us to swap
# we are setting the user_id to the negative user_id and then back
# to the user_id after the conflicting rows have been updated.
model.connection.execute("SET CONSTRAINTS #{model.connection.quote_table_name(foreign_key)} DEFERRED")
model.where(id: ids).update_all(user_id: -restored_user.id)
model.where(id: other_ids).update_all(user_id: source_user.id)
model.where(id: ids).update_all(user_id: restored_user.id)
end
Enrollment.send_later(:recompute_due_dates_and_scores, source_user.id)
Enrollment.send_later(:recompute_due_dates_and_scores, restored_user.id)
end
end
end
end
def restore_workflow_states_from_records(records)
records.each do |r|
c = r.context
next unless c && c.class.columns_hash.key?('workflow_state')
c.workflow_state = r.previous_workflow_state unless c.class == Attachment
c.file_state = r.previous_workflow_state if c.class == Attachment
c.save! if c.changed? && c.valid?
end
end
end

View File

@ -18,7 +18,8 @@
class UserMergeData < ActiveRecord::Base
belongs_to :user
belongs_to :from_user, class_name: 'User'
has_many :user_merge_data_records
has_many :records, class_name: 'UserMergeDataRecord', inverse_of: :merge_data
has_many :items, class_name: 'UserMergeDataItem', inverse_of: :merge_data
scope :active, -> { where.not(workflow_state: 'deleted') }
scope :splitable, -> { where('created_at > ?', split_time) }
@ -37,7 +38,7 @@ class UserMergeData < ActiveRecord::Base
self.shard.activate do
objects.each do |o|
user ||= o.user_id
r = self.user_merge_data_records.new(context: o, previous_user_id: user)
r = self.records.new(context: o, previous_user_id: user)
r.previous_workflow_state = o.workflow_state if o.class.columns_hash.key?('workflow_state')
r.previous_workflow_state = o.file_state if o.class == Attachment
r.previous_workflow_state = workflow_state if workflow_state

View File

@ -0,0 +1,23 @@
#
# Copyright (C) 2019 - present 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 <http://www.gnu.org/licenses/>.
#
class UserMergeDataItem < ActiveRecord::Base
belongs_to :user
belongs_to :merge_data, class_name: 'UserMergeData', inverse_of: :items
serialize :item
end

View File

@ -17,7 +17,7 @@
#
class UserMergeDataRecord < ActiveRecord::Base
belongs_to :previous_user, class_name: 'User'
belongs_to :user_merge_data
belongs_to :merge_data, class_name: 'UserMergeData', inverse_of: :records
belongs_to :context, polymorphic: [:account_user, :enrollment, :pseudonym, :user_observer, :user_observation_link,
:attachment, :communication_channel, :user_service,
:submission, {quiz_submission: 'Quizzes::QuizSubmission'}]

View File

@ -22,7 +22,8 @@ class RecomputeMergedEnrollments < ActiveRecord::Migration[4.2]
def up
start_date = DateTime.parse("2016-08-05")
merged_enrollment_ids = UserMergeDataRecord.where(:context_type => "Enrollment").
joins(:user_merge_data).where("user_merge_data.updated_at > ?", start_date).pluck(:context_id)
joins("INNER JOIN #{UserMergeData.quoted_table_name} ON user_merge_data_records.user_merge_data_id = user_merge_data.id").
where("user_merge_data.updated_at > ?", start_date).pluck(:context_id)
if merged_enrollment_ids.any?
Shard.partition_by_shard(merged_enrollment_ids) do |sliced_ids|

View File

@ -0,0 +1,29 @@
#
# Copyright (C) 2019 - present 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 <http://www.gnu.org/licenses/>.
class UserMergeDataItems < ActiveRecord::Migration[5.1]
tag :predeploy
def change
create_table :user_merge_data_items do |t|
t.references :user_merge_data, limit: 8, foreign_key: true, index: true, null: false
t.references :user, limit: 8, foreign_key: true, index: true, null: false
t.string :item_type, null: false, limit: 255
t.text :item, null: false
end
end
end

View File

@ -22,29 +22,45 @@ class UserMerge
end
attr_reader :from_user
attr_accessor :data
attr_accessor :target_user, :data, :merge_data
def initialize(from_user)
@from_user = from_user
@target_user = nil
@merge_data = nil
@data = []
end
def into(target_user)
return unless target_user
return if target_user == from_user
@target_user = target_user
target_user.associate_with_shard(from_user.shard, :shadow)
# we also store records for the from_user on the target shard for a split
from_user.associate_with_shard(target_user.shard, :shadow)
user_merge_data = target_user.shard.activate do
UserMergeData.create!(user: target_user, from_user: from_user)
end
target_user.shard.activate do
@merge_data = UserMergeData.create!(user: target_user, from_user: from_user)
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|
target_user[attr] = from_user[attr]
items = []
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|
items << merge_data.items.new(user: from_user, item_type: attr.to_s, item: from_user[attr]) if from_user[attr]
target_user[attr] = from_user[attr]
end
end
# record the users names and preferences in case of split.
items << merge_data.items.new(user: from_user, item_type: 'user_name', item: from_user.name)
items << merge_data.items.new(user: target_user, item_type: 'user_name', item: target_user.name)
UserMergeDataItem.bulk_insert_objects(items)
# bulk insert doesn't play nice with the hash values of preferences.
merge_data.items.create!(user: from_user, item_type: 'user_preferences', item: from_user.preferences)
merge_data.items.create!(user: target_user, item_type: 'user_preferences', item: target_user.preferences)
target_user.preferences = target_user.preferences.merge(from_user.preferences)
target_user.save if target_user.changed?
end
target_user.save if target_user.changed?
[:strong, :weak, :shadow].each do |strength|
from_user.associated_shards(strength).each do |shard|
@ -52,25 +68,22 @@ class UserMerge
end
end
populate_past_lti_ids(target_user)
handle_communication_channels(target_user, user_merge_data)
destroy_conflicting_module_progressions(@from_user, target_user)
move_enrollments(target_user, user_merge_data)
populate_past_lti_ids
handle_communication_channels
destroy_conflicting_module_progressions
move_enrollments
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
pseudonyms_to_move = Pseudonym.where(user_id: from_user)
user_merge_data.add_more_data(pseudonyms_to_move)
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)
end
handle_submissions(target_user, user_merge_data)
handle_submissions
from_user.all_conversations.find_each { |c| c.move_to_user(target_user) }
@ -86,11 +99,11 @@ class UserMerge
end
account_users = AccountUser.where(user_id: from_user)
user_merge_data.add_more_data(account_users)
merge_data.add_more_data(account_users)
account_users.update_all(user_id: target_user.id)
attachments = Attachment.where(user_id: from_user)
user_merge_data.add_more_data(attachments)
merge_data.add_more_data(attachments)
Attachment.send_later(:migrate_attachments, from_user, target_user)
updates = {}
@ -114,7 +127,7 @@ class UserMerge
scope = klass.where(column => from_user)
klass.transaction do
if version_updates.include?(table)
update_versions(from_user, target_user, scope, table, column)
update_versions(scope, table, column)
end
scope.update_all(column => target_user.id)
end
@ -131,7 +144,7 @@ class UserMerge
update_all(context_id: target_user.id, context_code: target_user.asset_string)
end
move_observees(target_user, user_merge_data)
move_observees
Enrollment.send_later(:recompute_due_dates_and_scores, target_user.id)
target_user.update_account_associations
@ -142,7 +155,7 @@ class UserMerge
from_user.destroy
end
def populate_past_lti_ids(target_user)
def populate_past_lti_ids
Shard.with_each_shard(from_user.associated_shards + from_user.associated_shards(:weak) + from_user.associated_shards(:shadow)) do
lti_ids = []
{enrollments: :course, group_memberships: :group, account_users: :account}.each do |klass, type|
@ -160,62 +173,62 @@ class UserMerge
end
end
def handle_communication_channels(target_user, user_merge_data)
def handle_communication_channels
max_position = target_user.communication_channels.last.try(:position) || 0
to_retire_ids = []
known_ccs = target_user.communication_channels.pluck(:id)
from_user.communication_channels.each do |cc|
# have to find conflicting CCs, and make sure we don't have conflicts
target_cc = detect_conflicting_cc(cc, target_user)
target_cc = detect_conflicting_cc(cc)
if !target_cc && from_user.shard != target_user.shard
User.clone_communication_channel(cc, target_user, max_position)
new_cc = target_user.communication_channels.where.not(id: known_ccs).take
known_ccs << new_cc.id
user_merge_data.build_more_data([new_cc], user: target_user, workflow_state: 'non_existent', data: data)
merge_data.build_more_data([new_cc], user: target_user, workflow_state: 'non_existent', data: data)
end
next unless target_cc
to_retire = handle_conflicting_ccs(cc, target_cc, target_user, max_position)
to_retire = handle_conflicting_ccs(cc, target_cc, max_position)
if to_retire
keeper = ([target_cc, cc] - [to_retire]).first
copy_notificaion_policies(to_retire, keeper, user_merge_data)
copy_notificaion_policies(to_retire, keeper)
to_retire_ids << to_retire.id
end
end
finish_ccs(max_position, target_user, to_retire_ids, user_merge_data)
finish_ccs(max_position, to_retire_ids)
end
def detect_conflicting_cc(source_cc, target_user)
def detect_conflicting_cc(source_cc)
target_user.communication_channels.detect do |c|
c.path.downcase == source_cc.path.downcase && c.path_type == source_cc.path_type
end
end
def finish_ccs(max_position, target_user, to_retire_ids, user_merge_data)
def finish_ccs(max_position, to_retire_ids)
if from_user.shard != target_user.shard
handle_cross_shard_cc(target_user, user_merge_data)
handle_cross_shard_cc
else
from_user.shard.activate do
ccs = CommunicationChannel.where(id: to_retire_ids).where.not(workflow_state: 'retired')
user_merge_data.build_more_data(ccs, data: data) unless to_retire_ids.empty?
merge_data.build_more_data(ccs, data: data) unless to_retire_ids.empty?
ccs.update_all(workflow_state: 'retired') unless to_retire_ids.empty?
end
scope = from_user.communication_channels.where.not(workflow_state: 'retired')
scope = scope.where.not(id: to_retire_ids) unless to_retire_ids.empty?
unless scope.empty?
user_merge_data.build_more_data(scope, data: data)
merge_data.build_more_data(scope, data: data)
scope.update_all(["user_id=?, position=position+?", target_user, max_position])
end
end
user_merge_data.bulk_insert_merge_data(data)
merge_data.bulk_insert_merge_data(data)
@data = []
end
def handle_cross_shard_cc(target_user, user_merge_data)
def handle_cross_shard_cc
ccs = from_user.communication_channels.where.not(workflow_state: 'retired')
user_merge_data.build_more_data(ccs, data: data) unless ccs.empty?
merge_data.build_more_data(ccs, data: data) unless ccs.empty?
ccs.update_all(workflow_state: 'retired') unless ccs.empty?
from_user.user_services.each do |us|
@ -223,13 +236,13 @@ class UserMerge
new_us.shard = target_user.shard
new_us.user = target_user
new_us.save!
user_merge_data.build_more_data([new_us], user: target_user, workflow_state: 'non_existent', data: data)
merge_data.build_more_data([new_us], user: target_user, workflow_state: 'non_existent', data: data)
end
user_merge_data.build_more_data(from_user.user_services, data: data)
merge_data.build_more_data(from_user.user_services, data: data)
from_user.user_services.delete_all
end
def handle_conflicting_ccs(source_cc, target_cc, target_user, max_position)
def handle_conflicting_ccs(source_cc, target_cc, max_position)
# we prefer keeping the "most" active one, preferring the target user if they're equal
# the comments inline show all the different cases, with the source cc on the left,
# target cc on the right. The * indicates the CC that will be retired in order
@ -265,7 +278,7 @@ class UserMerge
to_retire
end
def copy_notificaion_policies(to_retire, keeper, user_merge_data)
def copy_notificaion_policies(to_retire, keeper)
# cross shard channels get cloned and so do notification_policies
return unless to_retire.shard == keeper.shard
# if the communication_channel is already retired, don't bother.
@ -282,15 +295,15 @@ class UserMerge
NotificationPolicy.bulk_insert_objects(new_nps)
end
def move_observees(target_user, user_merge_data)
def move_observees
# record all the records before destroying them
# pass the from_user since user_id will be the observer
user_merge_data.build_more_data(from_user.as_observer_observation_links, user: from_user, data: data)
user_merge_data.build_more_data(from_user.as_student_observation_links, data: data)
merge_data.build_more_data(from_user.as_observer_observation_links, user: from_user, data: data)
merge_data.build_more_data(from_user.as_student_observation_links, data: data)
# delete duplicate or invalid observers/observees, move the rest
from_user.as_observer_observation_links.where(user_id: target_user.as_observer_observation_links.map(&:user_id)).destroy_all
from_user.as_observer_observation_links.where(user_id: target_user).destroy_all
user_merge_data.add_more_data(target_user.as_observer_observation_links.where(user_id: from_user), user: target_user, data: data)
merge_data.add_more_data(target_user.as_observer_observation_links.where(user_id: from_user), user: target_user, data: data)
@data = []
target_user.as_observer_observation_links.where(user_id: from_user).destroy_all
target_user.associate_with_shard(from_user.shard) if from_user.as_observer_observation_links.exists?
@ -304,7 +317,7 @@ class UserMerge
target_user.as_student_observation_links.where(observer_id: xor_observer_ids).each(&:create_linked_enrollments)
end
def destroy_conflicting_module_progressions(from_user, target_user)
def destroy_conflicting_module_progressions
# there is a unique index on the context_module_progressions table
# we need to delete all the conflicting context_module_progressions
# without impacting the users module progress and without having to
@ -374,7 +387,7 @@ class UserMerge
keeper.destroy
end
def handle_conflicts(column, target_user, user_merge_data)
def handle_conflicts(column)
users = [from_user, target_user]
# get each pair of conflicts and "handle them"
@ -391,49 +404,49 @@ class UserMerge
# both target and from users records will be recorded in case of a split.
if to_update.exists?
# record both records state since both will change
user_merge_data.build_more_data(scope, data: data)
merge_data.build_more_data(scope, data: data)
update_enrollment_state(scope, keeper)
end
# identify if the from users records are worse states than target user
to_delete = scope.active.where.not(id: keeper).where(column => from_user)
# record the current state in case of split
user_merge_data.build_more_data(to_delete, data: data)
merge_data.build_more_data(to_delete, data: data)
# mark all conflicts on from_user as deleted so they will not be moved later
to_delete.destroy_all
end
end
def remove_self_observers(target_user, user_merge_data)
def remove_self_observers
# 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.build_more_data(to_delete, data: data)
merge_data.build_more_data(to_delete, data: data)
to_delete.destroy_all
end
def move_enrollments(target_user, user_merge_data)
def move_enrollments
[:associated_user_id, :user_id].each do |column|
Shard.with_each_shard(from_user.associated_shards) do
Enrollment.transaction do
handle_conflicts(column, target_user, user_merge_data)
remove_self_observers(target_user, user_merge_data)
handle_conflicts(column)
remove_self_observers
# move all the enrollments that have not been marked as deleted to the target user
to_move = Enrollment.active.where(column => from_user)
# upgrade to strong association if there are any enrollments
target_user.associate_with_shard(from_user.shard) if to_move.exists?
user_merge_data.build_more_data(to_move, data: data)
merge_data.build_more_data(to_move, data: data)
to_move.update_all(column => target_user.id)
end
end
end
user_merge_data.bulk_insert_merge_data(data)
merge_data.bulk_insert_merge_data(data)
@data = []
end
def handle_submissions(target_user, user_merge_data)
def handle_submissions
[
[:assignment_id, :submissions],
[:quiz_id, :'quizzes/quiz_submissions']
@ -457,9 +470,9 @@ class UserMerge
to_move_ids += scope.having_submission.select(unique_id).where.not(unique_id => already_scope.having_submission.select(unique_id), id: to_move_ids).pluck(:id)
to_move = scope.where(id: to_move_ids).to_a
move_back = already_scope.where(unique_id => to_move.map(&unique_id)).to_a
user_merge_data.build_more_data(to_move, data: data)
user_merge_data.build_more_data(move_back, data: data)
swap_submission(model, move_back, table, target_user, to_move, to_move_ids, 'fk_rails_8d85741475')
merge_data.build_more_data(to_move, data: data)
merge_data.build_more_data(move_back, data: data)
swap_submission(model, move_back, table, to_move, to_move_ids, 'fk_rails_8d85741475')
elsif model.name == "Quizzes::QuizSubmission"
subscope = already_scope.to_a
to_move = model.where(user_id: from_user).joins(:submission).where(submissions: {user_id: target_user}).to_a
@ -467,19 +480,19 @@ class UserMerge
to_move += scope.where("#{unique_id} NOT IN (?)", [subscope.map(&unique_id), move_back.map(&unique_id)].flatten).to_a
move_back += already_scope.where(unique_id => to_move.map(&unique_id)).to_a
user_merge_data.build_more_data(to_move, data: data)
user_merge_data.build_more_data(move_back, data: data)
swap_submission(model, move_back, table, target_user, to_move, to_move, 'fk_rails_04850db4b4')
merge_data.build_more_data(to_move, data: data)
merge_data.build_more_data(move_back, data: data)
swap_submission(model, move_back, table, to_move, to_move, 'fk_rails_04850db4b4')
end
rescue => e
Rails.logger.error "migrating #{table} column user_id failed: #{e}"
end
end
user_merge_data.bulk_insert_merge_data(data)
merge_data.bulk_insert_merge_data(data)
@data = []
end
def swap_submission(model, move_back, table, target_user, to_move, to_move_ids, fk)
def swap_submission(model, move_back, table, to_move, to_move_ids, fk)
model.transaction do
# there is a unique index on assignment_id and user_id. Unique
# indexes are checked after every row during an update statement
@ -491,12 +504,12 @@ class UserMerge
model.where(id: move_back).update_all(user_id: -from_user.id) if target_user.shard == from_user.shard
model.where(id: to_move_ids).update_all(user_id: target_user.id)
model.where(id: move_back).update_all(user_id: from_user.id)
update_versions(from_user, target_user, model.where(id: to_move), table, :user_id)
update_versions(target_user, from_user, model.where(id: move_back), table, :user_id)
update_versions(model.where(id: to_move), table, :user_id)
update_versions(model.where(id: move_back), table, :user_id)
end
end
def update_versions(from_user, target_user, scope, table, column)
def update_versions(scope, table, column)
scope.find_ids_in_batches do |ids|
versionable_type = table.to_s.classify
# TODO: This is a hack to support namespacing

View File

@ -38,7 +38,7 @@ describe UserMerge do
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
expect(merge_data.records.pluck(:context_id).sort).to eq [pseudonym.id, pseudonym2.id].sort
user2.reload
expect(user2.pseudonyms).to be_empty
user1.reload
@ -60,7 +60,7 @@ describe UserMerge do
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
expect(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
@ -237,7 +237,7 @@ describe UserMerge do
UserMerge.from(user1).into(user2)
user1.reload
user2.reload
records = UserMergeData.where(user_id: user2).take.user_merge_data_records
records = UserMergeData.where(user_id: user2).take.records
expect(records.count).to eq 8
record = records.where(context_id: cc1).take
expect(record.previous_user_id).to eq user1.id
@ -286,12 +286,12 @@ describe UserMerge do
UserMerge.from(user1).into(user2)
merge_data = UserMergeData.where(user_id: user2).first
expect(merge_data.user_merge_data_records.pluck(:context_id).sort).
expect(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
merge_data_record = 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
@ -312,20 +312,20 @@ describe UserMerge do
UserMerge.from(user2).into(user1)
merge_data = UserMergeData.where(user_id: user1).first
expect(merge_data.user_merge_data_records.pluck(:context_id).sort).
expect(merge_data.records.pluck(:context_id).sort).
to eq [enrollment1.id, enrollment2.id].sort
enrollment1.reload
expect(enrollment1.user).to eq user1
expect(enrollment1.workflow_state).to eq 'active'
expect(enrollment1.enrollment_state.state).to eq 'active'
merge_data_record = merge_data.user_merge_data_records.where(context_id: enrollment1).first
merge_data_record = merge_data.records.where(context_id: enrollment1).first
expect(merge_data_record.previous_workflow_state).to eq 'invited'
enrollment2.reload
expect(enrollment2.user).to eq user2
expect(enrollment2.workflow_state).to eq 'deleted'
expect(enrollment2.enrollment_state.state).to eq 'deleted'
merge_data_record2 = merge_data.user_merge_data_records.where(context_id: enrollment2).first
merge_data_record2 = merge_data.records.where(context_id: enrollment2).first
expect(merge_data_record2.previous_workflow_state).to eq 'active'
end
@ -366,7 +366,7 @@ describe UserMerge do
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
o = 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'
@ -412,7 +412,7 @@ describe UserMerge do
UserMerge.from(user1).into(user2)
data = UserMergeData.where(user_id: user2).first
expect(data.user_merge_data_records.where(context_type: 'UserObservationLink').count).to eq 2
expect(data.records.where(context_type: 'UserObservationLink').count).to eq 2
user1.reload
expect(user1.linked_observers).to be_empty
expect(UserObservationLink.where(:student => user1).first.workflow_state).to eq 'deleted'

View File

@ -28,6 +28,58 @@ describe SplitUsers do
let(:account1) { Account.default }
let(:sub_account) { account1.sub_accounts.create! }
it 'should restore terms_of use one way' do
user1.accept_terms
user1.save!
UserMerge.from(user2).into(user1)
SplitUsers.split_db_users(user1)
expect(user2.reload.preferences[:accepted_terms]).to be_nil
expect(user1.reload.preferences[:accepted_terms]).to_not be_nil
end
it 'should restore terms_of use other way' do
user1.accept_terms
user1.save!
UserMerge.from(user1).into(user2)
expect(user2.reload.preferences[:accepted_terms]).to_not be_nil
SplitUsers.split_db_users(user2)
expect(user1.reload.preferences[:accepted_terms]).to_not be_nil
expect(user2.reload.preferences[:accepted_terms]).to be_nil
end
it 'should restore terms_of use no way' do
UserMerge.from(user1).into(user2)
user2.accept_terms
user2.save!
SplitUsers.split_db_users(user2)
expect(user2.reload.preferences[:accepted_terms]).to be_nil
expect(user1.reload.preferences[:accepted_terms]).to be_nil
end
it 'should restore terms_of use both ways' do
user1.accept_terms
user1.save!
user2.accept_terms
user2.save!
UserMerge.from(user1).into(user2)
SplitUsers.split_db_users(user2)
expect(user2.reload.preferences[:accepted_terms]).to_not be_nil
expect(user1.reload.preferences[:accepted_terms]).to_not be_nil
end
it 'should restore names' do
user1.name = "jimmy one"
user1.save!
user2.name = "jenny one"
user2.save!
UserMerge.from(user1).into(user2)
user2.name = "other name"
user2.save!
SplitUsers.split_db_users(user2)
expect(user1.reload.name).to eq "jimmy one"
expect(user2.reload.name).to eq "jenny one"
end
it 'should restore pseudonyms to the original user' do
pseudonym1 = user1.pseudonyms.create!(unique_id: 'sam1@example.com')
pseudonym2 = user2.pseudonyms.create!(unique_id: 'sam2@example.com')