Repository: django-cms/django-cms

Issue:
Add management command to copy_site
We have the ability to `copy_lang` but no ability to "clone" an entire cms site to a new site.



Discussion:

Resolving patch:
diff --git a/cms/management/commands/cms.py b/cms/management/commands/cms.py
--- a/cms/management/commands/cms.py
+++ b/cms/management/commands/cms.py
@@ -8,6 +8,7 @@
 from cms.management.commands.subcommands.tree import FixTreeCommand
 from cms.management.commands.subcommands.uninstall import UninstallCommand
 from cms.management.commands.subcommands.copy_lang import CopyLangCommand
+from cms.management.commands.subcommands.copy_site import CopySiteCommand
 from cms.management.commands.subcommands.delete_orphaned_plugins import DeleteOrphanedPluginsCommand
 from django.core.management.base import BaseCommand
 from optparse import make_option
@@ -30,6 +31,7 @@ class Command(SubcommandsCommand):
         'moderator': ModeratorCommand,
         'fix-tree': FixTreeCommand,
         'copy-lang': CopyLangCommand,
+        'copy-site': CopySiteCommand,
         'delete_orphaned_plugins': DeleteOrphanedPluginsCommand,
         'check': CheckInstallation,
         'publisher_publish': PublishCommand,
diff --git a/cms/management/commands/subcommands/copy_site.py b/cms/management/commands/subcommands/copy_site.py
new file mode 100644
--- /dev/null
+++ b/cms/management/commands/subcommands/copy_site.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+from django.core.management.base import BaseCommand, CommandError
+from django.contrib.sites.models import Site
+from django.db import transaction
+
+from cms.models import Page
+
+
+class CopySiteCommand(BaseCommand):
+    help = 'Copy the CMS pagetree from a specific SITE_ID.'
+    args = '<site_from site_to>'
+
+    def handle(self, *args, **options):
+        try:
+            assert len(args) >= 2
+
+            from_site_id = int(args[0])
+            to_site_id = int(args[1])
+
+            assert from_site_id != to_site_id
+        except AssertionError:
+            raise CommandError("Error: bad arguments -- Usage: manage.py cms copy-site <site_from> <site_to>")
+
+        to_site = self.get_site(to_site_id)
+
+        pages = Page.objects.drafts().filter(site=from_site_id, depth=1)
+
+        with transaction.atomic():
+            for page in pages:
+                page.copy_page(None, to_site)
+            self.stdout.write("Copied CMS Tree from SITE_ID {0} successfully to SITE_ID {1}.\n".format(from_site_id, to_site_id))
+
+    def get_site(self, site_id):
+        try:
+            return Site.objects.get(pk=site_id)
+        except Site.DoesNotExist:
+            raise CommandError("\nUnknown site: {0}. Please create a new site first.\n".format(site_id))


Test patch:
diff --git a/cms/tests/management.py b/cms/tests/management.py
--- a/cms/tests/management.py
+++ b/cms/tests/management.py
@@ -330,6 +330,63 @@ def test_copy_langs(self):
         self.assertEqual(stack_text_en.plugin_type, stack_text_de.plugin_type)
         self.assertEqual(stack_text_en.body, stack_text_de.body)
 
+    def test_copy_sites(self):
+        """
+        Various checks here:
+
+         * plugins are exactly doubled, half per site with no orphaned plugin
+         * the bottom-most plugins in the nesting chain maintain the same position and the same content
+         * the top-most plugin are of the same type
+        """
+        site_1_pk = 1
+        site_2 = Site.objects.create(name='site 2')
+        site_2_pk = site_2.pk
+        phs = []
+        for page in Page.objects.on_site(site_1_pk).drafts():
+            phs.extend(page.placeholders.values_list('pk', flat=True))
+        number_start_plugins = CMSPlugin.objects.filter(placeholder__in=phs).count()
+
+        out = StringIO()
+        command = cms.Command()
+        command.stdout = out
+        command.handle("copy-site", site_1_pk, site_2_pk)
+        for page in Page.objects.on_site(site_1_pk).drafts():
+            page.publish('en')
+        for page in Page.objects.on_site(site_2_pk).drafts():
+            page.publish('en')
+        pages_1 = list(Page.objects.on_site(site_1_pk).drafts())
+        pages_2 = list(Page.objects.on_site(site_2_pk).drafts())
+        for index, page in enumerate(pages_1):
+            self.assertEqual(page.get_title('en'), pages_2[index].get_title('en'))
+            self.assertEqual(page.depth, pages_2[index].depth)
+
+        phs_1 = []
+        phs_2 = []
+        for page in Page.objects.on_site(site_1_pk).drafts():
+            phs_1.extend(page.placeholders.values_list('pk', flat=True))
+        for page in Page.objects.on_site(site_2_pk).drafts():
+            phs_2.extend(page.placeholders.values_list('pk', flat=True))
+
+        # These asserts that no orphaned plugin exists
+        self.assertEqual(CMSPlugin.objects.filter(placeholder__in=phs_1).count(), number_start_plugins)
+        self.assertEqual(CMSPlugin.objects.filter(placeholder__in=phs_2).count(), number_start_plugins)
+
+        root_page_1 = Page.objects.on_site(site_1_pk).get_home(site_1_pk)
+        root_page_2 = Page.objects.on_site(site_2_pk).get_home(site_2_pk)
+        root_plugins_1 = CMSPlugin.objects.filter(placeholder=root_page_1.placeholders.get(slot="body"))
+        root_plugins_2 = CMSPlugin.objects.filter(placeholder=root_page_2.placeholders.get(slot="body"))
+
+        first_plugin_1, _ = root_plugins_1.get(language='en', parent=None).get_plugin_instance()
+        first_plugin_2, _ = root_plugins_2.get(language='en', parent=None).get_plugin_instance()
+
+        self.assertEqual(first_plugin_1.plugin_type, first_plugin_2.plugin_type)
+
+        link_1, _ = root_plugins_1.get(language='en', plugin_type='LinkPlugin').get_plugin_instance()
+        link_2, _ = root_plugins_2.get(language='en', plugin_type='LinkPlugin').get_plugin_instance()
+
+        self.assertEqual(link_1.url, link_2.url)
+        self.assertEqual(link_1.get_position_in_placeholder(), link_2.get_position_in_placeholder())
+
     def test_copy_existing_title(self):
         """
         Even if a title already exists the copy is successfull, the original