Skip to content

Commit 30b24d0

Browse files
committed
Ensure versioned database exists on submission create
1 parent bbbf092 commit 30b24d0

6 files changed

Lines changed: 170 additions & 76 deletions

File tree

contentcuration/contentcuration/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2581,6 +2581,9 @@ class CommunityLibrarySubmission(models.Model):
25812581
internal_notes = models.TextField(blank=True, null=True)
25822582

25832583
def save(self, *args, **kwargs):
2584+
# Not a top-level import to avoid circular import issues
2585+
from contentcuration.utils.publish import ensure_versioned_database_exists
2586+
25842587
# Validate on save that the submission author is an editor of the channel
25852588
# and that the version is not greater than the current channel version.
25862589
# These cannot be expressed as constraints because traversing
@@ -2603,6 +2606,12 @@ def save(self, *args, **kwargs):
26032606
code="impossibly_high_channel_version",
26042607
)
26052608

2609+
if self.pk is None:
2610+
# When creating a new submission, ensure the channel has a versioned database
2611+
# (it might not have if the channel was published before versioned databases
2612+
# were introduced).
2613+
ensure_versioned_database_exists(self.channel)
2614+
26062615
super().save(*args, **kwargs)
26072616

26082617
@classmethod

contentcuration/contentcuration/tests/test_models.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -549,12 +549,15 @@ def test_make_content_id_unique(self):
549549
self.assertNotEqual(copied_node_old_content_id, copied_node.content_id)
550550

551551

552+
@mock.patch(
553+
"contentcuration.utils.publish.ensure_versioned_database_exists", return_value=None
554+
)
552555
class CommunityLibrarySubmissionTestCase(PermissionQuerysetTestCase):
553556
@property
554557
def base_queryset(self):
555558
return CommunityLibrarySubmission.objects.all()
556559

557-
def test_create_submission(self):
560+
def test_create_submission(self, mock_ensure_db_exists):
558561
# Smoke test
559562
channel = testdata.channel()
560563
author = testdata.user()
@@ -577,51 +580,69 @@ def test_create_submission(self):
577580
submission.full_clean()
578581
submission.save()
579582

580-
def test_save__author_not_editor(self):
583+
def test_save__author_not_editor(self, mock_ensure_db_exists):
581584
submission = testdata.community_library_submission()
582585
user = testdata.user("some@email.com")
583586
submission.author = user
584587
with self.assertRaises(ValidationError):
585588
submission.save()
586589

587-
def test_save__nonpositive_channel_version(self):
590+
def test_save__nonpositive_channel_version(self, mock_ensure_db_exists):
588591
submission = testdata.community_library_submission()
589592
submission.channel_version = 0
590593
with self.assertRaises(ValidationError):
591594
submission.save()
592595

593-
def test_save__matching_channel_version(self):
596+
def test_save__matching_channel_version(self, mock_ensure_db_exists):
594597
submission = testdata.community_library_submission()
595598
submission.channel.version = 5
596599
submission.channel.save()
597600
submission.channel_version = 5
598601
submission.save()
599602

600-
def test_save__impossibly_high_channel_version(self):
603+
def test_save__impossibly_high_channel_version(self, mock_ensure_db_exists):
601604
submission = testdata.community_library_submission()
602605
submission.channel.version = 5
603606
submission.channel.save()
604607
submission.channel_version = 6
605608
with self.assertRaises(ValidationError):
606609
submission.save()
607610

608-
def test_filter_view_queryset__anonymous(self):
611+
def test_save__ensure_versioned_database_exists_on_create(
612+
self, mock_ensure_db_exists
613+
):
614+
submission = testdata.community_library_submission()
615+
616+
mock_ensure_db_exists.assert_called_once_with(submission.channel)
617+
618+
def test_save__dont_ensure_versioned_database_exists_on_update(
619+
self, mock_ensure_db_exists
620+
):
621+
submission = testdata.community_library_submission()
622+
mock_ensure_db_exists.reset_mock()
623+
624+
submission.description = "Updated description"
625+
submission.save()
626+
627+
mock_ensure_db_exists.assert_not_called()
628+
629+
def test_filter_view_queryset__anonymous(self, mock_ensure_db_exists):
609630
_ = testdata.community_library_submission()
610631

611632
queryset = CommunityLibrarySubmission.filter_view_queryset(
612633
self.base_queryset, user=self.anonymous_user
613634
)
614635
self.assertFalse(queryset.exists())
615636

616-
def test_filter_view_queryset__forbidden_user(self):
637+
def test_filter_view_queryset__forbidden_user(self, mock_ensure_db_exists):
617638
_ = testdata.community_library_submission()
618639

619640
queryset = CommunityLibrarySubmission.filter_view_queryset(
620641
self.base_queryset, user=self.forbidden_user
621642
)
622643
self.assertFalse(queryset.exists())
623644

624-
def test_filter_view_queryset__channel_editor(self):
645+
def test_filter_view_queryset__channel_editor(self, mock_ensure_db_exists):
625646
submission_a = testdata.community_library_submission()
626647
submission_b = testdata.community_library_submission()
627648

@@ -635,31 +656,31 @@ def test_filter_view_queryset__channel_editor(self):
635656
self.assertQuerysetContains(queryset, pk=submission_a.id)
636657
self.assertQuerysetDoesNotContain(queryset, pk=submission_b.id)
637658

638-
def test_filter_view_queryset__admin(self):
659+
def test_filter_view_queryset__admin(self, mock_ensure_db_exists):
639660
submission_a = testdata.community_library_submission()
640661

641662
queryset = CommunityLibrarySubmission.filter_view_queryset(
642663
self.base_queryset, user=self.admin_user
643664
)
644665
self.assertQuerysetContains(queryset, pk=submission_a.id)
645666

646-
def test_filter_edit_queryset__anonymous(self):
667+
def test_filter_edit_queryset__anonymous(self, mock_ensure_db_exists):
647668
_ = testdata.community_library_submission()
648669

649670
queryset = CommunityLibrarySubmission.filter_edit_queryset(
650671
self.base_queryset, user=self.anonymous_user
651672
)
652673
self.assertFalse(queryset.exists())
653674

654-
def test_filter_edit_queryset__forbidden_user(self):
675+
def test_filter_edit_queryset__forbidden_user(self, mock_ensure_db_exists):
655676
_ = testdata.community_library_submission()
656677

657678
queryset = CommunityLibrarySubmission.filter_edit_queryset(
658679
self.base_queryset, user=self.forbidden_user
659680
)
660681
self.assertFalse(queryset.exists())
661682

662-
def test_filter_edit_queryset__channel_editor(self):
683+
def test_filter_edit_queryset__channel_editor(self, mock_ensure_db_exists):
663684
submission = testdata.community_library_submission()
664685

665686
user = testdata.user()
@@ -671,7 +692,7 @@ def test_filter_edit_queryset__channel_editor(self):
671692
)
672693
self.assertFalse(queryset.exists())
673694

674-
def test_filter_edit_queryset__author(self):
695+
def test_filter_edit_queryset__author(self, mock_ensure_db_exists):
675696
submission_a = testdata.community_library_submission()
676697
submission_b = testdata.community_library_submission()
677698

@@ -681,7 +702,7 @@ def test_filter_edit_queryset__author(self):
681702
self.assertQuerysetContains(queryset, pk=submission_a.id)
682703
self.assertQuerysetDoesNotContain(queryset, pk=submission_b.id)
683704

684-
def test_filter_edit_queryset__admin(self):
705+
def test_filter_edit_queryset__admin(self, mock_ensure_db_exists):
685706
submission_a = testdata.community_library_submission()
686707

687708
queryset = CommunityLibrarySubmission.filter_edit_queryset(
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import os
2+
import tempfile
3+
from unittest import mock
4+
5+
from django.conf import settings
6+
from django.core.files.storage import FileSystemStorage
7+
8+
from contentcuration.tests import testdata
9+
from contentcuration.tests.base import StudioTestCase
10+
from contentcuration.utils.publish import ensure_versioned_database_exists
11+
12+
13+
class EnsureVersionedDatabaseTestCase(StudioTestCase):
14+
def setUp(self):
15+
super().setUp()
16+
17+
self._temp_directory_ctx = tempfile.TemporaryDirectory()
18+
self.test_db_root_dir = self._temp_directory_ctx.__enter__()
19+
20+
storage = FileSystemStorage(location=self.test_db_root_dir)
21+
22+
self._storage_patch_ctx = mock.patch(
23+
"contentcuration.utils.publish.storage",
24+
new=storage,
25+
)
26+
self._storage_patch_ctx.__enter__()
27+
28+
os.makedirs(
29+
os.path.join(self.test_db_root_dir, settings.DB_ROOT), exist_ok=True
30+
)
31+
32+
self.channel = testdata.channel()
33+
self.channel.version = 2
34+
self.channel.save()
35+
36+
self.versioned_db_path = os.path.join(
37+
self.test_db_root_dir,
38+
settings.DB_ROOT,
39+
f"{self.channel.id}-{self.channel.version}.sqlite3",
40+
)
41+
self.unversioned_db_path = os.path.join(
42+
self.test_db_root_dir, settings.DB_ROOT, f"{self.channel.id}.sqlite3"
43+
)
44+
45+
def tearDown(self):
46+
self._temp_directory_ctx.__exit__(None, None, None)
47+
self._storage_patch_ctx.__exit__(None, None, None)
48+
49+
super().tearDown()
50+
51+
def test_versioned_database_exists(self):
52+
# In reality, the versioned database for the current version
53+
# and the unversioned database would have the same content,
54+
# but here we provide different content so that we can test
55+
# that the versioned database is not overwritten.
56+
versioned_db_content = "Versioned content"
57+
unversioned_db_content = "Unversioned content"
58+
59+
with open(self.versioned_db_path, "w") as f:
60+
f.write(versioned_db_content)
61+
with open(self.unversioned_db_path, "w") as f:
62+
f.write(unversioned_db_content)
63+
64+
ensure_versioned_database_exists(self.channel)
65+
66+
with open(self.versioned_db_path) as f:
67+
read_versioned_content = f.read()
68+
self.assertEqual(read_versioned_content, versioned_db_content)
69+
70+
def test_versioned_database_does_not_exist(self):
71+
unversioned_db_content = "Unversioned content"
72+
73+
with open(self.unversioned_db_path, "w") as f:
74+
f.write(unversioned_db_content)
75+
76+
ensure_versioned_database_exists(self.channel)
77+
78+
with open(self.versioned_db_path) as f:
79+
read_versioned_content = f.read()
80+
self.assertEqual(read_versioned_content, unversioned_db_content)
81+
82+
def test_not_published(self):
83+
self.channel.version = 0
84+
self.channel.save()
85+
self.versioned_db_path = os.path.join(
86+
self.test_db_root_dir,
87+
settings.DB_ROOT,
88+
f"{self.channel.id}-{self.channel.version}.sqlite3",
89+
)
90+
91+
with self.assertRaises(ValueError):
92+
ensure_versioned_database_exists(self.channel)

contentcuration/contentcuration/utils/publish.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging as logmodule
55
import os
66
import re
7+
import shutil
78
import tempfile
89
import time
910
import traceback
@@ -1368,3 +1369,35 @@ def publish_channel( # noqa: C901
13681369
except SlowPublishError as e:
13691370
report_exception(e)
13701371
return channel
1372+
1373+
1374+
def ensure_versioned_database_exists(channel):
1375+
"""
1376+
Ensures that the versioned database exists, and if not, copies the unversioned database to the versioned path.
1377+
This happens if the channel was published back when versioned databases were not used.
1378+
"""
1379+
if channel.version == 0:
1380+
raise ValueError("An unpublished channel cannot have a versioned database.")
1381+
1382+
unversioned_db_storage_path = os.path.join(
1383+
settings.DB_ROOT, "{id}.sqlite3".format(id=channel.id)
1384+
)
1385+
versioned_db_storage_path = os.path.join(
1386+
settings.DB_ROOT,
1387+
"{id}-{version}.sqlite3".format(id=channel.id, version=channel.version),
1388+
)
1389+
1390+
if not storage.exists(versioned_db_storage_path):
1391+
if not storage.exists(unversioned_db_storage_path):
1392+
# This should never happen, a published channel should always have an unversioned database
1393+
raise FileNotFoundError(
1394+
f"Neither unversioned nor versioned database found for channel {channel.id}."
1395+
)
1396+
1397+
with storage.open(unversioned_db_storage_path, "rb") as unversioned_db_file:
1398+
with storage.open(versioned_db_storage_path, "wb") as versioned_db_file:
1399+
shutil.copyfileobj(unversioned_db_file, versioned_db_file)
1400+
1401+
logging.info(
1402+
f"Versioned database for channel {channel.id} did not exist, copied the unversioned database to {versioned_db_storage_path}."
1403+
)

contentcuration/kolibri_public/tests/test_export_channels_to_kolibri_public.py

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def tearDown(self):
152152
super().tearDown()
153153

154154
@mock.patch("kolibri_public.utils.export_channel_to_kolibri_public.ChannelMapper")
155-
def test_export_channel_to_kolibri_public__existing_version__versioned(
155+
def test_export_channel_to_kolibri_public__existing_version(
156156
self, mock_channel_mapper
157157
):
158158
categories = {
@@ -180,29 +180,6 @@ def test_export_channel_to_kolibri_public__existing_version__versioned(
180180
)
181181
mock_channel_mapper.return_value.run.assert_called_once_with()
182182

183-
@mock.patch("kolibri_public.utils.export_channel_to_kolibri_public.ChannelMapper")
184-
def test_export_channel_to_kolibri_public__existing_version__unversioned(
185-
self, mock_channel_mapper
186-
):
187-
os.remove(self.versioned_db_path)
188-
189-
export_channel_to_kolibri_public(
190-
channel_id=self.channel_id,
191-
channel_version=1,
192-
public=True,
193-
categories=None,
194-
countries=None,
195-
)
196-
197-
mock_channel_mapper.assert_called_once_with(
198-
channel=self.exported_channel_metadata,
199-
channel_version=1,
200-
public=True,
201-
categories=None,
202-
countries=None,
203-
)
204-
mock_channel_mapper.return_value.run.assert_called_once_with()
205-
206183
@mock.patch("kolibri_public.utils.export_channel_to_kolibri_public.ChannelMapper")
207184
def test_export_channel_to_kolibri_public__without_version(
208185
self, mock_channel_mapper

contentcuration/kolibri_public/utils/export_channel_to_kolibri_public.py

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,6 @@ def export_channel_to_kolibri_public(
7575
db_storage_path = unversioned_db_storage_path
7676
else:
7777
db_storage_path = versioned_db_storage_path
78-
_possibly_migrate_unversioned_database(
79-
channel_id=channel_id,
80-
channel_version=channel_version,
81-
unversioned_db_storage_path=unversioned_db_storage_path,
82-
versioned_db_storage_path=versioned_db_storage_path,
83-
)
8478

8579
with using_temp_migrated_database(db_storage_path):
8680
channel = ExportedChannelMetadata.objects.get(id=channel_id)
@@ -95,35 +89,3 @@ def export_channel_to_kolibri_public(
9589
countries=countries,
9690
)
9791
mapper.run()
98-
99-
100-
def _possibly_migrate_unversioned_database(
101-
channel_id,
102-
channel_version,
103-
unversioned_db_storage_path,
104-
versioned_db_storage_path,
105-
):
106-
"""
107-
Older channels may only have a single database file and not
108-
versioned databases. If this is the case and the requested channel version
109-
is present in the single database file, this function copies the database to a file
110-
containing the version in the filename.
111-
"""
112-
if storage.exists(versioned_db_storage_path) or not storage.exists(
113-
unversioned_db_storage_path
114-
):
115-
return
116-
117-
with using_temp_migrated_database(unversioned_db_storage_path):
118-
contains_requested_version = ExportedChannelMetadata.objects.filter(
119-
id=channel_id, version=channel_version
120-
).exists()
121-
122-
if contains_requested_version:
123-
logger.info(
124-
f"Migrating unversioned database {unversioned_db_storage_path} to versioned database {versioned_db_storage_path}"
125-
)
126-
127-
with storage.open(unversioned_db_storage_path, "rb") as unversioned_db_file:
128-
with storage.open(versioned_db_storage_path, "wb") as versioned_db_file:
129-
shutil.copyfileobj(unversioned_db_file, versioned_db_file)

0 commit comments

Comments
 (0)