Skip to content

Commit 5a78299

Browse files
rtibblesbotclaude
andcommitted
feat: backfill modality on existing public content nodes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent df2314f commit 5a78299

3 files changed

Lines changed: 88 additions & 0 deletions

File tree

‎Makefile‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ migrate:
4141
deploy-migrate:
4242
# studio#5974: remove at cutover.
4343
python contentcuration/manage.py backfill_column --model contentcuration.File --source-field file_size --target-field file_size_bigint
44+
# studio#6171: remove after release.
45+
python contentcuration/manage.py backfill_public_contentnode_modality
4446

4547
contentnodegc:
4648
python contentcuration/manage.py garbage_collect
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from django.core.management.base import BaseCommand
2+
from django.core.management.base import CommandError
3+
from django.db import transaction
4+
from kolibri_public.models import ContentNode
5+
from kolibri_public.search import annotate_modality
6+
7+
8+
class Command(BaseCommand):
9+
help = "Set kolibri_public ContentNode.modality from options.modality, in batches."
10+
11+
def add_arguments(self, parser):
12+
parser.add_argument("--batch-size", type=int, default=10000)
13+
14+
def handle(self, *args, **options):
15+
batch_size = options["batch_size"]
16+
if batch_size < 1:
17+
raise CommandError("--batch-size must be >= 1")
18+
19+
# Only unfilled rows, so re-runs resume past what is already done.
20+
unfilled = ContentNode.objects.filter(
21+
modality__isnull=True, options__contains='"modality":'
22+
)
23+
unfilled_pks = unfilled.order_by("pk").values_list("pk", flat=True)
24+
25+
# Keyset paging by pk: rows whose modality value is unrecognised stay
26+
# NULL, so the window must advance past them rather than re-query.
27+
batch_start = unfilled_pks.first()
28+
while batch_start is not None:
29+
batch_end = unfilled_pks.filter(pk__gte=batch_start)[
30+
batch_size - 1 : batch_size
31+
].first()
32+
window = unfilled.filter(pk__gte=batch_start)
33+
if batch_end is not None:
34+
window = window.filter(pk__lte=batch_end)
35+
with transaction.atomic():
36+
annotate_modality(window)
37+
self.stdout.write(
38+
"backfilled through pk={}".format(
39+
batch_start if batch_end is None else batch_end
40+
)
41+
)
42+
if batch_end is None:
43+
break
44+
batch_start = unfilled_pks.filter(pk__gt=batch_end).first()
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import uuid
2+
3+
from django.core.management import call_command
4+
from django.test import TestCase
5+
from kolibri_public import models
6+
from le_utils.constants import content_kinds
7+
from le_utils.constants import modalities
8+
9+
10+
class BackfillPublicContentNodeModalityTestCase(TestCase):
11+
def _create_node(self, options):
12+
return models.ContentNode.objects.create(
13+
pk=uuid.uuid4().hex,
14+
channel_id=uuid.uuid4().hex,
15+
content_id=uuid.uuid4().hex,
16+
kind=content_kinds.TOPIC,
17+
title="node",
18+
options=options,
19+
)
20+
21+
def test_sets_modality_from_options(self):
22+
quiz = self._create_node({"modality": modalities.QUIZ})
23+
course = self._create_node({"modality": modalities.COURSE})
24+
plain = self._create_node({})
25+
26+
call_command("backfill_public_contentnode_modality")
27+
28+
self.assertEqual(
29+
dict(models.ContentNode.objects.values_list("id", "modality")),
30+
{quiz.id: modalities.QUIZ, course.id: modalities.COURSE, plain.id: None},
31+
)
32+
33+
def test_backfills_across_batches_past_unrecognised_values(self):
34+
unknown = self._create_node({"modality": "NOT_A_MODALITY"})
35+
quizzes = [self._create_node({"modality": modalities.QUIZ}) for _ in range(4)]
36+
37+
call_command("backfill_public_contentnode_modality", batch_size=2)
38+
39+
self.assertEqual(
40+
dict(models.ContentNode.objects.values_list("id", "modality")),
41+
{unknown.id: None, **{q.id: modalities.QUIZ for q in quizzes}},
42+
)

0 commit comments

Comments
 (0)