Skip to content
Open
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
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ migrate:
deploy-migrate:
# studio#5974: remove at cutover.
python contentcuration/manage.py backfill_column --model contentcuration.File --source-field file_size --target-field file_size_bigint
# studio#6171: remove after release.
python contentcuration/manage.py backfill_public_contentnode_modality

contentnodegc:
python contentcuration/manage.py garbage_collect
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.core.management.base import BaseCommand
from kolibri_public.models import ContentNode
from kolibri_public.search import annotate_modality


class Command(BaseCommand):
help = "Set kolibri_public ContentNode.modality from options.modality."

def handle(self, *args, **options):
annotate_modality(ContentNode.objects.all())
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Generated by Django 3.2.24 on 2026-09-24 21:28
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations
from django.db import models


class Migration(migrations.Migration):

atomic = False

dependencies = [
("kolibri_public", "0010_localfile_file_size_bigint"),
]

operations = [
migrations.AddField(
model_name="contentnode",
name="modality",
field=models.CharField(
blank=True,
choices=[
("COURSE", "Course"),
("CUSTOM_NAVIGATION", "Custom Navigation"),
("LESSON", "Lesson"),
("QUIZ", "Quiz"),
("SURVEY", "Survey"),
("UNIT", "Unit"),
],
max_length=50,
null=True,
),
),
AddIndexConcurrently(
model_name="contentnode",
index=models.Index(
fields=["modality"], name="kolibri_pub_modalit_1ae095_idx"
),
),
]
7 changes: 7 additions & 0 deletions contentcuration/kolibri_public/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from kolibri_public.search import contentnode_bitmask_fieldnames
from kolibri_public.search import contentnode_metadata_bitmasks
from kolibri_public.search import has_all_labels
from le_utils.constants import modalities
from mptt.managers import TreeManager
from mptt.querysets import TreeQuerySet

Expand Down Expand Up @@ -53,9 +54,15 @@ class ContentNode(base_models.ContentNode):
ancestors = JSONField(
default=[], null=True, blank=True, load_kwargs={"strict": False}
)
modality = models.CharField(
max_length=50, blank=True, null=True, choices=modalities.choices
)

objects = ContentNodeManager()

class Meta:
indexes = [models.Index(fields=["modality"])]


for field_name in contentnode_bitmask_fieldnames:
field = models.BigIntegerField(default=0, null=True, blank=True)
Expand Down
16 changes: 16 additions & 0 deletions contentcuration/kolibri_public/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from django.db.models import Max
from django.db.models import Value
from django.db.models import When
from le_utils.constants import modalities
from le_utils.constants.labels.accessibility_categories import (
ACCESSIBILITYCATEGORIESLIST,
)
Expand Down Expand Up @@ -219,6 +220,21 @@ def annotate_contentnode_label_bitmasks(queryset):
return annotate_label_bitmasks(queryset, contentnode_bitmask_fieldnames)


def annotate_modality(queryset):
"""Update queryset to annotate `modality` field based on `options.modality`"""
queryset = queryset.filter(options__contains='"modality":')

when_statements = [
When(
options__contains=f'"modality": "{modality_value}"',
then=Value(modality_value),
)
for modality_value, _ in modalities.choices
]

queryset.update(modality=Case(*when_statements))


def annotate_channelmetadata_label_bitmasks(queryset):
return annotate_label_bitmasks(queryset, channelmetadata_bitmask_fieldnames)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import uuid

from django.core.management import call_command
from django.test import TestCase
from kolibri_public import models
from le_utils.constants import content_kinds
from le_utils.constants import modalities


class BackfillPublicContentNodeModalityTestCase(TestCase):
def _create_node(self, options):
return models.ContentNode.objects.create(
pk=uuid.uuid4().hex,
channel_id=uuid.uuid4().hex,
content_id=uuid.uuid4().hex,
kind=content_kinds.TOPIC,
title="node",
options=options,
)

def test_sets_modality_from_options(self):
quiz = self._create_node({"modality": modalities.QUIZ})
course = self._create_node({"modality": modalities.COURSE})
plain = self._create_node({})

call_command("backfill_public_contentnode_modality")

self.assertEqual(
dict(models.ContentNode.objects.values_list("id", "modality")),
{quiz.id: modalities.QUIZ, course.id: modalities.COURSE, plain.id: None},
)
148 changes: 140 additions & 8 deletions contentcuration/kolibri_public/tests/test_content_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from kolibri_public.tests.base import ChannelBuilder
from kolibri_public.tests.base import OKAY_TAG
from le_utils.constants import content_kinds
from le_utils.constants import modalities
from rest_framework.test import APITestCase

from contentcuration.models import generate_storage_url
Expand Down Expand Up @@ -165,6 +166,7 @@ def _assert_node(self, actual, expected):
"lft": expected.lft,
"rght": expected.rght,
"tree_id": expected.tree_id,
"modality": expected.modality,
"ancestors": [],
"tags": list(
expected.tags.all()
Expand Down Expand Up @@ -332,6 +334,137 @@ class ContentNodeAPITestCase(ContentNodeAPIBase, APITestCase):
Testcase for content API methods
"""

def _list_ids(self, **params):
response = self._get(reverse("publiccontentnode-list"), data=params)
self.assertEqual(response.status_code, 200)
return {node["id"] for node in response.data}

def _available_ids(self):
return set(
models.ContentNode.objects.filter(available=True).values_list(
"id", flat=True
)
)

def _insert_second_tree(self):
# Its lft/rght ranges overlap the fixture tree's; only tree_id tells them apart.
ChannelBuilder(levels=2, num_children=2).insert_into_default_db()
models.ContentNode.objects.all().update(available=True)

def _set_modality(self, node, modality):
models.ContentNode.objects.filter(id=node.id).update(modality=modality)

def _mark_lesson_and_course(self):
lesson, course = (
models.ContentNode.objects.filter(kind=content_kinds.TOPIC)
.exclude(parent=None)
.order_by("lft")[:2]
)
self._set_modality(lesson, modalities.LESSON)
self._set_modality(course, modalities.COURSE)
return lesson, course

def test_contentnode_modality_filter(self):
lesson, _ = self._mark_lesson_and_course()

response = self.client.get(
reverse("publiccontentnode-list"), data={"modality": modalities.LESSON}
)

self.assertEqual([node["id"] for node in response.data], [lesson.id])
self.assertEqual(response.data[0]["modality"], modalities.LESSON)

def test_contentnode_exclude_modalities_filter(self):
lesson, course = self._mark_lesson_and_course()

self.assertEqual(
self._list_ids(exclude_modalities=modalities.COURSE),
self._available_ids() - {course.id},
)
self.assertEqual(
self._list_ids(
exclude_modalities="{},{}".format(modalities.COURSE, modalities.LESSON)
),
self._available_ids() - {course.id, lesson.id},
)

def test_contentnode_exclude_course_ancestry_filter(self):
course = self.root.get_children().first()
self._set_modality(course, modalities.COURSE)
self._insert_second_tree()
descendant_ids = set(course.get_descendants().values_list("id", flat=True))

self.assertEqual(
self._list_ids(exclude_course_ancestry=True),
self._available_ids() - descendant_ids,
)
self.assertEqual(
self._list_ids(exclude_course_ancestry=False), self._available_ids()
)

def test_contentnode_contains_quiz_filter(self):
quiz = models.ContentNode.objects.exclude(kind=content_kinds.TOPIC).first()
self._set_modality(quiz, modalities.QUIZ)
self._insert_second_tree()

self.assertEqual(
self._list_ids(contains_quiz=True),
set(quiz.get_ancestors(include_self=True).values_list("id", flat=True)),
)
self.assertEqual(self._list_ids(contains_quiz="false"), self._available_ids())

def _set_search_fixtures(self):
photo, resp = models.ContentNode.objects.exclude(
kind=content_kinds.TOPIC
).order_by("lft")[:2]
models.ContentNode.objects.filter(id=photo.id).update(
title="Photosynthesis basics", description="Chlorophyll absorbs light"
)
models.ContentNode.objects.filter(id=resp.id).update(
title="Energy", description="Cellular respiration"
)
return photo.id, resp.id

def test_contentnode_search_terms_match_across_fields(self):
photo, _ = self._set_search_fixtures()
self.assertEqual(self._list_ids(search="photosynthesis chlorophyll"), {photo})

def test_contentnode_search_every_term_must_match(self):
self._set_search_fixtures()
self.assertEqual(self._list_ids(search="photosynthesis respiration"), set())

def test_contentnode_search_quoted_phrase(self):
photo, _ = self._set_search_fixtures()
self.assertEqual(self._list_ids(search='"photosynthesis basics"'), {photo})
self.assertEqual(self._list_ids(search='"basics photosynthesis"'), set())

def test_contentnode_search_drops_stopwords(self):
_, resp = self._set_search_fixtures()
self.assertEqual(self._list_ids(search="which respiration"), {resp})

def test_contentnode_search_keywords_param(self):
_, resp = self._set_search_fixtures()
self.assertEqual(self._list_ids(keywords="respiration"), {resp})

def test_contentnode_search_precedence(self):
photo, _ = self._set_search_fixtures()
self.assertEqual(
self._list_ids(
search="photosynthesis",
question="respiration",
keywords="respiration",
),
{photo},
)
self.assertEqual(
self._list_ids(question="photosynthesis", keywords="respiration"),
{photo},
)

def test_contentnode_search_punctuation_only_is_unfiltered(self):
self.assertEqual(self._list_ids(search="!?,"), self._available_ids())
self.assertEqual(self._list_ids(keywords="!?,"), self._available_ids())

def test_prerequisite_for_filter(self):
response = self.client.get(
reverse("publiccontentnode-list"),
Expand Down Expand Up @@ -466,7 +599,7 @@ def test_channelmetadata_content_available_field_false(self):
response = self.client.get(reverse("publicchannel-list"))
self.assertEqual(response.data[0]["available"], False)

def test_channelmetadata_has_exercises_filter(self):
def test_channelmetadata_exercise_filters(self):
# Has nothing else for that matter...
no_exercise_channel = models.ContentNode.objects.create(
pk="6a406ac66b224106aa2e93f73a94333d",
Expand All @@ -492,13 +625,12 @@ def test_channelmetadata_has_exercises_filter(self):
)
no_filter_response = self.client.get(reverse("publicchannel-list"))
self.assertEqual(len(no_filter_response.data), 2)
with_filter_response = self.client.get(
reverse("publicchannel-list"), {"has_exercise": True}
)
self.assertEqual(len(with_filter_response.data), 1)
self.assertEqual(
with_filter_response.data[0]["name"], self.channel_data["name"]
)
for param in ("has_exercise", "contains_exercise"):
with self.subTest(param=param):
response = self.client.get(reverse("publicchannel-list"), {param: True})
self.assertEqual(
[c["name"] for c in response.data], [self.channel_data["name"]]
)

def test_channelmetadata_public_filter_default_true(self):
community_channel = models.ContentNode.objects.create(
Expand Down
21 changes: 21 additions & 0 deletions contentcuration/kolibri_public/tests/test_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from kolibri_public.tests.base import OKAY_TAG
from kolibri_public.utils.mapper import ChannelMapper
from le_utils.constants import content_kinds
from le_utils.constants import modalities
from le_utils.constants.labels.subjects import SUBJECTSLIST

from contentcuration.models import Channel
Expand Down Expand Up @@ -290,6 +291,26 @@ def test_categories_bitmask_annotation(self):
)
self.assertEqual(mapper.mapped_channel.categories_bitmask_0, 1 | 4 | 16)

def test_modality_annotation(self):
with using_content_database(self.tempdb):
source = (
kolibri_content_models.ContentNode.objects.filter(
channel_id=self.channel.id, kind=content_kinds.TOPIC
)
.exclude(parent=None)
.first()
)
source.options = {"modality": modalities.COURSE}
source.save()

ChannelMapper(self.channel).run()

mapped = kolibri_public_models.ContentNode.objects.all()
self.assertEqual(mapped.get(id=source.id).modality, modalities.COURSE)
self.assertFalse(
mapped.exclude(id=source.id).filter(modality__isnull=False).exists()
)

def tearDown(self):
# Clean up datbase connection after the test
self._date_patcher.stop()
Expand Down
7 changes: 4 additions & 3 deletions contentcuration/kolibri_public/utils/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from kolibri_content.base_models import MAX_TAG_LENGTH
from kolibri_public import models as kolibri_public_models
from kolibri_public.search import annotate_contentnode_label_bitmasks
from kolibri_public.search import annotate_modality
from kolibri_public.utils.annotation import set_channel_metadata_fields
from le_utils.constants import content_kinds

Expand Down Expand Up @@ -69,9 +70,9 @@ def run(self):
self.mapped_channel.public = self.public
self.mapped_channel.save_base(raw=True)

annotate_contentnode_label_bitmasks(
self.mapped_root.get_descendants(include_self=True)
)
mapped_nodes = self.mapped_root.get_descendants(include_self=True)
annotate_contentnode_label_bitmasks(mapped_nodes)
annotate_modality(mapped_nodes)
# Rather than set the ancestors fields after mapping, like it is done in Kolibri
# here we set it during mapping as we are already recursing through the tree.

Expand Down
Loading
Loading