diff --git a/docs/manual_testing/general_notes/index.rst b/docs/manual_testing/general_notes/index.rst index 7040bfe88db..4ce6b23b67c 100644 --- a/docs/manual_testing/general_notes/index.rst +++ b/docs/manual_testing/general_notes/index.rst @@ -76,16 +76,16 @@ Within the Chrome Dev Tools, navigate to the Network panel. Select a connection For Kolibri, our target audience's network condition can be mimicked by setting connectivity to Regular 3G (100ms, 750kb/s, 250 kb/s). -Performance testing with Django Debug Panel -------------------------------------------- +Performance testing with Django Debug Toolbar +--------------------------------------------- -We have built in support for Django Debug Panel (a Chrome extension that allows tracking of AJAX requests to Django). +We have built in support for Django Debug Toolbar, a Django application that provides a set of panels to display various debug information about the current request/response. It is particularly useful for performance testing. -To use this, ensure that you have development dependencies installed, and install the `Django Debug Panel Chrome Extension `__. You can then run the development or production servers with the following environment variable set:: +To use this, ensure that you have development dependencies installed. You can then run the development or production servers with the following environment variable set:: - DJANGO_SETTINGS_MODULE=kolibri.deployment.default.settings.debug_panel + DJANGO_SETTINGS_MODULE=kolibri.deployment.default.settings.debug_toolbar -This will activate the debug panel, and will display in the Dev tools panel of Chrome. This panel will track all page loads and API requests. However, all data bootstrapping into the template will be disabled, as our data bootstrapping prevents the page load request from being profiled, and also does not profile the bootstrapped API requests. +This will activate the debug toolbar, and will display in the HTML page of the site as an overlay. It is most useful when looking at individual API requests using the browsable API. The simplest way to see it for an API request is to go to that API request in the Network tab in the browser developer tools and open the URL in a new tab. Generating user data diff --git a/kolibri/core/content/api.py b/kolibri/core/content/api.py index 83724ca071a..e6250320c56 100644 --- a/kolibri/core/content/api.py +++ b/kolibri/core/content/api.py @@ -258,12 +258,19 @@ def list(self, request, *args, **kwargs): return super(RemoteViewSet, self).list(request, *args, **kwargs) +class CharInFilter(BaseInFilter, CharFilter): + pass + + class ChannelMetadataFilter(FilterSet): available = BooleanFilter(method="filter_available", label="Available") contains_exercise = BooleanFilter( method="filter_contains_exercise", label="Has exercises" ) contains_quiz = BooleanFilter(method="filter_contains_quiz", label="Has quizzes") + languages = CharInFilter( + field_name="included_languages", label="Languages", distinct=True + ) class Meta: model = models.ChannelMetadata @@ -417,10 +424,6 @@ class UUIDInFilter(BaseInFilter, UUIDFilter): pass -class CharInFilter(BaseInFilter, CharFilter): - pass - - contentnode_filter_fields = [ "parent", "parent__isnull", @@ -470,7 +473,7 @@ class ContentNodeFilter(FilterSet): learner_needs = CharFilter(method="bitmask_contains_and") keywords = CharFilter(method="filter_keywords") channels = UUIDInFilter(field_name="channel_id") - languages = CharInFilter(field_name="lang_id") + languages = CharInFilter(field_name="included_languages") categories__isnull = BooleanFilter(field_name="categories", lookup_expr="isnull") lft__gt = NumberFilter(field_name="lft", lookup_expr="gt") rght__lt = NumberFilter(field_name="rght", lookup_expr="lt") @@ -671,10 +674,11 @@ def get_queryset(self): return models.ContentNode.objects.filter(available=True) def get_related_data_maps(self, items, queryset): + ids = [item["id"] for item in items] assessmentmetadata_map = { a["contentnode"]: a for a in models.AssessmentMetaData.objects.filter( - contentnode__in=queryset + contentnode__in=ids ).values( "assessment_item_ids", "number_of_assessments", @@ -688,7 +692,7 @@ def get_related_data_maps(self, items, queryset): files_map = {} files = list( - models.File.objects.filter(contentnode__in=queryset).values( + models.File.objects.filter(contentnode__in=ids).values( "id", "contentnode", "local_file__id", @@ -723,7 +727,7 @@ def get_related_data_maps(self, items, queryset): tags_map = {} for t in ( - models.ContentTag.objects.filter(tagged_content__in=queryset) + models.ContentTag.objects.filter(tagged_content__in=ids) .values( "tag_name", "tagged_content", diff --git a/kolibri/core/content/contentschema/versions/content_schema_current.py b/kolibri/core/content/contentschema/versions/content_schema_current.py index 939bb253929..8bdf71bdeb1 100644 --- a/kolibri/core/content/contentschema/versions/content_schema_current.py +++ b/kolibri/core/content/contentschema/versions/content_schema_current.py @@ -2,17 +2,13 @@ from sqlalchemy import BigInteger from sqlalchemy import Boolean from sqlalchemy import CHAR -from sqlalchemy import CheckConstraint from sqlalchemy import Column from sqlalchemy import Float -from sqlalchemy import ForeignKey -from sqlalchemy import ForeignKeyConstraint from sqlalchemy import Index from sqlalchemy import Integer from sqlalchemy import String from sqlalchemy import Text from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import relationship Base = declarative_base() metadata = Base.metadata @@ -47,34 +43,17 @@ class ContentLocalfile(Base): class ContentContentnode(Base): __tablename__ = "content_contentnode" __table_args__ = ( - CheckConstraint("lft >= 0"), - CheckConstraint("tree_id >= 0"), - CheckConstraint("level >= 0"), - CheckConstraint("duration >= 0"), - CheckConstraint("rght >= 0"), - ForeignKeyConstraint( - ["lang_id"], - ["content_language.id"], - deferrable=True, - initially="DEFERRED", - ), - ForeignKeyConstraint( - ["parent_id"], - ["content_contentnode.id"], - deferrable=True, - initially="DEFERRED", - ), Index( - "content_contentnode_level_channel_id_available_29f0bb18_idx", + "content_contentnode_level_channel_id_kind_fd732cc4_idx", "level", "channel_id", - "available", + "kind", ), Index( - "content_contentnode_level_channel_id_kind_fd732cc4_idx", + "content_contentnode_level_channel_id_available_29f0bb18_idx", "level", "channel_id", - "kind", + "available", ), ) @@ -89,6 +68,7 @@ class ContentContentnode(Base): kind = Column(String(200), nullable=False) available = Column(Boolean, nullable=False) lft = Column(Integer, nullable=False) + rght = Column(Integer, nullable=False) tree_id = Column(Integer, nullable=False, index=True) level = Column(Integer, nullable=False) lang_id = Column(String(14), index=True) @@ -112,12 +92,8 @@ class ContentContentnode(Base): learning_activities_bitmask_0 = Column(BigInteger) ancestors = Column(Text) admin_imported = Column(Boolean) - rght = Column(Integer, nullable=False) parent_id = Column(CHAR(32), index=True) - lang = relationship("ContentLanguage") - parent = relationship("ContentContentnode", remote_side=[id]) - class ContentAssessmentmetadata(Base): __tablename__ = "content_assessmentmetadata" @@ -128,22 +104,11 @@ class ContentAssessmentmetadata(Base): mastery_model = Column(Text, nullable=False) randomize = Column(Boolean, nullable=False) is_manipulable = Column(Boolean, nullable=False) - contentnode_id = Column( - ForeignKey("content_contentnode.id"), nullable=False, index=True - ) - - contentnode = relationship("ContentContentnode") + contentnode_id = Column(CHAR(32), nullable=False, index=True) class ContentChannelmetadata(Base): __tablename__ = "content_channelmetadata" - __table_args__ = ( - CheckConstraint('"order" >= 0'), - ForeignKeyConstraint( - ["root_id"], - ["content_contentnode.id"], - ), - ) id = Column(CHAR(32), primary_key=True) name = Column(String(200), nullable=False) @@ -163,8 +128,6 @@ class ContentChannelmetadata(Base): included_categories = Column(Text) included_grade_levels = Column(Text) - root = relationship("ContentContentnode") - class ContentContentnodeHasPrerequisite(Base): __tablename__ = "content_contentnode_has_prerequisite" @@ -178,22 +141,25 @@ class ContentContentnodeHasPrerequisite(Base): ) id = Column(Integer, primary_key=True) - from_contentnode_id = Column( - ForeignKey("content_contentnode.id"), nullable=False, index=True - ) - to_contentnode_id = Column( - ForeignKey("content_contentnode.id"), nullable=False, index=True - ) + from_contentnode_id = Column(CHAR(32), nullable=False, index=True) + to_contentnode_id = Column(CHAR(32), nullable=False, index=True) - from_contentnode = relationship( - "ContentContentnode", - primaryjoin="ContentContentnodeHasPrerequisite.from_contentnode_id == ContentContentnode.id", - ) - to_contentnode = relationship( - "ContentContentnode", - primaryjoin="ContentContentnodeHasPrerequisite.to_contentnode_id == ContentContentnode.id", + +class ContentContentnodeIncludedLanguages(Base): + __tablename__ = "content_contentnode_included_languages" + __table_args__ = ( + Index( + "content_contentnode_included_languages_contentnode_id_language_id_7d14ec8b_uniq", + "contentnode_id", + "language_id", + unique=True, + ), ) + id = Column(Integer, primary_key=True) + contentnode_id = Column(CHAR(32), nullable=False, index=True) + language_id = Column(String(14), nullable=False, index=True) + class ContentContentnodeRelated(Base): __tablename__ = "content_contentnode_related" @@ -207,21 +173,8 @@ class ContentContentnodeRelated(Base): ) id = Column(Integer, primary_key=True) - from_contentnode_id = Column( - ForeignKey("content_contentnode.id"), nullable=False, index=True - ) - to_contentnode_id = Column( - ForeignKey("content_contentnode.id"), nullable=False, index=True - ) - - from_contentnode = relationship( - "ContentContentnode", - primaryjoin="ContentContentnodeRelated.from_contentnode_id == ContentContentnode.id", - ) - to_contentnode = relationship( - "ContentContentnode", - primaryjoin="ContentContentnodeRelated.to_contentnode_id == ContentContentnode.id", - ) + from_contentnode_id = Column(CHAR(32), nullable=False, index=True) + to_contentnode_id = Column(CHAR(32), nullable=False, index=True) class ContentContentnodeTags(Base): @@ -236,15 +189,8 @@ class ContentContentnodeTags(Base): ) id = Column(Integer, primary_key=True) - contentnode_id = Column( - ForeignKey("content_contentnode.id"), nullable=False, index=True - ) - contenttag_id = Column( - ForeignKey("content_contenttag.id"), nullable=False, index=True - ) - - contentnode = relationship("ContentContentnode") - contenttag = relationship("ContentContenttag") + contentnode_id = Column(CHAR(32), nullable=False, index=True) + contenttag_id = Column(CHAR(32), nullable=False, index=True) class ContentFile(Base): @@ -254,19 +200,11 @@ class ContentFile(Base): supplementary = Column(Boolean, nullable=False) thumbnail = Column(Boolean, nullable=False) priority = Column(Integer, index=True) - contentnode_id = Column( - ForeignKey("content_contentnode.id"), nullable=False, index=True - ) - lang_id = Column(ForeignKey("content_language.id"), index=True) - local_file_id = Column( - ForeignKey("content_localfile.id"), nullable=False, index=True - ) + contentnode_id = Column(CHAR(32), nullable=False, index=True) + lang_id = Column(String(14), index=True) + local_file_id = Column(String(32), nullable=False, index=True) preset = Column(String(150), nullable=False) - contentnode = relationship("ContentContentnode") - lang = relationship("ContentLanguage") - local_file = relationship("ContentLocalfile") - class ContentChannelmetadataIncludedLanguages(Base): __tablename__ = "content_channelmetadata_included_languages" @@ -280,11 +218,6 @@ class ContentChannelmetadataIncludedLanguages(Base): ) id = Column(Integer, primary_key=True) - channelmetadata_id = Column( - ForeignKey("content_channelmetadata.id"), nullable=False, index=True - ) - language_id = Column(ForeignKey("content_language.id"), nullable=False, index=True) sort_value = Column(Integer, nullable=False) - - channelmetadata = relationship("ContentChannelmetadata") - language = relationship("ContentLanguage") + channelmetadata_id = Column(CHAR(32), nullable=False, index=True) + language_id = Column(String(14), nullable=False, index=True) diff --git a/kolibri/core/content/management/commands/generate_schema.py b/kolibri/core/content/management/commands/generate_schema.py index c6f2abe9a3f..269a8516d84 100644 --- a/kolibri/core/content/management/commands/generate_schema.py +++ b/kolibri/core/content/management/commands/generate_schema.py @@ -134,7 +134,7 @@ def handle(self, *args, **options): metadata.bind = engine generator = CodeGenerator( - metadata, False, False, True, True, False, nocomments=False + metadata, False, True, True, True, False, nocomments=False ) with io.open( diff --git a/kolibri/core/content/migrations/0040_contentnode_included_languages.py b/kolibri/core/content/migrations/0040_contentnode_included_languages.py new file mode 100644 index 00000000000..0785e517964 --- /dev/null +++ b/kolibri/core/content/migrations/0040_contentnode_included_languages.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.25 on 2024-12-18 00:14 +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("content", "0039_channelmetadata_ordered_fields"), + ] + + operations = [ + migrations.AddField( + model_name="contentnode", + name="included_languages", + field=models.ManyToManyField( + blank=True, + related_name="contentnodes", + to="content.Language", + verbose_name="languages", + ), + ), + ] diff --git a/kolibri/core/content/models.py b/kolibri/core/content/models.py index 78e461fb865..dc58579d0a8 100644 --- a/kolibri/core/content/models.py +++ b/kolibri/core/content/models.py @@ -215,6 +215,16 @@ class ContentNode(base_models.ContentNode): # needs a subsequent Kolibri upgrade step to backfill these values. admin_imported = models.BooleanField(null=True) + # Languages that are in this node and/or any descendant nodes of this node + # for non-topic nodes, this is the language of the node itself + # for topic nodes, this is the union of all languages of all descendant nodes + # and any language set on the topic node itself + # We do this to allow filtering of a topic tree by a specific language for + # multi-language channels. + included_languages = models.ManyToManyField( + "Language", related_name="contentnodes", verbose_name="languages", blank=True + ) + objects = ContentNodeManager() class Meta: diff --git a/kolibri/core/content/test/test_annotation.py b/kolibri/core/content/test/test_annotation.py index 2de250711eb..1d4123ecc5e 100644 --- a/kolibri/core/content/test/test_annotation.py +++ b/kolibri/core/content/test/test_annotation.py @@ -745,6 +745,268 @@ def test_two_channels_no_annotation_collision_child_true(self): self.assertFalse(root_node.available) self.assertFalse(root_node.coach_content) + def test_non_topic_node_included_languages(self): + """ + Test that non-topic nodes get their lang_id properly set as their included_language + """ + test_node = ContentNode.objects.exclude(kind=content_kinds.TOPIC).first() + test_language = Language.objects.create( + id="te-st", + lang_code="te", + lang_subcode="st", + lang_name="Test Language", + lang_direction="ltr", + ) + + # Set a language on our test node + test_node.lang = test_language + test_node.save() + + recurse_annotation_up_tree(channel_id="6199dde695db4ee4ab392222d5af1e5c") + + # Verify the node has exactly one included language matching its lang_id + self.assertEqual(test_node.included_languages.count(), 1) + self.assertEqual(test_node.included_languages.first(), test_language) + + def test_topic_node_not_includes_own_language(self): + """ + Test that topic nodes do not include their own language in included_languages + """ + topic_node = ContentNode.objects.filter(kind=content_kinds.TOPIC).first() + test_language = Language.objects.create( + id="te-st", + lang_code="te", + lang_subcode="st", + lang_name="Test Language", + lang_direction="ltr", + ) + + # Set a language on the topic + topic_node.lang = test_language + topic_node.save() + + recurse_annotation_up_tree(channel_id="6199dde695db4ee4ab392222d5af1e5c") + + # Verify the topic includes its own language + self.assertNotIn(test_language, topic_node.included_languages.all()) + + def test_topic_node_includes_child_languages(self): + """ + Test that topic nodes include languages from their child nodes + """ + topic_node = ContentNode.objects.filter(kind=content_kinds.TOPIC).first() + + # Create two test languages + lang1 = Language.objects.create( + id="te-st", + lang_code="te", + lang_subcode="st", + lang_name="Test Language 1", + lang_direction="ltr", + ) + lang2 = Language.objects.create( + id="tt-se", + lang_code="tt", + lang_subcode="se", + lang_name="Test Language 2", + lang_direction="ltr", + ) + + # Create two child nodes with different languages + ContentNode.objects.create( + title="test1", + id=uuid.uuid4().hex, + content_id=uuid.uuid4().hex, + channel_id=topic_node.channel_id, + parent=topic_node, + kind=content_kinds.VIDEO, + available=True, + lang=lang1, + ) + + ContentNode.objects.create( + title="test2", + id=uuid.uuid4().hex, + content_id=uuid.uuid4().hex, + channel_id=topic_node.channel_id, + parent=topic_node, + kind=content_kinds.VIDEO, + available=True, + lang=lang2, + ) + + recurse_annotation_up_tree(channel_id="6199dde695db4ee4ab392222d5af1e5c") + + # Verify the topic includes both child languages + included_languages = topic_node.included_languages.all() + self.assertEqual(len(included_languages), 2) + self.assertIn(lang1, included_languages) + self.assertIn(lang2, included_languages) + + def test_topic_node_includes_grandchild_languages(self): + """ + Test that topic nodes include languages from their grandchild nodes + """ + root_topic = ContentNode.objects.filter(kind=content_kinds.TOPIC).first() + + # Create test language + test_language = Language.objects.create( + id="te-st", + lang_code="te", + lang_subcode="st", + lang_name="Test Language", + lang_direction="ltr", + ) + + # Create child topic + child_topic = ContentNode.objects.create( + title="test_topic", + id=uuid.uuid4().hex, + content_id=uuid.uuid4().hex, + channel_id=root_topic.channel_id, + parent=root_topic, + kind=content_kinds.TOPIC, + available=True, + ) + + # Create grandchild with language + ContentNode.objects.create( + title="test_content", + id=uuid.uuid4().hex, + content_id=uuid.uuid4().hex, + channel_id=root_topic.channel_id, + parent=child_topic, + kind=content_kinds.VIDEO, + available=True, + lang=test_language, + ) + + recurse_annotation_up_tree(channel_id="6199dde695db4ee4ab392222d5af1e5c") + + # Verify both the child topic and root topic include the grandchild's language + self.assertIn(test_language, child_topic.included_languages.all()) + self.assertIn(test_language, root_topic.included_languages.all()) + + def test_topic_deduplicates_languages(self): + """ + Test that topic nodes don't duplicate languages when multiple children have the same language + """ + topic_node = ContentNode.objects.filter(kind=content_kinds.TOPIC).first() + test_language = Language.objects.create( + id="te-st", + lang_code="te", + lang_subcode="st", + lang_name="Test Language", + lang_direction="ltr", + ) + + # Create multiple children with the same language + for i in range(3): + ContentNode.objects.create( + title=f"test{i}", + id=uuid.uuid4().hex, + content_id=uuid.uuid4().hex, + channel_id=topic_node.channel_id, + parent=topic_node, + kind=content_kinds.VIDEO, + available=True, + lang=test_language, + ) + + recurse_annotation_up_tree(channel_id="6199dde695db4ee4ab392222d5af1e5c") + + # Verify the language only appears once + self.assertEqual(topic_node.included_languages.count(), 1) + self.assertEqual(topic_node.included_languages.first(), test_language) + + def test_non_available_child_languages_excluded(self): + """ + Test that languages from non-available children are not included in the topic's languages + """ + topic_node = ContentNode.objects.filter(kind=content_kinds.TOPIC).first() + test_language = Language.objects.create( + id="te-st", + lang_code="te", + lang_subcode="st", + lang_name="Test Language", + lang_direction="ltr", + ) + + # Create a non-available child with a language + ContentNode.objects.create( + title="test", + id=uuid.uuid4().hex, + content_id=uuid.uuid4().hex, + channel_id=topic_node.channel_id, + parent=topic_node, + kind=content_kinds.VIDEO, + available=False, + lang=test_language, + ) + + recurse_annotation_up_tree(channel_id="6199dde695db4ee4ab392222d5af1e5c") + + # Verify the topic doesn't include the language from the non-available child + self.assertEqual(topic_node.included_languages.count(), 0) + + def test_duplicate_language_handling_in_recursion(self): + """ + Test that the recursion handles cases where a topic might receive the same + language multiple times (from own lang_id and from children) + """ + # Create a topic with two levels of children + root_topic = ContentNode.objects.create( + title="root", + id=uuid.uuid4().hex, + content_id=uuid.uuid4().hex, + channel_id="6199dde695db4ee4ab392222d5af1e5c", + kind=content_kinds.TOPIC, + available=True, + ) + + test_language = Language.objects.create( + id="te-st", + lang_code="te", + lang_subcode="st", + lang_name="Test Language", + lang_direction="ltr", + ) + + # Set the root topic's language + root_topic.lang = test_language + root_topic.save() + + # Create a child topic with the same language + child_topic = ContentNode.objects.create( + title="child", + id=uuid.uuid4().hex, + content_id=uuid.uuid4().hex, + channel_id="6199dde695db4ee4ab392222d5af1e5c", + parent=root_topic, + kind=content_kinds.TOPIC, + available=True, + lang=test_language, + ) + + # Create a grandchild with the same language + ContentNode.objects.create( + title="grandchild", + id=uuid.uuid4().hex, + content_id=uuid.uuid4().hex, + channel_id="6199dde695db4ee4ab392222d5af1e5c", + parent=child_topic, + kind=content_kinds.VIDEO, + available=True, + lang=test_language, + ) + + # This should not raise an IntegrityError + recurse_annotation_up_tree(channel_id="6199dde695db4ee4ab392222d5af1e5c") + + # Verify the relationships are correct + self.assertEqual(root_topic.included_languages.count(), 1) + self.assertEqual(child_topic.included_languages.count(), 1) + def tearDown(self): call_command("flush", interactive=False) super(AnnotationTreeRecursion, self).tearDown() diff --git a/kolibri/core/content/test/test_content_app.py b/kolibri/core/content/test/test_content_app.py index 09836a5af2e..179c4276360 100644 --- a/kolibri/core/content/test/test_content_app.py +++ b/kolibri/core/content/test/test_content_app.py @@ -463,6 +463,210 @@ def test_contentnode_list_long(self): self.assertEqual(len(response.data), expected_output) self._assert_nodes(response.data, nodes) + def test_filter_by_single_included_language(self): + """ + Test filtering ContentNodes by a single included language + """ + c1 = content.ContentNode.objects.get(title="c1") + language = content.Language.objects.create( + id="en", + lang_code="en", + lang_subcode="", + lang_name="English", + lang_direction="ltr", + ) + c1.included_languages.set([language]) + c1.save() + + response = self.client.get( + reverse("kolibri:core:contentnode-list"), data={"languages": "en"} + ) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], "c1") + + def test_filter_by_multiple_included_languages(self): + """ + Test filtering ContentNodes that match any of the provided languages + """ + c1 = content.ContentNode.objects.get(title="c1") + c2 = content.ContentNode.objects.get(title="c2") + english = content.Language.objects.create( + id="en", + lang_code="en", + lang_subcode="", + lang_name="English", + lang_direction="ltr", + ) + spanish = content.Language.objects.create( + id="es", + lang_code="es", + lang_subcode="", + lang_name="Spanish", + lang_direction="ltr", + ) + c1.included_languages.set([english]) + c2.included_languages.set([spanish]) + c1.save() + c2.save() + + response = self.client.get( + reverse("kolibri:core:contentnode-list"), data={"languages": "en,es"} + ) + self.assertEqual(len(response.data), 2) + titles = [node["title"] for node in response.data] + self.assertIn("c1", titles) + self.assertIn("c2", titles) + + def test_filter_by_non_existent_language(self): + """ + Test filtering by a language that no ContentNode has + """ + c1 = content.ContentNode.objects.get(title="c1") + english = content.Language.objects.create( + id="en", + lang_code="en", + lang_subcode="", + lang_name="English", + lang_direction="ltr", + ) + c1.included_languages.set([english]) + c1.save() + + response = self.client.get( + reverse("kolibri:core:contentnode-list"), data={"languages": "fr"} + ) + self.assertEqual(len(response.data), 0) + + def test_filter_by_multiple_languages_per_node(self): + """ + Test filtering nodes that have multiple languages assigned + """ + c1 = content.ContentNode.objects.get(title="c1") + english = content.Language.objects.create( + id="en", + lang_code="en", + lang_subcode="", + lang_name="English", + lang_direction="ltr", + ) + spanish = content.Language.objects.create( + id="es", + lang_code="es", + lang_subcode="", + lang_name="Spanish", + lang_direction="ltr", + ) + c1.included_languages.set([english, spanish]) + c1.save() + + # Should match when searching for either language + response = self.client.get( + reverse("kolibri:core:contentnode-list"), data={"languages": "en"} + ) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], "c1") + + response = self.client.get( + reverse("kolibri:core:contentnode-list"), data={"languages": "es"} + ) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], "c1") + + def test_filter_by_empty_included_languages(self): + """ + Test that nodes with empty included_languages are not returned when filtering + """ + c1 = content.ContentNode.objects.get(title="c1") + c1.included_languages.set([]) + c1.save() + + response = self.client.get( + reverse("kolibri:core:contentnode-list"), data={"languages": "en"} + ) + self.assertEqual(len(response.data), 0) + + def test_filter_by_language_subcodes(self): + """ + Test filtering by language codes with subcodes, ensuring that: + 1. Searching by base language code returns all variants + 2. Searching by specific subcode only returns exact matches + """ + nodes = { + "es_node": content.ContentNode.objects.get(title="c1"), + "es_es_node": content.ContentNode.objects.get(title="c2"), + "es_419_node": content.ContentNode.objects.get(title="c2c1"), + "en_gb_node": content.ContentNode.objects.get(title="c2c2"), + } + + english = content.Language.objects.create( + id="en-gb", + lang_code="en", + lang_subcode="gb", + lang_name="English", + lang_direction="ltr", + ) + spanish = content.Language.objects.create( + id="es", + lang_code="es", + lang_subcode="", + lang_name="Spanish", + lang_direction="ltr", + ) + + spanish_spanish = content.Language.objects.create( + id="es-es", + lang_code="es", + lang_subcode="es", + lang_name="Spanish", + lang_direction="ltr", + ) + + latin_american_spanish = content.Language.objects.create( + id="es-419", + lang_code="es", + lang_subcode="419", + lang_name="Spanish", + lang_direction="ltr", + ) + + # Set up nodes with different language codes + nodes["es_node"].included_languages.set([spanish]) + nodes["es_es_node"].included_languages.set([spanish_spanish]) + nodes["es_419_node"].included_languages.set([latin_american_spanish]) + nodes["en_gb_node"].included_languages.set([english]) + + for node in nodes.values(): + node.save() + + # Test that searching by 'es' returns only the unpreifxed spanish variaent + response = self.client.get( + reverse("kolibri:core:contentnode-list"), data={"languages": "es"} + ) + self.assertEqual(len(response.data), 1) + title = response.data[0]["title"] + self.assertEqual(title, "c1") + + # Test that searching by specific Spanish variant only returns exact match + response = self.client.get( + reverse("kolibri:core:contentnode-list"), data={"languages": "es-419"} + ) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], "c2c1") + + # Test that searching by 'en' returns nothing + response = self.client.get( + reverse("kolibri:core:contentnode-list"), data={"languages": "en"} + ) + self.assertEqual(len(response.data), 0) + + # Test searching for multiple specific variants + response = self.client.get( + reverse("kolibri:core:contentnode-list"), data={"languages": "es-es,es-419"} + ) + self.assertEqual(len(response.data), 2) + titles = {node["title"] for node in response.data} + self.assertEqual(titles, {"c2", "c2c1"}) + def _recurse_and_assert(self, data, nodes, recursion_depth=0): recursion_depths = [] for actual, expected in zip(data, nodes): diff --git a/kolibri/core/content/utils/annotation.py b/kolibri/core/content/utils/annotation.py index a9cba7f146e..7b36b5f9ff3 100644 --- a/kolibri/core/content/utils/annotation.py +++ b/kolibri/core/content/utils/annotation.py @@ -530,6 +530,7 @@ def recurse_annotation_up_tree(channel_id): bridge = Bridge(app_name=CONTENT_APP_NAME) ContentNodeTable = bridge.get_table(ContentNode) + IncludedLanguagesTable = bridge.get_table(ContentNode.included_languages.through) connection = bridge.get_connection() @@ -583,6 +584,31 @@ def recurse_annotation_up_tree(channel_id): ) ) + # First clear out all existing language relationships for nodes in this channel + connection.execute( + IncludedLanguagesTable.delete().where( + IncludedLanguagesTable.c.contentnode_id.in_( + select([ContentNodeTable.c.id]).where( + ContentNodeTable.c.channel_id == channel_id + ) + ) + ) + ) + + # For non-topic nodes only, set included_languages based on their lang_id + connection.execute( + IncludedLanguagesTable.insert().from_select( + ["contentnode_id", "language_id"], + select([ContentNodeTable.c.id, ContentNodeTable.c.lang_id]).where( + and_( + ContentNodeTable.c.channel_id == channel_id, + ContentNodeTable.c.kind != content_kinds.TOPIC, + ContentNodeTable.c.lang_id.isnot(None), + ) + ), + ) + ) + # Expression to capture all available child nodes of a contentnode available_nodes = select(child.c.available).where( and_( @@ -661,6 +687,36 @@ def recurse_annotation_up_tree(channel_id): ) ) + # Update included languages for all topics at this level by combining + # their own language with their children's languages in one query + connection.execute( + IncludedLanguagesTable.insert().from_select( + ["contentnode_id", "language_id"], + # Languages from children + select([ContentNodeTable.c.id, IncludedLanguagesTable.c.language_id]) + .select_from(ContentNodeTable) + .join( + child, + and_( + child.c.parent_id == ContentNodeTable.c.id, + child.c.available == True, # noqa + ), + ) + .join( + IncludedLanguagesTable, + IncludedLanguagesTable.c.contentnode_id == child.c.id, + ) + .where( + and_( + ContentNodeTable.c.level == level - 1, + ContentNodeTable.c.channel_id == channel_id, + ContentNodeTable.c.kind == content_kinds.TOPIC, + ) + ) + .distinct(), + ) + ) + # commit the transaction trans.commit() diff --git a/kolibri/core/content/utils/channel_import.py b/kolibri/core/content/utils/channel_import.py index 1cb19f8d904..aea1f711c3d 100644 --- a/kolibri/core/content/utils/channel_import.py +++ b/kolibri/core/content/utils/channel_import.py @@ -59,6 +59,7 @@ models_to_exclude = [ apps.get_model(CONTENT_APP_NAME, "ChannelMetadata_included_languages"), + apps.get_model(CONTENT_APP_NAME, "ContentNode_included_languages"), ] + no_schema_models diff --git a/kolibri/deployment/default/dev_urls.py b/kolibri/deployment/default/dev_urls.py index 3504eda9b5b..bf53649eed0 100644 --- a/kolibri/deployment/default/dev_urls.py +++ b/kolibri/deployment/default/dev_urls.py @@ -1,6 +1,6 @@ -from django.conf import settings from django.http.response import HttpResponseRedirect from django.urls import include +from django.urls import path from django.urls import re_path from drf_yasg import openapi from drf_yasg.views import get_schema_view @@ -48,10 +48,5 @@ def webpack_redirect_view(request): name="schema-redoc", ), re_path(r"^api-auth/", include("rest_framework.urls", namespace="rest_framework")), + path("__debug__/", include("debug_toolbar.urls")), ] - -if getattr(settings, "DEBUG_PANEL_ACTIVE", False): - - import debug_toolbar - - urlpatterns = [re_path(r"^__debug__/", include(debug_toolbar.urls))] + urlpatterns diff --git a/kolibri/deployment/default/renderers.py b/kolibri/deployment/default/renderers.py new file mode 100644 index 00000000000..7bc4eccc0a6 --- /dev/null +++ b/kolibri/deployment/default/renderers.py @@ -0,0 +1,35 @@ +from rest_framework.renderers import BrowsableAPIRenderer + + +class LightBrowsableAPIRenderer(BrowsableAPIRenderer): + """ + Custom browsable API renderer that removes filtering and POST forms + for better performance with Django Debug Toolbar. + """ + + def get_filter_form(self, data, view, request): + """ + Don't render the filter form. + """ + return None + + def get_rendered_html_form(self, data, view, method, request): + """ + Don't render the HTML form. + """ + return None + + def get_context(self, data, accepted_media_type, renderer_context): + """ + Modify context to remove unnecessary components. + """ + context = super().get_context(data, accepted_media_type, renderer_context) + + # Remove form-related context + context["display_edit_forms"] = False + context["raw_data_post_form"] = None + context["raw_data_put_form"] = None + context["raw_data_patch_form"] = None + context["raw_data_put_or_patch_form"] = None + + return context diff --git a/kolibri/deployment/default/settings/debug_panel.py b/kolibri/deployment/default/settings/debug_panel.py deleted file mode 100644 index 1287d059999..00000000000 --- a/kolibri/deployment/default/settings/debug_panel.py +++ /dev/null @@ -1,18 +0,0 @@ -from .dev import * # noqa - -INTERNAL_IPS = ["127.0.0.1"] - -DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda x: True} - -MIDDLEWARE.append("debug_panel.middleware.DebugPanelMiddleware") # noqa - -INSTALLED_APPS += ["debug_toolbar", "debug_panel"] # noqa - -DEBUG_PANEL_ACTIVE = True - -CACHES["debug-panel"] = { # noqa - "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", - "LOCATION": "/var/tmp/debug-panel-cache", - "TIMEOUT": 300, - "OPTIONS": {"MAX_ENTRIES": 200}, -} diff --git a/kolibri/deployment/default/settings/debug_toolbar.py b/kolibri/deployment/default/settings/debug_toolbar.py new file mode 100644 index 00000000000..c03ff09439c --- /dev/null +++ b/kolibri/deployment/default/settings/debug_toolbar.py @@ -0,0 +1,12 @@ +from .dev import * # noqa + +INTERNAL_IPS = ["127.0.0.1"] + +INSTALLED_APPS += ["debug_toolbar"] # noqa + +MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa + +CACHES["default"]["TIMEOUT"] = 0 # noqa + +if "process_cache" in CACHES: # noqa + CACHES["process_cache"]["TIMEOUT"] = 0 # noqa diff --git a/kolibri/deployment/default/settings/dev.py b/kolibri/deployment/default/settings/dev.py index 5be1fa72634..ed27042bb35 100644 --- a/kolibri/deployment/default/settings/dev.py +++ b/kolibri/deployment/default/settings/dev.py @@ -34,7 +34,7 @@ ], "DEFAULT_RENDERER_CLASSES": ( "rest_framework.renderers.JSONRenderer", - "rest_framework.renderers.BrowsableAPIRenderer", + "kolibri.deployment.default.renderers.LightBrowsableAPIRenderer", ), "EXCEPTION_HANDLER": "kolibri.core.utils.exception_handler.custom_exception_handler", } diff --git a/kolibri/plugins/learn/assets/src/composables/__tests__/useSearch.spec.js b/kolibri/plugins/learn/assets/src/composables/__tests__/useSearch.spec.js deleted file mode 100644 index 3b0d5e1284e..00000000000 --- a/kolibri/plugins/learn/assets/src/composables/__tests__/useSearch.spec.js +++ /dev/null @@ -1,438 +0,0 @@ -import { get, set } from '@vueuse/core'; -import VueRouter from 'vue-router'; -import Vue, { nextTick, ref } from 'vue'; -import ContentNodeResource from 'kolibri-common/apiResources/ContentNodeResource'; -import { coreStoreFactory } from 'kolibri/store'; -import { AllCategories, NoCategories } from 'kolibri/constants'; -import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-disable-line -import useSearch from '../useSearch'; -import coreModule from '../../../../../../core/assets/src/state/modules/core'; - -Vue.use(VueRouter); - -jest.mock('kolibri/composables/useUser'); - -const name = 'not important'; - -function prep(query = {}, descendant = null) { - const store = coreStoreFactory({ - state: () => ({ - route: { - query, - name, - }, - }), - mutations: { - SET_QUERY(state, query) { - state.route.query = query; - }, - }, - }); - store.registerModule('core', coreModule); - const router = new VueRouter(); - router.push = jest.fn().mockReturnValue(Promise.resolve()); - return { - ...useSearch(descendant, store, router), - router, - store, - }; -} - -describe(`useSearch`, () => { - beforeEach(() => { - ContentNodeResource.fetchCollection = jest.fn(); - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); - useUser.mockImplementation(() => useUserMock()); - }); - describe(`searchTerms computed ref`, () => { - it(`returns an object with all relevant keys when query params are empty`, () => { - const { searchTerms } = prep(); - expect(get(searchTerms)).toEqual({ - accessibility_labels: {}, - categories: {}, - grade_levels: {}, - languages: {}, - learner_needs: {}, - learning_activities: {}, - keywords: '', - }); - }); - it(`returns an object with all relevant keys when query params have other keys`, () => { - const { searchTerms } = prep({ - search: { - this: true, - }, - keyword: 'how about this?', - }); - expect(get(searchTerms)).toEqual({ - accessibility_labels: {}, - categories: {}, - grade_levels: {}, - languages: {}, - learner_needs: {}, - learning_activities: {}, - keywords: '', - }); - }); - it(`returns an object with all relevant keys when query params are specified`, () => { - const { searchTerms } = prep({ - accessibility_labels: 'test1,test2', - keywords: 'I love paris in the springtime!', - categories: 'notatest,reallynotatest,absolutelynotatest', - grade_levels: 'lowerprimary,uppersecondary,adult', - languages: 'ar-jk,en-pr,en-gb', - learner_needs: 'internet,pencil,rolodex', - learning_activities: 'watch', - }); - expect(get(searchTerms)).toEqual({ - accessibility_labels: { - test1: true, - test2: true, - }, - categories: { - notatest: true, - reallynotatest: true, - absolutelynotatest: true, - }, - grade_levels: { - lowerprimary: true, - uppersecondary: true, - adult: true, - }, - languages: { - 'ar-jk': true, - 'en-pr': true, - 'en-gb': true, - }, - learner_needs: { - internet: true, - pencil: true, - rolodex: true, - }, - learning_activities: { - watch: true, - }, - keywords: 'I love paris in the springtime!', - }); - }); - it(`setting relevant keys will result in a router push`, () => { - const { searchTerms, router } = prep(); - set(searchTerms, { - keywords: 'test', - categories: { - cat1: true, - cat2: true, - }, - }); - expect(router.push).toHaveBeenCalledWith({ - name, - query: { - keywords: 'test', - categories: 'cat1,cat2', - }, - }); - }); - it(`removing keys will be propagated to the router`, () => { - const { searchTerms, router } = prep({ - keywords: 'test', - categories: 'cat1,cat2', - grade_levels: 'level1', - }); - set(searchTerms, { - keywords: '', - categories: { - cat2: true, - }, - }); - expect(router.push).toHaveBeenCalledWith({ - name, - query: { - categories: 'cat2', - }, - }); - }); - it(`setting keywords to null will be propagated to the router`, () => { - const { searchTerms, router } = prep({ - keywords: 'test', - categories: 'cat1,cat2', - grade_levels: 'level1', - }); - set(searchTerms, { - keywords: null, - categories: { - cat2: true, - }, - }); - expect(router.push).toHaveBeenCalledWith({ - name, - query: { - categories: 'cat2', - }, - }); - }); - }); - describe('displayingSearchResults computed property', () => { - const searchKeys = [ - 'learning_activities', - 'categories', - 'learner_needs', - 'accessibility_labels', - 'languages', - 'grade_levels', - ]; - it.each(searchKeys)('should be true when there are any values for %s', key => { - const { displayingSearchResults } = prep({ - [key]: 'test1,test2', - }); - expect(get(displayingSearchResults)).toBe(true); - }); - it('should be true when there is a value for keywords', () => { - const { displayingSearchResults } = prep({ - keywords: 'testing testing one two three', - }); - expect(get(displayingSearchResults)).toBe(true); - }); - }); - describe('search method', () => { - it('should call ContentNodeResource.fetchCollection when searchTerms changes', async () => { - const { store } = prep(); - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); - store.commit('SET_QUERY', { categories: 'test1,test2' }); - await nextTick(); - expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({ - getParams: { - categories: ['test1', 'test2'], - max_results: 25, - include_coach_content: false, - }, - }); - }); - it('should not call ContentNodeResource.fetchCollection if there is no search', () => { - const { search } = prep(); - ContentNodeResource.fetchCollection.mockClear(); - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); - search(); - expect(ContentNodeResource.fetchCollection).not.toHaveBeenCalled(); - }); - it('should clear labels and more if there is no search', () => { - const { search, labels, more } = prep(); - set(labels, ['test']); - set(more, { test: 'test' }); - search(); - expect(get(labels)).toBeNull(); - expect(get(more)).toBeNull(); - }); - it('should call ContentNodeResource.fetchCollection if there is no search but a descendant is set', () => { - const { search } = prep({}, ref({ tree_id: 1, lft: 10, rght: 20 })); - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); - search(); - expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({ - getParams: { - tree_id: 1, - lft__gt: 10, - rght__lt: 20, - max_results: 1, - include_coach_content: false, - }, - }); - }); - it('should set labels and clear more if there is no search but a descendant is set', async () => { - const { labels, more, search } = prep({}, ref({ tree_id: 1, lft: 10, rght: 20 })); - const labelsSet = { - available: ['labels'], - languages: [], - }; - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({ labels: labelsSet })); - set(more, { test: 'test' }); - search(); - await nextTick(); - expect(get(more)).toBeNull(); - expect(get(labels)).toEqual(labelsSet); - }); - it('should call ContentNodeResource.fetchCollection when searchTerms exist', () => { - const { search } = prep({ categories: 'test1,test2' }); - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); - search(); - expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({ - getParams: { - categories: ['test1', 'test2'], - max_results: 25, - include_coach_content: false, - }, - }); - }); - it('should ignore other categories when AllCategories is set and search for isnull false', () => { - const { search } = prep({ categories: `test1,test2,${NoCategories},${AllCategories}` }); - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); - search(); - expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({ - getParams: { categories__isnull: false, max_results: 25, include_coach_content: false }, - }); - }); - 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 }, - }); - }); - it('should set keywords when defined', () => { - const { search } = prep({ keywords: `this is just a test` }); - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); - search(); - expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({ - getParams: { - keywords: `this is just a test`, - max_results: 25, - include_coach_content: false, - }, - }); - }); - it('should set results, labels, and more with returned data', async () => { - const { labels, more, results, search } = prep({ categories: 'test1,test2' }); - const expectedLabels = { - available: ['labels'], - languages: [], - }; - const expectedMore = { - cursor: 'adalskdjsadlkjsadlkjsalkd', - }; - const expectedResults = [{ id: 'node-id1' }]; - ContentNodeResource.fetchCollection.mockReturnValue( - Promise.resolve({ - labels: expectedLabels, - results: expectedResults, - more: expectedMore, - }), - ); - search(); - await nextTick(); - expect(get(labels)).toEqual(expectedLabels); - expect(get(results)).toEqual(expectedResults); - expect(get(more)).toEqual(expectedMore); - }); - }); - describe('searchMore method', () => { - it('should not call anything when not displaying search terms', () => { - const { searchMore } = prep(); - ContentNodeResource.fetchCollection.mockClear(); - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); - searchMore(); - expect(ContentNodeResource.fetchCollection).not.toHaveBeenCalled(); - }); - it('should not call anything when more is null', () => { - const { more, searchMore } = prep({ categories: 'test1' }); - ContentNodeResource.fetchCollection.mockClear(); - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); - set(more, null); - searchMore(); - expect(ContentNodeResource.fetchCollection).not.toHaveBeenCalled(); - }); - it('should not call anything when moreLoading is true', () => { - const { more, moreLoading, searchMore } = prep({ categories: 'test1' }); - ContentNodeResource.fetchCollection.mockClear(); - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); - set(more, {}); - set(moreLoading, true); - searchMore(); - expect(ContentNodeResource.fetchCollection).not.toHaveBeenCalled(); - }); - it('should pass the more object directly to getParams', () => { - const { more, searchMore } = prep({ categories: `test1,test2,${NoCategories}` }); - ContentNodeResource.fetchCollection.mockReturnValue(Promise.resolve({})); - const moreExpected = { test: 'this', not: 'that' }; - set(more, moreExpected); - searchMore(); - expect(ContentNodeResource.fetchCollection).toHaveBeenCalledWith({ getParams: moreExpected }); - }); - it('should set results, more and labels', async () => { - const { labels, more, results, searchMore, search } = prep({ - categories: `test1,test2,${NoCategories}`, - }); - const expectedLabels = { - available: ['labels'], - languages: [], - }; - const expectedMore = { - cursor: 'adalskdjsadlkjsadlkjsalkd', - }; - const originalResults = [{ id: 'originalId', content_id: 'first' }]; - ContentNodeResource.fetchCollection.mockReturnValue( - Promise.resolve({ - labels: expectedLabels, - results: originalResults, - more: expectedMore, - }), - ); - search(); - await nextTick(); - const expectedResults = [{ id: 'node-id1', content_id: 'second' }]; - ContentNodeResource.fetchCollection.mockReturnValue( - Promise.resolve({ - labels: expectedLabels, - results: expectedResults, - more: expectedMore, - }), - ); - set(more, {}); - searchMore(); - await nextTick(); - expect(get(labels)).toEqual(expectedLabels); - expect(get(results)).toEqual(originalResults.concat(expectedResults)); - expect(get(more)).toEqual(expectedMore); - }); - }); - describe('removeFilterTag method', () => { - it('should remove a filter from the searchTerms', () => { - const { removeFilterTag, router } = prep({ - categories: 'test1,test2', - }); - removeFilterTag({ value: 'test1', key: 'categories' }); - expect(router.push).toHaveBeenCalledWith({ - name, - query: { - categories: 'test2', - }, - }); - }); - it('should remove keywords from the searchTerms', () => { - const { removeFilterTag, router } = prep({ - keywords: 'test', - }); - removeFilterTag({ value: 'test', key: 'keywords' }); - expect(router.push).toHaveBeenCalledWith({ - name, - query: {}, - }); - }); - it('should not remove any other filters', () => { - const { removeFilterTag, router } = prep({ - categories: 'test1,test2', - learning_activities: 'watch', - }); - removeFilterTag({ value: 'test1', key: 'categories' }); - expect(router.push).toHaveBeenCalledWith({ - name, - query: { - categories: 'test2', - learning_activities: 'watch', - }, - }); - }); - }); - describe('clearSearch method', () => { - it('should remove all filters from the searchTerms', () => { - const { clearSearch, router } = prep({ - categories: 'test1,test2', - learning_activities: 'watch', - keywords: 'this', - }); - clearSearch(); - expect(router.push).toHaveBeenCalledWith({ - name, - query: {}, - }); - }); - }); -}); diff --git a/kolibri/plugins/learn/assets/src/composables/useContentLink.js b/kolibri/plugins/learn/assets/src/composables/useContentLink.js index d5bce334591..00447fa62f6 100644 --- a/kolibri/plugins/learn/assets/src/composables/useContentLink.js +++ b/kolibri/plugins/learn/assets/src/composables/useContentLink.js @@ -2,6 +2,7 @@ import { get } from '@vueuse/core'; import isEmpty from 'lodash/isEmpty'; import pick from 'lodash/pick'; import { computed, getCurrentInstance } from 'vue'; +import { primaryLanguageKey } from 'kolibri-common/composables/useBaseSearch'; import { ExternalPagePaths, PageNames } from '../constants'; function _decodeBackLinkQuery(query) { @@ -17,6 +18,10 @@ export default function useContentLink(store) { function _makeNodeLink(id, isResource, query, deviceId) { const params = get(route).params; + const oldQuery = get(route).query || {}; + if (!isResource && oldQuery[primaryLanguageKey]) { + query[primaryLanguageKey] = oldQuery[primaryLanguageKey]; + } return { name: isResource ? PageNames.TOPICS_CONTENT : PageNames.TOPICS_TOPIC, params: pick({ id, deviceId: deviceId || params.deviceId }, ['id', 'deviceId']), diff --git a/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue b/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue index b0499ab0f87..4a9e997c1b8 100644 --- a/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue +++ b/kolibri/plugins/learn/assets/src/views/LibraryPage/index.vue @@ -49,9 +49,16 @@ v-else-if="!displayingSearchResults && !rootNodesLoading" data-test="channels" > -

- {{ channelsLabel }} -

+ + +

+ {{ channelsLabel }} +

+
+ + + +

@@ -122,6 +130,7 @@ v-model="searchTerms" data-test="side-panel" :width="`${sidePanelWidth}px`" + :showLanguages="displayingSearchResults" /> @@ -176,20 +185,22 @@ diff --git a/packages/kolibri-common/components/SearchFiltersPanel/SelectGroup.vue b/packages/kolibri-common/components/SearchFiltersPanel/SelectGroup.vue index cf26123bff5..0a94c833d44 100644 --- a/packages/kolibri-common/components/SearchFiltersPanel/SelectGroup.vue +++ b/packages/kolibri-common/components/SearchFiltersPanel/SelectGroup.vue @@ -1,16 +1,9 @@