Repository: learningequality/kolibri

Issue:
'canceling' can take a long time, but no loader is shown
### Observed behavior

Canceling an import of khan academy took many minutes on a fast computer. However, no loading indicator is shown:

![image](https://user-images.githubusercontent.com/2367265/69435125-d64a4800-0cf3-11ea-8ab7-dfe2803ff8e5.png)

### Expected behavior

Long-running task activities need a loading indicator

### User-facing consequences

confusion why the task can't be cleared


### Steps to reproduce

* start long import of KA
* cancel

### Context

0.13.0 beta1


Discussion:

Resolving patch:
diff --git a/kolibri/core/content/api.py b/kolibri/core/content/api.py
--- a/kolibri/core/content/api.py
+++ b/kolibri/core/content/api.py
@@ -957,6 +957,7 @@ def _studio_response_to_kolibri_response(cls, studioresp):
             "version": studioresp.get("version", 0),
             "included_languages": included_languages,
             "last_updated": studioresp.get("last_published"),
+            "version_notes": studioresp.get("version_notes"),
         }
 
         return resp
diff --git a/kolibri/core/content/utils/upgrade.py b/kolibri/core/content/utils/upgrade.py
--- a/kolibri/core/content/utils/upgrade.py
+++ b/kolibri/core/content/utils/upgrade.py
@@ -44,7 +44,7 @@ def diff_stats(channel_id, method, drive_id=None, baseurl=None):
         elif method == "disk":
             drive = get_mounted_drive_by_id(drive_id)
             call_command(
-                "importchannel", "disk", channel_id, drive, no_upgrade=True,
+                "importchannel", "disk", channel_id, drive.datafolder, no_upgrade=True,
             )
 
         # create all fields/tables at the annotated destination db, based on the current schema version
diff --git a/kolibri/core/tasks/api.py b/kolibri/core/tasks/api.py
--- a/kolibri/core/tasks/api.py
+++ b/kolibri/core/tasks/api.py
@@ -1,5 +1,6 @@
 import logging
 import os
+import requests
 from functools import partial
 
 from django.apps.registry import AppRegistryNotReady
@@ -17,7 +18,9 @@
 from kolibri.core.content.permissions import CanManageContent
 from kolibri.core.content.utils.channels import get_mounted_drive_by_id
 from kolibri.core.content.utils.channels import get_mounted_drives_with_channel_info
+from kolibri.core.content.utils.channels import read_channel_metadata_from_db_file
 from kolibri.core.content.utils.paths import get_content_database_file_path
+from kolibri.core.content.utils.paths import get_channel_lookup_url
 from kolibri.core.content.utils.upgrade import diff_stats
 from kolibri.core.discovery.models import NetworkLocation
 from kolibri.core.tasks.exceptions import JobNotFound
@@ -25,6 +28,7 @@
 from kolibri.core.tasks.job import State
 from kolibri.core.tasks.main import priority_queue
 from kolibri.core.tasks.main import queue
+from kolibri.core.tasks.utils import get_current_job
 from kolibri.utils import conf
 
 try:
@@ -46,12 +50,7 @@
 CATCHALL_SERVER_ERROR_STRING = _("There was an unknown error.")
 
 
-def validate_content_task(request, task_description, require_channel=False):
-    try:
-        channel_id = task_description["channel_id"]
-    except KeyError:
-        raise serializers.ValidationError("The channel_ids field is required.")
-
+def get_channel_name(channel_id, require_channel=False):
     try:
         channel = ChannelMetadata.objects.get(id=channel_id)
         channel_name = channel.name
@@ -60,6 +59,17 @@ def validate_content_task(request, task_description, require_channel=False):
             raise serializers.ValidationError("This channel does not exist")
         channel_name = ""
 
+    return channel_name
+
+
+def validate_content_task(request, task_description, require_channel=False):
+    try:
+        channel_id = task_description["channel_id"]
+    except KeyError:
+        raise serializers.ValidationError("The channel_ids field is required.")
+
+    channel_name = get_channel_name(channel_id, require_channel)
+
     node_ids = task_description.get("node_ids", None)
     exclude_node_ids = task_description.get("exclude_node_ids", None)
 
@@ -170,6 +180,47 @@ def destroy(self, request, pk=None):
         # unimplemented for now.
         pass
 
+    @list_route(methods=["post"])
+    def startchannelupdate(self, request):
+
+        sourcetype = request.data.pop("sourcetype", None)
+        new_version = request.data.pop("new_version", None)
+
+        if sourcetype == "remote":
+            task = validate_remote_import_task(request, request.data)
+            task.update({"type": "UPDATECHANNEL", "new_version": new_version})
+            job_id = queue.enqueue(
+                _remoteimport,
+                task["channel_id"],
+                task["baseurl"],
+                peer_id=task["peer_id"],
+                node_ids=task["node_ids"],
+                is_updating=True,
+                extra_metadata=task,
+                track_progress=True,
+                cancellable=True,
+            )
+        elif sourcetype == "local":
+            task = validate_local_import_task(request, request.data)
+            task.update({"type": "UPDATECHANNEL", "new_version": new_version})
+            job_id = queue.enqueue(
+                _diskimport,
+                task["channel_id"],
+                task["datafolder"],
+                drive_id=task["drive_id"],
+                node_ids=task["node_ids"],
+                is_updating=True,
+                extra_metadata=task,
+                track_progress=True,
+                cancellable=True,
+            )
+        else:
+            raise serializers.ValidationError("sourcetype must be 'remote' or 'local'")
+
+        resp = _job_to_response(queue.fetch_job(job_id))
+
+        return Response(resp)
+
     @list_route(methods=["post"])
     def startremotebulkimport(self, request):
         if not isinstance(request.data, list):
@@ -579,12 +630,23 @@ def channeldiffstats(self, request):
         if method == "network":
             baseurl = baseurl or conf.OPTIONS["Urls"]["CENTRAL_CONTENT_BASE_URL"]
             job_metadata["baseurl"] = baseurl
+            # get channel version metadata
+            url = get_channel_lookup_url(baseurl=baseurl, identifier=channel_id)
+            resp = requests.get(url)
+            channel_metadata = resp.json()
+            job_metadata["new_channel_version"] = channel_metadata[0]["version"]
         elif method == "disk":
             if not drive_id:
                 raise serializers.ValidationError(
                     "The drive_id field is required when using 'disk' method."
                 )
             job_metadata = _add_drive_info(job_metadata, request.data)
+            # get channel version metadata
+            drive = get_mounted_drive_by_id(drive_id)
+            channel_metadata = read_channel_metadata_from_db_file(
+                get_content_database_file_path(channel_id, drive.datafolder)
+            )
+            job_metadata["new_channel_version"] = channel_metadata.version
         else:
             raise serializers.ValidationError(
                 "'method' field should either be 'network' or 'disk'."
@@ -621,6 +683,7 @@ def _remoteimport(
     update_progress=None,
     check_for_cancel=None,
     node_ids=None,
+    is_updating=False,
     exclude_node_ids=None,
     extra_metadata=None,
 ):
@@ -633,17 +696,28 @@ def _remoteimport(
         update_progress=update_progress,
         check_for_cancel=check_for_cancel,
     )
-    call_command(
-        "importcontent",
-        "network",
-        channel_id,
-        baseurl=baseurl,
-        peer_id=peer_id,
-        node_ids=node_ids,
-        exclude_node_ids=exclude_node_ids,
-        update_progress=update_progress,
-        check_for_cancel=check_for_cancel,
-    )
+
+    # Add the channel name if it wasn't added initially
+    job = get_current_job()
+    if job and job.extra_metadata.get("channel_name", "") == "":
+        job.extra_metadata["channel_name"] = get_channel_name(channel_id)
+        job.save_meta()
+
+    # Skip importcontent step if updating and no nodes have changed
+    if is_updating and node_ids and len(node_ids) == 0:
+        pass
+    else:
+        call_command(
+            "importcontent",
+            "network",
+            channel_id,
+            baseurl=baseurl,
+            peer_id=peer_id,
+            node_ids=node_ids,
+            exclude_node_ids=exclude_node_ids,
+            update_progress=update_progress,
+            check_for_cancel=check_for_cancel,
+        )
 
 
 def _diskimport(
@@ -653,6 +727,7 @@ def _diskimport(
     update_progress=None,
     check_for_cancel=None,
     node_ids=None,
+    is_updating=False,
     exclude_node_ids=None,
     extra_metadata=None,
 ):
@@ -665,17 +740,28 @@ def _diskimport(
         update_progress=update_progress,
         check_for_cancel=check_for_cancel,
     )
-    call_command(
-        "importcontent",
-        "disk",
-        channel_id,
-        directory,
-        drive_id=drive_id,
-        node_ids=node_ids,
-        exclude_node_ids=exclude_node_ids,
-        update_progress=update_progress,
-        check_for_cancel=check_for_cancel,
-    )
+
+    # Add the channel name if it wasn't added initially
+    job = get_current_job()
+    if job and job.extra_metadata.get("channel_name", "") == "":
+        job.extra_metadata["channel_name"] = get_channel_name(channel_id)
+        job.save_meta()
+
+    # Skip importcontent step if updating and no nodes have changed
+    if is_updating and node_ids and len(node_ids) == 0:
+        pass
+    else:
+        call_command(
+            "importcontent",
+            "disk",
+            channel_id,
+            directory,
+            drive_id=drive_id,
+            node_ids=node_ids,
+            exclude_node_ids=exclude_node_ids,
+            update_progress=update_progress,
+            check_for_cancel=check_for_cancel,
+        )
 
 
 def _localexport(


Test patch:
diff --git a/kolibri/plugins/device/assets/test/treeViewUtils.spec.js b/kolibri/plugins/device/assets/test/treeViewUtils.spec.js
--- a/kolibri/plugins/device/assets/test/treeViewUtils.spec.js
+++ b/kolibri/plugins/device/assets/test/treeViewUtils.spec.js
@@ -48,7 +48,7 @@ describe('annotateNode utility correctly annotates', () => {
       checkboxType: 'unchecked',
     });
     assertAnnotationsEqual(exportAnnotated, {
-      message: '10 of 100 resources on your device',
+      message: '',
       disabled: false,
       checkboxType: 'unchecked',
     });
@@ -65,7 +65,7 @@ describe('annotateNode utility correctly annotates', () => {
       checkboxType: 'unchecked',
     });
     assertAnnotationsEqual(exportAnnotated, {
-      message: '10 of 100 resources on your device',
+      message: '',
       disabled: false,
       checkboxType: 'unchecked',
     });
@@ -100,7 +100,7 @@ describe('annotateNode utility correctly annotates', () => {
       checkboxType: 'unchecked',
     });
     assertAnnotationsEqual(exportAnnotated, {
-      message: '10 of 2,000 resources on your device',
+      message: '',
       disabled: false,
       checkboxType: 'unchecked',
     });
@@ -117,7 +117,7 @@ describe('annotateNode utility correctly annotates', () => {
       checkboxType: 'unchecked',
     });
     assertAnnotationsEqual(exportAnnotated, {
-      message: '10 of 2,000 resources on your device',
+      message: '',
       disabled: false,
       checkboxType: 'unchecked',
     });
@@ -157,7 +157,7 @@ describe('annotateNode utility correctly annotates', () => {
       checkboxType: 'checked',
     });
     assertAnnotationsEqual(exportAnnotated, {
-      message: '',
+      message: '1 resource selected',
       disabled: false,
       checkboxType: 'checked',
     });
diff --git a/kolibri/plugins/device/assets/test/views/AvailableChannelsPage.spec.js b/kolibri/plugins/device/assets/test/views/AvailableChannelsPage.spec.js
--- a/kolibri/plugins/device/assets/test/views/AvailableChannelsPage.spec.js
+++ b/kolibri/plugins/device/assets/test/views/AvailableChannelsPage.spec.js
@@ -179,7 +179,6 @@ describe('availableChannelsPage', () => {
       },
       query: {
         drive_id: undefined,
-        for_export: undefined,
       },
     });
   });