Repository: django-cms/django-cms

Issue:
Page- and title extensions not copied properly when duplicating pages
1. Duplicate a page that has a page extension using the cms tree copy function
2. Notice that the duplicated page seems to have copied the page extension as expected
3. Delete the duplicated page
4. Notice that the source page has lost its page extension, too.

I suppose it's because the extension copy mechanism was originally written to publish pages which works perfectly well. However, when I followed the duplication in the database I noticed that the duplicated page gets the 'published' entry of the extension, where as the source page keeps the 'unpublished' entry of the extension. Since the latter has a foreign key to the published extension, it is deleted as a consequence of the default 'cascade' behavior once the duplicated page is deleted.



Discussion:
In which django CMS version?
This should have been fixed in #3903

3.0.12

I can confirm that this is a bit of a mess right now. And that the `blocker` tag is necessary. =)


Resolving patch:
diff --git a/cms/extensions/extension_pool.py b/cms/extensions/extension_pool.py
--- a/cms/extensions/extension_pool.py
+++ b/cms/extensions/extension_pool.py
@@ -60,18 +60,21 @@ def _receiver(self, sender, **kwargs):
         public_page = draft_page.publisher_public
 
         if self.page_extensions:
-            self._copy_page_extensions(draft_page, public_page, language)
+            self._copy_page_extensions(draft_page, public_page, language, clone=False)
             self._remove_orphaned_page_extensions()
         if self.title_extensions:
-            self._copy_title_extensions(draft_page, None, language)
+            self._copy_title_extensions(draft_page, None, language, clone=False)
             self._remove_orphaned_title_extensions()
 
-    def _copy_page_extensions(self, source_page, target_page, language):
+    def _copy_page_extensions(self, source_page, target_page, language, clone=False):
         for extension in self.page_extensions:
             for instance in extension.objects.filter(extended_object=source_page):
-                instance.copy_to_public(target_page, language)
+                if clone:
+                    instance.copy(target_page, language)
+                else:
+                    instance.copy_to_public(target_page, language)
 
-    def _copy_title_extensions(self, source_page, target_page, language):
+    def _copy_title_extensions(self, source_page, target_page, language, clone=False):
         source_title = source_page.title_set.get(language=language)
         if target_page:
             target_title = target_page.title_set.get(language=language)
@@ -79,17 +82,20 @@ def _copy_title_extensions(self, source_page, target_page, language):
             target_title = source_title.publisher_public
         for extension in self.title_extensions:
             for instance in extension.objects.filter(extended_object=source_title):
-                instance.copy_to_public(target_title, language)
+                if clone:
+                    instance.copy(target_title, language)
+                else:
+                    instance.copy_to_public(target_title, language)
 
     def copy_extensions(self, source_page, target_page, languages=None):
         if not languages:
             languages = source_page.get_languages()
         if self.page_extensions:
-            self._copy_page_extensions(source_page, target_page, None)
+            self._copy_page_extensions(source_page, target_page, None, clone=True)
             self._remove_orphaned_page_extensions()
         for language in languages:
             if self.title_extensions:
-                self._copy_title_extensions(source_page, target_page, language)
+                self._copy_title_extensions(source_page, target_page, language, clone=True)
                 self._remove_orphaned_title_extensions()
 
     def _remove_orphaned_page_extensions(self):
@@ -125,4 +131,3 @@ def get_title_extensions(self, title=None):
         return extensions
 
 extension_pool = ExtensionPool()
-
diff --git a/cms/extensions/models.py b/cms/extensions/models.py
--- a/cms/extensions/models.py
+++ b/cms/extensions/models.py
@@ -23,7 +23,39 @@ def copy_relations(self, oldinstance, language):
         """
         pass
 
+    def copy(self, target, language):
+        """
+        This method copies this extension to an unrelated-target. If you intend
+        to "publish" this extension to the publisher counterpart of target, then
+        use copy_to_publish() instead.
+        """
+        clone = self.__class__.objects.get(pk=self.pk)  # get a copy of this instance
+        clone.pk = None
+        clone.public_extension = None
+        clone.extended_object = target  # set the new public object
+        clone.save(mark_page=False)
+
+        # If the target we're copying already has a publisher counterpart, then
+        # connect the dots.
+        target_prime = getattr(target, 'publisher_public')
+        if target_prime:
+            related_name = self.__class__.__name__.lower()
+            clone_prime = getattr(target_prime, related_name)
+            if clone_prime:
+                clone.public_extension = clone_prime
+            else:
+                clone.public_extension = None
+
+        clone.copy_relations(self, language)
+        clone.save(force_update=True, mark_page=False)
+        return clone
+
     def copy_to_public(self, public_object, language):
+        """
+        This method is used to "publish" this extension as part of the a larger
+        operation on the target. If you intend to copy this extension to an
+        unrelated object, use copy() instead.
+        """
         this = self.__class__.objects.get(pk=self.pk)  # get a copy of this instance
         public_extension = self.public_extension  # get the public version of this instance if any
 


Test patch:
diff --git a/cms/tests/extensions.py b/cms/tests/extensions.py
--- a/cms/tests/extensions.py
+++ b/cms/tests/extensions.py
@@ -138,6 +138,12 @@ def test_copy_extensions(self):
             self.assertNotEqual(extension_pool.get_title_extensions(new_page.title_set.get(language='en'))[0].pk,
                                 old_title_extension[index].pk)
 
+        # Test deleting original page for #3987
+        page.delete()
+        # asserting original extensions are gone, but copied ones should still exist
+        self.assertEqual(len(extension_pool.get_page_extensions()), 2)
+        self.assertEqual(len(extension_pool.get_title_extensions()), 2)
+
     def test_publish_page_extension(self):
         page = create_page('Test Page Extension', "nav_playground.html", "en")
         page_extension = MyPageExtension(extended_object=page, extra='page extension 1')