Duplicar instancias de modelos y sus objetos relacionados en Django / Algoritmo para duplicar recusivamente un objeto

Tengo modelos para Books , Chapters y Pages . Todos están escritos por un User :

 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 Book existente y actualizar su User a otra persona. El problema es que también me gustaría duplicar todas las instancias de modelos relacionadas con el Book , ¡todos los Chapters y Pages también!

Las cosas se vuelven realmente difíciles cuando se mira una Page . ¡No solo las nuevas Pages deben tener su campo de author actualizado, sino que también deben apuntar a los nuevos objetos del Chapter !

¿Django soporta una forma inmediata de hacer esto? ¿Cómo sería un algoritmo genérico para duplicar un modelo?

Aclamaciones,

Juan


Actualizar:

¡Las clases dadas arriba son solo un ejemplo para ilustrar el problema que estoy teniendo!

Esto ya no funciona en Django 1.3 ya que CollectedObjects se eliminó. Ver conjunto de cambios 14507

Publiqué mi solución en Django Snippets. Se basa en gran medida en el código django.db.models.query.CollectedObject 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 

Aquí hay una manera fácil de copiar su objeto.

Básicamente:

(1) establezca el ID de su objeto original en Ninguno:

book_to_copy.id = Ninguno

(2) cambie el atributo ‘autor’ y guarde el objeto:

book_to_copy.author = new_author

book_to_copy.save ()

(3) INSERT realizó en lugar de ACTUALIZAR

(No aborda el cambio de 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 la copia profunda de Python podría funcionar para ti.

EDITAR:

Puede definir el 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 recostackdor 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 unas tres capas de profundidad de clave externa. Utilice a su propio riesgo, por supuesto.

Para el chico ambicioso que lee esta publicación, debes considerar crear una subclase del recostackdor (o copiar la clase completa para eliminar esta dependencia en esta sección no publicada de la API de django) a una clase llamada algo 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 statement de “impresión” de depuración.

En Django 1.5 esto funciona para mí:

 thing.id = None thing.pk = None thing.save() 

El uso del fragmento de código 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 hay solo un par de copias en la base de datos que está creando, descubrí que solo 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 los casos en los que, por ejemplo, necesito crear un cóctel de “gimlet” y “vodka gimlet” en el que la única diferencia sea 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 hacer 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 forma de duplicar un elemento?

Forma simple no genérica

Las soluciones propuestas no funcionaron para mí, así que opté por la manera 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 

Creo que estarías más feliz con un modelo de datos más simple, también.

¿Es realmente cierto que una página está en algún capítulo pero 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 tu modelo es demasiado complejo.

Creo que estarías más feliz con algo más simple. Solo estoy adivinando esto, ya que no sé tu problema completo.

 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 la página y el 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 con respecto a 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 saber el orden en el que deben copiarse los modelos de la base de datos, creé un sistema de cola simple que empuja el modelo actual al final de la lista si se copia sin éxito. El código es postet abajo:

 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 un tanto simple. Esto no depende de ninguna API de Django no documentada. Se supone que desea duplicar un solo registro principal, junto con sus registros secundarios, nietos, etc. Se pasa una lista blanca de clases que deberían duplicarse, en forma de una list de nombres de las relaciones de uno a varios en cada objeto principal que apunta a sus objetos secundarios. Este código asume 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 de author anterior. No estoy seguro de si funcionaría con eso. Como han dicho otros, ese campo de 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)