Repository: learningequality/kolibri

Issue:
New channel added not appearing in Admin page list of content or on Learn
### Observed behavior
It seems that when updating a channel, the channel itself gets set as unavailable, and is absent from both learn and the device channel page.

This occurred once when I was importing content locally, and another time when I was importing content from Studio. When I tried importing again, it worked in the future.

### Expected behavior
You expect for a channel to appear once it has been loaded.

### User-facing consequences
It's highly confusing, and also requires work to be re-done (and in lower bandwidth 
…


### Context
Tell us about your environment, including:
 * v0.10
 * Windows
 * Chrome
…



Discussion:
@mrpau-julius could you see if you can replicate this bug on latest 0.10?
Additional report from UNETE: " i'm browsing Kolibri platform and found some bug, but maybe is just a usability thing. Some days ago i downloaded Open Stax channel (partially). Today i try the feature "importar más" inside the channel and get an "Actualizar" blue button... so i click on it hoping that the contents that I had were updated.. instead they delete... if i have to re-select the partial contents that i download in the past, is probably to forget which one pick last time"
@laurenlichtman in what channel you import to kolibri v0.10? So that it's easily to replicate the bug.  

Resolving patch:
diff --git a/kolibri/content/utils/channel_import.py b/kolibri/content/utils/channel_import.py
--- a/kolibri/content/utils/channel_import.py
+++ b/kolibri/content/utils/channel_import.py
@@ -38,6 +38,9 @@
     Language,
 ]
 
+models_not_to_overwrite = [
+    LocalFile,
+]
 
 class ImportCancelError(Exception):
     pass
@@ -267,8 +270,14 @@ def raw_attached_sqlite_table_import(self, model, row_mapper, table_mapper, unfl
                 val = convert_to_sqlite_value(model._meta.get_field(col).get_default())
             source_vals.append(val)
 
+        if model in models_not_to_overwrite:
+            method = "INSERT OR IGNORE"
+        else:
+            method = "REPLACE"
+
         # build and execute a raw SQL query to transfer the data in one fell swoop
-        query = """REPLACE INTO {table} ({destcols}) SELECT {sourcevals} FROM sourcedb.{table} AS source""".format(
+        query = """{method} INTO {table} ({destcols}) SELECT {sourcevals} FROM sourcedb.{table} AS source""".format(
+            method=method,
             table=dest_table.name,
             destcols=", ".join(dest_columns),
             sourcevals=", ".join(source_vals),
@@ -298,13 +307,14 @@ def orm_table_import(self, model, row_mapper, table_mapper, unflushed_rows):
         columns = [column_name for column_name, column_obj in dest_table.columns.items() if column_not_auto_integer_pk(column_obj)]
         data_to_insert = []
         merge = model in merge_models
+        do_not_overwrite = model in models_not_to_overwrite
         for record in table_mapper(SourceRecord):
             self.check_cancelled()
             data = {
                 str(column): row_mapper(record, column) for column in columns if row_mapper(record, column) is not None
             }
             if merge:
-                self.merge_record(data, model, DestinationRecord)
+                self.merge_record(data, model, DestinationRecord, do_not_overwrite=do_not_overwrite)
             else:
                 data_to_insert.append(data)
             unflushed_rows += 1
@@ -359,13 +369,16 @@ def table_import(self, model, row_mapper, table_mapper, unflushed_rows):
 
         return result
 
-    def merge_record(self, data, model, DestinationRecord):
+    def merge_record(self, data, model, DestinationRecord, do_not_overwrite=False):
         # Models that should be merged (see list above) need to be individually merged into the session
         # as SQL Alchemy ORM does not support INSERT ... ON DUPLICATE KEY UPDATE style queries,
         # as not available in SQLite, only MySQL as far as I can tell:
         # http://hackthology.com/how-to-compile-mysqls-on-duplicate-key-update-in-sql-alchemy.html
         RowEntry = self.destination.session.query(DestinationRecord).get(data[model._meta.pk.name])
         if RowEntry:
+            # record already exists, so if we don't want to overwrite, abort here
+            if do_not_overwrite:
+                return
             for key, value in data.items():
                 setattr(RowEntry, key, value)
         else:


Test patch:
diff --git a/kolibri/content/test/test_channel_import.py b/kolibri/content/test/test_channel_import.py
--- a/kolibri/content/test/test_channel_import.py
+++ b/kolibri/content/test/test_channel_import.py
@@ -27,6 +27,8 @@
 from kolibri.content.models import V020BETA1
 from kolibri.content.models import V040BETA3
 from kolibri.content.models import VERSION_1
+from kolibri.content.utils.annotation import recurse_availability_up_tree
+from kolibri.content.utils.annotation import set_leaf_node_availability_from_local_file_availability
 from kolibri.content.utils.channel_import import ChannelImport
 from kolibri.content.utils.channel_import import import_channel_from_local_db
 from kolibri.content.utils.channels import read_channel_metadata_from_db_file
@@ -479,6 +481,43 @@ def test_residual_contentnode_deleted_after_reimport(self):
         with self.assertRaises(ContentNode.DoesNotExist):
             assert ContentNode.objects.get(pk=obj_id)
 
+    def test_existing_localfiles_are_not_overwritten(self):
+
+        with patch('kolibri.content.utils.sqlalchemybridge.get_engine', new=self.get_engine):
+
+            channel_id = '6199dde695db4ee4ab392222d5af1e5c'
+
+            channel = ChannelMetadata.objects.get(id=channel_id)
+
+            # mark LocalFile objects as available
+            for f in channel.root.children.first().files.all():
+                f.local_file.available = True
+                f.local_file.save()
+
+            # channel's not yet available, as we haven't done the annotation
+            assert not channel.root.available
+
+            # propagate availability up the tree
+            set_leaf_node_availability_from_local_file_availability(channel_id)
+            recurse_availability_up_tree(channel_id=channel_id)
+
+            # after reloading, channel should now be available
+            channel.root.refresh_from_db()
+            assert channel.root.available
+
+            # set the channel version to a low number to ensure we trigger a re-import of metadata
+            ChannelMetadata.objects.filter(id=channel_id).update(version=-1)
+
+            # reimport the metadata
+            self.set_content_fixture()
+
+            # after reloading, the files and their ancestor ContentNodes should all still be available
+            channel.root.refresh_from_db()
+            assert channel.root.available
+            assert channel.root.children.first().available
+            assert channel.root.children.first().files.all()[0].available
+            assert channel.root.children.first().files.all()[0].local_file.available
+
 
 class ImportLongDescriptionsTestCase(ContentImportTestBase, TransactionTestCase):
     """