Repository: learningequality/kolibri

Issue:
ContentNodeSlim "popular" view can return an empty string as response
<!--
Instructions:
 * Fill out the sections below, replace …'s with information about your issue
 * Use the 'preview' function above this text box to verify formatting before submitting
-->

### Observed behavior

Related to #5407, a request to `/api/content/contentnode_slim/popular/?by_role=true&include_fields=num_coach_contents&contentCacheKey=1556915747`

Is returning a 200 response with `""` on my local version, which in turn causes the problem in #5407. The other detail view ("resume" and "next_steps") are working correctly.

**In the Network inspector in chrome dev tools**

<img width="638" alt="Screen Shot 2019-05-06 at 11 57 22 AM" src="https://user-images.githubusercontent.com/10248067/57248244-c3da1c80-6ff6-11e9-85a1-8cec909f6247.png">


I do not know what, specifically, is causing the "popular" view to return nothing.
 
<!--
Description of the behavior that was observed, including screenshots or other references when applicable
-->

…

### Expected behavior

Assuming, this is a legitimate response to, for example, not having any "popular" resources, a call to this endpoint returns something acceptable to the Api Resource class, like `[]`.

### User-facing consequences

Servers with the same conditions as mine (whatever they are) will not have a working "Learn > Recommended"  page.

### Errors and logs
<!--
Relevant logs from:
 * the command line
 * ~/.kolibri/kolibri.log
 * the browser console

Please wrap errors in triple backticks for clean formatting like this:
```
01:10 info: something happened
01:12 error: something bad happened
```
-->

…

### Steps to reproduce
<!--
Precise steps that someone else can follow in order to see this behavior
-->

…

### Context
0.12. develop


Discussion:
Inspecting this line in the debugger:

https://github.com/learningequality/kolibri/blob/cf19c722c067a102a415577058b1d95c25dab7f5/kolibri/core/content/api.py#L609

In my instance, `cache.get(cache_key)` is equal to None.

I don't see similar code in Next Steps or Recommended views, but see a slightly different cache-related code for the "ancestors" view (with a guard against the cached value being None):

https://github.com/learningequality/kolibri/blob/cf19c722c067a102a415577058b1d95c25dab7f5/kolibri/core/content/api.py#L461-L471

As an experiment, I changed this line in the popular view:

```diff
-       return Response(cache.get(cache_key))
+        return Response(serializer.data)
```

which fixed the problem for me.
When you returned the serializer.data what was the value, an empty list?
No, `serializer.data` was an array of contentnodes.
I'm seeing the same thing on the ancestors view

https://github.com/learningequality/kolibri/blob/cf19c722c067a102a415577058b1d95c25dab7f5/kolibri/core/content/api.py#L457-L472

Typing `cache.get(cache_key) is None` returns True, but in this case the ancestors API returns `Response(serialzier.data)`, so the Topics pages still work fine.

Is there something off about how the cache is set up locally, maybe?
We are using the Django null cache, which is meant to just accept sets, and then return None for gets. Maybe it's returning empty strings for fun?
Should just be returning the default (which as we are not passing the kwarg, should be None): https://github.com/django/django/blob/stable/1.11.x/django/core/cache/backends/dummy.py#L18
So what do lines like `Response(cache.get(cache_key)) ` mean if `cache.get` returns None?
They shouldn't be being invoked, as the cache is returning `None` and so shouldn't go down that branch, no?
For "ancestors", it seems like the `if cache.get(cache_key) is not None: ` is dead code, but for the "popular" view `return Response(cache.get(cache_key))` is always returned (but the intention is that the cache will be set immediately before this line if the cached value is None)

The ancestors view line is not dead code - we only use the dummy cache in development, in production we use a local memory cache.

The issue is probably that the dummy cache is returning None, so we need to not use the 'set cache then read from cache' idiom, because it will break in development mode because it always returns None.
Or use `Response(cache.get(cache_key, default=serialized_data))`
Right, either would work.

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
@@ -565,48 +565,50 @@ def popular(self, request, **kwargs):
                 cache_key = "popular_content_coach"
                 coach_content = True
 
+        if cache.get(cache_key) is not None:
+            return Response(cache.get(cache_key))
+
         queryset = self.get_queryset(prefetch=True)
 
-        if not cache.get(cache_key):
-            if ContentSessionLog.objects.count() < 50:
-                # return 25 random content nodes if not enough session logs
-                pks = queryset.values_list("pk", flat=True).exclude(
-                    kind=content_kinds.TOPIC
-                )
-                # .count scales with table size, so can get slow on larger channels
-                count_cache_key = "content_count_for_popular"
-                count = cache.get(count_cache_key) or min(pks.count(), 25)
-                queryset = queryset.filter(pk__in=sample(list(pks), count))
-                if not coach_content:
-                    queryset = queryset.exclude(coach_content=True)
-            else:
-                # get the most accessed content nodes
-                # search for content nodes that currently exist in the database
-                content_nodes = models.ContentNode.objects.filter(available=True)
-                if not coach_content:
-                    content_nodes = content_nodes.exclude(coach_content=True)
-                content_counts_sorted = (
-                    ContentSessionLog.objects.filter(
-                        content_id__in=content_nodes.values_list(
-                            "content_id", flat=True
-                        ).distinct()
-                    )
-                    .values_list("content_id", flat=True)
-                    .annotate(Count("content_id"))
-                    .order_by("-content_id__count")
+        if ContentSessionLog.objects.count() < 50:
+            # return 25 random content nodes if not enough session logs
+            pks = queryset.values_list("pk", flat=True).exclude(
+                kind=content_kinds.TOPIC
+            )
+            # .count scales with table size, so can get slow on larger channels
+            count_cache_key = "content_count_for_popular"
+            count = cache.get(count_cache_key) or min(pks.count(), 25)
+            queryset = queryset.filter(pk__in=sample(list(pks), count))
+            if not coach_content:
+                queryset = queryset.exclude(coach_content=True)
+        else:
+            # get the most accessed content nodes
+            # search for content nodes that currently exist in the database
+            content_nodes = models.ContentNode.objects.filter(available=True)
+            if not coach_content:
+                content_nodes = content_nodes.exclude(coach_content=True)
+            content_counts_sorted = (
+                ContentSessionLog.objects.filter(
+                    content_id__in=content_nodes.values_list(
+                        "content_id", flat=True
+                    ).distinct()
                 )
+                .values_list("content_id", flat=True)
+                .annotate(Count("content_id"))
+                .order_by("-content_id__count")
+            )
 
-                most_popular = queryset.filter(
-                    content_id__in=list(content_counts_sorted[:20])
-                )
-                queryset = most_popular.dedupe_by_content_id()
+            most_popular = queryset.filter(
+                content_id__in=list(content_counts_sorted[:20])
+            )
+            queryset = most_popular.dedupe_by_content_id()
 
-            serializer = self.get_serializer(queryset, many=True)
+        serializer = self.get_serializer(queryset, many=True)
 
-            # cache the popular results queryset for 10 minutes, for efficiency
-            cache.set(cache_key, serializer.data, 60 * 10)
+        # cache the popular results queryset for 10 minutes, for efficiency
+        cache.set(cache_key, serializer.data, 60 * 10)
 
-        return Response(cache.get(cache_key))
+        return Response(serializer.data)
 
     @detail_route(methods=["get"])
     def resume(self, request, **kwargs):


Test patch: