395 lines
18 KiB
Ruby
395 lines
18 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
#
|
|
# Copyright (C) 2016 - 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 SplitUsers
|
|
class UnsafeSplitError < StandardError; end
|
|
|
|
ENROLLMENT_DATA_UPDATES = [
|
|
{table: 'asset_user_accesses',
|
|
scope: -> { where(context_type: 'Course') }}.freeze,
|
|
{table: 'asset_user_accesses',
|
|
scope: -> { joins(:context_group).where(groups: {context_type: 'Course'}) }, context_id: 'groups.context_id'}.freeze,
|
|
{table: 'calendar_events',
|
|
scope: -> { where(context_type: 'Course') }}.freeze,
|
|
{table: 'calendar_events',
|
|
scope: -> { joins(:context_group).where(groups: {context_type: 'Course'}) },
|
|
context_id: 'groups.context_id'}.freeze,
|
|
{table: 'collaborations',
|
|
scope: -> { where(context_type: 'Course') }}.freeze,
|
|
{table: 'collaborations',
|
|
scope: -> { joins(:group).where(groups: {context_type: 'Course'}) },
|
|
context_id: 'groups.context_id'}.freeze,
|
|
{table: 'context_module_progressions',
|
|
scope: -> { joins(:context_module) },
|
|
context_id: 'context_modules.context_id'}.freeze,
|
|
{table: 'discussion_entries',
|
|
scope: -> { joins(:discussion_topic).where(discussion_topics: {context_type: 'Course'}) },
|
|
context_id: 'discussion_topics.context_id'}.freeze,
|
|
{table: 'discussion_entries',
|
|
scope: -> { joins({discussion_topic: :group}).where(groups: {context_type: 'Course'}) },
|
|
context_id: 'groups.context_id'}.freeze,
|
|
{table: 'discussion_entries', foreign_key: :editor_id,
|
|
scope: -> { joins(:discussion_topic).where(discussion_topics: {context_type: 'Course'}) },
|
|
context_id: 'discussion_topics.context_id'}.freeze,
|
|
{table: 'discussion_entries', foreign_key: :editor_id,
|
|
scope: -> { joins({discussion_topic: :group}).where(groups: {context_type: 'Course'}) },
|
|
context_id: 'groups.context_id'}.freeze,
|
|
{table: 'discussion_topics',
|
|
scope: -> { where(context_type: 'Course') }}.freeze,
|
|
{table: 'discussion_topics',
|
|
scope: -> { joins(:group).where(groups: {context_type: 'Course'}) },
|
|
context_id: 'groups.context_id'}.freeze,
|
|
{table: 'discussion_topics', foreign_key: :editor_id,
|
|
scope: -> { where(context_type: 'Course') }}.freeze,
|
|
{table: 'discussion_topics', foreign_key: :editor_id,
|
|
scope: -> { joins(:group).where(groups: {context_type: 'Course'}) },
|
|
context_id: 'groups.context_id'}.freeze,
|
|
{table: 'page_views',
|
|
scope: -> { where(context_type: 'Course') }}.freeze,
|
|
{table: 'rubric_assessments',
|
|
scope: -> { joins({submission: :assignment}) },
|
|
context_id: 'assignments.context_id'}.freeze,
|
|
{table: 'rubric_assessments', foreign_key: :assessor_id,
|
|
scope: -> { joins({submission: :assignment}) },
|
|
context_id: 'assignments.context_id'}.freeze,
|
|
{table: 'submission_comments', foreign_key: :author_id}.freeze,
|
|
{table: 'web_conference_participants',
|
|
scope: -> { joins(:web_conference).where(web_conferences: {context_type: 'Course'}) },
|
|
context_id: 'web_conferences.context_id'}.freeze,
|
|
{table: 'web_conference_participants',
|
|
scope: -> { joins({web_conference: :group}).where(web_conferences: {context_type: 'Course'}, groups: {context_type: 'Course'}) },
|
|
context_id: 'groups.context_id'}.freeze,
|
|
{table: 'web_conferences',
|
|
scope: -> { where(context_type: 'Course') }}.freeze,
|
|
{table: 'web_conferences',
|
|
scope: -> { joins(:group).where(web_conferences: {context_type: 'Course'}, groups: {context_type: 'Course'}) },
|
|
context_id: 'groups.context_id'}.freeze,
|
|
{table: 'wiki_pages',
|
|
scope: -> { joins({wiki: :course}) },
|
|
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 = new(user, merge_data).split_users
|
|
else
|
|
users = []
|
|
UserMergeData.active.splitable.where(user_id: user).shard(user).find_each do |data|
|
|
splitters = new(user, data).split_users
|
|
users = splitters | users
|
|
end
|
|
end
|
|
users
|
|
end
|
|
|
|
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
|
|
|
|
MERGE_ITEM_TYPES = {access_token: :user_id,
|
|
conversation_message: :author_id,
|
|
favorite: :user_id,
|
|
ignore: :user_id,
|
|
user_past_lti_id: :user_id,
|
|
'Polling::Poll': :user_id}.freeze
|
|
|
|
def restore_merge_items
|
|
Shard.with_each_shard(restored_user.associated_shards + restored_user.associated_shards(:weak) + restored_user.associated_shards(:shadow)) do
|
|
UserPastLtiId.where(user: source_user, user_lti_id: restored_user.lti_id).delete_all
|
|
end
|
|
source_user.shard.activate do
|
|
ConversationParticipant.where(id: merge_data.items.where(item_type: 'conversation_ids').take&.item).find_each {|c| c.move_to_user(restored_user)}
|
|
end
|
|
MERGE_ITEM_TYPES.each do |klass, user_attr|
|
|
ids = merge_data.items.where(item_type: klass.to_s + '_ids').take&.item
|
|
Shard.partition_by_shard(ids) { |shard_ids| klass.to_s.classify.constantize.where(id: shard_ids).update_all(user_attr => restored_user.id) } if ids
|
|
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)
|
|
|
|
Shard.partition_by_shard(account_users_ids) do |shard_account_user_ids|
|
|
AccountUser.where(id: shard_account_user_ids).update_all(user_id: restored_user.id)
|
|
end
|
|
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).where.not(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
|
|
|
|
# in cases where there are conflicting records
|
|
# between the source and target (of merge) comm records,
|
|
# we can eliminate some errors by detecting these and destroying
|
|
# the source record if it's already retired (because the one from
|
|
# the merge is about to overwrite it)
|
|
cc_records.where(previous_user_id: restored_user).each do |cr|
|
|
target_cc = cr.context
|
|
# if this cc didn't get moved, we don't need to worry
|
|
# about deconflicting it with the source users.
|
|
next unless target_cc.user_id == source_user.id
|
|
conflict_cc = restored_user.communication_channels.detect do |c|
|
|
c.path.downcase == target_cc.path.downcase && c.path_type == target_cc.path_type
|
|
end
|
|
if conflict_cc
|
|
# we need to resolve before we can un-merge
|
|
if conflict_cc.retired? || conflict_cc.unconfirmed?
|
|
# when the comm channel from the target record gets moved back, it will
|
|
# get restored to whatever state it needs. This one is in a useless state,
|
|
# so we could just blast this one away safely.
|
|
conflict_cc.destroy_permanently!
|
|
else
|
|
raise UnsafeSplitError, "Unsafe to decide automatically which CC to delete (for now): ( #{target_cc.id} , #{conflict_cc.id} ) from merge record #{cr.id}"
|
|
end
|
|
end
|
|
end
|
|
|
|
# move moved communication channels back
|
|
max_position = restored_user.communication_channels.last&.position&.+(1) || 0
|
|
scope = source_user.communication_channels.where(id: cc_records.where(previous_user_id: restored_user).pluck(:context_id))
|
|
# passing the array to update_all so we can get postgres to add the position for us.
|
|
scope.update_all(["user_id=?, position=position+?, root_account_ids='{?}'",
|
|
restored_user.id, max_position, restored_user.root_account_ids]) 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 undelete 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)
|
|
|
|
not_obs.update(workflow_state: 'active')
|
|
Shard.partition_by_shard(obs) do |shard_obs|
|
|
UserObservationLink.where(user_id: source_user.id, id: shard_obs).update_all(user_id: restored_user.id)
|
|
UserObservationLink.where(observer_id: source_user.id, id: shard_obs).update_all(observer_id: restored_user.id)
|
|
end
|
|
|
|
delete_ids = merge_data.records.where(context_type: 'UserObservationLink', previous_workflow_state: 'non_existent', previous_user_id: source_user).pluck(:context_id)
|
|
Shard.partition_by_shard(delete_ids) do |sharded_ids|
|
|
UserObservationLink.where(user_id: source_user.id).where(id: sharded_ids).delete_all
|
|
UserObservationLink.where(observer_id: source_user.id).where(id: sharded_ids).delete_all
|
|
end
|
|
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)).shard(source_user).
|
|
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)).shard(source_user).
|
|
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.delay.recompute_due_dates_and_scores(source_user.id)
|
|
Enrollment.delay.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
|