Skip to content

Commit c6230bb

Browse files
rtibblesbotclaude
andcommitted
Convert importability_annotation.py and the content/upgrade.py rollups
The temporary annotation in get_channel_annotation_stats rolls back through transaction.set_rollback rather than a bridge transaction, and reads land as ORM values_list, so coerce_key is no longer needed to turn PostgreSQL uuids back into hex. The two historical rollup hooks differ only in the column they roll up, so they share one helper. sqlalchemy.exc.DatabaseError stays: import_external_content_dbs catches it alongside sqlite3.DatabaseError around import_channel_from_local_db, which still goes through channel_import.py. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5f2431d commit c6230bb

4 files changed

Lines changed: 277 additions & 449 deletions

File tree

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,22 @@
11
from django.core.management import call_command
22
from django.test import TransactionTestCase
3-
from mock import patch
3+
from le_utils.constants import content_kinds
44

5+
from kolibri.core.content.models import ContentNode
56
from kolibri.core.content.models import File
67
from kolibri.core.content.models import LocalFile
8+
from kolibri.core.content.utils.channels import CHANNEL_UPDATE_STATS_CACHE_KEY
9+
from kolibri.core.content.utils.content_types_tools import renderable_files_presets
710
from kolibri.core.content.utils.importability_annotation import (
811
get_channel_annotation_stats,
912
)
10-
11-
from .sqlalchemytesting import django_connection_engine
12-
13-
14-
def get_engine(connection_string):
15-
return django_connection_engine()
16-
13+
from kolibri.core.utils.cache import process_cache
1714

1815
test_channel_id = "6199dde695db4ee4ab392222d5af1e5c"
1916
file_id_1 = "6bdfea4a01830fdd4a585181c0b8068c"
2017
file_id_2 = "e00699f859624e0f875ac6fe1e13d648"
2118

2219

23-
@patch("kolibri.core.content.utils.sqlalchemybridge.get_engine", new=get_engine)
2420
class ImportabilityStats(TransactionTestCase):
2521
fixtures = ["content_test.json"]
2622

@@ -37,6 +33,69 @@ def test_all_files_available_no_files_remote(self):
3733
stats = get_channel_annotation_stats(test_channel_id, checksums)
3834
self.assertEqual(len(stats), 4)
3935

36+
def test_root_stats_count_every_importable_resource(self):
37+
File.objects.update(supplementary=False)
38+
checksums = list(LocalFile.objects.all().values_list("id", flat=True))
39+
root = ContentNode.objects.get(channel_id=test_channel_id, level=0)
40+
# Counted off the tree, not by a second rollup query, so that a rollup that
41+
# summed over all children or lost its correlation cannot agree with it.
42+
expected = (
43+
root.get_descendants()
44+
.exclude(kind=content_kinds.TOPIC)
45+
.filter(
46+
files__supplementary=False, files__preset__in=renderable_files_presets
47+
)
48+
.distinct()
49+
.count()
50+
)
51+
stats = get_channel_annotation_stats(test_channel_id, checksums)
52+
self.assertEqual(expected, stats[root.id]["total_resources"])
53+
54+
def test_the_projection_is_rolled_back(self):
55+
File.objects.update(supplementary=False)
56+
checksums = list(LocalFile.objects.all().values_list("id", flat=True))
57+
before = self._annotation_columns()
58+
get_channel_annotation_stats(test_channel_id, checksums)
59+
self.assertEqual(before, self._annotation_columns())
60+
61+
def test_new_resources_are_flagged_on_the_root(self):
62+
File.objects.update(supplementary=False)
63+
checksums = list(LocalFile.objects.all().values_list("id", flat=True))
64+
root = ContentNode.objects.get(channel_id=test_channel_id, level=0)
65+
# A leaf the projection will call available, so that it reaches the stats dict.
66+
new_ids = list(
67+
root.get_descendants()
68+
.exclude(kind=content_kinds.TOPIC)
69+
.filter(
70+
files__supplementary=False, files__preset__in=renderable_files_presets
71+
)
72+
.distinct()
73+
.values_list("id", flat=True)[:1]
74+
)
75+
key = CHANNEL_UPDATE_STATS_CACHE_KEY.format(test_channel_id)
76+
process_cache.set(
77+
key, {"new_resource_ids": new_ids, "updated_resource_ids": new_ids}, None
78+
)
79+
self.addCleanup(process_cache.delete, key)
80+
before = self._annotation_columns()
81+
stats = get_channel_annotation_stats(test_channel_id, checksums)
82+
self.assertTrue(stats[root.id]["new_resource"])
83+
self.assertEqual(1, stats[root.id]["num_new_resources"])
84+
self.assertTrue(stats[new_ids[0]]["updated_resource"])
85+
# This block sets every node in the channel unavailable before measuring.
86+
self.assertEqual(before, self._annotation_columns())
87+
88+
def _annotation_columns(self):
89+
return list(
90+
ContentNode.objects.order_by("id").values_list(
91+
"id",
92+
"available",
93+
"coach_content",
94+
"num_coach_contents",
95+
"on_device_resources",
96+
)
97+
)
98+
4099
def tearDown(self):
41100
call_command("flush", interactive=False)
42101
super().tearDown()

kolibri/core/content/test/test_upgrade.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,12 @@
1818
from kolibri.core.content.upgrade import fix_multiple_trees_with_tree_id1
1919
from kolibri.core.content.upgrade import migrate_file_size_to_bigint
2020
from kolibri.core.content.upgrade import update_num_coach_contents
21+
from kolibri.core.content.upgrade import update_on_device_resources
2122
from kolibri.core.content.utils.content_types_tools import renderable_preset_bits
2223
from kolibri.core.content.utils.file_size_migration import drop_legacy_file_size_column
2324
from kolibri.core.content.utils.file_size_migration import localfile_columns
2425
from kolibri.core.content.utils.upgrade import diff_stats
2526

26-
from .sqlalchemytesting import django_connection_engine
27-
28-
29-
def get_engine(connection_string):
30-
return django_connection_engine()
31-
32-
3327
test_channel_id = "6199dde695db4ee4ab392222d5af1e5c"
3428

3529

@@ -186,7 +180,6 @@ def test_two_extra_channels_one_contentdb_exists(self, path_mock, import_mock):
186180
import_mock.assert_called_with(root_node_2.channel_id)
187181

188182

189-
@patch("kolibri.core.content.utils.sqlalchemybridge.get_engine", new=get_engine)
190183
class UpdateNumCoachContents(TransactionTestCase):
191184
fixtures = ["content_test.json"]
192185

@@ -304,6 +297,37 @@ def tearDown(self):
304297
super().tearDown()
305298

306299

300+
class UpdateOnDeviceResources(TransactionTestCase):
301+
fixtures = ["content_test.json"]
302+
303+
def setUp(self):
304+
super().setUp()
305+
ContentNode.objects.all().update(available=True, on_device_resources=0)
306+
self.root_node = ContentNode.objects.get(parent__isnull=True)
307+
self.leaves = (
308+
self.root_node.get_descendants().exclude(kind=content_kinds.TOPIC).count()
309+
)
310+
311+
def test_root_counts_its_available_leaf_descendants(self):
312+
update_on_device_resources()
313+
self.root_node.refresh_from_db()
314+
self.assertEqual(self.leaves, self.root_node.on_device_resources)
315+
316+
def test_an_unavailable_leaf_is_not_counted(self):
317+
leaf = (
318+
self.root_node.get_descendants().exclude(kind=content_kinds.TOPIC).first()
319+
)
320+
leaf.available = False
321+
leaf.save()
322+
update_on_device_resources()
323+
self.root_node.refresh_from_db()
324+
self.assertEqual(self.leaves - 1, self.root_node.on_device_resources)
325+
326+
def tearDown(self):
327+
call_command("flush", interactive=False)
328+
super().tearDown()
329+
330+
307331
class FileIncludedPresetsAnnotationTestCase(TestCase):
308332
"""
309333
Verifies the upgrade task backfills File.included_presets for rows that

kolibri/core/content/upgrade.py

Lines changed: 34 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@
77
import sqlite3
88

99
from django.db import connection
10+
from django.db import transaction
11+
from django.db.models import IntegerField
12+
from django.db.models import Sum
13+
from django.db.models.functions import Cast
1014
from le_utils.constants import content_kinds
1115
from le_utils.constants import library as library_constants
12-
from sqlalchemy import and_
13-
from sqlalchemy import cast
14-
from sqlalchemy import exists
15-
from sqlalchemy import func
16-
from sqlalchemy import Integer
17-
from sqlalchemy import select
16+
17+
# import_channel_from_local_db still goes through channel_import.py, which raises
18+
# SQLAlchemy errors.
1819
from sqlalchemy.exc import DatabaseError
1920

2021
from kolibri.core.auth.models import FacilityDataset
21-
from kolibri.core.content.apps import KolibriContentConfig
2222
from kolibri.core.content.constants.kind_to_learningactivity import kind_activity_map
2323
from kolibri.core.content.kolibri_plugin import synchronize_content_requests
2424
from kolibri.core.content.models import ChannelMetadata
@@ -27,9 +27,11 @@
2727
from kolibri.core.content.models import LocalFile
2828
from kolibri.core.content.tasks import backfill_content_request_priority
2929
from kolibri.core.content.tasks import enqueue_automatic_resource_import_if_needed
30+
from kolibri.core.content.utils.annotation import available_children_rollup
3031
from kolibri.core.content.utils.annotation import calculate_included_languages
3132
from kolibri.core.content.utils.annotation import calculate_ordered_categories
3233
from kolibri.core.content.utils.annotation import calculate_ordered_grade_levels
34+
from kolibri.core.content.utils.annotation import has_available_children
3335
from kolibri.core.content.utils.annotation import set_channel_ancestors
3436
from kolibri.core.content.utils.annotation import set_content_visibility_from_disk
3537
from kolibri.core.content.utils.channel_import import FutureSchemaError
@@ -44,7 +46,6 @@
4446
from kolibri.core.content.utils.search import annotate_label_bitmasks
4547
from kolibri.core.content.utils.search import annotate_modality
4648
from kolibri.core.content.utils.search import get_all_contentnode_label_metadata
47-
from kolibri.core.content.utils.sqlalchemybridge import Bridge
4849
from kolibri.core.content.utils.tree import get_channel_node_depth
4950
from kolibri.core.device.models import ContentCacheKey
5051
from kolibri.core.upgrade import version_upgrade
@@ -145,6 +146,27 @@ def fix_multiple_trees_with_tree_id1():
145146
)
146147

147148

149+
def _rollup_over_available_children(field, leaf_value):
150+
"""
151+
Set field on every leaf from leaf_value, then sum it up the tree.
152+
"""
153+
with transaction.atomic():
154+
ContentNode.objects.exclude(kind=content_kinds.TOPIC).update(
155+
**{field: leaf_value}
156+
)
157+
158+
for channel_id in ChannelMetadata.objects.all().values_list("id", flat=True):
159+
# Go from the deepest level to the shallowest
160+
for level in range(get_channel_node_depth(channel_id), 0, -1):
161+
ContentNode.objects.filter(
162+
level=level - 1, channel_id=channel_id, kind=content_kinds.TOPIC
163+
).filter(
164+
# A sum over no available children is NULL, so leave those
165+
# topics at the value they already carry.
166+
has_available_children()
167+
).update(**{field: available_children_rollup(Sum(field))})
168+
169+
148170
# This was introduced in 0.12.4, so only annotate
149171
# when upgrading from versions prior to this.
150172
@version_upgrade(old_version="<0.12.4")
@@ -153,73 +175,12 @@ def update_num_coach_contents():
153175
Function to set num_coach_content on all topic trees to account for
154176
those that were imported before annotations were performed
155177
"""
156-
bridge = Bridge(app_name=KolibriContentConfig.label)
157-
158-
ContentNodeTable = bridge.get_table(ContentNode)
159-
160-
connection = bridge.get_connection()
161-
162-
child = ContentNodeTable.alias()
163-
164178
logger.info("Updating num_coach_content on existing channels")
165179

166-
# start a transaction
167-
168-
trans = connection.begin()
169-
170-
# Update all leaf ContentNodes to have num_coach_content to 1 or 0
171-
connection.execute(
172-
ContentNodeTable.update()
173-
.where(
174-
# That are not topics
175-
ContentNodeTable.c.kind != content_kinds.TOPIC
176-
)
177-
.values(num_coach_contents=cast(ContentNodeTable.c.coach_content, Integer()))
178-
)
179-
180-
# Expression to capture all available child nodes of a contentnode
181-
available_nodes = select(child.c.available).where(
182-
and_(
183-
child.c.available == True, # noqa
184-
ContentNodeTable.c.id == child.c.parent_id,
185-
)
180+
_rollup_over_available_children(
181+
"num_coach_contents", Cast("coach_content", IntegerField())
186182
)
187183

188-
# Expression that sums the total number of coach contents for each child node
189-
# of a contentnode
190-
coach_content_num = select(func.sum(child.c.num_coach_contents)).where(
191-
and_(
192-
child.c.available == True, # noqa
193-
ContentNodeTable.c.id == child.c.parent_id,
194-
)
195-
)
196-
197-
for channel_id in ChannelMetadata.objects.all().values_list("id", flat=True):
198-
node_depth = get_channel_node_depth(channel_id)
199-
200-
# Go from the deepest level to the shallowest
201-
for level in range(node_depth, 0, -1):
202-
# Only modify topic availability here
203-
connection.execute(
204-
ContentNodeTable.update()
205-
.where(
206-
and_(
207-
ContentNodeTable.c.level == level - 1,
208-
ContentNodeTable.c.channel_id == channel_id,
209-
ContentNodeTable.c.kind == content_kinds.TOPIC,
210-
)
211-
)
212-
# Because we have set availability to False on all topics as a starting point
213-
# we only need to make updates to topics with available children.
214-
.where(exists(available_nodes))
215-
.values(num_coach_contents=coach_content_num.scalar_subquery())
216-
)
217-
218-
# commit the transaction
219-
trans.commit()
220-
221-
bridge.end()
222-
223184

224185
# This was introduced in 0.13.0, so only annotate
225186
# when upgrading from versions prior to this.
@@ -229,73 +190,12 @@ def update_on_device_resources():
229190
Function to set on_device_resource on all topic trees to account for
230191
those that were imported before annotations were performed
231192
"""
232-
bridge = Bridge(app_name=KolibriContentConfig.label)
233-
234-
ContentNodeTable = bridge.get_table(ContentNode)
235-
236-
connection = bridge.get_connection()
237-
238-
child = ContentNodeTable.alias()
239-
240193
logger.info("Updating on_device_resource on existing channels")
241194

242-
# start a transaction
243-
244-
trans = connection.begin()
245-
246-
# Update all leaf ContentNodes to have on_device_resource to 1 or 0
247-
connection.execute(
248-
ContentNodeTable.update()
249-
.where(
250-
# That are not topics
251-
ContentNodeTable.c.kind != content_kinds.TOPIC
252-
)
253-
.values(on_device_resources=cast(ContentNodeTable.c.available, Integer()))
254-
)
255-
256-
# Expression to capture all available child nodes of a contentnode
257-
available_nodes = select(child.c.available).where(
258-
and_(
259-
child.c.available == True, # noqa
260-
ContentNodeTable.c.id == child.c.parent_id,
261-
)
262-
)
263-
264-
# Expression that sums the total number of coach contents for each child node
265-
# of a contentnode
266-
on_device_num = select(func.sum(child.c.on_device_resources)).where(
267-
and_(
268-
child.c.available == True, # noqa
269-
ContentNodeTable.c.id == child.c.parent_id,
270-
)
195+
_rollup_over_available_children(
196+
"on_device_resources", Cast("available", IntegerField())
271197
)
272198

273-
for channel_id in ChannelMetadata.objects.all().values_list("id", flat=True):
274-
node_depth = get_channel_node_depth(channel_id)
275-
276-
# Go from the deepest level to the shallowest
277-
for level in range(node_depth, 0, -1):
278-
# Only modify topic availability here
279-
connection.execute(
280-
ContentNodeTable.update()
281-
.where(
282-
and_(
283-
ContentNodeTable.c.level == level - 1,
284-
ContentNodeTable.c.channel_id == channel_id,
285-
ContentNodeTable.c.kind == content_kinds.TOPIC,
286-
)
287-
)
288-
# Because we have set availability to False on all topics as a starting point
289-
# we only need to make updates to topics with available children.
290-
.where(exists(available_nodes))
291-
.values(on_device_resources=on_device_num)
292-
)
293-
294-
# commit the transaction
295-
trans.commit()
296-
297-
bridge.end()
298-
299199

300200
# This was introduced in 0.15.0, so only annotate
301201
# when upgrading from versions prior to this.

0 commit comments

Comments
 (0)