diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index a1626a576..4e2a82cb3 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -820,6 +820,12 @@ def _save_container( entity_key = data.get("key") container = containers_api.create_container( learning_package.id, + # As of Verawood, the primary identity of a container is its + # `container_code`. By convention, this equals the entity's + # `key` (aka `entity_ref`). It's safe to assume that all "v1" + # archives have an identical `key` and `container_code` for each + # entity-container. This assumpion may not hold true v2+. + container_code=data.pop("key"), **data, # should this be allowed to override any of the following fields? created_by=self.user_id, container_cls=container_cls, diff --git a/src/openedx_content/applets/containers/admin.py b/src/openedx_content/applets/containers/admin.py index df2a60ecd..45784bd9c 100644 --- a/src/openedx_content/applets/containers/admin.py +++ b/src/openedx_content/applets/containers/admin.py @@ -89,11 +89,13 @@ class ContainerAdmin(ReadOnlyModelAdmin): Django admin configuration for Container """ - list_display = ("key", "container_type_display", "published", "draft", "created") + list_display = ("container_code", "container_type_display", "published", "draft", "created") fields = [ "pk", "publishable_entity", "learning_package", + "container_code", + "container_type_display", "published", "draft", "created", @@ -101,8 +103,9 @@ class ContainerAdmin(ReadOnlyModelAdmin): "see_also", "most_recent_parent_entity_list", ] + # container_code is a model field; container_type_display is a method readonly_fields = fields # type: ignore[assignment] - search_fields = ["publishable_entity__uuid", "publishable_entity__key"] + search_fields = ["publishable_entity__uuid", "publishable_entity__key", "container_code"] inlines = [ContainerVersionInlineForContainer] def learning_package(self, obj: Container) -> SafeText: @@ -184,7 +187,7 @@ class ContainerVersionInlineForEntityList(admin.TabularInline): fields = [ "pk", "version_num", - "container_key", + "container_code", "title", "created", "created_by", @@ -203,8 +206,8 @@ def get_queryset(self, request): ) ) - def container_key(self, obj: ContainerVersion) -> SafeText: - return model_detail_link(obj.container, obj.container.key) + def container_code(self, obj: ContainerVersion) -> SafeText: + return model_detail_link(obj.container, obj.container.container_code) class EntityListRowInline(admin.TabularInline): diff --git a/src/openedx_content/applets/containers/api.py b/src/openedx_content/applets/containers/api.py index 9fe192ea8..db42a98ea 100644 --- a/src/openedx_content/applets/containers/api.py +++ b/src/openedx_content/applets/containers/api.py @@ -136,7 +136,7 @@ def parse(entities: EntityListInput) -> list[ParsedEntityReference]: def create_container( learning_package_id: LearningPackage.ID, - key: str, + container_code: str, created: datetime, created_by: int | None, *, @@ -149,7 +149,8 @@ def create_container( Args: learning_package_id: The ID of the learning package that contains the container. - key: The key of the container. + container_code: A local slug identifier for the container, unique within + the learning package (regardless of container type). created: The date and time the container was created. created_by: The ID of the user who created the container container_cls: The subclass of container to create (e.g. `Unit`) @@ -161,15 +162,20 @@ def create_container( assert issubclass(container_cls, Container) assert container_cls is not Container, "Creating plain containers is not allowed; use a subclass of Container" with atomic(): + # By convention, a Container's entity_key is set to its container_code. + # Do not bake this assumption into other systems. We may change it at some point. + entity_key = container_code publishable_entity = publishing_api.create_publishable_entity( learning_package_id, - key, + entity_key, created, created_by, can_stand_alone=can_stand_alone, ) container = container_cls.objects.create( publishable_entity=publishable_entity, + container_code=container_code, + learning_package_id=learning_package_id, container_type=container_cls.get_container_type(), ) return container @@ -339,7 +345,7 @@ def create_container_version( def create_container_and_version( learning_package_id: LearningPackage.ID, - key: str, + container_code: str, *, title: str, container_cls: type[ContainerModel], @@ -353,7 +359,8 @@ def create_container_and_version( Args: learning_package_id: The learning package ID. - key: The key. + container_code: A local slug identifier for the container, unique within + the learning package (regardless of container type). title: The title of the new container. container_cls: The subclass of container to create (e.g. Unit) entities: List of the entities that will comprise the entity list, in @@ -368,7 +375,7 @@ def create_container_and_version( with atomic(savepoint=False): container = create_container( learning_package_id, - key, + container_code, created, created_by, can_stand_alone=can_stand_alone, diff --git a/src/openedx_content/applets/containers/models.py b/src/openedx_content/applets/containers/models.py index ece19c799..8f9de8d23 100644 --- a/src/openedx_content/applets/containers/models.py +++ b/src/openedx_content/applets/containers/models.py @@ -11,8 +11,9 @@ from django.db import models from typing_extensions import deprecated -from openedx_django_lib.fields import case_sensitive_char_field +from openedx_django_lib.fields import case_sensitive_char_field, code_field, code_field_check +from ..publishing.models.learning_package import LearningPackage from ..publishing.models.publishable_entity import ( PublishableEntity, PublishableEntityMixin, @@ -171,6 +172,12 @@ class Container(PublishableEntityMixin): olx_tag_name: str = "" _type_instance: ContainerType # Cache used by get_container_type() + # This foreign key is technically redundant because we're already locked to + # a single LearningPackage through our publishable_entity relation. However, + # having this foreign key directly allows us to make indexes that efficiently + # query by other Container fields within a given LearningPackage. + learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) + # The type of the container. Cannot be changed once the container is created. container_type = models.ForeignKey( ContainerType, @@ -179,6 +186,11 @@ class Container(PublishableEntityMixin): editable=False, ) + # container_code is an identifier that is local to the learning_package. + # Unlike component_code, it is unique across all container types within + # the same LearningPackage. + container_code = code_field() + @property def id(self) -> ID: return cast(Container.ID, self.publishable_entity_id) @@ -194,6 +206,15 @@ def pk(self): # override this with a deprecated marker, so it shows a warning in developer's IDEs like VS Code. return self.id + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["learning_package", "container_code"], + name="oel_container_uniq_lp_cc", + ), + code_field_check("container_code", name="oel_container_code_regex"), + ] + @classmethod def validate_entity(cls, entity: PublishableEntity) -> None: """ diff --git a/src/openedx_content/applets/sections/api.py b/src/openedx_content/applets/sections/api.py index 0aee1c7bd..5449235eb 100644 --- a/src/openedx_content/applets/sections/api.py +++ b/src/openedx_content/applets/sections/api.py @@ -31,7 +31,7 @@ def get_section(section_id: Section.ID, /): def create_section_and_version( learning_package_id: LearningPackage.ID, - key: str, + container_code: str, *, title: str, subsections: Iterable[Subsection | SubsectionVersion] | None = None, @@ -48,7 +48,7 @@ def create_section_and_version( """ section, sv = containers_api.create_container_and_version( learning_package_id, - key=key, + container_code=container_code, title=title, entities=subsections, created=created, diff --git a/src/openedx_content/applets/subsections/api.py b/src/openedx_content/applets/subsections/api.py index 5d2a6cdb0..1d9544990 100644 --- a/src/openedx_content/applets/subsections/api.py +++ b/src/openedx_content/applets/subsections/api.py @@ -31,7 +31,7 @@ def get_subsection(subsection_id: Subsection.ID, /): def create_subsection_and_version( learning_package_id: LearningPackage.ID, - key: str, + container_code: str, *, title: str, units: Iterable[Unit | UnitVersion] | None = None, @@ -48,7 +48,7 @@ def create_subsection_and_version( """ subsection, sv = containers_api.create_container_and_version( learning_package_id, - key=key, + container_code=container_code, title=title, entities=units, created=created, diff --git a/src/openedx_content/applets/units/api.py b/src/openedx_content/applets/units/api.py index cde86bf0b..415ebf291 100644 --- a/src/openedx_content/applets/units/api.py +++ b/src/openedx_content/applets/units/api.py @@ -31,7 +31,7 @@ def get_unit(unit_id: Unit.ID, /): def create_unit_and_version( learning_package_id: LearningPackage.ID, - key: str, + container_code: str, *, title: str, components: Iterable[Component | ComponentVersion] | None = None, @@ -48,7 +48,7 @@ def create_unit_and_version( """ unit, uv = containers_api.create_container_and_version( learning_package_id, - key=key, + container_code=container_code, title=title, entities=components, created=created, diff --git a/src/openedx_content/migrations/0010_add_container_code.py b/src/openedx_content/migrations/0010_add_container_code.py new file mode 100644 index 000000000..4dc83cd6a --- /dev/null +++ b/src/openedx_content/migrations/0010_add_container_code.py @@ -0,0 +1,97 @@ +import re + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + +import openedx_django_lib.fields + + +def backfill_container_code(apps, schema_editor): + """ + Backfill container_code and learning_package from publishable_entity. + + For existing containers, container_code is set to the entity key (the + only identifier available at this point). Future containers will have + container_code set by the caller. + """ + Container = apps.get_model("openedx_content", "Container") + for container in Container.objects.select_related("publishable_entity__learning_package").all(): + container.learning_package = container.publishable_entity.learning_package + container.container_code = container.publishable_entity.key + container.save(update_fields=["learning_package", "container_code"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("openedx_content", "0009_rename_component_local_key_to_component_code"), + ] + + operations = [ + # 1. Add learning_package FK (nullable initially for backfill) + migrations.AddField( + model_name="container", + name="learning_package", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="openedx_content.learningpackage", + ), + ), + # 2. Add container_code (nullable initially for backfill) + migrations.AddField( + model_name="container", + name="container_code", + field=openedx_django_lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, + max_length=255, + null=True, + ), + ), + # 3. Backfill both fields from publishable_entity + migrations.RunPython(backfill_container_code, migrations.RunPython.noop), + # 4. Make both fields non-nullable and add regex validation to container_code + migrations.AlterField( + model_name="container", + name="learning_package", + field=models.ForeignKey( + null=False, + on_delete=django.db.models.deletion.CASCADE, + to="openedx_content.learningpackage", + ), + ), + migrations.AlterField( + model_name="container", + name="container_code", + field=openedx_django_lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, + max_length=255, + validators=[ + django.core.validators.RegexValidator( + re.compile(r"^[a-zA-Z0-9_.-]+\Z"), + "Enter a valid \"code name\" consisting of letters, numbers, " + "underscores, hyphens, or periods.", + "invalid", + ), + ], + ), + ), + # 5. Add uniqueness constraint + migrations.AddConstraint( + model_name="container", + constraint=models.UniqueConstraint( + fields=["learning_package", "container_code"], + name="oel_container_uniq_lp_cc", + ), + ), + # 6. Add db-level regex validation + migrations.AddConstraint( + model_name='container', + constraint=models.CheckConstraint( + condition=django.db.models.lookups.Regex(models.F('container_code'), '^[a-zA-Z0-9_.-]+\\Z'), + name='oel_container_code_regex', + violation_error_message='Enter a valid "code name" consisting of letters, numbers, underscores, hyphens, or periods.', + ), + ), + ] diff --git a/src/openedx_core/__init__.py b/src/openedx_core/__init__.py index c232e21a0..356ebefd6 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.41.0" +__version__ = "0.42.0" diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py index 6e9dc733c..8f60ff20a 100644 --- a/tests/openedx_content/applets/backup_restore/test_backup.py +++ b/tests/openedx_content/applets/backup_restore/test_backup.py @@ -160,7 +160,7 @@ def setUpTestData(cls): api.create_container( learning_package_id=cls.learning_package.id, - key="unit-1", + container_code="unit-1", created=cls.now, created_by=cls.user.id, container_cls=Unit, diff --git a/tests/openedx_content/applets/collections/test_api.py b/tests/openedx_content/applets/collections/test_api.py index daf839cfa..8bd52a1db 100644 --- a/tests/openedx_content/applets/collections/test_api.py +++ b/tests/openedx_content/applets/collections/test_api.py @@ -269,7 +269,7 @@ def setUpTestData(cls) -> None: created_time = datetime(2025, 4, 1, tzinfo=timezone.utc) cls.draft_unit = api.create_container( learning_package_id=cls.learning_package.id, - key="unit-1", + container_code="unit-1", created=created_time, created_by=cls.user.id, container_cls=Unit, diff --git a/tests/openedx_content/applets/containers/test_api.py b/tests/openedx_content/applets/containers/test_api.py index f40f16cd5..a902578f7 100644 --- a/tests/openedx_content/applets/containers/test_api.py +++ b/tests/openedx_content/applets/containers/test_api.py @@ -141,13 +141,16 @@ def _other_lp_child(lp2: LearningPackage) -> TestEntity: def create_test_container( - learning_package: LearningPackage, key: str, entities: containers_api.EntityListInput, title: str = "" + learning_package: LearningPackage, + container_code: str, + entities: containers_api.EntityListInput, + title: str = "", ) -> TestContainer: """Create a TestContainer with a draft version""" container, _version = containers_api.create_container_and_version( learning_package.id, - key=key, - title=title or f"Container ({key})", + container_code=container_code, + title=title or f"Container ({container_code})", entities=entities, container_cls=TestContainer, created=now, @@ -161,7 +164,7 @@ def _parent_of_two(lp: LearningPackage, child_entity1: TestEntity, child_entity2 """An TestContainer with two children""" return create_test_container( lp, - key="parent_of_two", + container_code="parent_of_two", title="Generic Container with Two Unpinned Children", entities=[child_entity1, child_entity2], ) @@ -177,7 +180,7 @@ def _parent_of_three( """An TestContainer with three children, two of which are pinned""" return create_test_container( lp, - key="parent_of_three", + container_code="parent_of_three", title="Generic Container with Two 📌 Pinned Children and One Unpinned", entities=[child_entity3.versioning.draft, child_entity2.versioning.draft, child_entity1], ) @@ -193,7 +196,7 @@ def _parent_of_six( """An TestContainer with six children, two of each entity, with different pinned combinations""" return create_test_container( lp, - key="parent_of_six", + container_code="parent_of_six", title="Generic Container with Two 📌 Pinned Children and One Unpinned", entities=[ # 1: both unpinned, 2: both pinned, and 3: pinned and unpinned @@ -216,7 +219,7 @@ def _grandparent( """An ContainerContainer with two unpinned children""" grandparent, _version = containers_api.create_container_and_version( lp.id, - key="grandparent", + container_code="grandparent", title="Generic Container with Two Unpinned TestContainer children", entities=[parent_of_two, parent_of_three], container_cls=ContainerContainer, @@ -235,7 +238,7 @@ def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: TestEntit # First create a TestContainer, then we'll modify it to simulate it being from an uninstalled plugin container, _ = containers_api.create_container_and_version( lp.id, - key="abandoned-container", + container_code="abandoned-container", title="Abandoned Container 1", entities=[child_entity1], container_cls=TestContainer, @@ -252,7 +255,7 @@ def _other_lp_parent(lp2: LearningPackage, other_lp_child: TestEntity) -> TestCo """An TestContainer with one child""" other_lp_parent, _version = containers_api.create_container_and_version( lp2.id, - key="other_lp_parent", + container_code="other_lp_parent", title="Generic Container with One Unpinned Child Entity", entities=[other_lp_child], container_cls=TestContainer, @@ -302,7 +305,7 @@ def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None """ container, container_v1 = containers_api.create_container_and_version( lp.id, - key="new-container-1", + container_code="new-container-1", title="Test Container 1", container_cls=TestContainer, created=now, @@ -356,10 +359,10 @@ def test_create_container_queries(lp: LearningPackage, child_entity1: TestEntity } # The exact numbers here aren't too important - this is just to alert us if anything significant changes. with django_assert_num_queries(31): - containers_api.create_container_and_version(lp.id, key="c1", **base_args) + containers_api.create_container_and_version(lp.id, container_code="c1", **base_args) # And try with a a container that has children: with django_assert_num_queries(32): - containers_api.create_container_and_version(lp.id, key="c2", **base_args, entities=[child_entity1]) + containers_api.create_container_and_version(lp.id, container_code="c2", **base_args, entities=[child_entity1]) # versioning helpers @@ -1154,7 +1157,7 @@ def test_publishing_shared_component(lp: LearningPackage): lp.id, entities=[c1, c2, c3], title="Unit 1", - key="unit:1", + container_code="unit-1", created=now, created_by=None, container_cls=TestContainer, @@ -1163,7 +1166,7 @@ def test_publishing_shared_component(lp: LearningPackage): lp.id, entities=[c2, c4, c5], title="Unit 2", - key="unit:2", + container_code="unit-2", created=now, created_by=None, container_cls=TestContainer, @@ -1276,7 +1279,7 @@ def test_deep_publish_log( # Create a "great grandparent" container that contains "grandparent" great_grandparent = create_test_container( lp, - key="great_grandparent", + container_code="great_grandparent", title="Great-grandparent container", entities=[grandparent], ) @@ -1401,7 +1404,7 @@ def test_snapshots_of_published_unit(lp: LearningPackage, child_entity1: TestEnt child_entity1_v1 = child_entity1.versioning.draft # At first the container has one child (unpinned): - container = create_test_container(lp, key="c", entities=[child_entity1]) + container = create_test_container(lp, container_code="c", entities=[child_entity1]) modify_entity(child_entity1, title="Component 1 as of checkpoint 1") _, before_publish = containers_api.get_entities_in_container_as_of(container, 0) assert not before_publish # Empty list diff --git a/tests/openedx_content/applets/sections/test_api.py b/tests/openedx_content/applets/sections/test_api.py index f4807640b..fd4b255fe 100644 --- a/tests/openedx_content/applets/sections/test_api.py +++ b/tests/openedx_content/applets/sections/test_api.py @@ -35,25 +35,25 @@ def setUp(self) -> None: "created_by": None, } self.unit_1, self.unit_1_v1 = content_api.create_unit_and_version( - key="unit_1", + container_code="unit_1", title="Grandchild Unit 1", components=[self.component_1, self.component_2], **common_args, ) self.unit_2, self.unit_2_v1 = content_api.create_unit_and_version( - key="unit_2", + container_code="unit_2", title="Grandchild Unit 2", components=[self.component_2, self.component_1], # Backwards order from Unit 1 **common_args, ) self.subsection_1, self.subsection_1_v1 = content_api.create_subsection_and_version( - key="subsection_1", + container_code="subsection_1", title="Child Subsection 1", units=[self.unit_1, self.unit_2], **common_args, ) self.subsection_2, self.subsection_2_v1 = content_api.create_subsection_and_version( - key="subsection_2", + container_code="subsection_2", title="Child Subsection 2", units=[self.unit_2, self.unit_1], # Backwards order from subsection 1 **common_args, @@ -64,12 +64,12 @@ def create_section_with_subsections( subsections: list[Subsection | SubsectionVersion], *, title="Section", - key="section:key", + container_code="section-key", ) -> Section: """Helper method to quickly create a section with some subsections""" section, _section_v1 = content_api.create_section_and_version( learning_package_id=self.learning_package.id, - key=key, + container_code=container_code, title=title, subsections=subsections, created=self.now, @@ -88,7 +88,7 @@ def test_create_empty_section_and_version(self) -> None: """ section, section_version = content_api.create_section_and_version( learning_package_id=self.learning_package.id, - key="section:key", + container_code="section-key", title="Section", created=self.now, created_by=None, @@ -196,7 +196,7 @@ def test_create_section_with_invalid_children(self): ValidationError, match='The entity "unit_1" cannot be added to a "section" container.', ): - self.create_section_with_subsections([self.unit_1], key="unit:key3", title="Unit 3") + self.create_section_with_subsections([self.unit_1], container_code="unit-key3", title="Unit 3") def test_is_registered(self): assert Section in content_api.get_all_container_subclasses() diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py index 74c7685b4..5d2378b90 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -30,7 +30,7 @@ def setUp(self) -> None: ) self.unit_1, self.unit_1_v1 = content_api.create_unit_and_version( learning_package_id=self.learning_package.id, - key="unit1", + container_code="unit1", title="Unit 1", components=[self.component_1, self.component_2], created=self.now, @@ -42,12 +42,12 @@ def create_subsection_with_units( units: list[Unit | UnitVersion], *, title="Subsection", - key="subsection:key", + container_code="subsection-key", ) -> Subsection: """Helper method to quickly create a unit with some units""" subsection, _subsection_v1 = content_api.create_subsection_and_version( learning_package_id=self.learning_package.id, - key=key, + container_code=container_code, title=title, units=units, created=self.now, @@ -66,7 +66,7 @@ def test_create_empty_subsection_and_version(self): """ subsection, subsection_version = content_api.create_subsection_and_version( learning_package_id=self.learning_package.id, - key="subsection:key", + container_code="subsection-key", title="Subsection", created=self.now, created_by=None, @@ -177,7 +177,7 @@ def test_create_subsection_with_invalid_children(self): ValidationError, 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") + self.create_subsection_with_units([self.component_1], container_code="unit-key3", title="Unit 3") def test_is_registered(self): assert Subsection in content_api.get_all_container_subclasses() diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 364970a8a..349ac36b2 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -35,12 +35,12 @@ def create_unit_with_components( components: list[Component | ComponentVersion], *, title="Unit", - key="unit:key", + container_code="unit-key", ) -> Unit: """Helper method to quickly create a unit with some components""" unit, _unit_v1 = content_api.create_unit_and_version( learning_package_id=self.learning_package.id, - key=key, + container_code=container_code, title=title, components=components, created=self.now, @@ -59,7 +59,7 @@ def test_create_empty_unit_and_version(self): """ unit, unit_version = content_api.create_unit_and_version( learning_package_id=self.learning_package.id, - key="unit:key", + container_code="unit-key", title="Unit", created=self.now, created_by=None, @@ -120,7 +120,7 @@ def test_get_unit_other_container_type(self) -> None: """Test `get_unit()` when the provided ID is for a non-Unit container""" other_container = content_api.create_container( self.learning_package.id, - key="test", + container_code="test", created=self.now, created_by=None, container_cls=TestContainer, @@ -153,11 +153,11 @@ def test_create_unit_with_invalid_children(self): # Create two units: unit = self.create_unit_with_components([]) unit_version = unit.versioning.draft - unit2 = self.create_unit_with_components([], key="unit:key2", title="Unit 2") + unit2 = self.create_unit_with_components([], container_code="unit-key2", title="Unit 2") # Try adding a Unit to a Unit with pytest.raises( - ValidationError, match='The entity "unit:key2" cannot be added to a "unit" container.' + ValidationError, match='The entity "unit-key2" cannot be added to a "unit" container.' ) as err: content_api.create_next_unit_version( unit, @@ -187,8 +187,8 @@ def test_create_unit_with_invalid_children(self): assert unit.versioning.draft == unit_version # Also check that `create_unit_and_version()` has the same restriction (not just `create_next_unit_version()`) - with pytest.raises(ValidationError, match='The entity "unit:key2" cannot be added to a "unit" container.'): - self.create_unit_with_components([unit2], key="unit:key3", title="Unit 3") + with pytest.raises(ValidationError, match='The entity "unit-key2" cannot be added to a "unit" container.'): + self.create_unit_with_components([unit2], container_code="unit-key3", title="Unit 3") def test_is_registered(self): assert Unit in content_api.get_all_container_subclasses()