Repository: learningequality/kolibri

Issue:
`exportchannel` command is migrated and all associated tasks updated
move [this logic](https://github.com/learningequality/kolibri/blob/develop/kolibri/core/content/management/commands/exportchannel.py#L25) to a separate util function copy_channel/export_channel 

Discussion:

Patch:
diff --git a/kolibri/core/content/management/commands/exportchannel.py b/kolibri/core/content/management/commands/exportchannel.py
index ed2384a1ea..d624ae6cb6 100644
--- a/kolibri/core/content/management/commands/exportchannel.py
+++ b/kolibri/core/content/management/commands/exportchannel.py
@@ -1,9 +1,7 @@
 import logging
-import os
 
-from ...utils import paths
+from kolibri.core.content.utils.channel_transfer import export_channel
 from kolibri.core.tasks.management.commands.base import AsyncCommand
-from kolibri.utils import file_transfer as transfer
 
 logger = logging.getLogger(__name__)
 
@@ -15,26 +13,5 @@ class Command(AsyncCommand):
 
     def handle_async(self, *args, **options):
         channel_id = options["channel_id"]
-        data_dir = os.path.realpath(options["destination"])
-        logger.info(
-            "Exporting channel database for channel id {} to {}".format(
-                channel_id, data_dir
-            )
-        )
-
-        src = paths.get_content_database_file_path(channel_id)
-        dest = paths.get_content_database_file_path(channel_id, datafolder=data_dir)
-
-        logger.debug("Source file: {}".format(src))
-        logger.debug("Destination file: {}".format(dest))
-
-        with transfer.FileCopy(src, dest, cancel_check=self.is_cancelled) as copy:
-
-            with self.start_progress(total=copy.transfer_size) as progress_update:
-                try:
-                    copy.run(progress_update=progress_update)
-                except transfer.TransferCanceled:
-                    pass
-
-                # Reraise any cancellation
-                self.check_for_cancel()
+        destination = options["destination"]
+        export_channel(channel_id, destination)
diff --git a/kolibri/core/content/tasks.py b/kolibri/core/content/tasks.py
index 83e7c3cea3..c44b2c3952 100644
--- a/kolibri/core/content/tasks.py
+++ b/kolibri/core/content/tasks.py
@@ -12,6 +12,7 @@ from kolibri.core.content.models import ContentRequestReason
 from kolibri.core.content.models import ContentRequestStatus
 from kolibri.core.content.models import ContentRequestType
 from kolibri.core.content.utils.channel_import import import_channel_from_data
+from kolibri.core.content.utils.channel_transfer import export_channel
 from kolibri.core.content.utils.channel_transfer import transfer_channel
 from kolibri.core.content.utils.channels import get_mounted_drive_by_id
 from kolibri.core.content.utils.channels import read_channel_metadata_from_db_file
@@ -463,11 +464,8 @@ def diskexport(
 
     drive = get_mounted_drive_by_id(drive_id)
 
-    call_command(
-        "exportchannel",
-        channel_id,
-        drive.datafolder,
-    )
+    export_channel(channel_id, drive.datafolder)
+
     call_command(
         "exportcontent",
         channel_id,
diff --git a/kolibri/core/content/utils/channel_transfer.py b/kolibri/core/content/utils/channel_transfer.py
index d9586e3e6d..6eb1e5524a 100644
--- a/kolibri/core/content/utils/channel_transfer.py
+++ b/kolibri/core/content/utils/channel_transfer.py
@@ -195,3 +195,30 @@ def transfer_channel(
         os.remove(new_channel_dest)
 
     return dest
+
+
+def export_channel(channel_id, destination):
+    job = get_job()
+    data_dir = os.path.realpath(destination)
+    logger.info(
+        "Exporting channel database for channel id {} to {}".format(
+            channel_id, data_dir
+        )
+    )
+    src = paths.get_content_database_file_path(channel_id)
+    dest = paths.get_content_database_file_path(channel_id, datafolder=data_dir)
+    logger.debug("Source file: {}".format(src))
+    logger.debug("Destination file: {}".format(dest))
+    with transfer.FileCopy(src, dest, cancel_check=job.is_cancelled) as copy:
+        job.update_progress(0, copy.transfer_size)
+
+        def progress_callback(bytes_transferred):
+            new_progress = job.progress + bytes_transferred
+            job.update_progress(new_progress, job.total_progress)
+
+        try:
+            copy.run(progress_update=progress_callback)
+        except transfer.TransferCanceled:
+            pass
+        # Reraise any cancellation
+        job.check_for_cancel()


Test patch:
diff --git a/kolibri/core/content/test/test_import_export.py b/kolibri/core/content/test/test_import_export.py
index 8eb40ed6bf..c300f90d46 100644
--- a/kolibri/core/content/test/test_import_export.py
+++ b/kolibri/core/content/test/test_import_export.py
@@ -454,6 +454,15 @@ class GetContentNodesDataTestCase(TestCase):
         self.assertEqual(total_bytes_to_transfer, 0)
 
 
+def create_dummy_job(is_cancelled=True, check_for_cancel_return=True):
+    dummy = MagicMock()
+    dummy.is_cancelled.return_value = is_cancelled
+    dummy.check_for_cancel.return_value = check_for_cancel_return
+    dummy.start_progress.return_value = None
+    dummy.update_progress.return_value = None
+    return dummy
+
+
 @patch("kolibri.core.content.utils.channel_import.import_channel_from_local_db")
 @patch(
     "kolibri.core.content.management.commands.importchannel.AsyncCommand.start_progress"
@@ -466,14 +475,6 @@ class ImportChannelTestCase(TestCase):
 
     the_channel_id = "6199dde695db4ee4ab392222d5af1e5c"
 
-    def _create_dummy_job(self, is_cancelled=True, check_for_cancel_return=True):
-        dummy = MagicMock()
-        dummy.is_cancelled.return_value = is_cancelled
-        dummy.check_for_cancel.return_value = check_for_cancel_return
-        dummy.start_progress.return_value = None
-        dummy.update_progress.return_value = None
-        return dummy
-
     @patch(
         "kolibri.core.content.utils.channel_transfer.paths.get_content_database_file_url"
     )
@@ -492,7 +493,7 @@ class ImportChannelTestCase(TestCase):
         import_channel_mock,
     ):
 
-        dummy_job = self._create_dummy_job()
+        dummy_job = create_dummy_job()
         get_current_job_mock.return_value = dummy_job
         fd, local_path = tempfile.mkstemp()
         os.close(fd)
@@ -524,7 +525,7 @@ class ImportChannelTestCase(TestCase):
         start_progress_mock,
         import_channel_mock,
     ):
-        dummy_job = self._create_dummy_job()
+        dummy_job = create_dummy_job()
         get_current_job_mock.return_value = dummy_job
         fd1, local_dest_path = tempfile.mkstemp()
         fd2, local_src_path = tempfile.mkstemp()
@@ -545,7 +546,7 @@ class ImportChannelTestCase(TestCase):
     def test_remote_import_sslerror(
         self, get_current_job_mock, start_progress_mock, import_channel_mock
     ):
-        dummy_job = self._create_dummy_job()
+        dummy_job = create_dummy_job()
         get_current_job_mock.return_value = dummy_job
         SSLERROR = SSLError(
             ["SSL routines", "ssl3_get_record", "decryption failed or bad record mac"]
@@ -581,7 +582,7 @@ class ImportChannelTestCase(TestCase):
         start_progress_mock,
         import_channel_mock,
     ):
-        dummy_job = self._create_dummy_job()
+        dummy_job = create_dummy_job()
         get_current_job_mock.return_value = dummy_job
         call_command("importchannel", "network", "197934f144305350b5820c7c4dd8e194")
         dummy_job.check_for_cancel.assert_called_with()
@@ -596,7 +597,7 @@ class ImportChannelTestCase(TestCase):
         start_progress_mock,
         import_channel_mock,
     ):
-        dummy_job = self._create_dummy_job()
+        dummy_job = create_dummy_job()
         get_current_job_mock.return_value = dummy_job
         # Get the current content cache key and sleep a bit to ensure
         # time has elapsed before it's updated.
@@ -2109,28 +2110,19 @@ class ExportChannelTestCase(TestCase):
     the_channel_id = "6199dde695db4ee4ab392222d5af1e5c"
 
     @patch(
-        "kolibri.core.content.management.commands.exportchannel.AsyncCommand.start_progress"
-    )
-    @patch(
-        "kolibri.core.content.management.commands.exportchannel.paths.get_content_database_file_path"
-    )
-    @patch("kolibri.core.content.management.commands.exportchannel.transfer.FileCopy")
-    @patch(
-        "kolibri.core.content.management.commands.exportchannel.AsyncCommand.check_for_cancel"
-    )
-    @patch(
-        "kolibri.core.content.management.commands.exportchannel.AsyncCommand.is_cancelled",
-        return_value=True,
+        "kolibri.core.content.utils.channel_transfer.paths.get_content_database_file_path"
     )
+    @patch("kolibri.core.content.utils.channel_transfer.transfer.FileCopy")
+    @patch("kolibri.core.content.utils.channel_transfer.get_current_job")
     def test_cancel_during_transfer(
         self,
-        is_cancelled_mock,
-        cancel_mock,
+        get_current_job_mock,
         FileCopyMock,
         local_path_mock,
-        start_progress_mock,
     ):
         # Make sure we clean up a database file that is canceled during export
+        dummy_job = create_dummy_job()
+        get_current_job_mock.return_value = dummy_job
         fd1, local_dest_path = tempfile.mkstemp()
         fd2, local_src_path = tempfile.mkstemp()
         os.close(fd1)
@@ -2139,9 +2131,9 @@ class ExportChannelTestCase(TestCase):
         FileCopyMock.return_value.run.side_effect = TransferCanceled()
         call_command("exportchannel", self.the_channel_id, local_dest_path)
         FileCopyMock.assert_called_with(
-            local_src_path, local_dest_path, cancel_check=is_cancelled_mock
+            local_src_path, local_dest_path, cancel_check=dummy_job.is_cancelled
         )
-        cancel_mock.assert_called_with()
+        dummy_job.check_for_cancel.assert_called_with()
         self.assertTrue(os.path.exists(local_dest_path))