Duplicar instancias de modelos y sus objetos relacionados en Django / Algorithm para duplicar recusivamente un objeto
-
22-07-2019 - |
Pregunta
Tengo modelos para Books
, Chapters
y Pages
. Todos están escritos por un Usuario
:
from django.db import models
class Book(models.Model)
author = models.ForeignKey('auth.User')
class Chapter(models.Model)
author = models.ForeignKey('auth.User')
book = models.ForeignKey(Book)
class Page(models.Model)
author = models.ForeignKey('auth.User')
book = models.ForeignKey(Book)
chapter = models.ForeignKey(Chapter)
Lo que me gustaría hacer es duplicar un Libro
existente y actualizar su Usuario
a otra persona. La arruga es que también me gustaría duplicar todas las instancias de modelo relacionadas en el Libro
, ¡todo es Capítulos
y Páginas
también!
Las cosas se ponen realmente difíciles cuando se mira una Página
- no solo las nuevas Páginas
necesitan tener su campo author
actualizado sino que también ¡también necesito apuntar a los nuevos objetos Chapter
!
¿Django es compatible con una forma original de hacer esto? ¿Cómo sería un algoritmo genérico para duplicar un modelo?
Saludos,
John
Actualización:
¡Las clases dadas arriba son solo un ejemplo para ilustrar el problema que estoy teniendo!
Solución
Esto ya no funciona en Django 1.3 ya que se eliminaron CollectedObjects. Consulte changeset 14507
Publiqué mi solución en Django Snippets. Se basa en gran medida en django.db.models.query.CollectedObject
código utilizado para eliminar objetos:
from django.db.models.query import CollectedObjects
from django.db.models.fields.related import ForeignKey
def duplicate(obj, value, field):
"""
Duplicate all related objects of `obj` setting
`field` to `value`. If one of the duplicate
objects has an FK to another duplicate object
update that as well. Return the duplicate copy
of `obj`.
"""
collected_objs = CollectedObjects()
obj._collect_sub_objects(collected_objs)
related_models = collected_objs.keys()
root_obj = None
# Traverse the related models in reverse deletion order.
for model in reversed(related_models):
# Find all FKs on `model` that point to a `related_model`.
fks = []
for f in model._meta.fields:
if isinstance(f, ForeignKey) and f.rel.to in related_models:
fks.append(f)
# Replace each `sub_obj` with a duplicate.
sub_obj = collected_objs[model]
for pk_val, obj in sub_obj.iteritems():
for fk in fks:
fk_value = getattr(obj, "%s_id" % fk.name)
# If this FK has been duplicated then point to the duplicate.
if fk_value in collected_objs[fk.rel.to]:
dupe_obj = collected_objs[fk.rel.to][fk_value]
setattr(obj, fk.name, dupe_obj)
# Duplicate the object and save it.
obj.id = None
setattr(obj, field, value)
obj.save()
if root_obj is None:
root_obj = obj
return root_obj
Otros consejos
Aquí hay una manera fácil de copiar su objeto.
Básicamente:
(1) establezca la identificación de su objeto original en Ninguno:
book_to_copy.id = Ninguno
(2) cambie el atributo 'author' y guarde el ojbect:
book_to_copy.author = new_author
book_to_copy.save ()
(3) INSERTAR realizado en lugar de ACTUALIZAR
(No se trata de cambiar el autor en la página; estoy de acuerdo con los comentarios sobre la reestructuración de los modelos)
No lo he probado en django, pero deepcopy de python podría funcionar para usted.
EDIT:
Puede definir un comportamiento de copia personalizado para sus modelos si implementa funciones:
__copy__() and __deepcopy__()
esta es una edición de http://www.djangosnippets.org/snippets/1282/
Ahora es compatible con el recopilador que reemplazó a CollectedObjects en 1.3.
Realmente no probé esto demasiado, pero lo probé con un objeto con aproximadamente 20,000 subobjetos, pero solo en aproximadamente tres capas de profundidad de clave externa. Úselo bajo su propio riesgo, por supuesto.
Para el tipo ambicioso que lee esta publicación, debe considerar subclasificar Collector (o copiar toda la clase para eliminar esta dependencia de esta sección no publicada de la API de django) a una clase llamada algo así como "DuplicateCollector". y escribir un método .duplicate que funcione de manera similar al método .delete. eso resolvería este problema de una manera real.
from django.db.models.deletion import Collector
from django.db.models.fields.related import ForeignKey
def duplicate(obj, value=None, field=None, duplicate_order=None):
"""
Duplicate all related objects of obj setting
field to value. If one of the duplicate
objects has an FK to another duplicate object
update that as well. Return the duplicate copy
of obj.
duplicate_order is a list of models which specify how
the duplicate objects are saved. For complex objects
this can matter. Check to save if objects are being
saved correctly and if not just pass in related objects
in the order that they should be saved.
"""
collector = Collector({})
collector.collect([obj])
collector.sort()
related_models = collector.data.keys()
data_snapshot = {}
for key in collector.data.keys():
data_snapshot.update({ key: dict(zip([item.pk for item in collector.data[key]], [item for item in collector.data[key]])) })
root_obj = None
# Sometimes it's good enough just to save in reverse deletion order.
if duplicate_order is None:
duplicate_order = reversed(related_models)
for model in duplicate_order:
# Find all FKs on model that point to a related_model.
fks = []
for f in model._meta.fields:
if isinstance(f, ForeignKey) and f.rel.to in related_models:
fks.append(f)
# Replace each `sub_obj` with a duplicate.
if model not in collector.data:
continue
sub_objects = collector.data[model]
for obj in sub_objects:
for fk in fks:
fk_value = getattr(obj, "%s_id" % fk.name)
# If this FK has been duplicated then point to the duplicate.
fk_rel_to = data_snapshot[fk.rel.to]
if fk_value in fk_rel_to:
dupe_obj = fk_rel_to[fk_value]
setattr(obj, fk.name, dupe_obj)
# Duplicate the object and save it.
obj.id = None
if field is not None:
setattr(obj, field, value)
obj.save()
if root_obj is None:
root_obj = obj
return root_obj
EDITAR: Se eliminó una depuración " print " declaración.
En Django 1.5 esto funciona para mí:
thing.id = None
thing.pk = None
thing.save()
El uso del fragmento de CollectedObjects anterior ya no funciona, pero se puede hacer con la siguiente modificación:
from django.contrib.admin.util import NestedObjects
from django.db import DEFAULT_DB_ALIAS
y
collector = NestedObjects(using=DEFAULT_DB_ALIAS)
en lugar de CollectorObjects
Si solo hay un par de copias en la base de datos que está creando, descubrí que puede usar el botón Atrás en la interfaz de administración, cambiar los campos necesarios y guardar la instancia nuevamente. Esto me ha funcionado en casos en los que, por ejemplo, necesito construir un '' gimlet '' y un "vodka gimlet" cóctel donde la única diferencia es reemplazar el nombre y un ingrediente. Obviamente, esto requiere un poco de previsión de los datos y no es tan poderoso como anular la copia / copia profunda de django, pero puede ser el truco para algunos.
Django tiene una forma integrada de duplicar un objeto a través del administrador, como se responde aquí: En la interfaz de administración de Django, ¿hay alguna manera de duplicar un elemento?
Forma simple no genérica
Las soluciones propuestas no funcionaron para mí, así que seguí el camino simple, no inteligente. Esto solo es útil para casos simples.
Para un modelo con la siguiente estructura
Book
|__ CroppedFace
|__ Photo
|__ AwsReco
|__ AwsLabel
|__ AwsFace
|__ AwsEmotion
esto funciona
def duplicate_book(book: Book, new_user: MyUser):
# AwsEmotion, AwsFace, AwsLabel, AwsReco, Photo, CroppedFace, Book
old_cropped_faces = book.croppedface_set.all()
old_photos = book.photo_set.all()
book.pk = None
book.user = new_user
book.save()
for cf in old_cropped_faces:
cf.pk = None
cf.book = book
cf.save()
for photo in old_photos:
photo.pk = None
photo.book = book
photo.save()
if hasattr(photo, 'awsreco'):
reco = photo.awsreco
old_aws_labels = reco.awslabel_set.all()
old_aws_faces = reco.awsface_set.all()
reco.pk = None
reco.photo = photo
reco.save()
for label in old_aws_labels:
label.pk = None
label.reco = reco
label.save()
for face in old_aws_faces:
old_aws_emotions = face.awsemotion_set.all()
face.pk = None
face.reco = reco
face.save()
for emotion in old_aws_emotions:
emotion.pk = None
emotion.aws_face = face
emotion.save()
return book
Intenté algunas de las respuestas en Django 2.2 / Python 3.6 y no parecían copiar objetos relacionados de uno a muchos y de muchos a muchos. Además, muchos incluían el conocimiento previo de hardcoding / incorporado de las estructuras de datos.
Escribí una manera de hacer esto de una manera más genérica, manejando objetos relacionados de uno a muchos y de muchos a muchos. Comentarios incluidos, y estoy buscando mejorarlo si tiene sugerencias:
def duplicate_object(self):
"""
Duplicate a model instance, making copies of all foreign keys pointing to it.
There are 3 steps that need to occur in order:
1. Enumerate the related child objects and m2m relations, saving in lists/dicts
2. Copy the parent object per django docs (doesn't copy relations)
3a. Copy the child objects, relating to the copied parent object
3b. Re-create the m2m relations on the copied parent object
"""
related_objects_to_copy = []
relations_to_set = {}
# Iterate through all the fields in the parent object looking for related fields
for field in self._meta.get_fields():
if field.one_to_many:
# One to many fields are backward relationships where many child
# objects are related to the parent. Enumerate them and save a list
# so we can copy them after duplicating our parent object.
print(f'Found a one-to-many field: {field.name}')
# 'field' is a ManyToOneRel which is not iterable, we need to get
# the object attribute itself.
related_object_manager = getattr(self, field.name)
related_objects = list(related_object_manager.all())
if related_objects:
print(f' - {len(related_objects)} related objects to copy')
related_objects_to_copy += related_objects
elif field.many_to_one:
# In testing, these relationships are preserved when the parent
# object is copied, so they don't need to be copied separately.
print(f'Found a many-to-one field: {field.name}')
elif field.many_to_many:
# Many to many fields are relationships where many parent objects
# can be related to many child objects. Because of this the child
# objects don't need to be copied when we copy the parent, we just
# need to re-create the relationship to them on the copied parent.
print(f'Found a many-to-many field: {field.name}')
related_object_manager = getattr(self, field.name)
relations = list(related_object_manager.all())
if relations:
print(f' - {len(relations)} relations to set')
relations_to_set[field.name] = relations
# Duplicate the parent object
self.pk = None
self.save()
print(f'Copied parent object ({str(self)})')
# Copy the one-to-many child objects and relate them to the copied parent
for related_object in related_objects_to_copy:
# Iterate through the fields in the related object to find the one that
# relates to the parent model.
for related_object_field in related_object._meta.fields:
if related_object_field.related_model == self.__class__:
# If the related_model on this field matches the parent
# object's class, perform the copy of the child object and set
# this field to the parent object, creating the new
# child -> parent relationship.
related_object.pk = None
setattr(related_object, related_object_field.name, self)
related_object.save()
text = str(related_object)
text = (text[:40] + '..') if len(text) > 40 else text
print(f'|- Copied child object ({text})')
# Set the many-to-many relations on the copied parent
for field_name, relations in relations_to_set.items():
# Get the field by name and set the relations, creating the new
# relationships.
field = getattr(self, field_name)
field.set(relations)
text_relations = []
for relation in relations:
text_relations.append(str(relation))
print(f'|- Set {len(relations)} many-to-many relations on {field_name} {text_relations}')
return self
Creo que también sería más feliz con un modelo de datos más simple.
¿Es realmente cierto que una página está en algún capítulo pero en un libro diferente?
userMe = User( username="me" )
userYou= User( username="you" )
bookMyA = Book( userMe )
bookYourB = Book( userYou )
chapterA1 = Chapter( book= bookMyA, author=userYou ) # "me" owns the Book, "you" owns the chapter?
chapterB2 = Chapter( book= bookYourB, author=userMe ) # "you" owns the book, "me" owns the chapter?
page1 = Page( book= bookMyA, chapter= chapterB2, author=userMe ) # Book and Author aggree, chapter doesn't?
Parece que su modelo es demasiado complejo.
Creo que estarías más feliz con algo más simple. Solo estoy adivinando esto, ya que no conozco todo el problema.
class Book(models.Model)
name = models.CharField(...)
class Chapter(models.Model)
name = models.CharField(...)
book = models.ForeignKey(Book)
class Page(models.Model)
author = models.ForeignKey('auth.User')
chapter = models.ForeignKey(Chapter)
Cada página tiene una autoría distinta. Cada capítulo, entonces, tiene una colección de autores, al igual que el libro. Ahora puede duplicar Libro, Capítulo y Páginas, asignando las Páginas clonadas al nuevo Autor.
De hecho, es posible que desee tener una relación de muchos a muchos entre Página y Capítulo, lo que le permite tener múltiples copias de solo la Página, sin clonar el libro y el Capítulo.
No tuve suerte con ninguna de las respuestas aquí con Django 2.1.2 , así que creé una forma genérica de realizar una copia profunda de un modelo de base de datos que se basa en gran medida en las respuestas publicadas anteriormente .
Las diferencias clave de las respuestas anteriores es que ForeignKey
ya no tiene un atributo llamado rel
, por lo que debe cambiarse a f.remote_field.model
etc.
Además, debido a la dificultad de conocer el orden en que deben copiarse los modelos de la base de datos, creé un sistema de colas simple que empuja el modelo actual al final de la lista si se copia sin éxito. El código se encuentra a continuación:
import queue
from django.contrib.admin.utils import NestedObjects
from django.db.models.fields.related import ForeignKey
def duplicate(obj, field=None, value=None, max_retries=5):
# Use the Nested Objects collector to retrieve the related models
collector = NestedObjects(using='default')
collector.collect([obj])
related_models = list(collector.data.keys())
# Create an object to map old primary keys to new ones
data_snapshot = {}
model_queue = queue.Queue()
for key in related_models:
data_snapshot.update(
{key: {item.pk: None for item in collector.data[key]}}
)
model_queue.put(key)
# For each of the models in related models copy their instances
root_obj = None
attempt_count = 0
while not model_queue.empty():
model = model_queue.get()
root_obj, success = copy_instances(model, related_models, collector, data_snapshot, root_obj)
# If the copy is not a success, it probably means that not
# all the related fields for the model has been copied yet.
# The current model is therefore pushed to the end of the list to be copied last
if not success:
# If the last model is unsuccessful or the number of max retries is reached, raise an error
if model_queue.empty() or attempt_count > max_retries:
raise DuplicationError(model)
model_queue.put(model)
attempt_count += 1
return root_obj
def copy_instances(model, related_models, collector, data_snapshot, root_obj):
# Store all foreign keys for the model in a list
fks = []
for f in model._meta.fields:
if isinstance(f, ForeignKey) and f.remote_field.model in related_models:
fks.append(f)
# Iterate over the instances of the model
for obj in collector.data[model]:
# For each of the models foreign keys check if the related object has been copied
# and if so, assign its personal key to the current objects related field
for fk in fks:
pk_field = f"{fk.name}_id"
fk_value = getattr(obj, pk_field)
# Fetch the dictionary containing the old ids
fk_rel_to = data_snapshot[fk.remote_field.model]
# If the value exists and is in the dictionary assign it to the object
if fk_value is not None and fk_value in fk_rel_to:
dupe_pk = fk_rel_to[fk_value]
# If the desired pk is none it means that the related object has not been copied yet
# so the function returns unsuccessful
if dupe_pk is None:
return root_obj, False
setattr(obj, pk_field, dupe_pk)
# Store the old pk and save the object without an id to create a shallow copy of the object
old_pk = obj.id
obj.id = None
if field is not None:
setattr(obj, field, value)
obj.save()
# Store the new id in the data snapshot object for potential use on later objects
data_snapshot[model][old_pk] = obj.id
if root_obj is None:
root_obj = obj
return root_obj, True
Espero que sea de alguna ayuda :)
El error de duplicación es solo una simple extensión de excepción:
class DuplicationError(Exception):
"""
Is raised when a duplication operation did not succeed
Attributes:
model -- The database model that failed
"""
def __init__(self, model):
self.error_model = model
def __str__(self):
return f'Was not able to duplicate database objects for model {self.error_model}'
Aquí hay una solución algo simple. Esto no depende de ninguna API de Django no documentada. Se supone que desea duplicar un registro principal único, junto con sus registros hijo, nieto, etc. Usted pasa una lista blanca de clases que en realidad deberían estar duplicadas, en forma de una list
de nombres de las relaciones uno a muchos en cada objeto primario que apuntan a sus objetos secundarios. Este código supone que, dada la lista blanca anterior, todo el árbol es autónomo, sin referencias externas de las que preocuparse.
Esta solución no hace nada especial para el campo author
anterior. No estoy seguro de si funcionaría con eso. Como otros han dicho, ese campo author
probablemente no debería repetirse en diferentes clases de modelos.
Una cosa más sobre este código: es verdaderamente recursivo, ya que se llama a sí mismo para cada nuevo nivel de descendientes.
from collections import OrderedDict
def duplicate_model_with_descendants(obj, whitelist, _new_parent_pk=None):
kwargs = {}
children_to_clone = OrderedDict()
for field in obj._meta.get_fields():
if field.name == "id":
pass
elif field.one_to_many:
if field.name in whitelist:
these_children = list(getattr(obj, field.name).all())
if children_to_clone.has_key(field.name):
children_to_clone[field.name] |= these_children
else:
children_to_clone[field.name] = these_children
else:
pass
elif field.many_to_one:
if _new_parent_pk:
kwargs[field.name + '_id'] = _new_parent_pk
elif field.concrete:
kwargs[field.name] = getattr(obj, field.name)
else:
pass
new_instance = obj.__class__(**kwargs)
new_instance.save()
new_instance_pk = new_instance.pk
for ky in children_to_clone.keys():
child_collection = getattr(new_instance, ky)
for child in children_to_clone[ky]:
child_collection.add(duplicate_model_with_descendants(child, whitelist=whitelist, _new_parent_pk=new_instance_pk))
return new_instance
Ejemplo de uso:
from django.db import models
class Book(models.Model)
author = models.ForeignKey('auth.User')
class Chapter(models.Model)
# author = models.ForeignKey('auth.User')
book = models.ForeignKey(Book, related_name='chapters')
class Page(models.Model)
# author = models.ForeignKey('auth.User')
# book = models.ForeignKey(Book)
chapter = models.ForeignKey(Chapter, related_name='pages')
WHITELIST = ['books', 'chapters', 'pages']
original_record = models.Book.objects.get(pk=1)
duplicate_record = duplicate_model_with_descendants(original_record, WHITELIST)
Experimenté la solución de Stephen G Tuggy y la encontré muy inteligente pero, desafortunadamente, no funcionará en algunas situaciones especiales.
Supongamos el siguiente escenario:
class FattAqp(models.Model):
descr = models.CharField('descrizione', max_length=200)
ef = models.ForeignKey(Esercizio, ...)
forn = models.ForeignKey(Fornitore, ...)
class Periodo(models.Model):
# id usato per identificare i documenti
# periodo rilevato in fattura
data_i_p = models.DateField('data inizio', blank=True)
idfatt = models.ForeignKey(FattAqp, related_name='periodo')
class Lettura(models.Model):
mc_i = models.DecimalField(max_digits=7, ...)
faqp = models.ForeignKey(FattAqp, related_name='lettura')
an_im = models.ForeignKey('cnd.AnagImm', ..)
class DettFAqp(models.Model):
imponibile = models.DecimalField(...)
voce = models.ForeignKey(VoceAqp, ...)
periodo = models.ForeignKey(Periodo, related_name='dettfaqp')
En este caso, si intentamos copiar en profundidad una instancia de FattAqp, los campos ef, forn, an_im y voce no se establecerán correctamente; por otro lado idfatt, faqp, periodo will.
Resolví el problema agregando un parámetro más a la función y con una ligera modificación al código. Lo probé con Python 3.6 y Django 2.2 Aquí está:
def duplicate_model_with_descendants(obj, whitelist, _new_parent_pk=None, static_fk=None):
kwargs = {}
children_to_clone = OrderedDict()
for field in obj._meta.get_fields():
if field.name == "id":
pass
elif field.one_to_many:
if field.name in whitelist:
these_children = list(getattr(obj, field.name).all())
if field.name in children_to_clone:
children_to_clone[field.name] |= these_children
else:
children_to_clone[field.name] = these_children
else:
pass
elif field.many_to_one:
name_with_id = field.name + '_id'
if _new_parent_pk:
kwargs[name_with_id] = _new_parent_pk
if name_with_id in static_fk:
kwargs[name_with_id] = getattr(obj, name_with_id)
elif field.concrete:
kwargs[field.name] = getattr(obj, field.name)
else:
pass
new_instance = obj.__class__(**kwargs)
new_instance.save()
new_instance_pk = new_instance.pk
for ky in children_to_clone.keys():
child_collection = getattr(new_instance, ky)
for child in children_to_clone[ky]:
child_collection.add(
duplicate_model_with_descendants(child, whitelist=whitelist, _new_parent_pk=new_instance_pk,static_fk=static_fk))
Ejemplo de uso:
original_record = FattAqp.objects.get(pk=4)
WHITELIST = ['lettura', 'periodo', 'dettfaqp']
STATIC_FK = ['fornitore_id','ef_id','an_im_id', 'voce_id']
duplicate_record = duplicate_model_with_descendants(original_record, WHITELIST, static_fk=STATIC_FK)