Repository: wagtail/wagtail

Issue:
Copy function regression with many to many fields
This seems to have popped up in 1.1. It didn't happen before.

There seems to be a few ways to reproduce. Happens on [wagtail blog](https://github.com/thelabnyc/wagtail_blog/). Check out the project. Create a Blog Index Page and Blog Page. Attempt to copy the blog page.

If you remove these two lines the error goes away. Inspecting the kwargs in the stack trace shows which fields are troublesome. [categories M2M field](https://github.com/thelabnyc/wagtail_blog/blob/master/blog/models.py#L199) and [tags ClusterTaggableManager](https://github.com/thelabnyc/wagtail_blog/blob/master/blog/models.py#L173)

Another way I found to reproduce is by adding a project like `django-notifications` that has generic foreign keys. I haven't explored what's going on here as much. 

```
Traceback:
File "/usr/local/lib/python3.4/site-packages/django/core/handlers/base.py" in get_response
  132.                     response = wrapped_callback(request, *callback_args, **callback_kwargs)
File "/usr/local/lib/python3.4/site-packages/django/views/decorators/cache.py" in _cache_controlled
  43.             response = viewfunc(request, *args, **kw)
File "/usr/local/lib/python3.4/site-packages/django/contrib/auth/decorators.py" in _wrapped_view
  22.                 return view_func(request, *args, **kwargs)
File "/usr/local/lib/python3.4/site-packages/wagtail/wagtailadmin/views/pages.py" in copy
  638.                 user=request.user,
File "/usr/local/lib/python3.4/site-packages/wagtail/wagtailcore/models.py" in copy
  810.         page_copy = self.specific_class(**specific_dict)
File "/usr/local/lib/python3.4/site-packages/wagtail/wagtailcore/models.py" in __init__
  325.         super(Page, self).__init__(*args, **kwargs)
File "/usr/local/lib/python3.4/site-packages/modelcluster/models.py" in __init__
  177.             super(ClusterableModel, self).__init__(*args, **kwargs)
File "/usr/local/lib/python3.4/site-packages/django/db/models/base.py" in __init__
  480.                 raise TypeError("'%s' is an invalid keyword argument for this function" % list(kwargs)[0])

Exception Type: TypeError at /admin/pages/4/copy/
Exception Value: 'blog_categories' is an invalid keyword argument for this function
```

Here is a internal project that has the same issue but more "bad" kwargs

/usr/local/lib/python3.4/site-packages/django/db/models/base.py", line 480

```
>>> kwargs
{'tags': <modelcluster.contrib.taggit._ClusterTaggableManager object at 0x7f6aa24adef0>, 'target_actions': <django.contrib.contenttypes.fields.create_generic_related_manager.<locals>.GenericRelatedObjectManager object at 0x7f6aa24b17b8>, 'action_object_actions': <django.contrib.contenttypes.fields.create_generic_related_manager.<locals>.GenericRelatedObjectManager object at 0x7f6aa24b1550>, 'blog_categories': <django.db.models.fields.related.create_many_related_manager.<locals>.ManyRelatedManager object at 0x7f6aa24b1780>, 'actor_actions': <django.contrib.contenttypes.fields.create_generic_related_manager.<locals>.GenericRelatedObjectManager object at 0x7f6aa24b1ac8>  }
```

Lots of bad kwargs. Seems like any ManyToMany or GenericRelatedObjectManager causes it.



Discussion:
Presumably this was triggered by #1660. Note that we don't formally support M2M relations (other than ClusterTaggableManager) since django-modelcluster doesn't currently implement them - the likely effect of this is that previews and saved drafts will show the live database state of those objects rather than the updated ones.

However, it would be good for us to avoid regressions where they _are_ being used - will investigate further.


Resolving patch:
diff --git a/wagtail/wagtailcore/models.py b/wagtail/wagtailcore/models.py
--- a/wagtail/wagtailcore/models.py
+++ b/wagtail/wagtailcore/models.py
@@ -739,6 +739,11 @@ def copy(self, recursive=False, to=None, update_attrs=None, copy_revisions=True,
                 if field.auto_created:
                     continue
 
+                # Ignore m2m relations - they will be copied as child objects
+                # if modelcluster supports them at all (as it does for tags)
+                if field.many_to_many:
+                    continue
+
                 # Ignore parent links (page_ptr)
                 if isinstance(field, models.OneToOneField) and field.parent_link:
                     continue


Test patch:
diff --git a/wagtail/tests/testapp/migrations/0014_m2m_blog_page.py b/wagtail/tests/testapp/migrations/0014_m2m_blog_page.py
new file mode 100644
--- /dev/null
+++ b/wagtail/tests/testapp/migrations/0014_m2m_blog_page.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import wagtail.wagtailcore.fields
+import modelcluster.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0020_add_index_on_page_first_published_at'),
+        ('tests', '0013_iconsetting_notyetregisteredsetting_testsetting'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='BlogCategory',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)),
+                ('name', models.CharField(unique=True, max_length=80)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='BlogCategoryBlogPage',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)),
+                ('category', models.ForeignKey(to='tests.BlogCategory', related_name='+')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='ManyToManyBlogPage',
+            fields=[
+                ('page_ptr', models.OneToOneField(primary_key=True, serialize=False, parent_link=True, auto_created=True, to='wagtailcore.Page')),
+                ('body', wagtail.wagtailcore.fields.RichTextField(blank=True)),
+                ('adverts', models.ManyToManyField(to='tests.Advert', blank=True)),
+                ('blog_categories', models.ManyToManyField(to='tests.BlogCategory', through='tests.BlogCategoryBlogPage', blank=True)),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=('wagtailcore.page',),
+        ),
+        migrations.AddField(
+            model_name='blogcategoryblogpage',
+            name='page',
+            field=modelcluster.fields.ParentalKey(to='tests.ManyToManyBlogPage', related_name='categories'),
+        ),
+    ]
diff --git a/wagtail/tests/testapp/migrations/0015_genericsnippetpage.py b/wagtail/tests/testapp/migrations/0015_genericsnippetpage.py
new file mode 100644
--- /dev/null
+++ b/wagtail/tests/testapp/migrations/0015_genericsnippetpage.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('wagtailcore', '0020_add_index_on_page_first_published_at'),
+        ('contenttypes', '0001_initial'),
+        ('tests', '0014_m2m_blog_page'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='GenericSnippetPage',
+            fields=[
+                ('page_ptr', models.OneToOneField(primary_key=True, parent_link=True, auto_created=True, to='wagtailcore.Page', serialize=False)),
+                ('snippet_object_id', models.PositiveIntegerField(null=True)),
+                ('snippet_content_type', models.ForeignKey(null=True, to='contenttypes.ContentType', on_delete=django.db.models.deletion.SET_NULL)),
+            ],
+            options={
+                'abstract': False,
+            },
+            bases=('wagtailcore.page',),
+        ),
+    ]
diff --git a/wagtail/tests/testapp/models.py b/wagtail/tests/testapp/models.py
--- a/wagtail/tests/testapp/models.py
+++ b/wagtail/tests/testapp/models.py
@@ -3,6 +3,8 @@
 from django.db import models
 from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
 from django.utils.encoding import python_2_unicode_compatible
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
 
 from taggit.models import TaggedItemBase
 from taggit.managers import TaggableManager
@@ -479,3 +481,37 @@ class IconSetting(BaseSetting):
 
 class NotYetRegisteredSetting(BaseSetting):
     pass
+
+
+class BlogCategory(models.Model):
+    name = models.CharField(unique=True, max_length=80)
+
+
+class BlogCategoryBlogPage(models.Model):
+    category = models.ForeignKey(BlogCategory, related_name="+")
+    page = ParentalKey('ManyToManyBlogPage', related_name='categories')
+    panels = [
+        FieldPanel('category'),
+    ]
+
+
+class ManyToManyBlogPage(Page):
+    """
+    A page type with two different kinds of M2M relation.
+    We don't formally support these, but we don't want them to cause
+    hard breakages either.
+    """
+    body = RichTextField(blank=True)
+    adverts = models.ManyToManyField(Advert, blank=True)
+    blog_categories = models.ManyToManyField(
+        BlogCategory, through=BlogCategoryBlogPage, blank=True)
+
+
+class GenericSnippetPage(Page):
+    """
+    A page containing a reference to an arbitrary snippet (or any model for that matter)
+    linked by a GenericForeignKey
+    """
+    snippet_content_type = models.ForeignKey(ContentType, on_delete=models.SET_NULL, null=True)
+    snippet_object_id = models.PositiveIntegerField(null=True)
+    snippet_content_object = GenericForeignKey('snippet_content_type', 'snippet_object_id')
diff --git a/wagtail/wagtailcore/tests/test_page_model.py b/wagtail/wagtailcore/tests/test_page_model.py
--- a/wagtail/wagtailcore/tests/test_page_model.py
+++ b/wagtail/wagtailcore/tests/test_page_model.py
@@ -16,7 +16,9 @@
 from wagtail.tests.testapp.models import (
     SingleEventPage, EventPage, EventIndex, SimplePage,
     BusinessIndex, BusinessSubIndex, BusinessChild, StandardIndex,
-    MTIBasePage, MTIChildPage, AbstractPage)
+    MTIBasePage, MTIChildPage, AbstractPage, TaggedPage,
+    BlogCategory, BlogCategoryBlogPage, Advert, ManyToManyBlogPage,
+    GenericSnippetPage)
 
 
 class TestSiteRouting(TestCase):
@@ -636,6 +638,75 @@ def test_copy_multi_table_inheritance(self):
         self.assertIsInstance(EventPage.objects.get(id=new_saint_patrick_event.id), EventPage)
         self.assertIsInstance(Page.objects.get(id=new_saint_patrick_event.id), Page)
 
+    def test_copy_page_copies_tags(self):
+        # create and publish a TaggedPage under Events
+        event_index = Page.objects.get(url_path='/home/events/')
+        tagged_page = TaggedPage(title='My tagged page', slug='my-tagged-page')
+        tagged_page.tags.add('wagtail', 'bird')
+        event_index.add_child(instance=tagged_page)
+        tagged_page.save_revision().publish()
+
+        old_tagged_item_ids = [item.id for item in tagged_page.tagged_items.all()]
+        # there should be two items here, with defined (truthy) IDs
+        self.assertEqual(len(old_tagged_item_ids), 2)
+        self.assertTrue(all(old_tagged_item_ids))
+
+        # copy to underneath homepage
+        homepage = Page.objects.get(url_path='/home/')
+        new_tagged_page = tagged_page.copy(to=homepage)
+
+        self.assertNotEqual(tagged_page.id, new_tagged_page.id)
+
+        # new page should also have two tags
+        new_tagged_item_ids = [item.id for item in new_tagged_page.tagged_items.all()]
+        self.assertEqual(len(new_tagged_item_ids), 2)
+        self.assertTrue(all(new_tagged_item_ids))
+
+        # new tagged_item IDs should differ from old ones
+        self.assertTrue(all([
+            item_id not in old_tagged_item_ids
+            for item_id in new_tagged_item_ids
+        ]))
+
+    def test_copy_page_with_m2m_relations(self):
+        # create and publish a ManyToManyBlogPage under Events
+        event_index = Page.objects.get(url_path='/home/events/')
+        category = BlogCategory.objects.create(name='Birds')
+        advert = Advert.objects.create(url='http://www.heinz.com/', text="beanz meanz heinz")
+
+        blog_page = ManyToManyBlogPage(title='My blog page', slug='my-blog-page')
+        event_index.add_child(instance=blog_page)
+
+        blog_page.adverts.add(advert)
+        BlogCategoryBlogPage.objects.create(category=category, page=blog_page)
+        blog_page.save_revision().publish()
+
+        # copy to underneath homepage
+        homepage = Page.objects.get(url_path='/home/')
+        new_blog_page = blog_page.copy(to=homepage)
+
+        # M2M relations are not formally supported, so for now we're only interested in
+        # the copy operation as a whole succeeding, rather than the child objects being copied
+        self.assertNotEqual(blog_page.id, new_blog_page.id)
+
+    def test_copy_page_with_generic_foreign_key(self):
+        # create and publish a GenericSnippetPage under Events
+        event_index = Page.objects.get(url_path='/home/events/')
+        advert = Advert.objects.create(url='http://www.heinz.com/', text="beanz meanz heinz")
+
+        page = GenericSnippetPage(title='My snippet page', slug='my-snippet-page')
+        page.snippet_content_object = advert
+        event_index.add_child(instance=page)
+
+        page.save_revision().publish()
+
+        # copy to underneath homepage
+        homepage = Page.objects.get(url_path='/home/')
+        new_page = page.copy(to=homepage)
+
+        self.assertNotEqual(page.id, new_page.id)
+        self.assertEqual(new_page.snippet_content_object, advert)
+
 
 class TestSubpageTypeBusinessRules(TestCase):
     def test_allowed_subpage_types(self):