Repository: learningequality/kolibri

Issue:
Add coach_content GET filtering to ContentNode endpoint 
<!--
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
<!--
Description of the behavior that was observed, including screenshots or other references when applicable
-->
While browsing channel content within Kolibri as an unauthenticated user, Coach content is correctly hidden from view. Although, as an unauthenticated user, I'm able to search for the name of the content to find it, open it, and view it.

#### Observed: Visible while signed in as user with Coach permissions
![visible-authenticated](https://user-images.githubusercontent.com/819838/57479672-d05ab100-7252-11e9-93bd-57ca55abb21d.png)

#### Observed: Hidden while signed out
![hidden-unauthenticated](https://user-images.githubusercontent.com/819838/57479711-e7010800-7252-11e9-9d57-084fc33e5a56.png)

#### Observed: Discoverable in search
![discoverable-in-search](https://user-images.githubusercontent.com/819838/57479737-f1230680-7252-11e9-9389-c9cf26ac8e99.png)


### Expected behavior
<!--
Description of what behavior was expected but did not occur
-->
Coach content or other restricted content should not be discoverable in search if the user is not authenticated.

### User-facing consequences
<!--
Implications and real-world consequences for learners, coaches, admins, and other users of the application
-->
Learners could access coach content by accessing it through search.

### 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
```
-->
Nothing relevant in logs.

### Steps to reproduce
<!--
Precise steps that someone else can follow in order to see this behavior
-->
1. Sign in as super admin into Kolibri instance.
2. Import entire "Epub Testing Channel" from token `gojad-patot`
3. Browse channel and access "Epub Edge Cases" topic
4. Observe "UNICEF Brochure" and "Grammar Worksheet" are accessible
5. Copy URL to "Epub Edge Cases" topic
6. Sign out
7. Go to previously copied URL
8. Observe "UNICEF Brochure" and "Grammar Worksheet" are missing
9. Search for "UNICEF"
10. Observe you can access the "UNICEF Brochure" from search

### Context
<!--
Tell us about your environment, including:
 * Kolibri version
 * Operating system
 * Browser
-->
Reproduced in both Chrome 74 and Firefox 66
```
Version:           0.12.3a0.dev0+git.588.gb16cbfff
OS:                Linux-4.15.0-48-generic-x86_64-with-Ubuntu-18.04-bionic
Installer:         devserver
Server:            WSGIServer/0.2 CPython/3.6.7
Database:          /home/bjester/Projects/learningequality/kolibri-data/develop/db.sqlite3
Device name:       phizbo
Free disk space:   1,135 GB
Server time:       Thu May 09 2019 11:54:45 GMT-0700 (Pacific Daylight Time)
Server timezone:   America/Los_Angeles
```



## Proposed solution

* Add coach_content GET param filtering to ContentNode Filter
* In the frontend set it based on the currently logged in user's role
  * false if not {isCoach, isAdmin, isSuperuser}
  * true otherwise


Discussion:
Some of the missing stuff looks like "Coach content" (they have that blue "person reading a book icon"), I think this is expected behavior, since you need to be logged in as a coach to see these items.
@jonboiser Okay so should expected behavior be that it should not be discoverable in search? Or do we care?
Expected behavior should be that Coach Content does not appear when a user is not authenticated, since an authenticated user is like a Learner, but with even less access (e.g. access to Classroom Assignments)
Seems like we don't filter based on coach content in the backend: https://github.com/learningequality/kolibri/blob/develop/kolibri/core/content/api.py#L715 although, I wonder if this is a case where filtering it on the frontend might be a better fix (to prevent the search from becoming cached on a per user basis only).
Or we should just be setting a coach_content filter GET param on the frontend - dependent on the user type.
Awhile back, @ralphiee22 implemented a filter on some ContentNode APIs that takes a `by_role` parameter (forgetting the exat usage). I assumed this was used everywhere on the Learn page to do the backend filtering
Yeah - that's a little problematic for caching as it uses user session info to determine the content of the query. It seems to currently be only used on recommendation endpoints (which is sort of OK, as that is per user mostly), but it is also used in the topic handlers.

We should probably change how we handle this to filter explicitly by the role, or filter after fetch on the frontend.
> Or we should just be setting a coach_content filter GET param on the frontend - dependent on the user type.

Certainly prefer this to client-side filtering, coupled with real API-level protection checks on the session authorization. Wouldn't want the kids stealing those answer keys ;)

0.12.y or 0.13.0? I'd lean towards the latter but don't feel strongly

Fine with the latter, just so as not to add more to the plate.

Resolving patch:
diff --git a/kolibri/core/analytics/measurements.py b/kolibri/core/analytics/measurements.py
--- a/kolibri/core/analytics/measurements.py
+++ b/kolibri/core/analytics/measurements.py
@@ -121,7 +121,8 @@ def format_url(url, base_url):
             requests.get(base_url).elapsed.total_seconds()
         )
         recommended_url = format_url(
-            "/api/content/contentnode_slim/popular/?user_kind=learner", base_url
+            "/api/content/contentnode_slim/popular/?include_coach_content=false",
+            base_url,
         )
         recommended_time = "{:.2f} s".format(
             requests.get(recommended_url).elapsed.total_seconds()
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
@@ -34,7 +34,6 @@
 from rest_framework.response import Response
 
 from kolibri.core.api import ValuesViewset
-from kolibri.core.auth.constants import user_kinds
 from kolibri.core.auth.middleware import session_exempt
 from kolibri.core.content import models
 from kolibri.core.content import serializers
@@ -149,10 +148,10 @@ class ContentNodeFilter(IdFilter):
         method="filter_kind",
         choices=(content_kinds.choices + (("content", _("Resource")),)),
     )
-    user_kind = ChoiceFilter(method="filter_user_kind", choices=user_kinds.choices)
     exclude_content_ids = CharFilter(method="filter_exclude_content_ids")
     kind_in = CharFilter(method="filter_kind_in")
     parent = UUIDFilter("parent")
+    include_coach_content = BooleanFilter(method="filter_include_coach_content")
 
     class Meta:
         model = models.ContentNode
@@ -166,7 +165,7 @@ class Meta:
             "content_id",
             "channel_id",
             "kind",
-            "user_kind",
+            "include_coach_content",
             "kind_in",
         ]
 
@@ -193,28 +192,14 @@ def filter_kind_in(self, queryset, name, value):
         kinds = value.split(",")
         return queryset.filter(kind__in=kinds).order_by("lft")
 
-    def filter_user_kind(self, queryset, name, value):
-        """
-        Show coach_content if they have coach role or higher.
-        This could be extended if we add other 'content role' types
-
-        :param queryset: content nodes
-        :param value: user_kind
-        :return: content nodes filtered by coach_content if appropiate
-        """
-        if value not in [
-            user_kinds.ADMIN,
-            user_kinds.SUPERUSER,
-            user_kinds.COACH,
-            user_kinds.ASSIGNABLE_COACH,
-        ]:
-            # Exclude nodes that are coach content
-            queryset = queryset.exclude(coach_content=True)
-        return queryset
-
     def filter_exclude_content_ids(self, queryset, name, value):
         return queryset.exclude_by_content_ids(value.split(","))
 
+    def filter_include_coach_content(self, queryset, name, value):
+        if value:
+            return queryset
+        return queryset.filter(coach_content=False)
+
 
 class OptionalPageNumberPagination(pagination.PageNumberPagination):
     """
@@ -628,20 +613,11 @@ def popular(self, request, **kwargs):
         :return: 10 most popular content nodes
         """
         cache_key = "popular_content"
-        coach_content = False
-
-        user = request.user
-        if user.is_facility_user:  # exclude anon users
-            if (
-                user.roles.exists() or user.is_superuser
-            ):  # must have coach role or higher
-                cache_key = "popular_content_coach"
-                coach_content = True
 
         if cache.get(cache_key) is not None:
             return Response(cache.get(cache_key))
 
-        queryset = self.prefetch_queryset(self.get_queryset())
+        queryset = self.filter_queryset(self.prefetch_queryset(self.get_queryset()))
 
         if ContentSessionLog.objects.count() < 50:
             # return 25 random content nodes if not enough session logs
@@ -654,14 +630,10 @@ def popular(self, request, **kwargs):
             queryset = queryset.filter_by_uuids(
                 sample(list(pks), count), validate=False
             )
-            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(


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
@@ -14,7 +14,6 @@
 from rest_framework import status
 from rest_framework.test import APITestCase
 
-from kolibri.core.auth.constants import user_kinds
 from kolibri.core.auth.models import Facility
 from kolibri.core.auth.models import FacilityUser
 from kolibri.core.auth.test.helpers import provision_device
@@ -795,7 +794,7 @@ def test_contentnode_parent(self):
         children = parent.get_children()
         response = self.client.get(
             reverse("kolibri:core:contentnode-list"),
-            data={"parent": parent.id, "user_kind": user_kinds.LEARNER},
+            data={"parent": parent.id, "include_coach_content": False},
         )
         self.assertEqual(len(response.data), children.count())
         for i in range(len(children)):
@@ -989,7 +988,7 @@ def get_progress_fraction(node):
     def test_filtering_coach_content_anon(self):
         response = self.client.get(
             reverse("kolibri:core:contentnode-list"),
-            data={"user_kind": user_kinds.ANONYMOUS},
+            data={"include_coach_content": False},
         )
         # TODO make content_test.json fixture more organized. Here just, hardcoding the correct count
         self.assertEqual(len(response.data), 7)
@@ -998,7 +997,7 @@ def test_filtering_coach_content_admin(self):
         self.client.login(username=self.admin.username, password=DUMMY_PASSWORD)
         response = self.client.get(
             reverse("kolibri:core:contentnode-list"),
-            data={"user_kind": user_kinds.ADMIN},
+            data={"include_coach_content": True},
         )
         expected_output = content.ContentNode.objects.exclude(
             available=False
@@ -1155,7 +1154,10 @@ def test_popular_no_coach_content(self):
         node.coach_content = True
         node.save()
         expected_content_ids = expected_content_ids[1:]
-        response = self.client.get(reverse("kolibri:core:contentnode-popular"))
+        response = self.client.get(
+            reverse("kolibri:core:contentnode-popular"),
+            data={"include_coach_content": False},
+        )
         response_content_ids = set(node["content_id"] for node in response.json())
         self.assertSetEqual(set(expected_content_ids), response_content_ids)
 
@@ -1169,7 +1171,10 @@ def test_popular_coach_has_coach_content(self):
         node.coach_content = True
         node.save()
         self.client.login(username="coach", password=DUMMY_PASSWORD)
-        response = self.client.get(reverse("kolibri:core:contentnode-popular"))
+        response = self.client.get(
+            reverse("kolibri:core:contentnode-popular"),
+            data={"include_coach_content": True},
+        )
         response_content_ids = set(node["content_id"] for node in response.json())
         self.assertSetEqual(set(expected_content_ids), response_content_ids)