Repository: learningequality/kolibri

Issue:
`deletecontent` command is migrated and all associated tasks updated

Discussion:
github-actions[bot]: ### [**Build Artifacts**](https://github.com/learningequality/kolibri/actions/runs/13844531948)
| Asset type | Download link |
|-|-|
| PEX file | [kolibri-0.18.0a3.dev0_git.124.gdbc829f0.pex](https://github.com/learningequality/kolibri/suites/35656012029/artifacts/2748872548) |
| Windows Installer (EXE) | [kolibri-0.18.0a3.dev0+git.124.gdbc829f0-windows-setup-unsigned.exe](https://github.com/learningequality/kolibri/suites/35656012029/artifacts/2748875420) |
| Debian Package | [kolibri_0.18.0a3.dev0+git.124.gdbc829f0-0ubuntu1_all.deb](https://github.com/learningequality/kolibri/suites/35656012029/artifacts/2748895349) |
| Mac Installer (DMG) | [kolibri-0.18.0a3.dev0+git.124.gdbc829f0.dmg](https://github.com/learningequality/kolibri/suites/35656012029/artifacts/2748899741) |
| Android Package (APK) | [kolibri-0.18.0a3.dev0+git.124.gdbc829f0-0.1.4-debug.apk](https://github.com/learningequality/kolibri/suites/35656012029/artifacts/2748992403) |
| Raspberry Pi Image | [kolibri-pi-image-0.18.0a3.dev0+git.124.gdbc829f0.zip](https://github.com/learningequality/kolibri/suites/35656012029/artifacts/2748927271) |
| TAR file | [kolibri-0.18.0a3.dev0+git.124.gdbc829f0.tar.gz](https://github.com/learningequality/kolibri/suites/35656012029/artifacts/2748867034) |
| WHL file | [kolibri-0.18.0a3.dev0+git.124.gdbc829f0-py2.py3-none-any.whl](https://github.com/learningequality/kolibri/suites/35656012029/artifacts/2748866665) |
thesujai: Hi @rtibbles, marking this as ready for review
MisRob: Thanks @thesujai! We will review in the upcoming weeks. I wanted to note that review waiting times are still longer.
rtibbles: For manual QA here, testing these workflows:

* Exporting a channel - this time the important part is exporting the resources themselves, so selecting a range of resources in a channel and confirming that all works.
* Deleting resources from a channel, and deleting an entire channel.
radinamatic: I tested a variety of delete & export scenarios of resources and full channels, always re-checking the size of the `content` folder to make sure it changes at every step, and a part from some discrepancies in the reported size of resources on device in Kolibri, did not notice any issues or regressions. 

Example here would be that the initial size of resources from the GDL channel present on device was 20MB, but resulted in (20+9+4=~33) MBs when I was deleting separate folders:
Before | After
-- | --
<img width="961" alt="2025-03-21_03-19-11" src="https://github.com/user-attachments/assets/28c06957-3a8b-490c-8f3f-166a736b7e88" /> | <img width="961" alt="2025-03-21_03-31-51" src="https://github.com/user-attachments/assets/07aa700f-f4de-48cd-a518-e684a94ab1c2" />

 The above almost certainly is not result of the changes in this PR, and no issues were observed in the Learn UI, as the deleted resources were properly not displayed at every step.

However at one point I noticed some request errors in the console, that at first did not seem an issue for performing delete operations, until I bumped into a situation where it was impossible to re-import from a second local drive resources from a channel that was originally exported there in the previous step. Sequence was the following:

1. Import some resources from the HP Life FR channel on Studio
2. Select a portion of those to export on the second drive
3. Delete the whole HP Life FR channel from Kolibri
4. Try to import it from the local drive to confirm that all previously exported ones are indeed present

Result was a hard stop:

<img width="961" alt="2025-03-21_03-59-01" src="https://github.com/user-attachments/assets/6a326f63-6500-4a79-b896-7402ba12e85f" />

Note that all 3 channels that are reporting 404 request errors were previously deleted from Kolibri during this testing session.

[DB-logs.zip](https://github.com/user-attachments/files/19380545/DB-logs.zip)

I'll upload the full home folder to GDrive (>500MB)
rtibbles: @thesujai if you can work out how to fix the issue here, that would be great - if not, I am happy to make a separate fix on develop so that we don't release this regression :)
thesujai: @rtibbles I thought I could solve it but I couldn't. Sorry for confusing you with my previous comment, because this regression is not caused by any of the refactor of management commands(Just observed). So this has been there since a long time
In release 0.17.x branch:
In this video:

1. In a fresh instance of kolibri, I import resources(china folder) from Touchable Earth en
2. Export them to a pendrive
3. Delete the entire channel
4. Re-import entire resources from pendrive
5. Try importing `russia folder`, but this time from studio
6. Then again export `russia folder` to drive(Note China folder was already exported) 
7. Delete the entire channel again
8. Try to import from drive again, I only see the `china folder`(Where is the `russia folder`

[Screencast from 21-03-25 09:39:38 PM IST.webm](https://github.com/user-attachments/assets/b4b503a1-05df-436c-9b85-5656b56d15ff)


After this, I empty the pendrive(note in my device also all the channels are deleted)(I haven't restarted my kolibri instance)
1. I again import some resources from Touchable earth
2. Export them to drive(selective exporting)
3. Delete the channel
4. Try to import back from the drive again, I observe resources that I haven't even imported(Why are they in my drive?)
5. Anyways I import them, the import is successful(But i dont see the channel in device)(Obviously because the resources never existed in my drive)

[Screencast from 21-03-25 09:53:36 PM IST.webm](https://github.com/user-attachments/assets/8abf3ef0-ce7f-4bb6-b8a7-b43d29f9f38f)

rtibbles: OK, as this seems to be pre-existing, I will file as a separate issue including the context from both of these reports. Thank you! 
rtibbles: Have rebased here because of our updates to our required checks, then this should be good to merge.

Patch:
diff --git a/kolibri/core/content/management/commands/deletecontent.py b/kolibri/core/content/management/commands/deletecontent.py
index c2258b54ef..56ef22daf0 100644
--- a/kolibri/core/content/management/commands/deletecontent.py
+++ b/kolibri/core/content/management/commands/deletecontent.py
@@ -1,95 +1,10 @@
 import logging
-import os
 
-from django.core.management.base import CommandError
-from django.db.models import Sum
-from le_utils.constants import content_kinds
-
-from kolibri.core.content.models import ChannelMetadata
-from kolibri.core.content.models import ContentNode
-from kolibri.core.content.models import LocalFile
-from kolibri.core.content.utils.annotation import propagate_forced_localfile_removal
-from kolibri.core.content.utils.annotation import reannotate_all_channels
-from kolibri.core.content.utils.annotation import set_content_invisible
-from kolibri.core.content.utils.content_request import propagate_contentnode_removal
-from kolibri.core.content.utils.importability_annotation import clear_channel_stats
-from kolibri.core.content.utils.paths import get_content_database_file_path
+from kolibri.core.content.utils.content_delete import delete_content
 from kolibri.core.tasks.management.commands.base import AsyncCommand
-from kolibri.core.tasks.utils import get_current_job
-from kolibri.core.utils.lock import db_lock
-
-logger = logging.getLogger(__name__)
-
-
-def delete_metadata(
-    channel,
-    node_ids,
-    exclude_node_ids,
-    force_delete,
-    ignore_admin_flags,
-    update_content_requests,
-):
-    # Only delete all metadata if we are not doing selective deletion
-    delete_all_metadata = not (node_ids or exclude_node_ids)
-
-    resources_before = set(
-        ContentNode.objects.filter(channel_id=channel.id, available=True)
-        .exclude(kind=content_kinds.TOPIC)
-        .values_list("id", flat=True)
-    )
-
-    # If we have been passed node ids do not do a full deletion pass
-    set_content_invisible(
-        channel.id, node_ids, exclude_node_ids, not ignore_admin_flags
-    )
-    # If everything has been made invisible, delete all the metadata
-    delete_all_metadata = delete_all_metadata or not channel.root.available
-
-    resources_after = set(
-        ContentNode.objects.filter(channel_id=channel.id, available=True)
-        .exclude(kind=content_kinds.TOPIC)
-        .values_list("id", flat=True)
-    )
-
-    removed_resources = resources_before.difference(resources_after)
-
-    total_resource_number = len(removed_resources)
-
-    if force_delete:
-        # Do this before we delete all the metadata, as otherwise we lose
-        # track of which local files were associated with the channel we
-        # just deleted.
-
-        unused_files = (
-            LocalFile.objects.filter(
-                available=True,
-                files__contentnode__channel_id=channel.id,
-                files__contentnode__available=False,
-            )
-            .distinct()
-            .values("id", "file_size", "extension")
-        )
 
-        with db_lock():
-            removed_resources.update(propagate_forced_localfile_removal(unused_files))
-        # Separate these operations as running the SQLAlchemy code in the latter
-        # seems to cause the Django ORM interactions in the former to roll back
-        # Not quite sure what is causing it, but presumably due to transaction
-        # scopes.
-        reannotate_all_channels()
 
-    if delete_all_metadata:
-        logger.info("Deleting all channel metadata")
-        with db_lock():
-            channel.delete_content_tree_and_files()
-
-    if update_content_requests and removed_resources:
-        propagate_contentnode_removal(list(removed_resources))
-
-    # Clear any previously set channel availability stats for this channel
-    clear_channel_stats(channel.id)
-
-    return total_resource_number, delete_all_metadata
+logger = logging.getLogger(__name__)
 
 
 class Command(AsyncCommand):
@@ -163,52 +78,11 @@ class Command(AsyncCommand):
         ignore_admin_flags = options["ignore_admin_flags"]
         update_content_requests = options["update_content_requests"]
 
-        try:
-            channel = ChannelMetadata.objects.get(pk=channel_id)
-        except ChannelMetadata.DoesNotExist:
-            raise CommandError(
-                "Channel matching id {id} does not exist".format(id=channel_id)
-            )
-
-        (total_resource_number, delete_all_metadata,) = delete_metadata(
-            channel,
+        delete_content(
+            channel_id,
             node_ids,
             exclude_node_ids,
             force_delete,
-            ignore_admin_flags,
-            update_content_requests,
+            ignore_admin_flags=ignore_admin_flags,
+            update_content_requests=update_content_requests,
         )
-        unused_files = LocalFile.objects.get_unused_files()
-        # Get the number of files that are being deleted
-        unused_files_count = unused_files.count()
-        deleted_bytes = unused_files.aggregate(size=Sum("file_size"))["size"] or 0
-
-        job = get_current_job()
-        if job:
-            job.extra_metadata["file_size"] = deleted_bytes
-            job.extra_metadata["total_resources"] = total_resource_number
-            job.save_meta()
-        progress_extra_data = {"channel_id": channel_id}
-        additional_progress = sum((1, bool(delete_all_metadata)))
-        total_progress = 0
-        target_progress = unused_files_count + additional_progress
-        with self.start_progress(total=target_progress) as progress_update:
-
-            for _ in LocalFile.objects.delete_unused_files():
-                progress_update(1, progress_extra_data)
-                total_progress += 1
-
-            with db_lock():
-                LocalFile.objects.delete_orphan_file_objects()
-
-            progress_update(1, progress_extra_data)
-
-            if delete_all_metadata:
-                try:
-                    os.remove(get_content_database_file_path(channel_id))
-                except OSError:
-                    pass
-
-                progress_update(1, progress_extra_data)
-
-            progress_update(target_progress - total_progress, progress_extra_data)
diff --git a/kolibri/core/content/management/commands/exportcontent.py b/kolibri/core/content/management/commands/exportcontent.py
index fb2e7865bf..bc0bcc6f28 100644
--- a/kolibri/core/content/management/commands/exportcontent.py
+++ b/kolibri/core/content/management/commands/exportcontent.py
@@ -1,18 +1,10 @@
 import logging
-import os
 
 from django.core.management.base import CommandError
 
 from ...utils import paths
-from kolibri.core.content.errors import InvalidStorageFilenameError
-from kolibri.core.content.models import ChannelMetadata
-from kolibri.core.content.utils.content_manifest import ContentManifest
-from kolibri.core.content.utils.import_export_content import get_content_nodes_data
-from kolibri.core.content.utils.import_export_content import get_import_export_nodes
-from kolibri.core.content.utils.paths import get_content_file_name
+from kolibri.core.content.utils.content_export import export_content
 from kolibri.core.tasks.management.commands.base import AsyncCommand
-from kolibri.core.tasks.utils import get_current_job
-from kolibri.utils import file_transfer as transfer
 
 logger = logging.getLogger(__name__)
 
@@ -67,97 +59,19 @@ class Command(AsyncCommand):
             help="Generate only the manifest.json file",
         )
 
-    def update_job_metadata(self, total_bytes_to_transfer, total_resource_count):
-        job = get_current_job()
-        if job:
-            job.extra_metadata["file_size"] = total_bytes_to_transfer
-            job.extra_metadata["total_resources"] = total_resource_count
-            job.save_meta()
-
     def handle_async(self, *args, **options):
         if paths.using_remote_storage():
             raise CommandError("Cannot export files when using remote file storage")
         channel_id = options["channel_id"]
-        data_dir = os.path.realpath(options["destination"])
+        destination = options["destination"]
         node_ids = options["node_ids"]
         exclude_node_ids = options["exclude_node_ids"]
-
-        channel_metadata = ChannelMetadata.objects.get(id=channel_id)
-
-        nodes_queries_list = get_import_export_nodes(
-            channel_id, node_ids, exclude_node_ids, available=True
-        )
-
-        (total_resource_count, files, total_bytes_to_transfer) = get_content_nodes_data(
-            channel_id, nodes_queries_list, available=True
-        )
-
-        self.update_job_metadata(total_bytes_to_transfer, total_resource_count)
-
-        # dont copy files if we are only exporting the manifest
-        if not options["manifest_only"]:
-            self.copy_content_files(
-                channel_id, data_dir, files, total_bytes_to_transfer
-            )
-
-        # Reraise any cancellation
-        self.check_for_cancel()
-
-        logger.info(
-            "Exporting manifest for channel id {} to {}".format(channel_id, data_dir)
-        )
-
-        manifest_path = os.path.join(data_dir, "content", "manifest.json")
-        content_manifest = ContentManifest()
-        content_manifest.read(manifest_path)
-        content_manifest.add_content_nodes(
-            channel_id, channel_metadata.version, nodes_queries_list
-        )
-        content_manifest.write(manifest_path)
-
-    def copy_content_files(self, channel_id, data_dir, files, total_bytes_to_transfer):
-        logger.info(
-            "Exporting content for channel id {} to {}".format(channel_id, data_dir)
+        manifest_only = options["manifest_only"]
+
+        export_content(
+            channel_id,
+            destination,
+            manifest_only=manifest_only,
+            node_ids=node_ids,
+            exclude_node_ids=exclude_node_ids,
         )
-        with self.start_progress(
-            total=total_bytes_to_transfer
-        ) as overall_progress_update:
-            for f in files:
-                if self.is_cancelled():
-                    break
-                self.export_file(f, data_dir, overall_progress_update)
-
-    def export_file(self, f, data_dir, overall_progress_update):
-        filename = get_content_file_name(f)
-        try:
-            srcpath = paths.get_content_storage_file_path(filename)
-            dest = paths.get_content_storage_file_path(filename, datafolder=data_dir)
-        except InvalidStorageFilenameError:
-            # If any files have an invalid storage file name, don't export them.
-            overall_progress_update(f["file_size"])
-            return
-
-        # if the file already exists, add its size to our overall progress, and skip
-        if os.path.isfile(dest) and os.path.getsize(dest) == f["file_size"]:
-            overall_progress_update(f["file_size"])
-            return
-        copy = transfer.FileCopy(srcpath, dest, cancel_check=self.is_cancelled)
-        with copy, self.start_progress(
-            total=copy.transfer_size
-        ) as file_cp_progress_update:
-
-            def progress_update(length):
-                self.exported_size = self.exported_size + length
-                overall_progress_update(length)
-                file_cp_progress_update(length)
-
-            try:
-                copy.run(progress_update=progress_update)
-            except transfer.TransferCanceled:
-                job = get_current_job()
-                if job:
-                    job.extra_metadata["file_size"] = self.exported_size
-                    job.extra_metadata["total_resources"] = 0
-                    job.save_meta()
-                return
-        return dest
diff --git a/kolibri/core/content/tasks.py b/kolibri/core/content/tasks.py
index c44b2c3952..4079bc2063 100644
--- a/kolibri/core/content/tasks.py
+++ b/kolibri/core/content/tasks.py
@@ -1,5 +1,4 @@
 from django.core.exceptions import ValidationError
-from django.core.management import call_command
 from django.db.models import Q
 from rest_framework import serializers
 
@@ -16,6 +15,8 @@ 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
+from kolibri.core.content.utils.content_delete import delete_content
+from kolibri.core.content.utils.content_export import export_content
 from kolibri.core.content.utils.content_request import incomplete_removals_queryset
 from kolibri.core.content.utils.content_request import process_content_removal_requests
 from kolibri.core.content.utils.content_request import process_content_requests
@@ -466,8 +467,7 @@ def diskexport(
 
     export_channel(channel_id, drive.datafolder)
 
-    call_command(
-        "exportcontent",
+    export_content(
         channel_id,
         drive.datafolder,
         node_ids=node_ids,
@@ -505,13 +505,7 @@ def deletechannel(
     """
     Delete a channel and all its associated content from the server.
     """
-    call_command(
-        "deletecontent",
-        channel_id,
-        node_ids=node_ids,
-        exclude_node_ids=exclude_node_ids,
-        force_delete=force_delete,
-    )
+    delete_content(channel_id, node_ids, exclude_node_ids, force_delete)
 
 
 @register_task(
diff --git a/kolibri/core/content/utils/channel_transfer.py b/kolibri/core/content/utils/channel_transfer.py
index 6eb1e5524a..70d95feec2 100644
--- a/kolibri/core/content/utils/channel_transfer.py
+++ b/kolibri/core/content/utils/channel_transfer.py
@@ -23,6 +23,7 @@ class DummyJob:
         # same default values as in the Job contructor
         self.progress = 0
         self.total_progress = 0
+        self.extra_metadata = {}
 
     def is_cancelled(self):
         return False
@@ -33,6 +34,12 @@ class DummyJob:
     def update_progress(self, bytes_transferred, extra_data=None):
         pass
 
+    def update_metadata(self, **kwargs):
+        pass
+
+    def save_meta(self):
+        pass
+
 
 def get_job():
     job = get_current_job()
diff --git a/kolibri/core/content/utils/content_delete.py b/kolibri/core/content/utils/content_delete.py
new file mode 100644
index 0000000000..589e90b1b6
--- /dev/null
+++ b/kolibri/core/content/utils/content_delete.py
@@ -0,0 +1,140 @@
+import logging
+import os
+
+from django.db.models import Sum
+from le_utils.constants import content_kinds
+
+from kolibri.core.content.models import ChannelMetadata
+from kolibri.core.content.models import ContentNode
+from kolibri.core.content.models import LocalFile
+from kolibri.core.content.utils.annotation import propagate_forced_localfile_removal
+from kolibri.core.content.utils.annotation import reannotate_all_channels
+from kolibri.core.content.utils.annotation import set_content_invisible
+from kolibri.core.content.utils.channel_transfer import get_job
+from kolibri.core.content.utils.content_request import propagate_contentnode_removal
+from kolibri.core.content.utils.importability_annotation import clear_channel_stats
+from kolibri.core.content.utils.paths import get_content_database_file_path
+from kolibri.core.utils.lock import db_lock
+
+
+logger = logging.getLogger(__name__)
+
+
+def delete_metadata(
+    channel,
+    node_ids,
+    exclude_node_ids,
+    force_delete,
+    ignore_admin_flags,
+    update_content_requests,
+):
+    # Only delete all metadata if we are not doing selective deletion
+    delete_all_metadata = not (node_ids or exclude_node_ids)
+
+    resources_before = set(
+        ContentNode.objects.filter(channel_id=channel.id, available=True)
+        .exclude(kind=content_kinds.TOPIC)
+        .values_list("id", flat=True)
+    )
+
+    # If we have been passed node ids do not do a full deletion pass
+    set_content_invisible(
+        channel.id, node_ids, exclude_node_ids, not ignore_admin_flags
+    )
+    # If everything has been made invisible, delete all the metadata
+    delete_all_metadata = delete_all_metadata or not channel.root.available
+
+    resources_after = set(
+        ContentNode.objects.filter(channel_id=channel.id, available=True)
+        .exclude(kind=content_kinds.TOPIC)
+        .values_list("id", flat=True)
+    )
+
+    removed_resources = resources_before.difference(resources_after)
+
+    total_resource_number = len(removed_resources)
+
+    if force_delete:
+        # Do this before we delete all the metadata, as otherwise we lose
+        # track of which local files were associated with the channel we
+        # just deleted.
+
+        unused_files = (
+            LocalFile.objects.filter(
+                available=True,
+                files__contentnode__channel_id=channel.id,
+                files__contentnode__available=False,
+            )
+            .distinct()
+            .values("id", "file_size", "extension")
+        )
+
+        with db_lock():
+            removed_resources.update(propagate_forced_localfile_removal(unused_files))
+        # Separate these operations as running the SQLAlchemy code in the latter
+        # seems to cause the Django ORM interactions in the former to roll back
+        # Not quite sure what is causing it, but presumably due to transaction
+        # scopes.
+        reannotate_all_channels()
+
+    if delete_all_metadata:
+        logger.info("Deleting all channel metadata")
+        with db_lock():
+            channel.delete_content_tree_and_files()
+
+    if update_content_requests and removed_resources:
+        propagate_contentnode_removal(list(removed_resources))
+
+    # Clear any previously set channel availability stats for this channel
+    clear_channel_stats(channel.id)
+
+    return total_resource_number, delete_all_metadata
+
+
+def delete_content(
+    channel_id,
+    node_ids,
+    exclude_node_ids,
+    force_delete,
+    ignore_admin_flags=True,
+    update_content_requests=True,
+):
+    job = get_job()
+    try:
+        channel = ChannelMetadata.objects.get(pk=channel_id)
+    except ChannelMetadata.DoesNotExist:
+        raise KeyError("Channel matching id {id} does not exist".format(id=channel_id))
+
+    (total_resource_number, delete_all_metadata,) = delete_metadata(
+        channel,
+        node_ids,
+        exclude_node_ids,
+        force_delete,
+        ignore_admin_flags,
+        update_content_requests,
+    )
+    unused_files = LocalFile.objects.get_unused_files()
+    # Get the number of files that are being deleted
+    unused_files_count = unused_files.count()
+    deleted_bytes = unused_files.aggregate(size=Sum("file_size"))["size"] or 0
+    job.extra_metadata["file_size"] = deleted_bytes
+    job.extra_metadata["total_resources"] = total_resource_number
+    job.save_meta()
+    additional_progress = sum((1, bool(delete_all_metadata)))
+    target_progress = unused_files_count + additional_progress
+    current_progress = 0
+    for _ in LocalFile.objects.delete_unused_files():
+        current_progress += 1
+        job.update_progress(current_progress, target_progress)
+    with db_lock():
+        LocalFile.objects.delete_orphan_file_objects()
+    current_progress += 1
+    job.update_progress(current_progress, target_progress)
+    if delete_all_metadata:
+        try:
+            os.remove(get_content_database_file_path(channel_id))
+        except OSError:
+            pass
+        current_progress += 1
+        job.update_progress(current_progress, target_progress)
+    job.update_progress(target_progress, target_progress)
diff --git a/kolibri/core/content/utils/content_export.py b/kolibri/core/content/utils/content_export.py
new file mode 100644
index 0000000000..08abdc41a9
--- /dev/null
+++ b/kolibri/core/content/utils/content_export.py
@@ -0,0 +1,88 @@
+import logging
+import os
+
+from kolibri.core.content.errors import InvalidStorageFilenameError
+from kolibri.core.content.models import ChannelMetadata
+from kolibri.core.content.utils import paths
+from kolibri.core.content.utils.channel_transfer import get_job
+from kolibri.core.content.utils.content_manifest import ContentManifest
+from kolibri.core.content.utils.import_export_content import get_content_nodes_data
+from kolibri.core.content.utils.import_export_content import get_import_export_nodes
+from kolibri.core.content.utils.paths import get_content_file_name
+from kolibri.utils import file_transfer as transfer
+
+logger = logging.getLogger(__name__)
+
+
+def export_content(
+    channel_id, destination, manifest_only=False, node_ids=None, exclude_node_ids=None
+):
+    job = get_job()
+    data_dir = os.path.realpath(destination)
+    channel_metadata = ChannelMetadata.objects.get(id=channel_id)
+
+    nodes_queries_list = get_import_export_nodes(
+        channel_id, node_ids, exclude_node_ids, available=True
+    )
+    (total_resource_count, files, total_bytes_to_transfer) = get_content_nodes_data(
+        channel_id, nodes_queries_list, available=True
+    )
+    # update job meta data
+    job.extra_metadata["file_size"] = total_bytes_to_transfer
+    job.extra_metadata["total_resources"] = total_resource_count
+    job.save_meta()
+    # dont copy files if we are only exporting the manifest
+    if not manifest_only:
+        copy_content_files(channel_id, data_dir, files, total_bytes_to_transfer)
+    # Reraise any cancellation
+    job.check_for_cancel()
+    logger.info(
+        "Exporting manifest for channel id {} to {}".format(channel_id, data_dir)
+    )
+    manifest_path = os.path.join(data_dir, "content", "manifest.json")
+    content_manifest = ContentManifest()
+    content_manifest.read(manifest_path)
+    content_manifest.add_content_nodes(
+        channel_id, channel_metadata.version, nodes_queries_list
+    )
+    content_manifest.write(manifest_path)
+
+
+def copy_content_files(channel_id, data_dir, files, total_bytes_to_transfer):
+    job = get_job()
+    logger.info(
+        "Exporting content for channel id {} to {}".format(channel_id, data_dir)
+    )
+    for f in files:
+        if job.is_cancelled():
+            break
+        export_file(f, data_dir, total_bytes_to_transfer)
+
+
+def export_file(f, data_dir, total_bytes_to_transfer):
+    job = get_job()
+    filename = get_content_file_name(f)
+    try:
+        srcpath = paths.get_content_storage_file_path(filename)
+        dest = paths.get_content_storage_file_path(filename, datafolder=data_dir)
+    except InvalidStorageFilenameError:
+        # If any files have an invalid storage file name, don't export them.
+        job.update_progress(job.progress + f["file_size"], total_bytes_to_transfer)
+        return
+    # if the file already exists, add its size to our overall progress, and skip
+    if os.path.isfile(dest) and os.path.getsize(dest) == f["file_size"]:
+        job.update_progress(job.progress + f["file_size"], total_bytes_to_transfer)
+        return
+    with transfer.FileCopy(srcpath, dest, cancel_check=job.is_cancelled) as copy:
+
+        def progress_update(length):
+            job.update_progress(job.progress + length, total_bytes_to_transfer)
+
+        try:
+            copy.run(progress_update=progress_update)
+        except transfer.TransferCanceled:
+            job.extra_metadata["file_size"] = job.progress
+            job.extra_metadata["total_resources"] = 0
+            job.save_meta()
+            return
+        return dest


Test patch:
diff --git a/kolibri/core/content/test/test_delete_content.py b/kolibri/core/content/test/test_delete_content.py
index 0f616028a4..4bd17894dc 100644
--- a/kolibri/core/content/test/test_delete_content.py
+++ b/kolibri/core/content/test/test_delete_content.py
@@ -184,7 +184,7 @@ class DeleteContentTestCase(TransactionTestCase):
         except ContentNode.DoesNotExist:
             self.fail("Content node has been deleted")
 
-    @patch("kolibri.core.content.management.commands.deletecontent.clear_channel_stats")
+    @patch("kolibri.core.content.utils.content_delete.clear_channel_stats")
     def test_deleting_channel_clears_stats_cache(self, channel_stats_clear_mock):
         self.assertFalse(channel_stats_clear_mock.called)
         self._call_delete_command(node_ids=self._get_node_ids())
diff --git a/kolibri/core/content/test/test_import_export.py b/kolibri/core/content/test/test_import_export.py
index c300f90d46..6ad47aabb3 100644
--- a/kolibri/core/content/test/test_import_export.py
+++ b/kolibri/core/content/test/test_import_export.py
@@ -460,6 +460,8 @@ def create_dummy_job(is_cancelled=True, check_for_cancel_return=True):
     dummy.check_for_cancel.return_value = check_for_cancel_return
     dummy.start_progress.return_value = None
     dummy.update_progress.return_value = None
+    dummy.extra_metadata = {}
+    dummy.save_meta.return_value = None
     return dummy
 
 
@@ -2138,9 +2140,9 @@ class ExportChannelTestCase(TestCase):
 
 
 @override_option("Paths", "CONTENT_DIR", tempfile.mkdtemp())
-@patch("kolibri.core.content.management.commands.exportcontent.get_import_export_nodes")
-@patch("kolibri.core.content.management.commands.exportcontent.get_content_nodes_data")
-@patch("kolibri.core.content.management.commands.exportcontent.ContentManifest")
+@patch("kolibri.core.content.utils.content_export.get_import_export_nodes")
+@patch("kolibri.core.content.utils.content_export.get_content_nodes_data")
+@patch("kolibri.core.content.utils.content_export.ContentManifest")
 class ExportContentTestCase(TestCase):
     """
     Test case for the exportcontent management command.
@@ -2149,24 +2151,19 @@ class ExportContentTestCase(TestCase):
     fixtures = ["content_test.json"]
     the_channel_id = "6199dde695db4ee4ab392222d5af1e5c"
 
-    @patch("kolibri.core.content.management.commands.exportcontent.transfer.FileCopy")
-    @patch(
-        "kolibri.core.content.management.commands.exportcontent.AsyncCommand.check_for_cancel"
-    )
-    @patch(
-        "kolibri.core.content.management.commands.exportcontent.AsyncCommand.is_cancelled",
-        return_value=True,
-    )
+    @patch("kolibri.core.content.utils.content_export.transfer.FileCopy")
+    @patch("kolibri.core.content.utils.content_export.get_job")
     def test_local_cancel_immediately(
         self,
-        is_cancelled_mock,
-        cancel_mock,
+        get_job_mock,
         FileCopyMock,
         ContentManifestMock,
         get_content_nodes_data_mock,
         get_import_export_nodes_mock,
     ):
         # If cancel comes in before we do anything, make sure nothing happens!
+        dummy_job = create_dummy_job()
+        get_job_mock.return_value = dummy_job
         FileCopyMock.return_value.run.side_effect = TransferCanceled()
         get_content_nodes_data_mock.return_value = (
             1,
@@ -2174,35 +2171,26 @@ class ExportContentTestCase(TestCase):
             10,
         )
         call_command("exportcontent", self.the_channel_id, tempfile.mkdtemp())
-        is_cancelled_mock.assert_has_calls([call()])
+        dummy_job.is_cancelled.assert_has_calls([call()])
         FileCopyMock.assert_not_called()
-        cancel_mock.assert_called_with()
+        dummy_job.check_for_cancel.assert_called_with()
 
     @patch(
-        "kolibri.core.content.management.commands.exportcontent.AsyncCommand.start_progress"
-    )
-    @patch(
-        "kolibri.core.content.management.commands.exportcontent.paths.get_content_storage_file_path"
-    )
-    @patch("kolibri.core.content.management.commands.exportcontent.transfer.FileCopy")
-    @patch(
-        "kolibri.core.content.management.commands.exportcontent.AsyncCommand.check_for_cancel"
-    )
-    @patch(
-        "kolibri.core.content.management.commands.exportcontent.AsyncCommand.is_cancelled",
-        side_effect=[False, True, True],
+        "kolibri.core.content.utils.content_export.paths.get_content_storage_file_path"
     )
+    @patch("kolibri.core.content.utils.content_export.transfer.FileCopy")
+    @patch("kolibri.core.content.utils.content_export.get_job")
     def test_local_cancel_during_transfer(
         self,
-        is_cancelled_mock,
-        cancel_mock,
+        get_job_mock,
         FileCopyMock,
         local_path_mock,
-        start_progress_mock,
         ContentManifestMock,
         get_content_nodes_data_mock,
         get_import_export_nodes_mock,
     ):
+        dummy_job = create_dummy_job()
+        get_job_mock.return_value = dummy_job
         # Make sure we cancel during transfer
         fd1, local_dest_path = tempfile.mkstemp()
         fd2, local_src_path = tempfile.mkstemp()
@@ -2216,15 +2204,10 @@ class ExportContentTestCase(TestCase):
             10,
         )
         call_command("exportcontent", self.the_channel_id, tempfile.mkdtemp())
-        is_cancelled_mock.assert_has_calls([call()])
-        FileCopyMock.assert_called_with(
-            local_src_path, local_dest_path, cancel_check=is_cancelled_mock
-        )
-        cancel_mock.assert_called_with()
+        dummy_job.is_cancelled.assert_has_calls([call()])
+        dummy_job.check_for_cancel.assert_called_with()
 
-    @patch(
-        "kolibri.core.content.management.commands.exportcontent.Command.copy_content_files"
-    )
+    @patch("kolibri.core.content.utils.content_export.copy_content_files")
     def test_manifest_only(
         self,
         copy_content_files_mock,
@@ -2247,8 +2230,6 @@ class ExportContentTestCase(TestCase):
 
         ContentManifestMock.return_value.write.assert_called_once()
 
-        # Shall be enough mock assertions for now ?
-
 
 class TestFilesToTransfer(TestCase):