Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions kolibri/core/content/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from kolibri.core.content import models
from kolibri.core.content import serializers
from kolibri.core.content.models import ContentDownloadRequest
from kolibri.core.content.models import ContentNode
from kolibri.core.content.models import ContentRemovalRequest
from kolibri.core.content.models import ContentRequestReason
from kolibri.core.content.models import ContentRequestStatus
Expand Down Expand Up @@ -111,6 +112,20 @@ def get_cache_key(*args, **kwargs):
return str(ContentCacheKey.get_cache_key())


def get_course_ids():
cache_key = "COURSE_IDS_{}".format(get_cache_key())
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
updated_data = list(
ContentNode.objects.filter(
available=True, modality=modalities.COURSE
).values_list("id", flat=True)
)
cache.set(cache_key, updated_data, 3600)
return updated_data


def metadata_cache(view_func, cache_key_func=get_cache_key):
"""
Decorator to apply an Etag sensitive page cache
Expand Down Expand Up @@ -465,6 +480,7 @@ class ChoiceInFilter(BaseInFilter, ChoiceFilter):
"tree_id",
"lft__gt",
"rght__lt",
"exclude_course_ancestry",
]


Expand Down Expand Up @@ -498,11 +514,30 @@ class ContentNodeFilter(FilterSet):
exclude_modalities = ChoiceInFilter(
field_name="modality", choices=modalities.choices, exclude=True
)
exclude_course_ancestry = BooleanFilter(method="filter_exclude_course_ancestry")

class Meta:
model = models.ContentNode
fields = contentnode_filter_fields

def filter_exclude_course_ancestry(self, queryset, name, value):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blocking: The value parameter is not checked. Since this is a BooleanFilter, passing exclude_course_ancestry=false will still trigger the exclusion. The filter should be conditional on value:

def filter_exclude_course_ancestry(self, queryset, name, value):
    if not value:
        return queryset
    ...

Currently the filter is always called with true from the frontend, but since this is a public API filter, it should handle false correctly.

"""
Exclude any resources that are descended from a Course.
:param queryset: ContentNode queryset to filter
:param name: filter field name
:param value: boolean indicating whether to apply the exclusion
:return: filtered queryset excluding course descendants if value is True
"""
if not value:
return queryset
has_course_ancestor = ContentNode.objects.filter(
id__in=get_course_ids(),
lft__lt=OuterRef("lft"),
rght__gt=OuterRef("rght"),
tree_id=OuterRef("tree_id"),
)
return queryset.exclude(Exists(has_course_ancestor))

def filter_ids(self, queryset, name, value):
return queryset.filter_by_uuids(value)

Expand Down Expand Up @@ -570,7 +605,10 @@ def filter_kind_in(self, queryset, name, value):
return queryset.filter(kind__in=kinds).order_by("lft")

def filter_exclude_content_ids(self, queryset, name, value):
return queryset.exclude_by_content_ids(value.split(","))
if not value:
return queryset
else:
return queryset.exclude_by_content_ids(value.split(","))

def filter_include_coach_content(self, queryset, name, value):
if value:
Expand Down Expand Up @@ -1278,7 +1316,6 @@ def search(self, value, max_results, filter=True):

# iterate over each query type, and build up search results
for query in all_queries:

# in each pass, don't take any items already in the result set
matches = (
queryset.exclude_by_content_ids(list(content_ids), validate=False)
Expand Down Expand Up @@ -1700,7 +1737,10 @@ def filter_by_popular(self, queryset, name, value):

class Meta:
model = models.ContentNode
fields = contentnode_filter_fields + ["resume", "lesson"]
fields = contentnode_filter_fields + [
"resume",
"lesson",
]


class UserContentNodeViewset(
Expand Down Expand Up @@ -1868,7 +1908,6 @@ def _make_channel_endpoint_request(
identifier=identifier, baseurl=baseurl, keyword=keyword, language=language
)
try:

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did this line offend thee?

resp = client.get(url)
# map the channel list into the format the Kolibri client-side expects
channels = list(map(self._studio_response_to_kolibri_response, resp.json()))
Expand Down
61 changes: 61 additions & 0 deletions kolibri/core/content/test/test_content_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1607,6 +1607,67 @@ def test_exclude_modalities_filter_multiple(self):
self.assertNotIn(str(lesson_topic.id), [node["id"] for node in response.data])
self.assertNotIn(str(course_topic.id), [node["id"] for node in response.data])

def test_exclude_course_ancestry_filter(self):
# Mark c2 (a topic with children) as a Course
course_node = content.ContentNode.objects.get(title="c2")
course_node.modality = modalities.COURSE
course_node.available = True
course_node.save()

# Get the descendants of the course node
descendant_ids = set(
str(pk)
for pk in content.ContentNode.objects.filter(
tree_id=course_node.tree_id,
lft__gt=course_node.lft,
rght__lt=course_node.rght,
)
.filter(available=True)
.values_list("id", flat=True)
)
self.assertGreater(len(descendant_ids), 0)

response = self.client.get(
reverse("kolibri:core:contentnode-list"),
data={"exclude_course_ancestry": True},
)

result_ids = {node["id"] for node in response.data}

# Course descendants should be excluded
self.assertTrue(descendant_ids.isdisjoint(result_ids))

# The course node itself should still be present
self.assertIn(str(course_node.id), result_ids)

def test_exclude_course_ancestry_filter_false(self):
# Mark c2 as a Course
course_node = content.ContentNode.objects.get(title="c2")
course_node.modality = modalities.COURSE
course_node.available = True
course_node.save()

descendant_ids = set(
str(pk)
for pk in content.ContentNode.objects.filter(
tree_id=course_node.tree_id,
lft__gt=course_node.lft,
rght__lt=course_node.rght,
)
.filter(available=True)
.values_list("id", flat=True)
)

response = self.client.get(
reverse("kolibri:core:contentnode-list"),
data={"exclude_course_ancestry": False},
)

result_ids = {node["id"] for node in response.data}

# With false, descendants should NOT be excluded
self.assertTrue(descendant_ids.issubset(result_ids))

def _setup_contentnode_progress(self):
# set up data for testing progress_fraction field on content node endpoint
facility = Facility.objects.create(name="MyFac")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ describe(`useLearnerResources`, () => {

describe('fetchResumableContentNodes', () => {
it('should set resumable content nodes and the more value', async () => {
const more = { test: 1 };
const more = { test: 1, exclude_course_ancestry: true };
ContentNodeResource.fetchResume = jest
.fn()
.mockResolvedValue({ results: TEST_RESUMABLE_CONTENT_NODES, more });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,12 @@ export default function useLearnerResources() {
* @public
*/
function fetchResumableContentNodes() {
const params = { resume: true, max_results: 12, ordering: '-last_interacted' };
const params = {
resume: true,
max_results: 12,
ordering: '-last_interacted',
exclude_course_ancestry: true,
};
fetchContentNodeProgress(params);
return ContentNodeResource.fetchResume(params).then(({ results, more }) => {
if (!results || !results.length) {
Expand Down
6 changes: 5 additions & 1 deletion kolibri/plugins/learn/frontend/views/TopicsPage/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@
import useUser from 'kolibri/composables/useUser';
import { ContentNodeKinds } from 'kolibri/constants';
import commonCoreStrings from 'kolibri/uiText/commonCoreStrings';
import Modalities from 'kolibri-constants/Modalities';
import { throttle } from 'frame-throttle';
import ImmersivePage from 'kolibri/components/pages/ImmersivePage';
import samePageCheckGenerator from 'kolibri-common/utils/samePageCheckGenerator';
Expand Down Expand Up @@ -303,7 +304,7 @@
}

function _getChildren(id, topic, skip) {
let children = topic.children.results || [];
let children = topic?.children?.results || [];
let skipped = false;
// If there is only one child, and that child is a topic, then display that instead
while (skip && children.length === 1 && !children[0].is_leaf) {
Expand Down Expand Up @@ -432,6 +433,9 @@
const skip = route.query && route.query.skip === 'true';
const params = {
include_coach_content: get(isAdmin) || get(isCoach) || get(isSuperuser),
// Only hide COURSE from Learners in the library view
exclude_modalities:
get(isAdmin) || get(isCoach) || get(isSuperuser) ? null : Modalities.COURSE,
baseurl,
};
if (get(isUserLoggedIn) && !baseurl) {
Expand Down
7 changes: 7 additions & 0 deletions kolibri/plugins/learn/kolibri_plugin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.urls import reverse
from le_utils.constants import modalities

from kolibri.core.auth.constants.user_kinds import ANONYMOUS
from kolibri.core.auth.constants.user_kinds import LEARNER
Expand Down Expand Up @@ -58,6 +59,11 @@ class LearnAsset(webpack_hooks.WebpackBundleHook):
def plugin_data(self):
from kolibri.core.discovery.well_known import CENTRAL_CONTENT_BASE_URL
from kolibri.core.discovery.well_known import CENTRAL_CONTENT_BASE_INSTANCE_ID
from kolibri.core.content.models import ContentNode

courses_exist = ContentNode.objects.filter(
available=True, modality=modalities.COURSE
).exists()

return {
"allowDownloadOnMeteredConnection": get_device_setting(
Expand All @@ -75,6 +81,7 @@ def plugin_data(self):
"base_url": CENTRAL_CONTENT_BASE_URL,
"instance_id": CENTRAL_CONTENT_BASE_INSTANCE_ID,
},
"courses_exist": courses_exist,
}


Expand Down
8 changes: 7 additions & 1 deletion kolibri/plugins/learn/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,12 @@ def get(self, request, format=None):
if not classrooms or not any(_resumable_resources(classrooms)):
resumable_resources = user_contentnode_viewset.serialize_list(
request,
{"resume": True, "max_results": 12, "ordering": "-last_interacted"},
{
"resume": True,
"max_results": 12,
"ordering": "-last_interacted",
"exclude_course_ancestry": True,
},
)
resumable_resources_progress = (
contentnode_progress_viewset.serialize_list(
Expand All @@ -359,6 +364,7 @@ def get(self, request, format=None):
"resume": True,
"max_results": 12,
"ordering": "-last_interacted",
"exclude_course_ancestry": True,
},
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ContentNodeResource from 'kolibri-common/apiResources/ContentNodeResource
import { coreStoreFactory } from 'kolibri/store';
import { AllCategories, ContentNodeKinds, NoCategories } from 'kolibri/constants';
import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-disable-line
import Modalities from 'kolibri-constants/Modalities';
import useBaseSearch from '../useBaseSearch';
import coreModule from '../../../../kolibri/core/frontend/state/modules/core';

Expand Down Expand Up @@ -204,6 +205,8 @@ describe(`useBaseSearch`, () => {
categories: ['test1', 'test2'],
max_results: 25,
include_coach_content: false,
exclude_modalities: Modalities.COURSE,
exclude_course_ancestry: true,
},
});
});
Expand Down Expand Up @@ -233,6 +236,8 @@ describe(`useBaseSearch`, () => {
rght__lt: 20,
max_results: 1,
include_coach_content: false,
exclude_modalities: Modalities.COURSE,
exclude_course_ancestry: true,
},
});
});
Expand All @@ -245,6 +250,8 @@ describe(`useBaseSearch`, () => {
kind: ContentNodeKinds.EXERCISE,
max_results: 1,
include_coach_content: false,
exclude_modalities: Modalities.COURSE,
exclude_course_ancestry: true,
},
});
});
Expand All @@ -270,6 +277,8 @@ describe(`useBaseSearch`, () => {
categories: ['test1', 'test2'],
max_results: 25,
include_coach_content: false,
exclude_modalities: Modalities.COURSE,
exclude_course_ancestry: true,
},
});
});
Expand All @@ -278,15 +287,27 @@ describe(`useBaseSearch`, () => {
ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
search();
expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
getParams: { categories__isnull: false, max_results: 25, include_coach_content: false },
getParams: {
categories__isnull: false,
max_results: 25,
include_coach_content: false,
exclude_modalities: Modalities.COURSE,
exclude_course_ancestry: true,
},
});
});
it('should ignore other categories when NoCategories is set and search for isnull true', () => {
const { search } = prep({ categories: `test1,test2,${NoCategories}` });
ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({}));
search();
expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({
getParams: { categories__isnull: true, max_results: 25, include_coach_content: false },
getParams: {
categories__isnull: true,
max_results: 25,
include_coach_content: false,
exclude_modalities: Modalities.COURSE,
exclude_course_ancestry: true,
},
});
});
it('should set keywords when defined', () => {
Expand All @@ -298,6 +319,8 @@ describe(`useBaseSearch`, () => {
keywords: `this is just a test`,
max_results: 25,
include_coach_content: false,
exclude_modalities: Modalities.COURSE,
exclude_course_ancestry: true,
},
});
});
Expand Down
4 changes: 4 additions & 0 deletions packages/kolibri-common/composables/useBaseSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from 'kolibri/constants';
import useUser from 'kolibri/composables/useUser';

import Modalities from 'kolibri-constants/Modalities';
import { deduplicateResources } from '../utils/contentNode';

export const logging = logger.getLogger(__filename);
Expand Down Expand Up @@ -242,6 +243,9 @@ export default function useBaseSearch({

function createBaseSearchGetParams() {
const getParams = {
exclude_modalities:
get(isAdmin) || get(isCoach) || get(isSuperuser) ? null : Modalities.COURSE,
exclude_course_ancestry: !(get(isAdmin) || get(isCoach) || get(isSuperuser)),
include_coach_content: get(isAdmin) || get(isCoach) || get(isSuperuser),
baseurl: get(baseurl),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this changed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude noticed that the previous implementation would result in there being baseurl: undefined in the params due to the test outputs.

I think Claude noticed the baseurl: undefined in the diff and figured it was related to the test failures or maybe just saw "undefined value in params is bad" but the change made sense to me to avoid sending undefined values.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, but undefined params don't get sent, so this is fine.

};
Expand Down
Loading