Skip to content

Commit 43ffd37

Browse files
rtibblesbotclaude
andcommitted
fix: match Kolibri's v2 contentnode and channel filter semantics
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5a78299 commit 43ffd37

2 files changed

Lines changed: 230 additions & 50 deletions

File tree

‎contentcuration/kolibri_public/tests/test_content_app.py‎

Lines changed: 140 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from kolibri_public.tests.base import ChannelBuilder
1414
from kolibri_public.tests.base import OKAY_TAG
1515
from le_utils.constants import content_kinds
16+
from le_utils.constants import modalities
1617
from rest_framework.test import APITestCase
1718

1819
from contentcuration.models import generate_storage_url
@@ -165,6 +166,7 @@ def _assert_node(self, actual, expected):
165166
"lft": expected.lft,
166167
"rght": expected.rght,
167168
"tree_id": expected.tree_id,
169+
"modality": expected.modality,
168170
"ancestors": [],
169171
"tags": list(
170172
expected.tags.all()
@@ -332,6 +334,137 @@ class ContentNodeAPITestCase(ContentNodeAPIBase, APITestCase):
332334
Testcase for content API methods
333335
"""
334336

337+
def _list_ids(self, **params):
338+
response = self._get(reverse("publiccontentnode-list"), data=params)
339+
self.assertEqual(response.status_code, 200)
340+
return {node["id"] for node in response.data}
341+
342+
def _available_ids(self):
343+
return set(
344+
models.ContentNode.objects.filter(available=True).values_list(
345+
"id", flat=True
346+
)
347+
)
348+
349+
def _insert_second_tree(self):
350+
# Its lft/rght ranges overlap the fixture tree's; only tree_id tells them apart.
351+
ChannelBuilder(levels=2, num_children=2).insert_into_default_db()
352+
models.ContentNode.objects.all().update(available=True)
353+
354+
def _set_modality(self, node, modality):
355+
models.ContentNode.objects.filter(id=node.id).update(modality=modality)
356+
357+
def _mark_lesson_and_course(self):
358+
lesson, course = (
359+
models.ContentNode.objects.filter(kind=content_kinds.TOPIC)
360+
.exclude(parent=None)
361+
.order_by("lft")[:2]
362+
)
363+
self._set_modality(lesson, modalities.LESSON)
364+
self._set_modality(course, modalities.COURSE)
365+
return lesson, course
366+
367+
def test_contentnode_modality_filter(self):
368+
lesson, _ = self._mark_lesson_and_course()
369+
370+
response = self.client.get(
371+
reverse("publiccontentnode-list"), data={"modality": modalities.LESSON}
372+
)
373+
374+
self.assertEqual([node["id"] for node in response.data], [lesson.id])
375+
self.assertEqual(response.data[0]["modality"], modalities.LESSON)
376+
377+
def test_contentnode_exclude_modalities_filter(self):
378+
lesson, course = self._mark_lesson_and_course()
379+
380+
self.assertEqual(
381+
self._list_ids(exclude_modalities=modalities.COURSE),
382+
self._available_ids() - {course.id},
383+
)
384+
self.assertEqual(
385+
self._list_ids(
386+
exclude_modalities="{},{}".format(modalities.COURSE, modalities.LESSON)
387+
),
388+
self._available_ids() - {course.id, lesson.id},
389+
)
390+
391+
def test_contentnode_exclude_course_ancestry_filter(self):
392+
course = self.root.get_children().first()
393+
self._set_modality(course, modalities.COURSE)
394+
self._insert_second_tree()
395+
descendant_ids = set(course.get_descendants().values_list("id", flat=True))
396+
397+
self.assertEqual(
398+
self._list_ids(exclude_course_ancestry=True),
399+
self._available_ids() - descendant_ids,
400+
)
401+
self.assertEqual(
402+
self._list_ids(exclude_course_ancestry=False), self._available_ids()
403+
)
404+
405+
def test_contentnode_contains_quiz_filter(self):
406+
quiz = models.ContentNode.objects.exclude(kind=content_kinds.TOPIC).first()
407+
self._set_modality(quiz, modalities.QUIZ)
408+
self._insert_second_tree()
409+
410+
self.assertEqual(
411+
self._list_ids(contains_quiz=True),
412+
set(quiz.get_ancestors(include_self=True).values_list("id", flat=True)),
413+
)
414+
self.assertEqual(self._list_ids(contains_quiz="false"), self._available_ids())
415+
416+
def _set_search_fixtures(self):
417+
photo, resp = models.ContentNode.objects.exclude(
418+
kind=content_kinds.TOPIC
419+
).order_by("lft")[:2]
420+
models.ContentNode.objects.filter(id=photo.id).update(
421+
title="Photosynthesis basics", description="Chlorophyll absorbs light"
422+
)
423+
models.ContentNode.objects.filter(id=resp.id).update(
424+
title="Energy", description="Cellular respiration"
425+
)
426+
return photo.id, resp.id
427+
428+
def test_contentnode_search_terms_match_across_fields(self):
429+
photo, _ = self._set_search_fixtures()
430+
self.assertEqual(self._list_ids(search="photosynthesis chlorophyll"), {photo})
431+
432+
def test_contentnode_search_every_term_must_match(self):
433+
self._set_search_fixtures()
434+
self.assertEqual(self._list_ids(search="photosynthesis respiration"), set())
435+
436+
def test_contentnode_search_quoted_phrase(self):
437+
photo, _ = self._set_search_fixtures()
438+
self.assertEqual(self._list_ids(search='"photosynthesis basics"'), {photo})
439+
self.assertEqual(self._list_ids(search='"basics photosynthesis"'), set())
440+
441+
def test_contentnode_search_drops_stopwords(self):
442+
_, resp = self._set_search_fixtures()
443+
self.assertEqual(self._list_ids(search="which respiration"), {resp})
444+
445+
def test_contentnode_search_keywords_param(self):
446+
_, resp = self._set_search_fixtures()
447+
self.assertEqual(self._list_ids(keywords="respiration"), {resp})
448+
449+
def test_contentnode_search_precedence(self):
450+
photo, _ = self._set_search_fixtures()
451+
self.assertEqual(
452+
self._list_ids(
453+
search="photosynthesis",
454+
question="respiration",
455+
keywords="respiration",
456+
),
457+
{photo},
458+
)
459+
self.assertEqual(
460+
self._list_ids(question="photosynthesis", keywords="respiration"),
461+
{photo},
462+
)
463+
464+
def test_contentnode_search_punctuation_only_is_unfiltered(self):
465+
self.assertEqual(self._list_ids(search="!?,"), self._available_ids())
466+
self.assertEqual(self._list_ids(keywords="!?,"), self._available_ids())
467+
335468
def test_prerequisite_for_filter(self):
336469
response = self.client.get(
337470
reverse("publiccontentnode-list"),
@@ -466,7 +599,7 @@ def test_channelmetadata_content_available_field_false(self):
466599
response = self.client.get(reverse("publicchannel-list"))
467600
self.assertEqual(response.data[0]["available"], False)
468601

469-
def test_channelmetadata_has_exercises_filter(self):
602+
def test_channelmetadata_exercise_filters(self):
470603
# Has nothing else for that matter...
471604
no_exercise_channel = models.ContentNode.objects.create(
472605
pk="6a406ac66b224106aa2e93f73a94333d",
@@ -492,13 +625,12 @@ def test_channelmetadata_has_exercises_filter(self):
492625
)
493626
no_filter_response = self.client.get(reverse("publicchannel-list"))
494627
self.assertEqual(len(no_filter_response.data), 2)
495-
with_filter_response = self.client.get(
496-
reverse("publicchannel-list"), {"has_exercise": True}
497-
)
498-
self.assertEqual(len(with_filter_response.data), 1)
499-
self.assertEqual(
500-
with_filter_response.data[0]["name"], self.channel_data["name"]
501-
)
628+
for param in ("has_exercise", "contains_exercise"):
629+
with self.subTest(param=param):
630+
response = self.client.get(reverse("publicchannel-list"), {param: True})
631+
self.assertEqual(
632+
[c["name"] for c in response.data], [self.channel_data["name"]]
633+
)
502634

503635
def test_channelmetadata_public_filter_default_true(self):
504636
community_channel = models.ContentNode.objects.create(

0 commit comments

Comments
 (0)