Skip to content

Instantly share code, notes, and snippets.

@nick-merrill
Created December 5, 2014 01:07
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save nick-merrill/7c395aa3634b2f2a0cb4 to your computer and use it in GitHub Desktop.
Save nick-merrill/7c395aa3634b2f2a0cb4 to your computer and use it in GitHub Desktop.
A variation on a snippet that handles one-to-one relationships by recursively migrating those relationships' field data to the `primary_object`'s related object.
# Based on https://djangosnippets.org/snippets/2283/
from django.db import transaction
from django.db.models import get_models, Model
from django.contrib.contenttypes.generic import GenericForeignKey
@transaction.atomic
def merge_model_objects(primary_object, alias_objects=None, keep_old=False):
"""
Use this function to merge model objects (i.e. Users, Organizations, Polls,
etc.) and migrate all of the related fields from the alias objects to the
primary object.
Usage:
from django.contrib.auth.models import User
primary_user = User.objects.get(email='good_email@example.com')
duplicate_user = User.objects.get(email='good_email+duplicate@example.com')
merge_model_objects(primary_user, duplicate_user)
"""
if not alias_objects: alias_objects = []
if not isinstance(alias_objects, list):
alias_objects = [alias_objects]
# check that all aliases are the same class as primary one and that
# they are subclass of model
primary_class = primary_object.__class__
if not issubclass(primary_class, Model):
raise TypeError('Only django.db.models.Model subclasses can be merged')
for alias_object in alias_objects:
if not isinstance(alias_object, primary_class):
raise TypeError('Only models of same class can be merged')
# Get a list of all GenericForeignKeys in all models
# TODO: this is a bit of a hack, since the generics framework should provide a similar
# method to the ForeignKey field for accessing the generic related fields.
generic_fields = []
for model in get_models():
for field_name, field in filter(lambda x: isinstance(x[1], GenericForeignKey), model.__dict__.iteritems()):
generic_fields.append(field)
blank_local_fields = set([field.attname for field in primary_object._meta.local_fields if getattr(primary_object, field.attname) in [None, '']])
# Loop through all alias objects and migrate their data to the primary object.
for alias_object in alias_objects:
# Migrate all foreign key references from alias object to primary object.
for related_object in alias_object._meta.get_all_related_objects():
# The variable name on the alias_object model.
alias_varname = related_object.get_accessor_name()
# The variable name on the related model.
obj_varname = related_object.field.name
related_objects = getattr(alias_object, alias_varname)
if hasattr(related_objects, 'all'):
for obj in related_objects.all():
setattr(obj, obj_varname, primary_object)
obj.save()
else:
# `related_objects` is a one-to-one field.
# Merge related one-to-one fields.
alias_related_object = related_objects
primary_related_object = getattr(primary_object, alias_varname)
# The delete will cascade later if `keep_old` is False.
# Otherwise, could violate a not-null one-to-one field constraint.
merge_model_objects(primary_related_object, alias_related_object, keep_old=True)
# Migrate all many to many references from alias object to primary object.
for related_many_object in alias_object._meta.get_all_related_many_to_many_objects():
alias_varname = related_many_object.get_accessor_name()
obj_varname = related_many_object.field.name
if alias_varname is not None:
# standard case
related_many_objects = getattr(alias_object, alias_varname).all()
else:
# special case, symmetrical relation, no reverse accessor
related_many_objects = getattr(alias_object, obj_varname).all()
for obj in related_many_objects.all():
getattr(obj, obj_varname).remove(alias_object)
getattr(obj, obj_varname).add(primary_object)
# Migrate all generic foreign key references from alias object to primary object.
for field in generic_fields:
filter_kwargs = {}
filter_kwargs[field.fk_field] = alias_object._get_pk_val()
filter_kwargs[field.ct_field] = field.get_content_type(alias_object)
for generic_related_object in field.model.objects.filter(**filter_kwargs):
setattr(generic_related_object, field.name, primary_object)
generic_related_object.save()
# Try to fill all missing values in primary object by values of duplicates
filled_up = set()
for field_name in blank_local_fields:
val = getattr(alias_object, field_name)
if val not in [None, '']:
setattr(primary_object, field_name, val)
filled_up.add(field_name)
blank_local_fields -= filled_up
if not keep_old:
alias_object.delete()
primary_object.save()
return primary_object
@rh0dium
Copy link

rh0dium commented Dec 16, 2014

Awesome - I know you can't take all the credit but this is really good.

@wdahab
Copy link

wdahab commented Jan 8, 2015

Has functionality of get_all_related_many_to_many_objects() changed? Using Django 1.7, this function isn't copying any M2M relationships that are defined on the merging objects, though it handles the ones that are defined on other objects. Testing by hand, get_all_related_objects() returns any items that reference the merging objects, but get_all_related_many_to_many_objects() returns an empty list. Checked ModelOfObjectToMerge._meta.local_many_to_many, and it returns the M2M fields for the fields that are missing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment