Django: объединение объектов

у меня такая модель:

class Place(models.Model):
    name = models.CharField(max_length=80, db_index=True)
    city = models.ForeignKey(City)
    address = models.CharField(max_length=255, db_index=True)
    # and so on

поскольку я импортирую их из многих источников, и пользователи моего сайта могут добавлять новые места, мне нужен способ объединить их из интерфейса администратора. Проблема в том, что название не очень надежное, так как их можно писать по-разному и т. д Я привык использовать что-то вроде этого:

class Place(models.Model):
    name = models.CharField(max_length=80, db_index=True) # canonical
    city = models.ForeignKey(City)
    address = models.CharField(max_length=255, db_index=True)
    # and so on

class PlaceName(models.Model):
    name = models.CharField(max_length=80, db_index=True)
    place = models.ForeignKey(Place)

запрос такой

Place.objects.get(placename__name='St Paul's Cathedral', city=london)

и слиться вот так

class PlaceAdmin(admin.ModelAdmin):
    actions = ('merge', )

    def merge(self, request, queryset):
        main = queryset[0]
        tail = queryset[1:]

        PlaceName.objects.filter(place__in=tail).update(place=main)
        SomeModel1.objects.filter(place__in=tail).update(place=main)
        SomeModel2.objects.filter(place__in=tail).update(place=main)
        # ... etc ...

        for t in tail:
            t.delete()

        self.message_user(request, "%s is merged with other places, now you can give it a canonical name." % main)
    merge.short_description = "Merge places"

как вы можете видеть, я должен обновить все остальные модели с FK для размещения с новыми значениями. Но это не очень хорошее решение, так как я должен добавить модель в этот список.

как "каскадное обновление" всех внешних ключей к некоторым объектам до их удаления?

или может есть другие решения, чтобы сделать/избежать слияния

5 ответов


Если кто-то вмешался, вот действительно общий код для этого:

def merge(self, request, queryset):
    main = queryset[0]
    tail = queryset[1:]

    related = main._meta.get_all_related_objects()

    valnames = dict()
    for r in related:
        valnames.setdefault(r.model, []).append(r.field.name)

    for place in tail:
        for model, field_names in valnames.iteritems():
            for field_name in field_names:
                model.objects.filter(**{field_name: place}).update(**{field_name: main})

        place.delete()

    self.message_user(request, "%s is merged with other places, now you can give it a canonical name." % main)

основываясь на фрагменте, представленном в комментариях в принятом ответе, я смог разработать следующее. Этот код не обрабатывает GenericForeignKeys. Я не приписываю их использованию, поскольку считаю, что это указывает на проблему с используемой вами моделью.

этот код обрабатывает unique_together ограничения, которые мешали атомарным транзакциям завершаться с другими фрагментами, которые я нашел. По общему признанию, он немного избит в своей реализации. Я также использую django-audit-log, и я не хочу объединять эти записи с изменениями. Я также хочу соответствующим образом изменить созданные и измененные поля. Этот код работает с Django 1.10 и более новой моделью _META API.

from django.db import transaction
from django.utils import timezone
from django.db.models import Model

def flatten(l, a=None):
    """Flattens a list."""
    if a is None:
        a = []
    for i in l:
        if isinstance(i, Iterable) and type(i) != str:
            flatten(i, a)
        else:
            a.append(i)
    return a


@transaction.atomic()
def merge(primary_object, alias_objects=list()):
    """
    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.  This does not look at GenericForeignKeys.

    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 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')

    for alias_object in alias_objects:
        if alias_object != primary_object:
            for attr_name in dir(alias_object):
                if 'auditlog' not in attr_name:
                    attr = getattr(alias_object, attr_name, None)
                    if attr and "RelatedManager" in type(attr).__name__:
                        if attr.exists():
                            if type(attr).__name__ == "ManyRelatedManager":
                                for instance in attr.all():
                                    getattr(alias_object, attr_name).remove(instance)
                                    getattr(primary_object, attr_name).add(instance)
                            else:
                                # do an update on the related model
                                #   we have to stop ourselves from violating unique_together
                                field = attr.field.name
                                model = attr.model
                                unique = [f for f in flatten(model._meta.unique_together) if f != field]
                                updater = model.objects.filter(**{field: alias_object})
                                if len(unique) == 1:
                                    to_exclude = {
                                        "%s__in" % unique[0]: model.objects.filter(
                                            **{field: primary_object}
                                        ).values_list(unique[0], flat=True)
                                    }
                                # Concat requires at least 2 arguments
                                elif len(unique) > 1:
                                    casted = {"%s_casted" % f: Cast(f, TextField()) for f in unique}
                                    to_exclude = {
                                        'checksum__in': model.objects.filter(
                                            **{field: primary_object}
                                        ).annotate(**casted).annotate(
                                            checksum=Concat(*casted.keys(), output_field=TextField())
                                        ).values_list('checksum', flat=True)
                                    }
                                    updater = updater.annotate(**casted).annotate(
                                        checksum=Concat(*casted.keys(), output_field=TextField())
                                    )
                                else:
                                    to_exclude = {}

                                # perform the update
                                updater.exclude(**to_exclude).update(**{field: primary_object})

                                # delete the records that would have been duplicated
                                model.objects.filter(**{field: alias_object}).delete()

            if hasattr(primary_object, "created"):
                if alias_object.created and primary_object.created:
                    primary_object.created = min(alias_object.created, primary_object.created)
                if primary_object.created:
                    if primary_object.created == alias_object.created:
                        primary_object.created_by = alias_object.created_by
                primary_object.modified = timezone.now()

            alias_object.delete()

    primary_object.save()
    return primary_object

протестировано на Django 1.10. Надеюсь, это поможет.

def merge(primary_object, alias_objects, model):
"""Merge 2 or more objects from the same django model
The alias objects will be deleted and all the references 
towards them will be replaced by references toward the 
primary object
"""
if not isinstance(alias_objects, list):
    alias_objects = [alias_objects]

if not isinstance(primary_object, model):
    raise TypeError('Only %s instances can be merged' % model)

for alias_object in alias_objects:
    if not isinstance(alias_object, model):
        raise TypeError('Only %s instances can be merged' % model)

for alias_object in alias_objects:
    # Get all the related Models and the corresponding field_name
    related_models = [(o.related_model, o.field.name) for o in alias_object._meta.related_objects]
    for (related_model, field_name) in related_models:
        relType = related_model._meta.get_field(field_name).get_internal_type()
        if relType == "ForeignKey":
            qs = related_model.objects.filter(**{ field_name: alias_object })
            for obj in qs:
                setattr(obj, field_name, primary_object)
                obj.save()
        elif relType == "ManyToManyField":
            qs = related_model.objects.filter(**{ field_name: alias_object })
            for obj in qs:
                mtmRel = getattr(obj, field_name)
                mtmRel.remove(alias_object)
                mtmRel.add(primary_object)
    alias_object.delete()
return True

теперь существуют две библиотеки с современными функциями слияния моделей, которые включают связанные модели:

Расширения Django'merge_model_instances команды управления.

Django Super Deduper


Я искал решение для объединения записей в Django Admin и нашел пакет, который это делает (https://github.com/saxix/django-adminactions).

как использовать:

пакет установки : pip install django-adminactions

добавить adminactions к INSTALLED_APPS:

INSTALLED_APPS = (
    'adminactions',
    'django.contrib.admin',
    'django.contrib.messages',
)

добавить действия к admin.py:

from django.contrib.admin import site
import adminactions.actions as actions

actions.add_to_site(site)

добавить url-адрес службы в urls.py:url(r'^adminactions/', include('adminactions.urls')),

пробовал это только сейчас, он работает для мне.