From 466bda62bd6448e1c98f002a9ed75ba5a755dc81 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Wed, 15 Apr 2026 06:06:06 -0400 Subject: [PATCH 1/4] feat!: Component.local_key -> Component.component_code Renames the Component.local_key field to component_code, switching from key_field() to code_field() (stricter validation: [A-Za-z0-9\-\_\.]+, max_length=255). Updates all API call sites, backup/restore, and tests. Backup/restore now writes an [entity.component] section in each component TOML file containing component_type and component_code explicitly, so that restore does not need to parse the entity key. Old archives (without the [entity.component] section) are still accepted by falling back to the existing entity key parsing. BREAKING CHANGE: Component.local_key has been renamed to Component.component_code. BREAKING CHANGE: Component.component_code now validates against [A-Za-z0-9\-\_\.]+ and has a max_length of 255. Previously local_key used key_field() (no regex validation, max_length=500). BREAKING CHANGE: Function parameters renamed from local_key to component_code in create_component(...) and create_component_and_version(...). BREAKING CHANGE: Functions get_component_by_key(...)/component_exists_by_key(...), renamed to get_component_by_code(...)/component_exists_by_code(...), and parameters renamed from local_key to component_code. Part of: https://github.com/openedx/openedx-core/issues/322 Co-Authored-By: Claude Sonnet 4.6 --- .../management/commands/load_components.py | 4 +- .../applets/backup_restore/serializers.py | 48 +++++++++--- .../applets/backup_restore/toml.py | 9 +++ .../applets/backup_restore/zipper.py | 2 +- src/openedx_content/applets/components/api.py | 42 +++++------ .../applets/components/models.py | 37 ++++----- .../publishing/models/publishable_entity.py | 2 +- .../commands/add_assets_to_component.py | 16 ++-- ...e_component_local_key_to_component_code.py | 66 ++++++++++++++++ .../applets/backup_restore/test_backup.py | 8 +- .../applets/backup_restore/test_restore.py | 75 +++++++++++++++++++ .../applets/collections/test_api.py | 4 +- .../applets/components/test_api.py | 58 +++++++------- .../applets/components/test_assets.py | 2 +- .../applets/components/test_models.py | 6 +- .../applets/sections/test_api.py | 4 +- .../applets/subsections/test_api.py | 8 +- .../openedx_content/applets/units/test_api.py | 4 +- 18 files changed, 287 insertions(+), 108 deletions(-) create mode 100644 src/openedx_content/migrations/0009_rename_component_local_key_to_component_code.py diff --git a/olx_importer/management/commands/load_components.py b/olx_importer/management/commands/load_components.py index 175175040..da51fbae5 100644 --- a/olx_importer/management/commands/load_components.py +++ b/olx_importer/management/commands/load_components.py @@ -140,7 +140,7 @@ def import_block_type(self, block_type_name, now): # , publish_log_entry): for xml_file_path in block_data_path.glob("*.xml"): components_found += 1 - local_key = xml_file_path.stem + component_code = xml_file_path.stem # Do some basic parsing of the content to see if it's even well # constructed enough to add (or whether we should skip/error on it). @@ -155,7 +155,7 @@ def import_block_type(self, block_type_name, now): # , publish_log_entry): _component, component_version = content_api.create_component_and_version( self.learning_package.id, component_type=block_type, - local_key=local_key, + component_code=component_code, title=display_name, created=now, created_by=None, diff --git a/src/openedx_content/applets/backup_restore/serializers.py b/src/openedx_content/applets/backup_restore/serializers.py index 549f3280f..4995e0cf1 100644 --- a/src/openedx_content/applets/backup_restore/serializers.py +++ b/src/openedx_content/applets/backup_restore/serializers.py @@ -59,21 +59,49 @@ class EntityVersionSerializer(serializers.Serializer): # pylint: disable=abstra class ComponentSerializer(EntitySerializer): # pylint: disable=abstract-method """ Serializer for components. - Contains logic to convert entity_key to component_type and local_key. + + Extracts component_type and component_code from the [entity.component] + section if present (archives created in Verawood or later). Falls back to + parsing the entity key for archives created in Ulmo. """ + component = serializers.DictField(required=False) + def validate(self, attrs): """ - Custom validation logic: - parse the entity_key into (component_type, local_key). + Custom validation logic: resolve component_type and component_code. + + Archives created in Verawood or later supply an [entity.component] + section with ``component_type`` (e.g. "xblock.v1:problem") and + ``component_code`` (e.g. "my_example"). Archives created in Ulmo only + have the entity ``key`` in the format + ``"{namespace}:{type_name}:{component_code}"``, so we fall back to + parsing that for backwards compatibility. """ - entity_key = attrs["key"] - try: - component_type_obj, local_key = components_api.get_or_create_component_type_by_entity_key(entity_key) - attrs["component_type"] = component_type_obj - attrs["local_key"] = local_key - except ValueError as exc: - raise serializers.ValidationError({"key": str(exc)}) + component_section = attrs.pop("component", None) + if component_section: + # Verawood+ format: component_type and component_code are explicit. + component_type_str = component_section.get("component_type", "") + component_code = component_section.get("component_code", "") + try: + namespace, type_name = component_type_str.split(":", 1) + except ValueError as exc: + raise serializers.ValidationError( + {"component": f"Invalid component_type format: {component_type_str!r}. " + "Expected '{namespace}:{type_name}'."} + ) from exc + component_type_obj = components_api.get_or_create_component_type(namespace, type_name) + else: + # Ulmo (legacy) format: parse the entity key. + entity_key = attrs["key"] + try: + component_type_obj, component_code = ( + components_api.get_or_create_component_type_by_entity_key(entity_key) + ) + except ValueError as exc: + raise serializers.ValidationError({"key": str(exc)}) + attrs["component_type"] = component_type_obj + attrs["component_code"] = component_code return attrs diff --git a/src/openedx_content/applets/backup_restore/toml.py b/src/openedx_content/applets/backup_restore/toml.py index 520d6b877..05f8b7f43 100644 --- a/src/openedx_content/applets/backup_restore/toml.py +++ b/src/openedx_content/applets/backup_restore/toml.py @@ -108,6 +108,15 @@ def _get_toml_publishable_entity_table( published_table.add(tomlkit.comment("unpublished: no published_version_num")) entity_table.add("published", published_table) + if hasattr(entity, "component"): + component = entity.component + component_table = tomlkit.table() + # Write component_type and component_code explicitly so that restore + # (Verawood and later) does not need to parse the entity key. + component_table.add("component_type", str(component.component_type)) + component_table.add("component_code", component.component_code) + entity_table.add("component", component_table) + if hasattr(entity, "container"): container_table = tomlkit.table() container_types = ["section", "subsection", "unit"] diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 988daffe2..a1626a576 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -332,7 +332,7 @@ def create_zip(self, path: str) -> None: # v1/ # static/ - entity_filename = self.get_entity_toml_filename(entity.component.local_key) + entity_filename = self.get_entity_toml_filename(entity.component.component_code) component_root_folder = ( # Example: "entities/xblock.v1/html/" diff --git a/src/openedx_content/applets/components/api.py b/src/openedx_content/applets/components/api.py index 1c3ed3e40..0a70b8928 100644 --- a/src/openedx_content/applets/components/api.py +++ b/src/openedx_content/applets/components/api.py @@ -40,10 +40,10 @@ "create_next_component_version", "create_component_and_version", "get_component", - "get_component_by_key", + "get_component_by_code", "get_component_by_uuid", "get_component_version_by_uuid", - "component_exists_by_key", + "component_exists_by_code", "get_collection_components", "get_components", "create_component_version_media", @@ -79,27 +79,27 @@ def get_or_create_component_type_by_entity_key(entity_key: str) -> tuple[Compone Get or create a ComponentType based on a full entity key string. The entity key is expected to be in the format - ``"{namespace}:{type_name}:{local_key}"``. This function will parse out the - ``namespace`` and ``type_name`` parts and use those to get or create the + ``"{namespace}:{type_name}:{component_code}"``. This function will parse out + the ``namespace`` and ``type_name`` parts and use those to get or create the ComponentType. Raises ValueError if the entity_key is not in the expected format. """ try: - namespace, type_name, local_key = entity_key.split(':', 2) + namespace, type_name, component_code = entity_key.split(':', 2) except ValueError as exc: raise ValueError( f"Invalid entity_key format: {entity_key!r}. " - "Expected format: '{namespace}:{type_name}:{local_key}'" + "Expected format: '{namespace}:{type_name}:{component_code}'" ) from exc - return get_or_create_component_type(namespace, type_name), local_key + return get_or_create_component_type(namespace, type_name), component_code def create_component( learning_package_id: LearningPackage.ID, /, component_type: ComponentType, - local_key: str, + component_code: str, created: datetime, created_by: int | None, *, @@ -108,7 +108,7 @@ def create_component( """ Create a new Component (an entity like a Problem or Video) """ - key = f"{component_type.namespace}:{component_type.name}:{local_key}" + key = f"{component_type.namespace}:{component_type.name}:{component_code}" with atomic(): publishable_entity = publishing_api.create_publishable_entity( learning_package_id, @@ -121,7 +121,7 @@ def create_component( publishable_entity=publishable_entity, learning_package_id=learning_package_id, component_type=component_type, - local_key=local_key, + component_code=component_code, ) return component @@ -293,7 +293,7 @@ def create_component_and_version( # pylint: disable=too-many-positional-argumen learning_package_id: LearningPackage.ID, /, component_type: ComponentType, - local_key: str, + component_code: str, title: str, created: datetime, created_by: int | None = None, @@ -307,7 +307,7 @@ def create_component_and_version( # pylint: disable=too-many-positional-argumen component = create_component( learning_package_id, component_type, - local_key, + component_code, created, created_by, can_stand_alone=can_stand_alone, @@ -331,22 +331,22 @@ def get_component(component_id: Component.ID, /) -> Component: return Component.with_publishing_relations.get(pk=component_id) -def get_component_by_key( +def get_component_by_code( learning_package_id: LearningPackage.ID, /, namespace: str, type_name: str, - local_key: str, + component_code: str, ) -> Component: """ - Get a Component by its unique (namespace, type, local_key) tuple. + Get a Component by its unique (namespace, type, component_code) tuple. """ return Component.with_publishing_relations \ .get( learning_package_id=learning_package_id, component_type__namespace=namespace, component_type__name=type_name, - local_key=local_key, + component_code=component_code, ) @@ -366,12 +366,12 @@ def get_component_version_by_uuid(uuid: UUID) -> ComponentVersion: ) -def component_exists_by_key( +def component_exists_by_code( learning_package_id: LearningPackage.ID, /, namespace: str, type_name: str, - local_key: str + component_code: str ) -> bool: """ Return True/False for whether a Component exists. @@ -384,7 +384,7 @@ def component_exists_by_key( learning_package_id=learning_package_id, component_type__namespace=namespace, component_type__name=type_name, - local_key=local_key, + component_code=component_code, ) return True except Component.DoesNotExist: @@ -423,12 +423,12 @@ def get_components( # pylint: disable=too-many-positional-arguments if draft_title is not None: qset = qset.filter( Q(publishable_entity__draft__version__title__icontains=draft_title) | - Q(local_key__icontains=draft_title) + Q(component_code__icontains=draft_title) ) if published_title is not None: qset = qset.filter( Q(publishable_entity__published__version__title__icontains=published_title) | - Q(local_key__icontains=published_title) + Q(component_code__icontains=published_title) ) return qset diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py index 6fbab2ba3..42d129994 100644 --- a/src/openedx_content/applets/components/models.py +++ b/src/openedx_content/applets/components/models.py @@ -22,7 +22,7 @@ from django.db import models from typing_extensions import deprecated -from openedx_django_lib.fields import case_sensitive_char_field, key_field +from openedx_django_lib.fields import case_sensitive_char_field, code_field, key_field from openedx_django_lib.managers import WithRelationsManager from ..media.models import Media @@ -119,9 +119,10 @@ class Component(PublishableEntityMixin): State Consistency ----------------- - The ``key`` field on Component's ``publishable_entity`` is dervied from the - ``component_type`` and ``local_key`` fields in this model. We don't support - changing the keys yet, but if we do, those values need to be kept in sync. + The ``key`` field on Component's ``publishable_entity`` is derived from the + ``component_type`` and ``component_code`` fields in this model. We don't + support changing the keys yet, but if we do, those values need to be kept + in sync. How build on this model ----------------------- @@ -176,37 +177,37 @@ def pk(self): # XBlock block_type, but we want it to be more flexible in the long term. component_type = models.ForeignKey(ComponentType, on_delete=models.PROTECT) - # local_key is an identifier that is local to the learning_package and - # component_type. The publishable.key should be calculated as a - # combination of component_type and local_key. - local_key = key_field() + # component_code is an identifier that is local to the learning_package and + # component_type. The publishable.key is derived from component_type and + # component_code. + component_code = code_field() class Meta: constraints = [ - # The combination of (component_type, local_key) is unique within - # a given LearningPackage. Note that this means it is possible to - # have two Components in the same LearningPackage to have the same - # local_key if the component_types are different. So for example, - # you could have a ProblemBlock and VideoBlock that both have the - # local_key "week_1". + # The combination of (component_type, component_code) is unique + # within a given LearningPackage. Note that this means it is + # possible to have two Components in the same LearningPackage with + # the same component_code if their component_types differ. For + # example, a ProblemBlock and VideoBlock could both have the + # component_code "week_1". models.UniqueConstraint( fields=[ "learning_package", "component_type", - "local_key", + "component_code", ], name="oel_component_uniq_lc_ct_lk", ), ] indexes = [ - # Global Component-Type/Local-Key Index: + # Global Component-Type/Component-Code Index: # * Search by the different Components fields across all Learning # Packages on the site. This would be a support-oriented tool # from Django Admin. models.Index( fields=[ "component_type", - "local_key", + "component_code", ], name="oel_component_idx_ct_lk", ), @@ -217,7 +218,7 @@ class Meta: verbose_name_plural = "Components" def __str__(self) -> str: - return f"{self.component_type.namespace}:{self.component_type.name}:{self.local_key}" + return f"{self.component_type.namespace}:{self.component_type.name}:{self.component_code}" class ComponentVersion(PublishableEntityVersionMixin): diff --git a/src/openedx_content/applets/publishing/models/publishable_entity.py b/src/openedx_content/applets/publishing/models/publishable_entity.py index 907b50248..c897b26cb 100644 --- a/src/openedx_content/applets/publishing/models/publishable_entity.py +++ b/src/openedx_content/applets/publishing/models/publishable_entity.py @@ -415,7 +415,7 @@ class VersioningHelper: learning_package_id=learning_package.id, namespace="xblock.v1", type="problem", - local_key="monty_hall", + component_code="monty_hall", title="Monty Hall Problem", created=now, created_by=None, diff --git a/src/openedx_content/management/commands/add_assets_to_component.py b/src/openedx_content/management/commands/add_assets_to_component.py index fc207e20a..ccd5b6f07 100644 --- a/src/openedx_content/management/commands/add_assets_to_component.py +++ b/src/openedx_content/management/commands/add_assets_to_component.py @@ -9,7 +9,7 @@ from django.core.management.base import BaseCommand -from ...api import create_next_component_version, get_component_by_key, get_learning_package_by_key +from ...api import create_next_component_version, get_component_by_code, get_learning_package_by_key class Command(BaseCommand): @@ -59,22 +59,22 @@ def handle(self, *args, **options): learning_package = get_learning_package_by_key(learning_package_key) # Parse something like: "xblock.v1:problem:area_of_circle_1" - namespace, type_name, local_key = component_key.split(":", 2) - component = get_component_by_key( - learning_package.id, namespace, type_name, local_key + namespace, type_name, component_code = component_key.split(":", 2) + component = get_component_by_code( + learning_package.id, namespace, type_name, component_code ) created = datetime.now(tz=timezone.utc) - local_keys_to_content_bytes = {} + media_path_to_content_bytes = {} for file_mapping in file_mappings: - local_key, file_path = file_mapping.split(":", 1) + media_path, file_path = file_mapping.split(":", 1) - local_keys_to_content_bytes[local_key] = pathlib.Path(file_path).read_bytes() if file_path else None + media_path_to_content_bytes[media_path] = pathlib.Path(file_path).read_bytes() if file_path else None next_version = create_next_component_version( component.id, - media_to_replace=local_keys_to_content_bytes, + media_to_replace=media_path_to_content_bytes, created=created, ) diff --git a/src/openedx_content/migrations/0009_rename_component_local_key_to_component_code.py b/src/openedx_content/migrations/0009_rename_component_local_key_to_component_code.py new file mode 100644 index 000000000..e5819a9d5 --- /dev/null +++ b/src/openedx_content/migrations/0009_rename_component_local_key_to_component_code.py @@ -0,0 +1,66 @@ +""" +Rename Component.local_key -> Component.component_code and change from key_field to code_field. +""" +import re + +import django.core.validators +from django.db import migrations, models + +import openedx_django_lib.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('openedx_content', '0008_rename_collection_key_to_collection_code'), + ] + + operations = [ + # Drop old constraint and index (reference the old field name). + migrations.RemoveConstraint( + model_name='component', + name='oel_component_uniq_lc_ct_lk', + ), + migrations.RemoveIndex( + model_name='component', + name='oel_component_idx_ct_lk', + ), + # Rename the column. + migrations.RenameField( + model_name='component', + old_name='local_key', + new_name='component_code', + ), + # Change from key_field (max_length=500, no validator) to code_field + # (max_length=255, with regex validator). + migrations.AlterField( + model_name='component', + name='component_code', + field=openedx_django_lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, + max_length=255, + validators=[ + django.core.validators.RegexValidator( + re.compile('^[a-zA-Z0-9_.-]+\\Z'), + 'Enter a valid "code name" consisting of letters, numbers, underscores, hyphens, or periods.', + 'invalid', + ), + ], + ), + ), + # Re-add constraint and index with the new field name. + migrations.AddConstraint( + model_name='component', + constraint=models.UniqueConstraint( + fields=('learning_package', 'component_type', 'component_code'), + name='oel_component_uniq_lc_ct_lk', + ), + ), + migrations.AddIndex( + model_name='component', + index=models.Index( + fields=['component_type', 'component_code'], + name='oel_component_idx_ct_lk', + ), + ), + ] diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py index edafe3f66..6e9dc733c 100644 --- a/tests/openedx_content/applets/backup_restore/test_backup.py +++ b/tests/openedx_content/applets/backup_restore/test_backup.py @@ -69,7 +69,7 @@ def setUpTestData(cls): cls.published_component, _ = api.create_component_and_version( cls.learning_package.id, cls.problem_type, - local_key="my_published_example", + component_code="my_published_example", title="My published problem", created=cls.now, created_by=cls.user.id, @@ -80,7 +80,7 @@ def setUpTestData(cls): cls.published_component2, _ = api.create_component_and_version( cls.learning_package.id, cls.problem_type, - local_key="My_published_example", + component_code="My_published_example", title="My published problem 2", created=cls.now, created_by=cls.user.id, @@ -116,7 +116,7 @@ def setUpTestData(cls): cls.draft_component, _ = api.create_component_and_version( cls.learning_package.id, cls.html_type, - local_key="my_draft_example", + component_code="my_draft_example", title="My draft html", created=cls.now, created_by=cls.user.id, @@ -315,7 +315,7 @@ def test_queries_n_plus_problem(self): api.create_component_and_version( self.learning_package.id, self.problem_type, - local_key="my_published_example2", + component_code="my_published_example2", title="My published problem 2", created=self.now, created_by=self.user.id, diff --git a/tests/openedx_content/applets/backup_restore/test_restore.py b/tests/openedx_content/applets/backup_restore/test_restore.py index 46d2f94ea..fd62e8a6c 100644 --- a/tests/openedx_content/applets/backup_restore/test_restore.py +++ b/tests/openedx_content/applets/backup_restore/test_restore.py @@ -8,6 +8,7 @@ from django.core.management import call_command from django.test import TestCase +from openedx_content.applets.backup_restore.serializers import ComponentSerializer from openedx_content.applets.backup_restore.zipper import LearningPackageUnzipper, generate_staged_lp_key from openedx_content.applets.collections import api as collections_api from openedx_content.applets.components import api as components_api @@ -360,6 +361,80 @@ def test_success_metadata_using_user_context(self): assert metadata == expected_metadata +class ComponentSerializerBackcompatTest(TestCase): + """ + Unit tests for ComponentSerializer's back-compat handling of Ulmo archives + (entity key only) vs. Verawood+ archives ([entity.component] section). + """ + + BASE_DATA = { + "can_stand_alone": True, + "key": "xblock.v1:problem:my_code", + "created": "2025-09-04T22:51:59Z", + } + + def _serialize(self, extra=None, base=None): + data = {**(base or self.BASE_DATA), **(extra or {})} + s = ComponentSerializer(data=data) + s.is_valid() + return s + + def test_legacy_entity_key_parsed(self): + """Ulmo archives have no [entity.component] section; fall back to parsing the entity key.""" + s = self._serialize() + assert s.is_valid(), s.errors + assert s.validated_data["component_type"].namespace == "xblock.v1" + assert s.validated_data["component_type"].name == "problem" + assert s.validated_data["component_code"] == "my_code" + assert "component" not in s.validated_data + + def test_new_component_section(self): + """Verawood+ archives supply an explicit [entity.component] section.""" + s = self._serialize({ + "component": { + "component_type": "xblock.v1:problem", + "component_code": "my_code", + } + }) + assert s.is_valid(), s.errors + assert s.validated_data["component_type"].namespace == "xblock.v1" + assert s.validated_data["component_type"].name == "problem" + assert s.validated_data["component_code"] == "my_code" + assert "component" not in s.validated_data + + def test_component_section_overrides_key(self): + """When [entity.component] is present, it is used even if the key has different info.""" + s = self._serialize({ + "component": { + "component_type": "xblock.v1:html", + "component_code": "different_code", + } + }) + assert s.is_valid(), s.errors + assert s.validated_data["component_type"].name == "html" + assert s.validated_data["component_code"] == "different_code" + + def test_invalid_entity_key_format(self): + """Entity key without enough colons raises a validation error.""" + s = self._serialize(base={ + "can_stand_alone": True, + "key": "invalid-key", + "created": "2025-09-04T22:51:59Z", + }) + assert not s.is_valid() + + def test_invalid_component_type_format(self): + """component_type without a colon raises a validation error.""" + s = self._serialize({ + "component": { + "component_type": "no-colon-here", + "component_code": "my_code", + } + }) + assert not s.is_valid() + assert "component" in s.errors + + class RestoreUtilitiesTest(TestCase): """Tests for utility functions used in the restore process.""" diff --git a/tests/openedx_content/applets/collections/test_api.py b/tests/openedx_content/applets/collections/test_api.py index 42e4f1638..daf839cfa 100644 --- a/tests/openedx_content/applets/collections/test_api.py +++ b/tests/openedx_content/applets/collections/test_api.py @@ -279,7 +279,7 @@ def setUpTestData(cls) -> None: cls.published_component, _ = api.create_component_and_version( cls.learning_package.id, cls.problem_type, - local_key="my_published_example", + component_code="my_published_example", title="My published problem", created=cls.now, created_by=cls.user.id, @@ -294,7 +294,7 @@ def setUpTestData(cls) -> None: cls.draft_component, _ = api.create_component_and_version( cls.learning_package.id, cls.html_type, - local_key="my_draft_example", + component_code="my_draft_example", title="My draft html", created=cls.now, created_by=cls.user.id, diff --git a/tests/openedx_content/applets/components/test_api.py b/tests/openedx_content/applets/components/test_api.py index 74f482090..c46746061 100644 --- a/tests/openedx_content/applets/components/test_api.py +++ b/tests/openedx_content/applets/components/test_api.py @@ -54,14 +54,14 @@ def publish_component(self, component: Component): ), ) - def create_component(self, *, title: str = "Test Component", key: str = "component:1") -> tuple[ + def create_component(self, *, title: str = "Test Component", component_code: str = "component_1") -> tuple[ Component, ComponentVersion ]: """ Helper method to quickly create a component """ return components_api.create_component_and_version( self.learning_package.id, component_type=self.problem_type, - local_key=key, + component_code=component_code, title=title, created=self.now, created_by=None, @@ -86,7 +86,7 @@ def test_component_num_queries(self) -> None: component, _version = components_api.create_component_and_version( self.learning_package.id, component_type=self.problem_type, - local_key="Query Counting", + component_code="Query_Counting", title="Querying Counting Problem", created=self.now, created_by=None, @@ -131,7 +131,7 @@ def setUpTestData(cls) -> None: cls.published_problem, _version = components_api.create_component_and_version( cls.learning_package.id, component_type=v2_problem_type, - local_key="pp_lk", + component_code="pp_lk", title="Published Problem", created=cls.now, created_by=None, @@ -139,7 +139,7 @@ def setUpTestData(cls) -> None: cls.published_html, _version = components_api.create_component_and_version( cls.learning_package.id, component_type=cls.html_type, - local_key="ph_lk", + component_code="ph_lk", title="Published HTML", created=cls.now, created_by=None, @@ -153,7 +153,7 @@ def setUpTestData(cls) -> None: cls.unpublished_problem, _version = components_api.create_component_and_version( cls.learning_package.id, component_type=v2_problem_type, - local_key="upp_lk", + component_code="upp_lk", title="Unpublished Problem", created=cls.now, created_by=None, @@ -161,7 +161,7 @@ def setUpTestData(cls) -> None: cls.unpublished_html, _version = components_api.create_component_and_version( cls.learning_package.id, component_type=cls.html_type, - local_key="uph_lk", + component_code="uph_lk", title="Unpublished HTML", created=cls.now, created_by=None, @@ -172,7 +172,7 @@ def setUpTestData(cls) -> None: cls.deleted_video, _version = components_api.create_component_and_version( cls.learning_package.id, component_type=cls.video_type, - local_key="dv_lk", + component_code="dv_lk", title="Deleted Video", created=cls.now, created_by=None, @@ -324,14 +324,14 @@ def setUpTestData(cls) -> None: cls.problem = components_api.create_component( cls.learning_package.id, component_type=cls.problem_type, - local_key='my_component', + component_code='my_component', created=cls.now, created_by=None, ) cls.html = components_api.create_component( cls.learning_package.id, component_type=cls.html_type, - local_key='my_component', + component_code='my_component', created=cls.now, created_by=None, can_stand_alone=False, @@ -343,46 +343,46 @@ def test_simple_get(self): components_api.get_component(-1) def test_publishing_entity_key_convention(self): - """Our mapping convention is {namespace}:{component_type}:{local_key}""" + """Our mapping convention is {namespace}:{component_type}:{component_code}""" assert self.problem.key == "xblock.v1:problem:my_component" def test_stand_alone_flag(self): """Check if can_stand_alone flag is set""" - component = components_api.get_component_by_key( + component = components_api.get_component_by_code( self.learning_package.id, namespace='xblock.v1', type_name='html', - local_key='my_component', + component_code='my_component', ) assert not component.publishable_entity.can_stand_alone - def test_get_by_key(self): - assert self.html == components_api.get_component_by_key( + def test_get_by_code(self): + assert self.html == components_api.get_component_by_code( self.learning_package.id, namespace='xblock.v1', type_name='html', - local_key='my_component', + component_code='my_component', ) with self.assertRaises(ObjectDoesNotExist): - components_api.get_component_by_key( + components_api.get_component_by_code( self.learning_package.id, namespace='xblock.v1', type_name='video', # 'video' doesn't match anything we have - local_key='my_component', + component_code='my_component', ) - def test_exists_by_key(self): - assert components_api.component_exists_by_key( + def test_exists_by_code(self): + assert components_api.component_exists_by_code( self.learning_package.id, namespace='xblock.v1', type_name='problem', - local_key='my_component', + component_code='my_component', ) - assert not components_api.component_exists_by_key( + assert not components_api.component_exists_by_code( self.learning_package.id, namespace='xblock.v1', type_name='problem', - local_key='not_my_component', + component_code='not_my_component', ) @@ -399,7 +399,7 @@ def setUpTestData(cls) -> None: cls.problem = components_api.create_component( cls.learning_package.id, component_type=cls.problem_type, - local_key='my_component', + component_code='my_component', created=cls.now, created_by=None, ) @@ -659,7 +659,7 @@ def setUpTestData(cls) -> None: cls.published_problem, _ = components_api.create_component_and_version( cls.learning_package.id, component_type=v2_problem_type, - local_key="pp_lk", + component_code="pp_lk", title="Published Problem", created=cls.now, created_by=None, @@ -697,26 +697,26 @@ class TestComponentTypeUtils(TestCase): """ def test_get_or_create_component_type_by_entity_key_creates_new(self): - comp_type, local_key = components_api.get_or_create_component_type_by_entity_key( + comp_type, component_code = components_api.get_or_create_component_type_by_entity_key( "video:youtube:abcd1234" ) assert isinstance(comp_type, ComponentType) assert comp_type.namespace == "video" assert comp_type.name == "youtube" - assert local_key == "abcd1234" + assert component_code == "abcd1234" assert ComponentType.objects.count() == 1 def test_get_or_create_component_type_by_entity_key_existing(self): ComponentType.objects.create(namespace="video", name="youtube") - comp_type, local_key = components_api.get_or_create_component_type_by_entity_key( + comp_type, component_code = components_api.get_or_create_component_type_by_entity_key( "video:youtube:efgh5678" ) assert comp_type.namespace == "video" assert comp_type.name == "youtube" - assert local_key == "efgh5678" + assert component_code == "efgh5678" assert ComponentType.objects.count() == 1 def test_get_or_create_component_type_by_entity_key_invalid_format(self): diff --git a/tests/openedx_content/applets/components/test_assets.py b/tests/openedx_content/applets/components/test_assets.py index a9c378681..f146b3298 100644 --- a/tests/openedx_content/applets/components/test_assets.py +++ b/tests/openedx_content/applets/components/test_assets.py @@ -59,7 +59,7 @@ def setUpTestData(cls) -> None: cls.component, cls.component_version = components_api.create_component_and_version( cls.learning_package.id, component_type=cls.problem_type, - local_key="my_problem", + component_code="my_problem", title="My Problem", created=cls.now, created_by=None, diff --git a/tests/openedx_content/applets/components/test_models.py b/tests/openedx_content/applets/components/test_models.py index bdcdea725..e029bed17 100644 --- a/tests/openedx_content/applets/components/test_models.py +++ b/tests/openedx_content/applets/components/test_models.py @@ -51,7 +51,7 @@ def test_latest_version(self) -> None: component, component_version = create_component_and_version( self.learning_package.id, component_type=self.problem_type, - local_key="monty_hall", + component_code="monty_hall", title="Monty Hall Problem", created=self.now, created_by=None, @@ -82,7 +82,7 @@ def test_last_publish_log(self): component_with_changes, _ = create_component_and_version( self.learning_package.id, component_type=self.problem_type, - local_key="with_changes", + component_code="with_changes", title="Component with changes v1", created=self.now, created_by=None, @@ -92,7 +92,7 @@ def test_last_publish_log(self): component_with_no_changes, _ = create_component_and_version( self.learning_package.id, component_type=self.problem_type, - local_key="with_no_changes", + component_code="with_no_changes", title="Component with no changes v1", created=self.now, created_by=None, diff --git a/tests/openedx_content/applets/sections/test_api.py b/tests/openedx_content/applets/sections/test_api.py index 3e873915a..f4807640b 100644 --- a/tests/openedx_content/applets/sections/test_api.py +++ b/tests/openedx_content/applets/sections/test_api.py @@ -22,11 +22,11 @@ def setUp(self) -> None: """Create some potential desdendants for use in these tests.""" super().setUp() self.component_1, self.component_1_v1 = self.create_component( - key="component_1", + component_code="component_1", title="Great-grandchild component", ) self.component_2, self.component_2_v1 = self.create_component( - key="component_2", + component_code="component_2", title="Great-grandchild component", ) common_args: dict[str, Any] = { diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py index e4bc871e2..74c7685b4 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -21,11 +21,11 @@ class SubsectionsTestCase(ComponentTestCase): def setUp(self) -> None: super().setUp() self.component_1, self.component_1_v1 = self.create_component( - key="Query Counting", + component_code="Query_Counting", title="Querying Counting Problem", ) self.component_2, self.component_2_v1 = self.create_component( - key="Query Counting (2)", + component_code="Query_Counting_2", title="Querying Counting Problem (2)", ) self.unit_1, self.unit_1_v1 = content_api.create_unit_and_version( @@ -156,7 +156,7 @@ def test_create_subsection_with_invalid_children(self): # Try adding a Component to a Subsection with pytest.raises( ValidationError, - match='The entity "xblock.v1:problem:Query Counting" cannot be added to a "subsection" container.', + match='The entity "xblock.v1:problem:Query_Counting" cannot be added to a "subsection" container.', ) as err: content_api.create_next_subsection_version( subsection, @@ -175,7 +175,7 @@ def test_create_subsection_with_invalid_children(self): # (not just `create_next_subsection_version()`) with pytest.raises( ValidationError, - match='The entity "xblock.v1:problem:Query Counting" cannot be added to a "subsection" container.', + match='The entity "xblock.v1:problem:Query_Counting" cannot be added to a "subsection" container.', ): self.create_subsection_with_units([self.component_1], key="unit:key3", title="Unit 3") diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 6ca67fdf0..364970a8a 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -22,11 +22,11 @@ class UnitsTestCase(ComponentTestCase): def setUp(self) -> None: super().setUp() self.component_1, self.component_1_v1 = self.create_component( - key="Query Counting", + component_code="Query_Counting", title="Querying Counting Problem", ) self.component_2, self.component_2_v1 = self.create_component( - key="Query Counting (2)", + component_code="Query_Counting_2", title="Querying Counting Problem (2)", ) From a4f59ecd01bce084f3d9b5959c48980fda8e1f3a Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Sun, 19 Apr 2026 15:09:45 -0400 Subject: [PATCH 2/4] fix: code_field_check for Component.component_code --- src/openedx_content/applets/components/models.py | 3 ++- .../0009_rename_component_local_key_to_component_code.py | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py index 42d129994..0fbeaccb0 100644 --- a/src/openedx_content/applets/components/models.py +++ b/src/openedx_content/applets/components/models.py @@ -22,7 +22,7 @@ from django.db import models from typing_extensions import deprecated -from openedx_django_lib.fields import case_sensitive_char_field, code_field, key_field +from openedx_django_lib.fields import case_sensitive_char_field, code_field, code_field_check, key_field from openedx_django_lib.managers import WithRelationsManager from ..media.models import Media @@ -198,6 +198,7 @@ class Meta: ], name="oel_component_uniq_lc_ct_lk", ), + code_field_check("component_code", name="oel_component_code_regex"), ] indexes = [ # Global Component-Type/Component-Code Index: diff --git a/src/openedx_content/migrations/0009_rename_component_local_key_to_component_code.py b/src/openedx_content/migrations/0009_rename_component_local_key_to_component_code.py index e5819a9d5..3879c55b6 100644 --- a/src/openedx_content/migrations/0009_rename_component_local_key_to_component_code.py +++ b/src/openedx_content/migrations/0009_rename_component_local_key_to_component_code.py @@ -56,6 +56,14 @@ class Migration(migrations.Migration): name='oel_component_uniq_lc_ct_lk', ), ), + migrations.AddConstraint( + model_name='component', + constraint=models.CheckConstraint( + condition=django.db.models.lookups.Regex(models.F('component_code'), '^[a-zA-Z0-9_.-]+\\Z'), + name='oel_component_code_regex', + violation_error_message='Enter a valid "code name" consisting of letters, numbers, underscores, hyphens, or periods.', + ), + ), migrations.AddIndex( model_name='component', index=models.Index( From 13cad83d8234361bbc30eb384d80a8ee10603cb0 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Mon, 20 Apr 2026 12:45:33 -0400 Subject: [PATCH 3/4] fix: Revert changes to backup restore-format --- .../applets/backup_restore/serializers.py | 80 ++++++++++--------- .../applets/backup_restore/toml.py | 9 --- .../applets/backup_restore/test_restore.py | 75 ----------------- 3 files changed, 42 insertions(+), 122 deletions(-) diff --git a/src/openedx_content/applets/backup_restore/serializers.py b/src/openedx_content/applets/backup_restore/serializers.py index 4995e0cf1..8c5f8e955 100644 --- a/src/openedx_content/applets/backup_restore/serializers.py +++ b/src/openedx_content/applets/backup_restore/serializers.py @@ -6,6 +6,7 @@ from rest_framework import serializers from ..components import api as components_api +from ..components.models import ComponentType class LearningPackageSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -59,52 +60,55 @@ class EntityVersionSerializer(serializers.Serializer): # pylint: disable=abstra class ComponentSerializer(EntitySerializer): # pylint: disable=abstract-method """ Serializer for components. - - Extracts component_type and component_code from the [entity.component] - section if present (archives created in Verawood or later). Falls back to - parsing the entity key for archives created in Ulmo. + Contains logic to convert entity_key to component_type and component_code. """ - component = serializers.DictField(required=False) - def validate(self, attrs): """ - Custom validation logic: resolve component_type and component_code. - - Archives created in Verawood or later supply an [entity.component] - section with ``component_type`` (e.g. "xblock.v1:problem") and - ``component_code`` (e.g. "my_example"). Archives created in Ulmo only - have the entity ``key`` in the format - ``"{namespace}:{type_name}:{component_code}"``, so we fall back to - parsing that for backwards compatibility. + Custom validation logic: + parse the entity_key into (component_type, component_code). """ - component_section = attrs.pop("component", None) - if component_section: - # Verawood+ format: component_type and component_code are explicit. - component_type_str = component_section.get("component_type", "") - component_code = component_section.get("component_code", "") - try: - namespace, type_name = component_type_str.split(":", 1) - except ValueError as exc: - raise serializers.ValidationError( - {"component": f"Invalid component_type format: {component_type_str!r}. " - "Expected '{namespace}:{type_name}'."} - ) from exc - component_type_obj = components_api.get_or_create_component_type(namespace, type_name) - else: - # Ulmo (legacy) format: parse the entity key. - entity_key = attrs["key"] - try: - component_type_obj, component_code = ( - components_api.get_or_create_component_type_by_entity_key(entity_key) - ) - except ValueError as exc: - raise serializers.ValidationError({"key": str(exc)}) - attrs["component_type"] = component_type_obj - attrs["component_code"] = component_code + entity_key = attrs["key"] + try: + component_type_obj, component_code = _get_or_create_component_type_by_entity_key(entity_key) + attrs["component_type"] = component_type_obj + attrs["component_code"] = component_code + except ValueError as exc: + raise serializers.ValidationError({"key": str(exc)}) return attrs +def _get_or_create_component_type_by_entity_key(entity_key: str) -> tuple[ComponentType, str]: + """ + Get or create a ComponentType based on a full [entity].key string. + + The entity key is expected to be in the format + ``"{namespace}:{type_name}:{component_code}"``. This function will parse out + the ``namespace`` and ``type_name`` parts and use those to get or create the + ComponentType. + + Raises ValueError if the entity_key is not in the expected format. + + Historical note: In Ulmo, this function was part of the public API. This was + inappropriate because the exact format of entity_keys is just a convention + rather than something API callers should count on. That said, it is safe to + assume that in all "v1" archives, the components' entity keys are safe to + parse into (namespace, type, code). So, we have moved this parsing logic + from the public API to just this internal halper function. Future devs, + please do not make new external guarantees about the format of entity keys + (aka entity_refs). A future "v2" backup-restore format will drop this + assumption of parse-ability.. + """ + try: + namespace, type_name, component_code = entity_key.split(':', 2) + except ValueError as exc: + raise ValueError( + f"Invalid entity_key format: {entity_key!r}. " + "Expected format: '{namespace}:{type_name}:{component_code}'" + ) from exc + return components_api.get_or_create_component_type(namespace, type_name), component_code + + class ComponentVersionSerializer(EntityVersionSerializer): # pylint: disable=abstract-method """ Serializer for component versions. diff --git a/src/openedx_content/applets/backup_restore/toml.py b/src/openedx_content/applets/backup_restore/toml.py index 05f8b7f43..520d6b877 100644 --- a/src/openedx_content/applets/backup_restore/toml.py +++ b/src/openedx_content/applets/backup_restore/toml.py @@ -108,15 +108,6 @@ def _get_toml_publishable_entity_table( published_table.add(tomlkit.comment("unpublished: no published_version_num")) entity_table.add("published", published_table) - if hasattr(entity, "component"): - component = entity.component - component_table = tomlkit.table() - # Write component_type and component_code explicitly so that restore - # (Verawood and later) does not need to parse the entity key. - component_table.add("component_type", str(component.component_type)) - component_table.add("component_code", component.component_code) - entity_table.add("component", component_table) - if hasattr(entity, "container"): container_table = tomlkit.table() container_types = ["section", "subsection", "unit"] diff --git a/tests/openedx_content/applets/backup_restore/test_restore.py b/tests/openedx_content/applets/backup_restore/test_restore.py index fd62e8a6c..46d2f94ea 100644 --- a/tests/openedx_content/applets/backup_restore/test_restore.py +++ b/tests/openedx_content/applets/backup_restore/test_restore.py @@ -8,7 +8,6 @@ from django.core.management import call_command from django.test import TestCase -from openedx_content.applets.backup_restore.serializers import ComponentSerializer from openedx_content.applets.backup_restore.zipper import LearningPackageUnzipper, generate_staged_lp_key from openedx_content.applets.collections import api as collections_api from openedx_content.applets.components import api as components_api @@ -361,80 +360,6 @@ def test_success_metadata_using_user_context(self): assert metadata == expected_metadata -class ComponentSerializerBackcompatTest(TestCase): - """ - Unit tests for ComponentSerializer's back-compat handling of Ulmo archives - (entity key only) vs. Verawood+ archives ([entity.component] section). - """ - - BASE_DATA = { - "can_stand_alone": True, - "key": "xblock.v1:problem:my_code", - "created": "2025-09-04T22:51:59Z", - } - - def _serialize(self, extra=None, base=None): - data = {**(base or self.BASE_DATA), **(extra or {})} - s = ComponentSerializer(data=data) - s.is_valid() - return s - - def test_legacy_entity_key_parsed(self): - """Ulmo archives have no [entity.component] section; fall back to parsing the entity key.""" - s = self._serialize() - assert s.is_valid(), s.errors - assert s.validated_data["component_type"].namespace == "xblock.v1" - assert s.validated_data["component_type"].name == "problem" - assert s.validated_data["component_code"] == "my_code" - assert "component" not in s.validated_data - - def test_new_component_section(self): - """Verawood+ archives supply an explicit [entity.component] section.""" - s = self._serialize({ - "component": { - "component_type": "xblock.v1:problem", - "component_code": "my_code", - } - }) - assert s.is_valid(), s.errors - assert s.validated_data["component_type"].namespace == "xblock.v1" - assert s.validated_data["component_type"].name == "problem" - assert s.validated_data["component_code"] == "my_code" - assert "component" not in s.validated_data - - def test_component_section_overrides_key(self): - """When [entity.component] is present, it is used even if the key has different info.""" - s = self._serialize({ - "component": { - "component_type": "xblock.v1:html", - "component_code": "different_code", - } - }) - assert s.is_valid(), s.errors - assert s.validated_data["component_type"].name == "html" - assert s.validated_data["component_code"] == "different_code" - - def test_invalid_entity_key_format(self): - """Entity key without enough colons raises a validation error.""" - s = self._serialize(base={ - "can_stand_alone": True, - "key": "invalid-key", - "created": "2025-09-04T22:51:59Z", - }) - assert not s.is_valid() - - def test_invalid_component_type_format(self): - """component_type without a colon raises a validation error.""" - s = self._serialize({ - "component": { - "component_type": "no-colon-here", - "component_code": "my_code", - } - }) - assert not s.is_valid() - assert "component" in s.errors - - class RestoreUtilitiesTest(TestCase): """Tests for utility functions used in the restore process.""" From 8707382c88a9a7955e8ba17a8954b7e980b6d973 Mon Sep 17 00:00:00 2001 From: Kyle D McCormick Date: Mon, 20 Apr 2026 14:10:33 -0400 Subject: [PATCH 4/4] build: bump to 0.41.0 --- src/openedx_core/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openedx_core/__init__.py b/src/openedx_core/__init__.py index b5fa534ec..c232e21a0 100644 --- a/src/openedx_core/__init__.py +++ b/src/openedx_core/__init__.py @@ -6,4 +6,4 @@ """ # The version for the entire repository -__version__ = "0.40.0" +__version__ = "0.41.0"