Skip to content

Commit 89d04ed

Browse files
kdmccormickclaude
andcommitted
feat: back-compat for collection 'key'/'collection_code' in backup/restore
Backup now writes both 'key' and 'collection_code' to collection TOML files, so older software (which only knows 'key') can still read new archives. Restore accepts either field, preferring 'collection_code' and falling back to the legacy 'key'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 568f1fe commit 89d04ed

4 files changed

Lines changed: 76 additions & 1 deletion

File tree

src/openedx_content/applets/backup_restore/serializers.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,24 @@ class CollectionSerializer(serializers.Serializer): # pylint: disable=abstract-
156156
Serializer for collections.
157157
"""
158158
title = serializers.CharField(required=True)
159-
key = serializers.CharField(required=True, source="collection_code")
159+
# 'collection_code' is the current field name; 'key' is the old name kept for
160+
# back-compat with archives written before the rename. At least one must be present.
161+
collection_code = serializers.CharField(required=False)
162+
key = serializers.CharField(required=False)
160163
description = serializers.CharField(required=True, allow_blank=True)
161164
entities = serializers.ListField(
162165
child=serializers.CharField(),
163166
required=True,
164167
allow_empty=True,
165168
)
169+
170+
def validate(self, attrs):
171+
# Prefer 'collection_code'; fall back to legacy 'key'. Always remove
172+
# both so only the normalised 'collection_code' key reaches the caller.
173+
code = attrs.pop("collection_code", None)
174+
legacy_key = attrs.pop("key", None)
175+
code = code or legacy_key
176+
if not code:
177+
raise serializers.ValidationError("Either 'collection_code' or 'key' is required.")
178+
attrs["collection_code"] = code
179+
return attrs

src/openedx_content/applets/backup_restore/toml.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,10 @@ def toml_collection(collection: Collection, entity_keys: list[str]) -> str:
220220

221221
collection_table = tomlkit.table()
222222
collection_table.add("title", collection.title)
223+
# Write both names so that older software (which reads 'key') stays compatible
224+
# with archives produced after the Collection.key -> Collection.collection_code rename.
223225
collection_table.add("key", collection.collection_code)
226+
collection_table.add("collection_code", collection.collection_code)
224227
collection_table.add("description", collection.description)
225228
collection_table.add("created", collection.created)
226229
collection_table.add("entities", entities_array)

tests/openedx_content/applets/backup_restore/test_backup.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,17 @@ def test_lp_dump_command(self):
276276
for file_path, expected_content in expected_files.items():
277277
self.check_toml_file(zip_path, Path(file_path), expected_content)
278278

279+
# Verify that collection TOMLs include both 'key' (legacy) and 'collection_code'
280+
# (new name), so older software can still read archives produced after the rename.
281+
self.check_toml_file(
282+
zip_path,
283+
Path("collections/col1.toml"),
284+
[
285+
'key = "COL1"',
286+
'collection_code = "COL1"',
287+
]
288+
)
289+
279290
# Check the output message
280291
message = f'{lp_key} written to {file_name}'
281292
self.assertIn(message, out.getvalue())

tests/openedx_content/applets/backup_restore/test_restore.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.core.management import call_command
99
from django.test import TestCase
1010

11+
from openedx_content.applets.backup_restore.serializers import CollectionSerializer
1112
from openedx_content.applets.backup_restore.zipper import LearningPackageUnzipper, generate_staged_lp_key
1213
from openedx_content.applets.collections import api as collections_api
1314
from openedx_content.applets.components import api as components_api
@@ -353,6 +354,52 @@ def test_success_metadata_using_user_context(self):
353354
assert metadata == expected_metadata
354355

355356

357+
class CollectionSerializerTest(TestCase):
358+
"""
359+
Unit tests for CollectionSerializer's back-compat handling of 'key' vs 'collection_code'.
360+
"""
361+
362+
BASE_DATA = {
363+
"title": "My Collection",
364+
"description": "",
365+
"entities": [],
366+
}
367+
368+
def _serialize(self, extra):
369+
data = {**self.BASE_DATA, **extra}
370+
s = CollectionSerializer(data=data)
371+
s.is_valid()
372+
return s
373+
374+
def test_legacy_key_field(self):
375+
"""Archives written before the rename use 'key'; restore must accept it."""
376+
s = self._serialize({"key": "my-col"})
377+
assert s.is_valid(), s.errors
378+
assert s.validated_data["collection_code"] == "my-col"
379+
assert "key" not in s.validated_data
380+
381+
def test_new_collection_code_field(self):
382+
"""Archives written after the rename use 'collection_code'."""
383+
s = self._serialize({"collection_code": "my-col"})
384+
assert s.is_valid(), s.errors
385+
assert s.validated_data["collection_code"] == "my-col"
386+
assert "key" not in s.validated_data
387+
388+
def test_both_fields_collection_code_wins(self):
389+
"""When both fields are present (new archives include both for back-compat),
390+
'collection_code' takes precedence over 'key'."""
391+
s = self._serialize({"key": "old-value", "collection_code": "new-value"})
392+
assert s.is_valid(), s.errors
393+
assert s.validated_data["collection_code"] == "new-value"
394+
assert "key" not in s.validated_data
395+
396+
def test_neither_field_is_an_error(self):
397+
"""Missing both 'key' and 'collection_code' must fail validation."""
398+
s = self._serialize({})
399+
assert not s.is_valid()
400+
assert "non_field_errors" in s.errors
401+
402+
356403
class RestoreUtilitiesTest(TestCase):
357404
"""Tests for utility functions used in the restore process."""
358405

0 commit comments

Comments
 (0)