Repository: learningequality/kolibri

Issue:
Add something like `is_leaf` Boolean field to serialized ContentNodes to replace common "leaf or branch" checks in JS code
In the `ContentNodeViewSet` (and other related viewsets), it would be nice to have a field like `is_leaf` that returns `true` whenever the node is a resource/leaf and `false` if it is a topic or a channel.

There are dozens of places in the JS code where we have to check if this is the case based on the `kind` field that could be simplified if we had this field.

![image](https://user-images.githubusercontent.com/10248067/108550758-d729ab00-72a3-11eb-9725-47f0b41a4bb5.png)



Discussion:
@jonboiser I would like to work on this issue
@jonboiser The constants defined for the content kinds in ` le_utils.constants.content_kinds` don't include `CHANNEL`, but in the JS code `ContentNodeKinds.CHANNEL` has been referred several times. So, should the `is_leaf` boolean field return false only when the resource is `TOPIC`, as `CHANNEL` kind does not exist in ` le_utils.constants.content_kinds`, or am I missing something here?
https://github.com/learningequality/le-utils/blob/6febaf26fd11433fe6668d310c3db8d4c50f3522/le_utils/constants/content_kinds.py#L10-L19
On the Python side, you can tell that a ContentNode is a channel if its `parent` field is `None`. So you can have `is_leaf = True` when `kind` is `TOPIC` or ~`parent` is `None`~ (simpler solution in comment below)
Noting that on the backend `TOPIC` should be the kind on every node that isn't a leaf node (even the channel root node), wherever the special `CHANNEL` kind is being set it is only on the frontend.

So, the annotated field on the ContentNodeViewset would just be a simple boolean check of `kind == 'TOPIC'`.
> Noting that on the backend `TOPIC` should be the kind on every node that isn't a leaf node (even the channel root node), wherever the special `CHANNEL` kind is being set it is only on the frontend.
> 
> So, the annotated field on the ContentNodeViewset would just be a simple boolean check of `kind == 'TOPIC'`.

@jonboiser  @rtibbles Thanks for clearing that. 

So, after adding the `is_leaf` field on the `ContentNodeViewset`, checks on the frontend like `kind  ===  ContentNodeKinds.TOPIC || kind  ===  ContentNodeKinds.CHANNEL`  can be replace by a simple `!is_leaf`. Am I correct?
> So, after adding the `is_leaf` field on the `ContentNodeViewset`, checks on the frontend like `kind === ContentNodeKinds.TOPIC || kind === ContentNodeKinds.CHANNEL` can be replace by a simple `!is_leaf`. Am I correct?

Yes, that's right. I think we can even safely replace the `kind === ContentNodeKinds.TOPIC` check (without the check for `CHANNEL`) in most places as well.

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
@@ -373,6 +373,7 @@ def consolidate(self, items, queryset):
                         ancestors,
                     )
                 )
+                item["is_leaf"] = item.get("kind") != content_kinds.TOPIC
                 output.append(item)
         return output
 
@@ -399,6 +400,7 @@ def descendants(self, request):
 
             def copy_node(new_node):
                 new_node["ancestor_id"] = node.id
+                new_node["is_leaf"] = new_node.get("kind") != content_kinds.TOPIC
                 return new_node
 
             node_data = node.get_descendants().filter(available=True)
@@ -516,10 +518,16 @@ def next_content(self, request, **kwargs):
                     "id": next_item.id,
                     "title": next_item.title,
                     "thumbnail": thumbnails[0]["storage_url"],
+                    "is_leaf": next_item.kind != content_kinds.TOPIC,
                 }
             )
         return Response(
-            {"kind": next_item.kind, "id": next_item.id, "title": next_item.title}
+            {
+                "kind": next_item.kind,
+                "id": next_item.id,
+                "title": next_item.title,
+                "is_leaf": next_item.kind != content_kinds.TOPIC,
+            }
         )
 
     @detail_route(methods=["get"])
diff --git a/kolibri/core/content/serializers.py b/kolibri/core/content/serializers.py
--- a/kolibri/core/content/serializers.py
+++ b/kolibri/core/content/serializers.py
@@ -283,6 +283,7 @@ class ContentNodeGranularSerializer(serializers.ModelSerializer):
     new_resource = serializers.SerializerMethodField()
     num_new_resources = serializers.SerializerMethodField()
     updated_resource = serializers.SerializerMethodField()
+    is_leaf = serializers.SerializerMethodField()
 
     class Meta:
         model = ContentNode
@@ -291,6 +292,7 @@ class Meta:
             "available",
             "coach_content",
             "importable",
+            "is_leaf",
             "kind",
             "num_coach_contents",
             "on_device_resources",
@@ -355,6 +357,9 @@ def get_updated_resource(self, instance):
             return None
         return self.channel_stats.get(instance.id, {}).get("updated_resource", False)
 
+    def get_is_leaf(self, instance):
+        return instance.kind != content_kinds.TOPIC
+
 
 class ContentNodeProgressListSerializer(serializers.ListSerializer):
     def to_representation(self, data):
diff --git a/kolibri/core/logger/serializers.py b/kolibri/core/logger/serializers.py
--- a/kolibri/core/logger/serializers.py
+++ b/kolibri/core/logger/serializers.py
@@ -233,6 +233,7 @@ class ContentSummaryLogSerializer(KolibriModelSerializer):
     currentmasterylog = serializers.SerializerMethodField()
     extra_fields = serializers.JSONField(default="{}")
     update_fields = ()
+    is_leaf = serializers.SerializerMethodField()
 
     class Meta:
         model = ContentSummaryLog
@@ -249,6 +250,7 @@ class Meta:
             "progress",
             "kind",
             "extra_fields",
+            "is_leaf",
         )
 
     def get_currentmasterylog(self, obj):
@@ -258,6 +260,9 @@ def get_currentmasterylog(self, obj):
         except MasteryLog.DoesNotExist:
             return None
 
+    def get_is_leaf(self, obj):
+        return obj.kind != content_kinds.TOPIC
+
     def create(self, validated_data):
         instance = super(ContentSummaryLogSerializer, self).create(validated_data)
         # dont create notifications upon creating a summary log for an exercise


Test patch:
diff --git a/kolibri/core/content/test/test_content_app.py b/kolibri/core/content/test/test_content_app.py
--- a/kolibri/core/content/test/test_content_app.py
+++ b/kolibri/core/content/test/test_content_app.py
@@ -277,6 +277,7 @@ def test_contentnode_granular_network_import(self, stats_mock):
                 "id": c1_id,
                 "title": "root",
                 "kind": "topic",
+                "is_leaf": False,
                 "available": False,
                 "total_resources": 2,
                 "on_device_resources": 0,
@@ -292,6 +293,7 @@ def test_contentnode_granular_network_import(self, stats_mock):
                         "id": c2_id,
                         "title": "c1",
                         "kind": "video",
+                        "is_leaf": True,
                         "available": False,
                         "total_resources": 1,
                         "on_device_resources": 0,
@@ -306,6 +308,7 @@ def test_contentnode_granular_network_import(self, stats_mock):
                         "id": c3_id,
                         "title": "c2",
                         "kind": "topic",
+                        "is_leaf": False,
                         "available": False,
                         "total_resources": 1,
                         "on_device_resources": 0,
@@ -354,6 +357,7 @@ def test_contentnode_granular_local_import(self, stats_mock):
                 "id": c1_id,
                 "title": "root",
                 "kind": "topic",
+                "is_leaf": False,
                 "available": False,
                 "total_resources": 1,
                 "on_device_resources": 0,
@@ -369,6 +373,7 @@ def test_contentnode_granular_local_import(self, stats_mock):
                         "id": c2_id,
                         "title": "c1",
                         "kind": "video",
+                        "is_leaf": True,
                         "available": False,
                         "total_resources": 0,
                         "on_device_resources": 0,
@@ -383,6 +388,7 @@ def test_contentnode_granular_local_import(self, stats_mock):
                         "id": c3_id,
                         "title": "c2",
                         "kind": "topic",
+                        "is_leaf": False,
                         "available": False,
                         "total_resources": 1,
                         "on_device_resources": 0,
@@ -430,6 +436,7 @@ def test_contentnode_granular_remote_import(self, stats_mock):
                 "id": c1_id,
                 "title": "root",
                 "kind": "topic",
+                "is_leaf": False,
                 "available": False,
                 "total_resources": 1,
                 "on_device_resources": 0,
@@ -445,6 +452,7 @@ def test_contentnode_granular_remote_import(self, stats_mock):
                         "id": c2_id,
                         "title": "c1",
                         "kind": "video",
+                        "is_leaf": True,
                         "available": False,
                         "total_resources": 0,
                         "on_device_resources": 0,
@@ -459,6 +467,7 @@ def test_contentnode_granular_remote_import(self, stats_mock):
                         "id": c3_id,
                         "title": "c2",
                         "kind": "topic",
+                        "is_leaf": False,
                         "available": False,
                         "total_resources": 1,
                         "on_device_resources": 0,
@@ -487,6 +496,7 @@ def test_contentnode_granular_export_available(self):
                 "id": c1_id,
                 "title": "c1",
                 "kind": "video",
+                "is_leaf": True,
                 "available": True,
                 "total_resources": 1,
                 "on_device_resources": 1,
@@ -515,6 +525,7 @@ def test_contentnode_granular_export_unavailable(self):
                 "id": c1_id,
                 "title": "c1",
                 "kind": "video",
+                "is_leaf": True,
                 "available": False,
                 "total_resources": 0,
                 "on_device_resources": 0,