1313from kolibri_public .tests .base import ChannelBuilder
1414from kolibri_public .tests .base import OKAY_TAG
1515from le_utils .constants import content_kinds
16+ from le_utils .constants import modalities
1617from rest_framework .test import APITestCase
1718
1819from 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