diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py index e34a3977..3ec181fa 100644 --- a/src/openedx_content/applets/components/models.py +++ b/src/openedx_content/applets/components/models.py @@ -64,16 +64,16 @@ class ComponentType(models.Model): # the UsageKey. name = case_sensitive_char_field(max_length=100, blank=True) - # TODO: this needs to go into a class Meta - constraints = [ - models.UniqueConstraint( - fields=[ - "namespace", - "name", - ], - name="oel_component_type_uniq_ns_n", - ), - ] + class Meta: + constraints = [ + models.UniqueConstraint( + fields=[ + "namespace", + "name", + ], + name="oel_component_type_uniq_ns_n", + ), + ] def __str__(self) -> str: return f"{self.namespace}:{self.name}" diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 64b190a0..4a84db2a 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -1387,6 +1387,7 @@ def create_container( ) container = container_cls.objects.create( publishable_entity=publishable_entity, + container_type=container_cls.get_type(), ) return container diff --git a/src/openedx_content/applets/publishing/models/container.py b/src/openedx_content/applets/publishing/models/container.py index e34bb6a7..e3af58b4 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/publishing/models/container.py @@ -1,13 +1,48 @@ """ Container and ContainerVersion models """ + from django.core.exceptions import ValidationError from django.db import models +from openedx_django_lib.fields import case_sensitive_char_field + from .entity_list import EntityList from .publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin +class ContainerType(models.Model): + """ + Normalized representation of a type of Container. + + Typical container types are "unit", "subsection", and "section", but there + may be others in the future. + """ + + id = models.AutoField(primary_key=True) + + # name uniquely identifies the type of container. + # Plugins/apps that add their own ContainerTypes should prefix it, e.g. + # "myapp_custom_unit" instead of "custom_unit", to avoid collisions. + name = case_sensitive_char_field( + max_length=100, + blank=False, + unique=True, + ) + + class Meta: + constraints = [ + models.CheckConstraint( + # No whitespace, uppercase, or special characters allowed in "name". + condition=models.lookups.Regex(models.F("name"), r"^[a-z0-9\-_\.]+$"), + name="oex_publishing_containertype_name_rx", + ), + ] + + def __str__(self) -> str: + return self.name + + class Container(PublishableEntityMixin): """ A Container is a type of PublishableEntity that holds other @@ -18,11 +53,30 @@ class Container(PublishableEntityMixin): containers/components/enities they hold. As we complete the Containers API, we will also add support for dynamic containers which may contain different entities for different learners or at different times. - - NOTE: We're going to want to eventually have some association between the - PublishLog and Containers that were affected in a publish because their - child elements were published. """ + CONTAINER_TYPE: str = "" # Subclasses need to override this. + + # The type of the container. Cannot be changed once the container is created. + container_type = models.ForeignKey( + ContainerType, + null=False, + on_delete=models.RESTRICT, + editable=False, + ) + + @classmethod + def get_type(cls) -> ContainerType: + """ + Helper method to get the ContainerType for a given Container subclass. + + e.g. `assert Unit.get_type().name == "unit"` + """ + if cls is Container: + raise TypeError('Creating "naked" Containers is not allowed. Use a subclass of Container like Unit.') + assert cls.CONTAINER_TYPE, f"Container subclasses like {cls.__name__} must override CONTAINER_TYPE" + if not hasattr(cls, "_type_instance"): + cls._type_instance, _ = ContainerType.objects.get_or_create(name=cls.CONTAINER_TYPE) + return cls._type_instance class ContainerVersion(PublishableEntityVersionMixin): diff --git a/src/openedx_content/applets/sections/models.py b/src/openedx_content/applets/sections/models.py index afcb0ae0..007ef624 100644 --- a/src/openedx_content/applets/sections/models.py +++ b/src/openedx_content/applets/sections/models.py @@ -18,6 +18,8 @@ class Section(Container): Via Container and its PublishableEntityMixin, Sections are also publishable entities and can be added to other containers. """ + CONTAINER_TYPE = "section" + container = models.OneToOneField( Container, on_delete=models.CASCADE, diff --git a/src/openedx_content/applets/subsections/models.py b/src/openedx_content/applets/subsections/models.py index 8d662ed4..0bd47a95 100644 --- a/src/openedx_content/applets/subsections/models.py +++ b/src/openedx_content/applets/subsections/models.py @@ -18,6 +18,8 @@ class Subsection(Container): Via Container and its PublishableEntityMixin, Subsections are also publishable entities and can be added to other containers. """ + CONTAINER_TYPE = "subsection" + container = models.OneToOneField( Container, on_delete=models.CASCADE, diff --git a/src/openedx_content/applets/units/models.py b/src/openedx_content/applets/units/models.py index 0c525584..b92db564 100644 --- a/src/openedx_content/applets/units/models.py +++ b/src/openedx_content/applets/units/models.py @@ -18,6 +18,8 @@ class Unit(Container): Via Container and its PublishableEntityMixin, Units are also publishable entities and can be added to other containers. """ + CONTAINER_TYPE = "unit" + container = models.OneToOneField( Container, on_delete=models.CASCADE, diff --git a/src/openedx_content/migrations/0004_componenttype_constraint.py b/src/openedx_content/migrations/0004_componenttype_constraint.py new file mode 100644 index 00000000..f556d14b --- /dev/null +++ b/src/openedx_content/migrations/0004_componenttype_constraint.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.11 on 2026-03-02 23:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("openedx_content", "0003_rename_content_to_media"), + ] + + operations = [ + migrations.AddConstraint( + model_name="componenttype", + constraint=models.UniqueConstraint(fields=("namespace", "name"), name="oel_component_type_uniq_ns_n"), + ), + ] diff --git a/src/openedx_content/migrations/0005_containertypes.py b/src/openedx_content/migrations/0005_containertypes.py new file mode 100644 index 00000000..9e1b482d --- /dev/null +++ b/src/openedx_content/migrations/0005_containertypes.py @@ -0,0 +1,82 @@ +# Generated by Django 5.2.11 on 2026-03-03 00:54 + +import django.db.models.deletion +import django.db.models.lookups +import openedx_django_lib.fields +from django.db import migrations, models + + +def backfill_container_types(apps, schema_editor): + """ + Fill in the new, mandatory "container_type" foreign key field on all + existing containers. + """ + Container = apps.get_model("openedx_content", "Container") + ContainerType = apps.get_model("openedx_content", "ContainerType") + section_type, _ = ContainerType.objects.get_or_create(name="section") + subsection_type, _ = ContainerType.objects.get_or_create(name="subsection") + unit_type, _ = ContainerType.objects.get_or_create(name="unit") + + containers_to_update = Container.objects.filter(container_type=None) + + containers_to_update.exclude(section=None).update(container_type=section_type) + containers_to_update.exclude(subsection=None).update(container_type=subsection_type) + containers_to_update.exclude(unit=None).update(container_type=unit_type) + + unknown_containers = containers_to_update.all() + if unknown_containers: + raise ValueError(f"container {unknown_containers[0]} is of unknown container type. Cannot apply migration.") + + +class Migration(migrations.Migration): + dependencies = [ + ("openedx_content", "0004_componenttype_constraint"), + ] + + operations = [ + # 1. Create the new ContainerType model + migrations.CreateModel( + name="ContainerType", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ( + "name", + openedx_django_lib.fields.MultiCollationCharField( + db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, max_length=100, unique=True + ), + ), + ], + options={ + "constraints": [ + models.CheckConstraint( + condition=django.db.models.lookups.Regex(models.F("name"), "^[a-z0-9\\-_\\.]+$"), + name="oex_publishing_containertype_name_rx", + ) + ], + }, + ), + # 2. Define the ForeignKey from Container to ContainerType + migrations.AddField( + model_name="container", + name="container_type", + field=models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.RESTRICT, + to="openedx_content.containertype", + ), + ), + # 3. Populate the container_type column, which is currently NULL for all existing containers + migrations.RunPython(backfill_container_types), + # 4. disallow NULL values from now on + migrations.AlterField( + model_name="container", + name="container_type", + field=models.ForeignKey( + editable=False, + null=False, + on_delete=django.db.models.deletion.RESTRICT, + to="openedx_content.containertype", + ), + ), + ]