diff -Nru python-django-modelcluster-5.0.2/CHANGELOG.txt python-django-modelcluster-5.1/CHANGELOG.txt --- python-django-modelcluster-5.0.2/CHANGELOG.txt 2020-05-26 09:13:09.000000000 +0000 +++ python-django-modelcluster-5.1/CHANGELOG.txt 2020-09-10 16:23:20.000000000 +0000 @@ -1,6 +1,13 @@ Changelog ========= +5.1 (10.09.2020) +~~~~~~~~~~~~~~~~ +* Allow child form class to be overridden in the `formsets` Meta property of ClusterForm (Helder Correia) +* Add prefetch_related support to ParentalManyToManyField (Andy Chosak) +* Implement `copy_child_relation` and `copy_all_child_relations` methods on ClusterableModel (Karl Hobley) +* Fix: Fix behavior of ParentalKeys and prefetch_related() supplied with a lookup queryset (Juha Yrjölä) + 5.0.2 (26.05.2020) ~~~~~~~~~~~~~~~~~~ * Fix: Fix compatibility with django-taggit 1.3.0 (Martin Sandström) diff -Nru python-django-modelcluster-5.0.2/debian/changelog python-django-modelcluster-5.1/debian/changelog --- python-django-modelcluster-5.0.2/debian/changelog 2020-06-10 08:16:29.000000000 +0000 +++ python-django-modelcluster-5.1/debian/changelog 2020-10-27 19:53:40.000000000 +0000 @@ -1,3 +1,20 @@ +python-django-modelcluster (5.1-1) unstable; urgency=low + + [ Debian Janitor ] + * Set upstream metadata fields: Bug-Database, Bug-Submit, Repository, + Repository-Browse. + + [ Ondřej Nový ] + * d/control: Update Maintainer field with new Debian Python Team + contact address. + * d/control: Update Vcs-* fields with new Debian Python Team Salsa + layout. + + [ Michael Fladischer ] + * New upstream release. + + -- Michael Fladischer Tue, 27 Oct 2020 20:53:40 +0100 + python-django-modelcluster (5.0.2-1) unstable; urgency=low * New upstream release. diff -Nru python-django-modelcluster-5.0.2/debian/control python-django-modelcluster-5.1/debian/control --- python-django-modelcluster-5.0.2/debian/control 2020-06-10 08:16:29.000000000 +0000 +++ python-django-modelcluster-5.1/debian/control 2020-10-27 19:53:40.000000000 +0000 @@ -1,7 +1,7 @@ Source: python-django-modelcluster Section: python Priority: optional -Maintainer: Debian Python Modules Team +Maintainer: Debian Python Team Uploaders: Michael Fladischer , Build-Depends: @@ -13,8 +13,8 @@ python3-setuptools, Standards-Version: 4.5.0 Homepage: https://github.com/wagtail/django-modelcluster/ -Vcs-Browser: https://salsa.debian.org/python-team/modules/python-django-modelcluster -Vcs-Git: https://salsa.debian.org/python-team/modules/python-django-modelcluster.git +Vcs-Browser: https://salsa.debian.org/python-team/packages/python-django-modelcluster +Vcs-Git: https://salsa.debian.org/python-team/packages/python-django-modelcluster.git Testsuite: autopkgtest-pkg-python Rules-Requires-Root: no diff -Nru python-django-modelcluster-5.0.2/debian/upstream/metadata python-django-modelcluster-5.1/debian/upstream/metadata --- python-django-modelcluster-5.0.2/debian/upstream/metadata 1970-01-01 00:00:00.000000000 +0000 +++ python-django-modelcluster-5.1/debian/upstream/metadata 2020-10-27 19:53:40.000000000 +0000 @@ -0,0 +1,5 @@ +--- +Bug-Database: https://github.com/wagtail/django-modelcluster/issues +Bug-Submit: https://github.com/wagtail/django-modelcluster/issues/new +Repository: https://github.com/wagtail/django-modelcluster.git +Repository-Browse: https://github.com/wagtail/django-modelcluster diff -Nru python-django-modelcluster-5.0.2/modelcluster/fields.py python-django-modelcluster-5.1/modelcluster/fields.py --- python-django-modelcluster-5.0.2/modelcluster/fields.py 2020-05-26 09:13:09.000000000 +0000 +++ python-django-modelcluster-5.1/modelcluster/fields.py 2020-09-10 16:23:20.000000000 +0000 @@ -1,7 +1,7 @@ from __future__ import unicode_literals from django.core import checks -from django.db import IntegrityError, router +from django.db import IntegrityError, connections, router from django.db.models import CASCADE from django.db.models.fields.related import ForeignKey, ManyToManyField from django.utils.functional import cached_property @@ -69,7 +69,10 @@ def _apply_rel_filters(self, queryset): # Implemented as empty for compatibility sake # But there is probably a better implementation of this function - return queryset._next_is_sticky() + # + # NOTE: _apply_rel_filters() must return a copy of the queryset + # to work correctly with prefetch + return queryset._next_is_sticky().all() def get_prefetch_queryset(self, instances, queryset=None): if queryset is None: @@ -271,14 +274,19 @@ def create_deferring_forward_many_to_many_manager(rel, original_manager_cls): - relation_name = rel.field.name + rel_field = rel.field + relation_name = rel_field.name + query_field_name = rel_field.related_query_name() + source_field_name = rel_field.m2m_field_name() rel_model = rel.model superclass = rel_model._default_manager.__class__ + rel_through = rel.through class DeferringManyRelatedManager(superclass): def __init__(self, instance=None): super(DeferringManyRelatedManager, self).__init__() self.model = rel_model + self.through = rel_through self.instance = instance def get_original_manager(self): @@ -317,6 +325,46 @@ return FakeQuerySet(rel_model, results) + def get_prefetch_queryset(self, instances, queryset=None): + # Derived from Django's ManyRelatedManager.get_prefetch_queryset. + if queryset is None: + queryset = super().get_queryset() + + queryset._add_hints(instance=instances[0]) + queryset = queryset.using(queryset._db or self._db) + + query = {'%s__in' % query_field_name: instances} + queryset = queryset._next_is_sticky().filter(**query) + + fk = self.through._meta.get_field(source_field_name) + join_table = fk.model._meta.db_table + + connection = connections[queryset.db] + qn = connection.ops.quote_name + + queryset = queryset.extra(select={ + '_prefetch_related_val_%s' % f.attname: + '%s.%s' % (qn(join_table), qn(f.column)) for f in fk.local_related_fields}) + + return ( + queryset, + lambda result: tuple( + getattr(result, '_prefetch_related_val_%s' % f.attname) + for f in fk.local_related_fields + ), + lambda inst: tuple( + f.get_db_prep_value(getattr(inst, f.attname), connection) + for f in fk.foreign_related_fields + ), + False, + relation_name, + False, + ) + + def _apply_rel_filters(self, queryset): + # Required for get_prefetch_queryset. + return queryset._next_is_sticky() + def get_object_list(self): """ return the mutable list that forms the current in-memory state of diff -Nru python-django-modelcluster-5.0.2/modelcluster/forms.py python-django-modelcluster-5.1/modelcluster/forms.py --- python-django-modelcluster-5.0.2/modelcluster/forms.py 2020-05-26 09:13:09.000000000 +0000 +++ python-django-modelcluster-5.1/modelcluster/forms.py 2020-09-10 16:23:20.000000000 +0000 @@ -261,6 +261,7 @@ kwargs = { 'extra': cls.extra_form_count, + 'form': cls.child_form(), 'formfield_callback': formfield_callback, 'fk_name': rel.field.name, 'widgets': widgets @@ -273,7 +274,7 @@ except AttributeError: pass - formset = childformset_factory(opts.model, rel.field.model, form=cls.child_form(), **kwargs) + formset = childformset_factory(opts.model, rel.field.model, **kwargs) formsets[rel_name] = formset new_class.formsets = formsets diff -Nru python-django-modelcluster-5.0.2/modelcluster/models.py python-django-modelcluster-5.1/modelcluster/models.py --- python-django-modelcluster-5.0.2/modelcluster/models.py 2020-05-26 09:13:09.000000000 +0000 +++ python-django-modelcluster-5.1/modelcluster/models.py 2020-09-10 16:23:20.000000000 +0000 @@ -4,7 +4,7 @@ import datetime from django.core.exceptions import FieldDoesNotExist -from django.db import models +from django.db import models, transaction from django.db.models.fields.related import ForeignObjectRel from django.utils.encoding import is_protected_type from django.core.serializers.json import DjangoJSONEncoder @@ -271,5 +271,86 @@ def from_json(cls, json_data, check_fks=True, strict_fks=False): return cls.from_serializable_data(json.loads(json_data), check_fks=check_fks, strict_fks=strict_fks) + @transaction.atomic + def copy_child_relation(self, child_relation, target, commit=False, append=False): + """ + Copies all of the objects in the accessor_name to the target object. + + For example, say we have an event with speakers (my_event) and we need to copy these to another event (my_other_event): + + my_event.copy_child_relation('speakers', my_other_event) + + By default, this copies the child objects without saving them. Set the commit paremter to True to save the objects + but note that this would cause an exception if the target object is not saved. + + This will overwrite the child relation on the target object. This is to avoid any issues with unique keys + and/or sort_order. If you want it to append. set the `append` parameter to True. + + This method returns a dictionary mapping the child relation/primary key on the source object to the new object created for the + target object. + """ + # A dict that maps child objects from their old IDs to their new objects + child_object_map = {} + + if isinstance(child_relation, str): + child_relation = self._meta.get_field(child_relation) + + if not isinstance(child_relation.remote_field, ParentalKey): + raise LookupError("copy_child_relation can only be used for relationships defined with a ParentalKey") + + # The name of the ParentalKey field on the child model + parental_key_name = child_relation.field.attname + + # Get managers for both the source and target objects + source_manager = getattr(self, child_relation.get_accessor_name()) + target_manager = getattr(target, child_relation.get_accessor_name()) + + if not append: + target_manager.clear() + + for child_object in source_manager.all().order_by('pk'): + old_pk = child_object.pk + is_saved = old_pk is not None + child_object.pk = None + setattr(child_object, parental_key_name, target.id) + target_manager.add(child_object) + + # Add mapping to object + # If the PK is none, add them into a list since there may be multiple of these + if old_pk is not None: + child_object_map[(child_relation, old_pk)] = child_object + else: + if (child_relation, None) not in child_object_map: + child_object_map[(child_relation, None)] = [] + + child_object_map[(child_relation, None)].append(child_object) + + if commit: + target_manager.commit() + + return child_object_map + + def copy_all_child_relations(self, target, exclude=None, commit=False, append=False): + """ + Copies all of the objects in all child relations to the target object. + + This will overwrite all of the child relations on the target object. + + Set exclude to a list of child relation accessor names that shouldn't be copied. + + This method returns a dictionary mapping the child_relation/primary key on the source object to the new object created for the + target object. + """ + exclude = exclude or [] + child_object_map = {} + + for child_relation in get_all_child_relations(self): + if child_relation.get_accessor_name() in exclude: + continue + + child_object_map.update(self.copy_child_relation(child_relation, target, commit=commit, append=append)) + + return child_object_map + class Meta: abstract = True diff -Nru python-django-modelcluster-5.0.2/setup.py python-django-modelcluster-5.1/setup.py --- python-django-modelcluster-5.0.2/setup.py 2020-05-26 09:13:09.000000000 +0000 +++ python-django-modelcluster-5.1/setup.py 2020-09-10 16:23:20.000000000 +0000 @@ -7,7 +7,7 @@ setup( name='django-modelcluster', - version='5.0.2', + version='5.1', description="Django extension to allow working with 'clusters' of models as a single unit, independently of the database", author='Matthew Westcott', author_email='matthew.westcott@torchbox.com', diff -Nru python-django-modelcluster-5.0.2/tests/tests/test_cluster_form.py python-django-modelcluster-5.1/tests/tests/test_cluster_form.py --- python-django-modelcluster-5.0.2/tests/tests/test_cluster_form.py 2020-05-26 09:13:09.000000000 +0000 +++ python-django-modelcluster-5.1/tests/tests/test_cluster_form.py 2020-09-10 16:23:20.000000000 +0000 @@ -154,6 +154,21 @@ self.assertNotIn('release_date', form.formsets['albums'].forms[0].fields) self.assertEqual(Textarea, type(form.formsets['albums'].forms[0]['name'].field.widget)) + def test_custom_formset_form(self): + class AlbumForm(ClusterForm): + pass + + class BandForm(ClusterForm): + class Meta: + model = Band + formsets = { + 'albums': {'fields': ['name'], 'form': AlbumForm} + } + fields = ['name'] + + form = BandForm() + self.assertTrue(isinstance(form.formsets.get("albums").forms[0], AlbumForm)) + def test_formfield_callback(self): def formfield_for_dbfield(db_field, **kwargs): diff -Nru python-django-modelcluster-5.0.2/tests/tests/test_cluster.py python-django-modelcluster-5.1/tests/tests/test_cluster.py --- python-django-modelcluster-5.0.2/tests/tests/test_cluster.py 2020-05-26 09:13:09.000000000 +0000 +++ python-django-modelcluster-5.1/tests/tests/test_cluster.py 2020-09-10 16:23:20.000000000 +0000 @@ -5,12 +5,13 @@ from django.test import TestCase from django.db import IntegrityError +from django.db.models import Prefetch from modelcluster.models import get_all_child_relations from modelcluster.queryset import FakeQuerySet from tests.models import Band, BandMember, Place, Restaurant, SeafoodRestaurant, Review, Album, \ - Article, Author, Category, Person, Room, House, Log + Article, Author, Category, Person, Room, House, Log, Dish, MenuItem, Wine class ClusterTest(TestCase): @@ -764,6 +765,64 @@ ) +class ParentalManyToManyPrefetchTests(TestCase): + def setUp(self): + # Create 10 articles with 10 authors each. + authors = Author.objects.bulk_create( + Author(id=i, name=str(i)) for i in range(10) + ) + authors = Author.objects.all() + + for i in range(10): + article = Article(title=str(i)) + article.authors = authors + article.save() + + def get_author_names(self, articles): + return [ + author.name + for article in articles + for author in article.authors.all() + ] + + def test_prefetch_related(self): + with self.assertNumQueries(11): + names = self.get_author_names(Article.objects.all()) + + with self.assertNumQueries(2): + prefetched_names = self.get_author_names( + Article.objects.prefetch_related('authors') + ) + + self.assertEqual(names, prefetched_names) + + def test_prefetch_related_with_custom_queryset(self): + from django.db.models import Prefetch + + with self.assertNumQueries(2): + names = self.get_author_names( + Article.objects.prefetch_related( + Prefetch('authors', queryset=Author.objects.filter(name__lt='5')) + ) + ) + + self.assertEqual(len(names), 50) + + def test_prefetch_from_fake_queryset(self): + article = Article(title='Article with related articles') + article.related_articles = list(Article.objects.all()) + + with self.assertNumQueries(10): + names = self.get_author_names(article.related_articles.all()) + + with self.assertNumQueries(1): + prefetched_names = self.get_author_names( + article.related_articles.prefetch_related('authors') + ) + + self.assertEqual(names, prefetched_names) + + class PrefetchRelatedTest(TestCase): def test_fakequeryset_prefetch_related(self): person1 = Person.objects.create(name='Joe') @@ -795,3 +854,21 @@ with self.assertNumQueries(0): main_rooms = [ house.main_room for house in person1.houses.all() ] self.assertEqual(len(main_rooms), 2) + + def test_prefetch_related_with_lookup(self): + restaurant1 = Restaurant.objects.create(name='The Jolly Beaver') + restaurant2 = Restaurant.objects.create(name='The Prancing Rhino') + dish1 = Dish.objects.create(name='Goodies') + dish2 = Dish.objects.create(name='Baddies') + wine1 = Wine.objects.create(name='Chateau1') + wine2 = Wine.objects.create(name='Chateau2') + menu_item1 = MenuItem.objects.create(restaurant=restaurant1, dish=dish1, recommended_wine=wine1, price=1) + menu_item2 = MenuItem.objects.create(restaurant=restaurant2, dish=dish2, recommended_wine=wine2, price=10) + + query = Restaurant.objects.all().prefetch_related( + Prefetch('menu_items', queryset=MenuItem.objects.only('price', 'recommended_wine').select_related('recommended_wine')) + ) + + res = list(query) + self.assertEqual(query[0].menu_items.all()[0], menu_item1) + self.assertEqual(query[1].menu_items.all()[0], menu_item2) diff -Nru python-django-modelcluster-5.0.2/tests/tests/test_copy_child_relations.py python-django-modelcluster-5.1/tests/tests/test_copy_child_relations.py --- python-django-modelcluster-5.0.2/tests/tests/test_copy_child_relations.py 1970-01-01 00:00:00.000000000 +0000 +++ python-django-modelcluster-5.1/tests/tests/test_copy_child_relations.py 2020-09-10 16:23:20.000000000 +0000 @@ -0,0 +1,427 @@ +from django.db.utils import IntegrityError +from django.test import TestCase + +from modelcluster.models import get_all_child_relations + +from tests.models import Album, Band, BandMember + +# Get child relations +band_child_rels_by_model = { + rel.related_model: rel + for rel in get_all_child_relations(Band) +} +band_members_rel = band_child_rels_by_model[BandMember] +band_albums_rel = band_child_rels_by_model[Album] + + +class TestCopyChildRelations(TestCase): + def setUp(self): + self.beatles = Band(name='The Beatles', members=[ + BandMember(name='John Lennon'), + BandMember(name='Paul McCartney'), + ]) + + def test_copy_child_relations_between_unsaved_objects(self): + # This test clones the Beatles into a new band. We haven't saved them in either the old record + # or the new one. + + # Clone the beatle + beatles_clone = Band(name='The Beatles 2020 comeback') + + child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + + self.assertIsNone(new_john.pk) + self.assertIsNone(new_paul.pk) + self.assertEqual(new_john.band, beatles_clone) + self.assertEqual(new_paul.band, beatles_clone) + + # As the source is unsaved, both band members are added into a list in the key with PK None + self.assertEqual(child_object_mapping, { + (band_members_rel, None): [new_john, new_paul] + }) + + def test_copy_child_relations_from_saved_to_unsaved_object(self): + # This test clones the beatles from a previously saved band/child objects. + # The only difference here is we can return the old IDs in the mapping. + + self.beatles.save() + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + + beatles_clone = Band(name='The Beatles 2020 comeback') + + child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + + self.assertIsNone(new_john.pk) + self.assertIsNone(new_paul.pk) + self.assertEqual(new_john.band, beatles_clone) + self.assertEqual(new_paul.band, beatles_clone) + + # The objects are saved in the source, so we can give each item it's own entry in the mapping + self.assertEqual(child_object_mapping, { + (band_members_rel, john.pk): new_john, + (band_members_rel, paul.pk): new_paul, + }) + + def test_copy_child_relations_from_saved_and_unsaved_to_unsaved_object(self): + # This test combines the two above tests. We save the beatles band to the database with John and Paul. + # But we then add George and Ringo in memory. When we clone them, we have IDs for John and Paul but + # the others are treated like the unsaved John and Paul from earlier. + + self.beatles.save() + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + george = self.beatles.members.add(BandMember(name='George Harrison')) + ringo = self.beatles.members.add(BandMember(name='Ringo Starr')) + + beatles_clone = Band(name='The Beatles 2020 comeback') + + child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + new_george = beatles_clone.members.get(name='George Harrison') + new_ringo = beatles_clone.members.get(name='Ringo Starr') + + self.assertIsNone(new_john.pk) + self.assertIsNone(new_paul.pk) + self.assertIsNone(new_george.pk) + self.assertIsNone(new_ringo.pk) + self.assertEqual(new_john.band, beatles_clone) + self.assertEqual(new_paul.band, beatles_clone) + self.assertEqual(new_george.band, beatles_clone) + self.assertEqual(new_ringo.band, beatles_clone) + + # The objects are saved in the source, so we can give each item it's own entry in the mapping + self.assertEqual(child_object_mapping, { + (band_members_rel, john.pk): new_john, + (band_members_rel, paul.pk): new_paul, + (band_members_rel, None): [new_george, new_ringo], + }) + + def test_copy_child_relations_from_unsaved_to_saved_object(self): + # This test copies unsaved child relations into a saved object. + # This shouldn't commit the new child objects to the database + + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + + beatles_clone = Band(name='The Beatles 2020 comeback') + beatles_clone.save() + + child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + + self.assertIsNone(new_john.pk) + self.assertIsNone(new_paul.pk) + self.assertEqual(new_john.band, beatles_clone) + self.assertEqual(new_paul.band, beatles_clone) + + self.assertEqual(child_object_mapping, { + (band_members_rel, None): [new_john, new_paul], + }) + + # Bonus test! Let's save the clone again, and see if we can access the new PKs from child_object_mapping + # (Django should mutate the objects we already have when we save them) + beatles_clone.save() + self.assertTrue(child_object_mapping[(band_members_rel, None)][0].pk) + self.assertTrue(child_object_mapping[(band_members_rel, None)][1].pk) + + def test_copy_child_relations_between_saved_objects(self): + # This test copies child relations between two saved objects + # This also shouldn't commit the new child objects to the database + + self.beatles.save() + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + + beatles_clone = Band(name='The Beatles 2020 comeback') + beatles_clone.save() + + child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + + self.assertIsNone(new_john.pk) + self.assertIsNone(new_paul.pk) + self.assertEqual(new_john.band, beatles_clone) + self.assertEqual(new_paul.band, beatles_clone) + + self.assertEqual(child_object_mapping, { + (band_members_rel, john.pk): new_john, + (band_members_rel, paul.pk): new_paul, + }) + + def test_overwrites_existing_child_relations(self): + # By default, the copy_child_relations should overwrite existing items + # This is the safest option as there could be unique keys or sort_order + # fields that might not like being duplicated in this way. + + self.beatles.save() + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + + beatles_clone = Band(name='The Beatles 2020 comeback') + beatles_clone.members.add(BandMember(name='Julian Lennon')) + beatles_clone.save() + + self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) + + self.beatles.copy_child_relation('members', beatles_clone) + + self.assertFalse(beatles_clone.members.filter(name='Julian Lennon').exists()) + + def test_commit(self): + # The commit parameter will instruct the method to save the child objects straight away + + self.beatles.save() + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + + beatles_clone = Band(name='The Beatles 2020 comeback') + beatles_clone.save() + + child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone, commit=True) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + + self.assertIsNotNone(new_john.pk) + self.assertIsNotNone(new_paul.pk) + self.assertEqual(new_john.band, beatles_clone) + self.assertEqual(new_paul.band, beatles_clone) + + self.assertEqual(child_object_mapping, { + (band_members_rel, john.pk): new_john, + (band_members_rel, paul.pk): new_paul, + }) + + def test_commit_to_unsaved(self): + # You can't use commit if the target isn't saved + self.beatles.save() + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + + beatles_clone = Band(name='The Beatles 2020 comeback') + + with self.assertRaises(IntegrityError): + self.beatles.copy_child_relation('members', beatles_clone, commit=True) + + def test_append(self): + # But you can specify append=True, which appends them to the existing list + + self.beatles.save() + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + + beatles_clone = Band(name='The Beatles 2020 comeback') + beatles_clone.members.add(BandMember(name='Julian Lennon')) + beatles_clone.save() + + self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) + + child_object_mapping = self.beatles.copy_child_relation('members', beatles_clone, append=True) + + self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + + self.assertIsNone(new_john.pk) + self.assertIsNone(new_paul.pk) + self.assertEqual(new_john.band, beatles_clone) + self.assertEqual(new_paul.band, beatles_clone) + + self.assertEqual(child_object_mapping, { + (band_members_rel, john.pk): new_john, + (band_members_rel, paul.pk): new_paul, + }) + + +class TestCopyAllChildRelations(TestCase): + def setUp(self): + self.beatles = Band(name='The Beatles', members=[ + BandMember(name='John Lennon'), + BandMember(name='Paul McCartney'), + ], albums=[ + Album(name='Please Please Me', sort_order=1), + Album(name='With The Beatles', sort_order=2), + Album(name='Abbey Road', sort_order=3), + ]) + + def test_copy_all_child_relations_unsaved(self): + # Let's imagine that cloned bands own the albums of their source + # (I'm not creative enough to come up with new album names to keep this analogy going...) + + beatles_clone = Band(name='The Beatles 2020 comeback') + child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + + new_album_1 = beatles_clone.albums.get(sort_order=1) + new_album_2 = beatles_clone.albums.get(sort_order=2) + new_album_3 = beatles_clone.albums.get(sort_order=3) + + self.assertEqual(child_object_mapping, { + (band_members_rel, None): [new_john, new_paul], + (band_albums_rel, None): [new_album_1, new_album_2, new_album_3], + }) + + def test_copy_all_child_relations_saved(self): + self.beatles.save() + + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + album_1 = self.beatles.albums.get(sort_order=1) + album_2 = self.beatles.albums.get(sort_order=2) + album_3 = self.beatles.albums.get(sort_order=3) + + beatles_clone = Band(name='The Beatles 2020 comeback') + child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + + new_album_1 = beatles_clone.albums.get(sort_order=1) + new_album_2 = beatles_clone.albums.get(sort_order=2) + new_album_3 = beatles_clone.albums.get(sort_order=3) + + self.assertEqual(child_object_mapping, { + (band_members_rel, john.pk): new_john, + (band_members_rel, paul.pk): new_paul, + (band_albums_rel, album_1.pk): new_album_1, + (band_albums_rel, album_2.pk): new_album_2, + (band_albums_rel, album_3.pk): new_album_3, + }) + + def test_exclude(self): + beatles_clone = Band(name='The Beatles 2020 comeback') + child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone, exclude=['albums']) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + + self.assertFalse(beatles_clone.albums.exists()) + + self.assertEqual(child_object_mapping, { + (band_members_rel, None): [new_john, new_paul], + }) + + def test_overwrites_existing_child_relations(self): + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + + beatles_clone = Band(name='The Beatles 2020 comeback') + beatles_clone.members.add(BandMember(name='Julian Lennon')) + + self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) + + child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone) + + self.assertFalse(beatles_clone.members.filter(name='Julian Lennon').exists()) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + + new_album_1 = beatles_clone.albums.get(sort_order=1) + new_album_2 = beatles_clone.albums.get(sort_order=2) + new_album_3 = beatles_clone.albums.get(sort_order=3) + + self.assertIsNone(new_john.pk) + self.assertIsNone(new_paul.pk) + self.assertEqual(new_john.band, beatles_clone) + self.assertEqual(new_paul.band, beatles_clone) + + self.assertEqual(child_object_mapping, { + (band_members_rel, None): [new_john, new_paul], + (band_albums_rel, None): [new_album_1, new_album_2, new_album_3], + }) + + def test_commit(self): + # The commit parameter will instruct the method to save the child objects straight away + + self.beatles.save() + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + album_1 = self.beatles.albums.get(sort_order=1) + album_2 = self.beatles.albums.get(sort_order=2) + album_3 = self.beatles.albums.get(sort_order=3) + + beatles_clone = Band(name='The Beatles 2020 comeback') + beatles_clone.save() + + child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone, commit=True) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + + new_album_1 = beatles_clone.albums.get(sort_order=1) + new_album_2 = beatles_clone.albums.get(sort_order=2) + new_album_3 = beatles_clone.albums.get(sort_order=3) + + self.assertIsNotNone(new_john.pk) + self.assertIsNotNone(new_paul.pk) + self.assertIsNotNone(new_album_1.pk) + self.assertIsNotNone(new_album_2.pk) + self.assertIsNotNone(new_album_3.pk) + + self.assertEqual(new_john.band, beatles_clone) + self.assertEqual(new_paul.band, beatles_clone) + self.assertEqual(new_album_1.band, beatles_clone) + self.assertEqual(new_album_2.band, beatles_clone) + self.assertEqual(new_album_3.band, beatles_clone) + + self.assertEqual(child_object_mapping, { + (band_members_rel, john.pk): new_john, + (band_members_rel, paul.pk): new_paul, + (band_albums_rel, album_1.pk): new_album_1, + (band_albums_rel, album_2.pk): new_album_2, + (band_albums_rel, album_3.pk): new_album_3, + }) + + def test_commit_to_unsaved(self): + # You can't use commit if the target isn't saved + self.beatles.save() + john = self.beatles.members.get(name='John Lennon') + paul = self.beatles.members.get(name='Paul McCartney') + + beatles_clone = Band(name='The Beatles 2020 comeback') + + with self.assertRaises(IntegrityError): + self.beatles.copy_all_child_relations(beatles_clone, commit=True) + + def test_append(self): + beatles_clone = Band(name='The Beatles 2020 comeback') + beatles_clone.members.add(BandMember(name='Julian Lennon')) + + self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) + + child_object_mapping = self.beatles.copy_all_child_relations(beatles_clone, append=True) + + self.assertTrue(beatles_clone.members.filter(name='Julian Lennon').exists()) + + new_john = beatles_clone.members.get(name='John Lennon') + new_paul = beatles_clone.members.get(name='Paul McCartney') + new_album_1 = beatles_clone.albums.get(sort_order=1) + new_album_2 = beatles_clone.albums.get(sort_order=2) + new_album_3 = beatles_clone.albums.get(sort_order=3) + + self.assertIsNone(new_john.pk) + self.assertIsNone(new_paul.pk) + self.assertEqual(new_john.band, beatles_clone) + self.assertEqual(new_paul.band, beatles_clone) + + self.assertEqual(child_object_mapping, { + (band_members_rel, None): [new_john, new_paul], + (band_albums_rel, None): [new_album_1, new_album_2, new_album_3], + })