We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Reinstate 'all' on recommendations page with more efficient pagination
## Summary

Currently we use page based pagination to show all content on the learn page. This can result in very slow API requests for large channels.

For now, this has been fixed by removing the `all` section from the learn page, but it should be reinstated for the anonymous user case, alongside a cursor based pagination that will allow for more efficient queries.

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `kolibri/content/api.py`
Content:
```
1 import os
2 from functools import reduce
3 from random import sample
4 
5 from django.core.cache import cache
6 from django.db.models import Q
7 from django.db.models.aggregates import Count
8 from kolibri.content import models, serializers
9 from kolibri.content.content_db_router import get_active_content_database
10 from kolibri.logger.models import ContentSessionLog, ContentSummaryLog
11 from le_utils.constants import content_kinds
12 from rest_framework import filters, pagination, viewsets
13 from rest_framework.decorators import detail_route
14 from rest_framework.response import Response
15 
16 from .permissions import OnlyDeviceOwnerCanDelete
17 from .utils.search import fuzz
18 from .utils.paths import get_content_database_file_path
19 
20 
21 def _join_with_logical_operator(lst, operator):
22     op = ") {operator} (".format(operator=operator)
23     return "(({items}))".format(items=op.join(lst))
24 
25 
26 class ChannelMetadataCacheViewSet(viewsets.ModelViewSet):
27     permission_classes = (OnlyDeviceOwnerCanDelete,)
28     serializer_class = serializers.ChannelMetadataCacheSerializer
29 
30     def get_queryset(self):
31         return models.ChannelMetadataCache.objects.all()
32 
33     def destroy(self, request, pk=None):
34         """
35         Destroys the ChannelMetadata object and its associated sqlite3 file on
36         the filesystem.
37         """
38         super(ChannelMetadataCacheViewSet, self).destroy(request)
39 
40         if self.delete_content_db_file(pk):
41             response_msg = 'Channel {} removed from device'.format(pk)
42         else:
43             response_msg = 'Channel {} removed, but no content database was found'.format(pk)
44 
45         return Response(response_msg)
46 
47     def delete_content_db_file(self, channel_id):
48         try:
49             os.remove(get_content_database_file_path(channel_id))
50             return True
51         except OSError:
52             return False
53 
54 
55 class ContentNodeFilter(filters.FilterSet):
56     search = filters.django_filters.MethodFilter(action='title_description_filter')
57     recommendations_for = filters.django_filters.MethodFilter()
58     next_steps = filters.django_filters.MethodFilter()
59     popular = filters.django_filters.MethodFilter()
60     resume = filters.django_filters.MethodFilter()
61     kind = filters.django_filters.MethodFilter()
62     ids = filters.django_filters.MethodFilter()
63 
64     class Meta:
65         model = models.ContentNode
66         fields = ['parent', 'search', 'prerequisite_for', 'has_prerequisite', 'related', 'recommendations_for', 'ids', 'content_id']
67 
68     def title_description_filter(self, queryset, value):
69         """
70         search for title or description that contains the keywords that are not necessary in adjacent
71         """
72         exact_match = queryset.filter(Q(parent__isnull=False), Q(title__icontains=value) | Q(description__icontains=value))
73         if exact_match:
74             return exact_match
75         # if no exact match, fuzzy search using the stemmed_metaphone field in ContentNode that covers the title and description
76         fuzzed_tokens = [fuzz(word) for word in value.split()]
77         if not fuzzed_tokens[0]:
78             return []
79         token_queries = [reduce(lambda x, y: x | y, [Q(stemmed_metaphone__contains=token) for token in tokens]) for tokens in fuzzed_tokens]
80         return queryset.filter(
81             Q(parent__isnull=False),
82             reduce(lambda x, y: x & y, token_queries))
83 
84     def filter_recommendations_for(self, queryset, value):
85         """
86         Recommend items that are similar to this piece of content.
87         """
88         recc_node = queryset.get(pk=value)
89         descendants = recc_node.get_descendants(include_self=False).exclude(kind__in=['topic', ''])
90         siblings = recc_node.get_siblings(include_self=False).exclude(kind__in=['topic', ''])
91         data = descendants | siblings  # concatenates different querysets
92         return data
93 
94     def filter_next_steps(self, queryset, value):
95         """
96         Recommend uncompleted content, content that has user completed content as a prerequisite.
97 
98         :param queryset: all content nodes for this channel
99         :param value: id of currently logged in user, or none if user is anonymous
100         :return: uncompleted content nodes, or empty queryset if user is anonymous
101         """
102 
103         # if user is anonymous, don't return any nodes
104         if not value:
105             return queryset.none()
106 
107         tables = [
108             '"{summarylog_table}" AS "complete_log"',
109             '"{summarylog_table}" AS "incomplete_log"',
110             '"{content_table}" AS "complete_node"',
111             '"{content_table}" AS "incomplete_node"',
112         ]
113         table_names = {
114             "summarylog_table": ContentSummaryLog._meta.db_table,
115             "content_table": models.ContentNode._meta.db_table,
116         }
117         # aliases for sql table names
118         sql_tables_and_aliases = [table.format(**table_names) for table in tables]
119         # where conditions joined by ANDs
120         where_statements = ["NOT (incomplete_log.progress < 1 AND incomplete_log.content_id = incomplete_node.content_id)",
121                             "complete_log.user_id = '{user_id}'".format(user_id=value),
122                             "incomplete_log.user_id = '{user_id}'".format(user_id=value),
123                             "complete_log.progress = 1",
124                             "complete_node.rght = incomplete_node.lft - 1",
125                             "complete_log.content_id = complete_node.content_id"]
126         # custom SQL query to get uncompleted content based on mptt algorithm
127         next_steps_recommendations = "SELECT incomplete_node.* FROM {tables} WHERE {where}".format(
128             tables=", ".join(sql_tables_and_aliases),
129             where=_join_with_logical_operator(where_statements, "AND")
130         )
131         return models.ContentNode.objects.raw(next_steps_recommendations)
132 
133     def filter_popular(self, queryset, value):
134         """
135         Recommend content that is popular with all users.
136 
137         :param queryset: all content nodes for this channel
138         :param value: id of currently logged in user, or none if user is anonymous
139         :return: 10 most popular content nodes
140         """
141         if ContentSessionLog.objects.count() < 50:
142             # return 25 random content nodes if not enough session logs
143             pks = queryset.values_list('pk', flat=True).exclude(kind__in=['topic', ''])
144             count = min(pks.count(), 25)
145             return queryset.filter(pk__in=sample(list(pks), count))
146 
147         cache_key = 'popular_for_{}'.format(get_active_content_database())
148         if cache.get(cache_key):
149             return cache.get(cache_key)
150 
151         # get the most accessed content nodes
152         content_counts_sorted = ContentSessionLog.objects \
153             .filter(channel_id=get_active_content_database()) \
154             .values_list('content_id', flat=True) \
155             .annotate(Count('content_id')) \
156             .order_by('-content_id__count')
157 
158         most_popular = queryset.filter(content_id__in=list(content_counts_sorted[:10]))
159 
160         # cache the popular results queryset for 10 minutes, for efficiency
161         cache.set(cache_key, most_popular, 60 * 10)
162         return most_popular
163 
164     def filter_resume(self, queryset, value):
165         """
166         Recommend content that the user has recently engaged with, but not finished.
167 
168         :param queryset: all content nodes for this channel
169         :param value: id of currently logged in user, or none if user is anonymous
170         :return: 10 most recently viewed content nodes
171         """
172 
173         # if user is anonymous, return no nodes
174         if not value:
175             return queryset.none()
176 
177         # get the most recently viewed, but not finished, content nodes
178         content_ids = ContentSummaryLog.objects \
179             .filter(user=value, channel_id=get_active_content_database()) \
180             .exclude(progress=1) \
181             .order_by('end_timestamp') \
182             .values_list('content_id', flat=True) \
183             .distinct()
184 
185         resume = queryset.filter(content_id__in=list(content_ids[:10]))
186 
187         return resume
188 
189     def filter_kind(self, queryset, value):
190         """
191         Show only content of a given kind.
192 
193         :param queryset: all content nodes for this channel
194         :param value: 'content' for everything except topics, or one of the content kind constants
195         :return: content nodes of the given kind
196         """
197         if value == 'content':
198             return queryset.exclude(kind=content_kinds.TOPIC).order_by("lft")
199         return queryset.filter(kind=value).order_by("lft")
200 
201     def filter_ids(self, queryset, value):
202         return queryset.filter(pk__in=value.split(','))
203 
204 
205 class OptionalPageNumberPagination(pagination.PageNumberPagination):
206     """
207     Pagination class that allows for page number-style pagination, when requested.
208     To activate, the `page_size` argument must be set. For example, to request the first 20 records:
209     `?page_size=20&page=1`
210     """
211     page_size = None
212     page_size_query_param = "page_size"
213 
214 
215 class ContentNodeViewset(viewsets.ModelViewSet):
216     serializer_class = serializers.ContentNodeSerializer
217     filter_backends = (filters.DjangoFilterBackend,)
218     filter_class = ContentNodeFilter
219     pagination_class = OptionalPageNumberPagination
220 
221     def get_queryset(self):
222         return models.ContentNode.objects.all().select_related(
223             'parent',
224             'license',
225         ).prefetch_related(
226             'assessmentmetadata',
227             'files',
228         )
229 
230     @detail_route(methods=['get'])
231     def descendants(self, request, **kwargs):
232         node = self.get_object()
233         kind = self.request.query_params.get('descendant_kind', None)
234         descendants = node.get_descendants()
235         if kind:
236             descendants = descendants.filter(kind=kind)
237 
238         serializer = self.get_serializer(descendants, many=True)
239         return Response(serializer.data)
240 
241     @detail_route(methods=['get'])
242     def ancestors(self, request, **kwargs):
243         return Response(self.get_object().get_ancestors().values('pk', 'title'))
244 
245     @detail_route(methods=['get'])
246     def next_content(self, request, **kwargs):
247         # retrieve the "next" content node, according to depth-first tree traversal
248         this_item = self.get_object()
249         next_item = models.ContentNode.objects.filter(tree_id=this_item.tree_id, lft__gt=this_item.rght).order_by("lft").first()
250         if not next_item:
251             next_item = this_item.get_root()
252         return Response({'kind': next_item.kind, 'id': next_item.id, 'title': next_item.title})
253 
254 
255 class FileViewset(viewsets.ModelViewSet):
256     serializer_class = serializers.FileSerializer
257     pagination_class = OptionalPageNumberPagination
258 
259     def get_queryset(self):
260         return models.File.objects.all()
261
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/kolibri/content/api.py b/kolibri/content/api.py
--- a/kolibri/content/api.py
+++ b/kolibri/content/api.py
@@ -1,16 +1,19 @@
 import os
+
+from collections import OrderedDict
 from functools import reduce
 from random import sample
 
 from django.core.cache import cache
 from django.db.models import Q
 from django.db.models.aggregates import Count
+from future.moves.urllib.parse import parse_qs, urlparse
 from kolibri.content import models, serializers
 from kolibri.content.content_db_router import get_active_content_database
 from kolibri.logger.models import ContentSessionLog, ContentSummaryLog
 from le_utils.constants import content_kinds
 from rest_framework import filters, pagination, viewsets
-from rest_framework.decorators import detail_route
+from rest_framework.decorators import detail_route, list_route
 from rest_framework.response import Response
 
 from .permissions import OnlyDeviceOwnerCanDelete
@@ -212,6 +215,39 @@
     page_size_query_param = "page_size"
 
 
+class AllContentCursorPagination(pagination.CursorPagination):
+    page_size = 10
+    ordering = 'lft'
+    cursor_query_param = 'cursor'
+
+    def get_paginated_response(self, data):
+        """
+        By default the get_paginated_response method of the CursorPagination class returns the url link
+        to the next and previous queries of they exist.
+        For Kolibri this is not very helpful, as we construct our URLs on the client side rather than
+        directly querying passed in URLs.
+        Instead, return the cursor value that points to the next and previous items, so that they can be put
+        in a GET parameter in future queries.
+        """
+        if self.has_next:
+            # The CursorPagination class has no internal methods to just return the cursor value, only
+            # the url of the next and previous, so we have to generate the URL, parse it, and then
+            # extract the cursor parameter from it to return in the Response.
+            next_item = parse_qs(urlparse(self.get_next_link()).query).get(self.cursor_query_param)
+        else:
+            next_item = None
+        if self.has_previous:
+            # Similarly to next, we have to create the previous link and then parse it to get the cursor value
+            prev_item = parse_qs(urlparse(self.get_previous_link()).query).get(self.cursor_query_param)
+        else:
+            prev_item = None
+
+        return Response(OrderedDict([
+            ('next', next_item),
+            ('previous', prev_item),
+            ('results', data)
+        ]))
+
 class ContentNodeViewset(viewsets.ModelViewSet):
     serializer_class = serializers.ContentNodeSerializer
     filter_backends = (filters.DjangoFilterBackend,)
@@ -251,6 +287,17 @@
             next_item = this_item.get_root()
         return Response({'kind': next_item.kind, 'id': next_item.id, 'title': next_item.title})
 
+    @list_route(methods=['get'], pagination_class=AllContentCursorPagination)
+    def all_content(self, request, **kwargs):
+        queryset = self.get_queryset().exclude(kind=content_kinds.TOPIC)
+        page = self.paginate_queryset(queryset)
+        if page is not None:
+            serializer = self.get_serializer(page, many=True)
+            return self.get_paginated_response(serializer.data)
+
+        serializer = self.get_serializer(queryset, many=True)
+        return Response(serializer.data)
+
 
 class FileViewset(viewsets.ModelViewSet):
     serializer_class = serializers.FileSerializer

{"golden_diff": "diff --git a/kolibri/content/api.py b/kolibri/content/api.py\n--- a/kolibri/content/api.py\n+++ b/kolibri/content/api.py\n@@ -1,16 +1,19 @@\n import os\n+\n+from collections import OrderedDict\n from functools import reduce\n from random import sample\n \n from django.core.cache import cache\n from django.db.models import Q\n from django.db.models.aggregates import Count\n+from future.moves.urllib.parse import parse_qs, urlparse\n from kolibri.content import models, serializers\n from kolibri.content.content_db_router import get_active_content_database\n from kolibri.logger.models import ContentSessionLog, ContentSummaryLog\n from le_utils.constants import content_kinds\n from rest_framework import filters, pagination, viewsets\n-from rest_framework.decorators import detail_route\n+from rest_framework.decorators import detail_route, list_route\n from rest_framework.response import Response\n \n from .permissions import OnlyDeviceOwnerCanDelete\n@@ -212,6 +215,39 @@\n     page_size_query_param = \"page_size\"\n \n \n+class AllContentCursorPagination(pagination.CursorPagination):\n+    page_size = 10\n+    ordering = 'lft'\n+    cursor_query_param = 'cursor'\n+\n+    def get_paginated_response(self, data):\n+        \"\"\"\n+        By default the get_paginated_response method of the CursorPagination class returns the url link\n+        to the next and previous queries of they exist.\n+        For Kolibri this is not very helpful, as we construct our URLs on the client side rather than\n+        directly querying passed in URLs.\n+        Instead, return the cursor value that points to the next and previous items, so that they can be put\n+        in a GET parameter in future queries.\n+        \"\"\"\n+        if self.has_next:\n+            # The CursorPagination class has no internal methods to just return the cursor value, only\n+            # the url of the next and previous, so we have to generate the URL, parse it, and then\n+            # extract the cursor parameter from it to return in the Response.\n+            next_item = parse_qs(urlparse(self.get_next_link()).query).get(self.cursor_query_param)\n+        else:\n+            next_item = None\n+        if self.has_previous:\n+            # Similarly to next, we have to create the previous link and then parse it to get the cursor value\n+            prev_item = parse_qs(urlparse(self.get_previous_link()).query).get(self.cursor_query_param)\n+        else:\n+            prev_item = None\n+\n+        return Response(OrderedDict([\n+            ('next', next_item),\n+            ('previous', prev_item),\n+            ('results', data)\n+        ]))\n+\n class ContentNodeViewset(viewsets.ModelViewSet):\n     serializer_class = serializers.ContentNodeSerializer\n     filter_backends = (filters.DjangoFilterBackend,)\n@@ -251,6 +287,17 @@\n             next_item = this_item.get_root()\n         return Response({'kind': next_item.kind, 'id': next_item.id, 'title': next_item.title})\n \n+    @list_route(methods=['get'], pagination_class=AllContentCursorPagination)\n+    def all_content(self, request, **kwargs):\n+        queryset = self.get_queryset().exclude(kind=content_kinds.TOPIC)\n+        page = self.paginate_queryset(queryset)\n+        if page is not None:\n+            serializer = self.get_serializer(page, many=True)\n+            return self.get_paginated_response(serializer.data)\n+\n+        serializer = self.get_serializer(queryset, many=True)\n+        return Response(serializer.data)\n+\n \n class FileViewset(viewsets.ModelViewSet):\n     serializer_class = serializers.FileSerializer\n", "issue": "Reinstate 'all' on recommendations page with more efficient pagination\n## Summary\r\n\r\nCurrently we use page based pagination to show all content on the learn page. This can result in very slow API requests for large channels.\r\n\r\nFor now, this has been fixed by removing the `all` section from the learn page, but it should be reinstated for the anonymous user case, alongside a cursor based pagination that will allow for more efficient queries.\n", "before_files": [{"content": "import os\nfrom functools import reduce\nfrom random import sample\n\nfrom django.core.cache import cache\nfrom django.db.models import Q\nfrom django.db.models.aggregates import Count\nfrom kolibri.content import models, serializers\nfrom kolibri.content.content_db_router import get_active_content_database\nfrom kolibri.logger.models import ContentSessionLog, ContentSummaryLog\nfrom le_utils.constants import content_kinds\nfrom rest_framework import filters, pagination, viewsets\nfrom rest_framework.decorators import detail_route\nfrom rest_framework.response import Response\n\nfrom .permissions import OnlyDeviceOwnerCanDelete\nfrom .utils.search import fuzz\nfrom .utils.paths import get_content_database_file_path\n\n\ndef _join_with_logical_operator(lst, operator):\n    op = \") {operator} (\".format(operator=operator)\n    return \"(({items}))\".format(items=op.join(lst))\n\n\nclass ChannelMetadataCacheViewSet(viewsets.ModelViewSet):\n    permission_classes = (OnlyDeviceOwnerCanDelete,)\n    serializer_class = serializers.ChannelMetadataCacheSerializer\n\n    def get_queryset(self):\n        return models.ChannelMetadataCache.objects.all()\n\n    def destroy(self, request, pk=None):\n        \"\"\"\n        Destroys the ChannelMetadata object and its associated sqlite3 file on\n        the filesystem.\n        \"\"\"\n        super(ChannelMetadataCacheViewSet, self).destroy(request)\n\n        if self.delete_content_db_file(pk):\n            response_msg = 'Channel {} removed from device'.format(pk)\n        else:\n            response_msg = 'Channel {} removed, but no content database was found'.format(pk)\n\n        return Response(response_msg)\n\n    def delete_content_db_file(self, channel_id):\n        try:\n            os.remove(get_content_database_file_path(channel_id))\n            return True\n        except OSError:\n            return False\n\n\nclass ContentNodeFilter(filters.FilterSet):\n    search = filters.django_filters.MethodFilter(action='title_description_filter')\n    recommendations_for = filters.django_filters.MethodFilter()\n    next_steps = filters.django_filters.MethodFilter()\n    popular = filters.django_filters.MethodFilter()\n    resume = filters.django_filters.MethodFilter()\n    kind = filters.django_filters.MethodFilter()\n    ids = filters.django_filters.MethodFilter()\n\n    class Meta:\n        model = models.ContentNode\n        fields = ['parent', 'search', 'prerequisite_for', 'has_prerequisite', 'related', 'recommendations_for', 'ids', 'content_id']\n\n    def title_description_filter(self, queryset, value):\n        \"\"\"\n        search for title or description that contains the keywords that are not necessary in adjacent\n        \"\"\"\n        exact_match = queryset.filter(Q(parent__isnull=False), Q(title__icontains=value) | Q(description__icontains=value))\n        if exact_match:\n            return exact_match\n        # if no exact match, fuzzy search using the stemmed_metaphone field in ContentNode that covers the title and description\n        fuzzed_tokens = [fuzz(word) for word in value.split()]\n        if not fuzzed_tokens[0]:\n            return []\n        token_queries = [reduce(lambda x, y: x | y, [Q(stemmed_metaphone__contains=token) for token in tokens]) for tokens in fuzzed_tokens]\n        return queryset.filter(\n            Q(parent__isnull=False),\n            reduce(lambda x, y: x & y, token_queries))\n\n    def filter_recommendations_for(self, queryset, value):\n        \"\"\"\n        Recommend items that are similar to this piece of content.\n        \"\"\"\n        recc_node = queryset.get(pk=value)\n        descendants = recc_node.get_descendants(include_self=False).exclude(kind__in=['topic', ''])\n        siblings = recc_node.get_siblings(include_self=False).exclude(kind__in=['topic', ''])\n        data = descendants | siblings  # concatenates different querysets\n        return data\n\n    def filter_next_steps(self, queryset, value):\n        \"\"\"\n        Recommend uncompleted content, content that has user completed content as a prerequisite.\n\n        :param queryset: all content nodes for this channel\n        :param value: id of currently logged in user, or none if user is anonymous\n        :return: uncompleted content nodes, or empty queryset if user is anonymous\n        \"\"\"\n\n        # if user is anonymous, don't return any nodes\n        if not value:\n            return queryset.none()\n\n        tables = [\n            '\"{summarylog_table}\" AS \"complete_log\"',\n            '\"{summarylog_table}\" AS \"incomplete_log\"',\n            '\"{content_table}\" AS \"complete_node\"',\n            '\"{content_table}\" AS \"incomplete_node\"',\n        ]\n        table_names = {\n            \"summarylog_table\": ContentSummaryLog._meta.db_table,\n            \"content_table\": models.ContentNode._meta.db_table,\n        }\n        # aliases for sql table names\n        sql_tables_and_aliases = [table.format(**table_names) for table in tables]\n        # where conditions joined by ANDs\n        where_statements = [\"NOT (incomplete_log.progress < 1 AND incomplete_log.content_id = incomplete_node.content_id)\",\n                            \"complete_log.user_id = '{user_id}'\".format(user_id=value),\n                            \"incomplete_log.user_id = '{user_id}'\".format(user_id=value),\n                            \"complete_log.progress = 1\",\n                            \"complete_node.rght = incomplete_node.lft - 1\",\n                            \"complete_log.content_id = complete_node.content_id\"]\n        # custom SQL query to get uncompleted content based on mptt algorithm\n        next_steps_recommendations = \"SELECT incomplete_node.* FROM {tables} WHERE {where}\".format(\n            tables=\", \".join(sql_tables_and_aliases),\n            where=_join_with_logical_operator(where_statements, \"AND\")\n        )\n        return models.ContentNode.objects.raw(next_steps_recommendations)\n\n    def filter_popular(self, queryset, value):\n        \"\"\"\n        Recommend content that is popular with all users.\n\n        :param queryset: all content nodes for this channel\n        :param value: id of currently logged in user, or none if user is anonymous\n        :return: 10 most popular content nodes\n        \"\"\"\n        if ContentSessionLog.objects.count() < 50:\n            # return 25 random content nodes if not enough session logs\n            pks = queryset.values_list('pk', flat=True).exclude(kind__in=['topic', ''])\n            count = min(pks.count(), 25)\n            return queryset.filter(pk__in=sample(list(pks), count))\n\n        cache_key = 'popular_for_{}'.format(get_active_content_database())\n        if cache.get(cache_key):\n            return cache.get(cache_key)\n\n        # get the most accessed content nodes\n        content_counts_sorted = ContentSessionLog.objects \\\n            .filter(channel_id=get_active_content_database()) \\\n            .values_list('content_id', flat=True) \\\n            .annotate(Count('content_id')) \\\n            .order_by('-content_id__count')\n\n        most_popular = queryset.filter(content_id__in=list(content_counts_sorted[:10]))\n\n        # cache the popular results queryset for 10 minutes, for efficiency\n        cache.set(cache_key, most_popular, 60 * 10)\n        return most_popular\n\n    def filter_resume(self, queryset, value):\n        \"\"\"\n        Recommend content that the user has recently engaged with, but not finished.\n\n        :param queryset: all content nodes for this channel\n        :param value: id of currently logged in user, or none if user is anonymous\n        :return: 10 most recently viewed content nodes\n        \"\"\"\n\n        # if user is anonymous, return no nodes\n        if not value:\n            return queryset.none()\n\n        # get the most recently viewed, but not finished, content nodes\n        content_ids = ContentSummaryLog.objects \\\n            .filter(user=value, channel_id=get_active_content_database()) \\\n            .exclude(progress=1) \\\n            .order_by('end_timestamp') \\\n            .values_list('content_id', flat=True) \\\n            .distinct()\n\n        resume = queryset.filter(content_id__in=list(content_ids[:10]))\n\n        return resume\n\n    def filter_kind(self, queryset, value):\n        \"\"\"\n        Show only content of a given kind.\n\n        :param queryset: all content nodes for this channel\n        :param value: 'content' for everything except topics, or one of the content kind constants\n        :return: content nodes of the given kind\n        \"\"\"\n        if value == 'content':\n            return queryset.exclude(kind=content_kinds.TOPIC).order_by(\"lft\")\n        return queryset.filter(kind=value).order_by(\"lft\")\n\n    def filter_ids(self, queryset, value):\n        return queryset.filter(pk__in=value.split(','))\n\n\nclass OptionalPageNumberPagination(pagination.PageNumberPagination):\n    \"\"\"\n    Pagination class that allows for page number-style pagination, when requested.\n    To activate, the `page_size` argument must be set. For example, to request the first 20 records:\n    `?page_size=20&page=1`\n    \"\"\"\n    page_size = None\n    page_size_query_param = \"page_size\"\n\n\nclass ContentNodeViewset(viewsets.ModelViewSet):\n    serializer_class = serializers.ContentNodeSerializer\n    filter_backends = (filters.DjangoFilterBackend,)\n    filter_class = ContentNodeFilter\n    pagination_class = OptionalPageNumberPagination\n\n    def get_queryset(self):\n        return models.ContentNode.objects.all().select_related(\n            'parent',\n            'license',\n        ).prefetch_related(\n            'assessmentmetadata',\n            'files',\n        )\n\n    @detail_route(methods=['get'])\n    def descendants(self, request, **kwargs):\n        node = self.get_object()\n        kind = self.request.query_params.get('descendant_kind', None)\n        descendants = node.get_descendants()\n        if kind:\n            descendants = descendants.filter(kind=kind)\n\n        serializer = self.get_serializer(descendants, many=True)\n        return Response(serializer.data)\n\n    @detail_route(methods=['get'])\n    def ancestors(self, request, **kwargs):\n        return Response(self.get_object().get_ancestors().values('pk', 'title'))\n\n    @detail_route(methods=['get'])\n    def next_content(self, request, **kwargs):\n        # retrieve the \"next\" content node, according to depth-first tree traversal\n        this_item = self.get_object()\n        next_item = models.ContentNode.objects.filter(tree_id=this_item.tree_id, lft__gt=this_item.rght).order_by(\"lft\").first()\n        if not next_item:\n            next_item = this_item.get_root()\n        return Response({'kind': next_item.kind, 'id': next_item.id, 'title': next_item.title})\n\n\nclass FileViewset(viewsets.ModelViewSet):\n    serializer_class = serializers.FileSerializer\n    pagination_class = OptionalPageNumberPagination\n\n    def get_queryset(self):\n        return models.File.objects.all()\n", "path": "kolibri/content/api.py"}], "after_files": [{"content": "import os\n\nfrom collections import OrderedDict\nfrom functools import reduce\nfrom random import sample\n\nfrom django.core.cache import cache\nfrom django.db.models import Q\nfrom django.db.models.aggregates import Count\nfrom future.moves.urllib.parse import parse_qs, urlparse\nfrom kolibri.content import models, serializers\nfrom kolibri.content.content_db_router import get_active_content_database\nfrom kolibri.logger.models import ContentSessionLog, ContentSummaryLog\nfrom le_utils.constants import content_kinds\nfrom rest_framework import filters, pagination, viewsets\nfrom rest_framework.decorators import detail_route, list_route\nfrom rest_framework.response import Response\n\nfrom .permissions import OnlyDeviceOwnerCanDelete\nfrom .utils.search import fuzz\nfrom .utils.paths import get_content_database_file_path\n\n\ndef _join_with_logical_operator(lst, operator):\n    op = \") {operator} (\".format(operator=operator)\n    return \"(({items}))\".format(items=op.join(lst))\n\n\nclass ChannelMetadataCacheViewSet(viewsets.ModelViewSet):\n    permission_classes = (OnlyDeviceOwnerCanDelete,)\n    serializer_class = serializers.ChannelMetadataCacheSerializer\n\n    def get_queryset(self):\n        return models.ChannelMetadataCache.objects.all()\n\n    def destroy(self, request, pk=None):\n        \"\"\"\n        Destroys the ChannelMetadata object and its associated sqlite3 file on\n        the filesystem.\n        \"\"\"\n        super(ChannelMetadataCacheViewSet, self).destroy(request)\n\n        if self.delete_content_db_file(pk):\n            response_msg = 'Channel {} removed from device'.format(pk)\n        else:\n            response_msg = 'Channel {} removed, but no content database was found'.format(pk)\n\n        return Response(response_msg)\n\n    def delete_content_db_file(self, channel_id):\n        try:\n            os.remove(get_content_database_file_path(channel_id))\n            return True\n        except OSError:\n            return False\n\n\nclass ContentNodeFilter(filters.FilterSet):\n    search = filters.django_filters.MethodFilter(action='title_description_filter')\n    recommendations_for = filters.django_filters.MethodFilter()\n    next_steps = filters.django_filters.MethodFilter()\n    popular = filters.django_filters.MethodFilter()\n    resume = filters.django_filters.MethodFilter()\n    kind = filters.django_filters.MethodFilter()\n    ids = filters.django_filters.MethodFilter()\n\n    class Meta:\n        model = models.ContentNode\n        fields = ['parent', 'search', 'prerequisite_for', 'has_prerequisite', 'related', 'recommendations_for', 'ids', 'content_id']\n\n    def title_description_filter(self, queryset, value):\n        \"\"\"\n        search for title or description that contains the keywords that are not necessary in adjacent\n        \"\"\"\n        exact_match = queryset.filter(Q(parent__isnull=False), Q(title__icontains=value) | Q(description__icontains=value))\n        if exact_match:\n            return exact_match\n        # if no exact match, fuzzy search using the stemmed_metaphone field in ContentNode that covers the title and description\n        fuzzed_tokens = [fuzz(word) for word in value.split()]\n        if not fuzzed_tokens[0]:\n            return []\n        token_queries = [reduce(lambda x, y: x | y, [Q(stemmed_metaphone__contains=token) for token in tokens]) for tokens in fuzzed_tokens]\n        return queryset.filter(\n            Q(parent__isnull=False),\n            reduce(lambda x, y: x & y, token_queries))\n\n    def filter_recommendations_for(self, queryset, value):\n        \"\"\"\n        Recommend items that are similar to this piece of content.\n        \"\"\"\n        recc_node = queryset.get(pk=value)\n        descendants = recc_node.get_descendants(include_self=False).exclude(kind__in=['topic', ''])\n        siblings = recc_node.get_siblings(include_self=False).exclude(kind__in=['topic', ''])\n        data = descendants | siblings  # concatenates different querysets\n        return data\n\n    def filter_next_steps(self, queryset, value):\n        \"\"\"\n        Recommend uncompleted content, content that has user completed content as a prerequisite.\n\n        :param queryset: all content nodes for this channel\n        :param value: id of currently logged in user, or none if user is anonymous\n        :return: uncompleted content nodes, or empty queryset if user is anonymous\n        \"\"\"\n\n        # if user is anonymous, don't return any nodes\n        if not value:\n            return queryset.none()\n\n        tables = [\n            '\"{summarylog_table}\" AS \"complete_log\"',\n            '\"{summarylog_table}\" AS \"incomplete_log\"',\n            '\"{content_table}\" AS \"complete_node\"',\n            '\"{content_table}\" AS \"incomplete_node\"',\n        ]\n        table_names = {\n            \"summarylog_table\": ContentSummaryLog._meta.db_table,\n            \"content_table\": models.ContentNode._meta.db_table,\n        }\n        # aliases for sql table names\n        sql_tables_and_aliases = [table.format(**table_names) for table in tables]\n        # where conditions joined by ANDs\n        where_statements = [\"NOT (incomplete_log.progress < 1 AND incomplete_log.content_id = incomplete_node.content_id)\",\n                            \"complete_log.user_id = '{user_id}'\".format(user_id=value),\n                            \"incomplete_log.user_id = '{user_id}'\".format(user_id=value),\n                            \"complete_log.progress = 1\",\n                            \"complete_node.rght = incomplete_node.lft - 1\",\n                            \"complete_log.content_id = complete_node.content_id\"]\n        # custom SQL query to get uncompleted content based on mptt algorithm\n        next_steps_recommendations = \"SELECT incomplete_node.* FROM {tables} WHERE {where}\".format(\n            tables=\", \".join(sql_tables_and_aliases),\n            where=_join_with_logical_operator(where_statements, \"AND\")\n        )\n        return models.ContentNode.objects.raw(next_steps_recommendations)\n\n    def filter_popular(self, queryset, value):\n        \"\"\"\n        Recommend content that is popular with all users.\n\n        :param queryset: all content nodes for this channel\n        :param value: id of currently logged in user, or none if user is anonymous\n        :return: 10 most popular content nodes\n        \"\"\"\n        if ContentSessionLog.objects.count() < 50:\n            # return 25 random content nodes if not enough session logs\n            pks = queryset.values_list('pk', flat=True).exclude(kind__in=['topic', ''])\n            count = min(pks.count(), 25)\n            return queryset.filter(pk__in=sample(list(pks), count))\n\n        cache_key = 'popular_for_{}'.format(get_active_content_database())\n        if cache.get(cache_key):\n            return cache.get(cache_key)\n\n        # get the most accessed content nodes\n        content_counts_sorted = ContentSessionLog.objects \\\n            .filter(channel_id=get_active_content_database()) \\\n            .values_list('content_id', flat=True) \\\n            .annotate(Count('content_id')) \\\n            .order_by('-content_id__count')\n\n        most_popular = queryset.filter(content_id__in=list(content_counts_sorted[:10]))\n\n        # cache the popular results queryset for 10 minutes, for efficiency\n        cache.set(cache_key, most_popular, 60 * 10)\n        return most_popular\n\n    def filter_resume(self, queryset, value):\n        \"\"\"\n        Recommend content that the user has recently engaged with, but not finished.\n\n        :param queryset: all content nodes for this channel\n        :param value: id of currently logged in user, or none if user is anonymous\n        :return: 10 most recently viewed content nodes\n        \"\"\"\n\n        # if user is anonymous, return no nodes\n        if not value:\n            return queryset.none()\n\n        # get the most recently viewed, but not finished, content nodes\n        content_ids = ContentSummaryLog.objects \\\n            .filter(user=value, channel_id=get_active_content_database()) \\\n            .exclude(progress=1) \\\n            .order_by('end_timestamp') \\\n            .values_list('content_id', flat=True) \\\n            .distinct()\n\n        resume = queryset.filter(content_id__in=list(content_ids[:10]))\n\n        return resume\n\n    def filter_kind(self, queryset, value):\n        \"\"\"\n        Show only content of a given kind.\n\n        :param queryset: all content nodes for this channel\n        :param value: 'content' for everything except topics, or one of the content kind constants\n        :return: content nodes of the given kind\n        \"\"\"\n        if value == 'content':\n            return queryset.exclude(kind=content_kinds.TOPIC).order_by(\"lft\")\n        return queryset.filter(kind=value).order_by(\"lft\")\n\n    def filter_ids(self, queryset, value):\n        return queryset.filter(pk__in=value.split(','))\n\n\nclass OptionalPageNumberPagination(pagination.PageNumberPagination):\n    \"\"\"\n    Pagination class that allows for page number-style pagination, when requested.\n    To activate, the `page_size` argument must be set. For example, to request the first 20 records:\n    `?page_size=20&page=1`\n    \"\"\"\n    page_size = None\n    page_size_query_param = \"page_size\"\n\n\nclass AllContentCursorPagination(pagination.CursorPagination):\n    page_size = 10\n    ordering = 'lft'\n    cursor_query_param = 'cursor'\n\n    def get_paginated_response(self, data):\n        \"\"\"\n        By default the get_paginated_response method of the CursorPagination class returns the url link\n        to the next and previous queries of they exist.\n        For Kolibri this is not very helpful, as we construct our URLs on the client side rather than\n        directly querying passed in URLs.\n        Instead, return the cursor value that points to the next and previous items, so that they can be put\n        in a GET parameter in future queries.\n        \"\"\"\n        if self.has_next:\n            # The CursorPagination class has no internal methods to just return the cursor value, only\n            # the url of the next and previous, so we have to generate the URL, parse it, and then\n            # extract the cursor parameter from it to return in the Response.\n            next_item = parse_qs(urlparse(self.get_next_link()).query).get(self.cursor_query_param)\n        else:\n            next_item = None\n        if self.has_previous:\n            # Similarly to next, we have to create the previous link and then parse it to get the cursor value\n            prev_item = parse_qs(urlparse(self.get_previous_link()).query).get(self.cursor_query_param)\n        else:\n            prev_item = None\n\n        return Response(OrderedDict([\n            ('next', next_item),\n            ('previous', prev_item),\n            ('results', data)\n        ]))\n\nclass ContentNodeViewset(viewsets.ModelViewSet):\n    serializer_class = serializers.ContentNodeSerializer\n    filter_backends = (filters.DjangoFilterBackend,)\n    filter_class = ContentNodeFilter\n    pagination_class = OptionalPageNumberPagination\n\n    def get_queryset(self):\n        return models.ContentNode.objects.all().select_related(\n            'parent',\n            'license',\n        ).prefetch_related(\n            'assessmentmetadata',\n            'files',\n        )\n\n    @detail_route(methods=['get'])\n    def descendants(self, request, **kwargs):\n        node = self.get_object()\n        kind = self.request.query_params.get('descendant_kind', None)\n        descendants = node.get_descendants()\n        if kind:\n            descendants = descendants.filter(kind=kind)\n\n        serializer = self.get_serializer(descendants, many=True)\n        return Response(serializer.data)\n\n    @detail_route(methods=['get'])\n    def ancestors(self, request, **kwargs):\n        return Response(self.get_object().get_ancestors().values('pk', 'title'))\n\n    @detail_route(methods=['get'])\n    def next_content(self, request, **kwargs):\n        # retrieve the \"next\" content node, according to depth-first tree traversal\n        this_item = self.get_object()\n        next_item = models.ContentNode.objects.filter(tree_id=this_item.tree_id, lft__gt=this_item.rght).order_by(\"lft\").first()\n        if not next_item:\n            next_item = this_item.get_root()\n        return Response({'kind': next_item.kind, 'id': next_item.id, 'title': next_item.title})\n\n    @list_route(methods=['get'], pagination_class=AllContentCursorPagination)\n    def all_content(self, request, **kwargs):\n        queryset = self.get_queryset().exclude(kind=content_kinds.TOPIC)\n        page = self.paginate_queryset(queryset)\n        if page is not None:\n            serializer = self.get_serializer(page, many=True)\n            return self.get_paginated_response(serializer.data)\n\n        serializer = self.get_serializer(queryset, many=True)\n        return Response(serializer.data)\n\n\nclass FileViewset(viewsets.ModelViewSet):\n    serializer_class = serializers.FileSerializer\n    pagination_class = OptionalPageNumberPagination\n\n    def get_queryset(self):\n        return models.File.objects.all()\n", "path": "kolibri/content/api.py"}]}