Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions src/openedx_content/applets/components/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
1 change: 1 addition & 0 deletions src/openedx_content/applets/publishing/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,7 @@ def create_container(
)
container = container_cls.objects.create(
publishable_entity=publishable_entity,
container_type=container_cls.get_type(),
)
return container

Expand Down
62 changes: 58 additions & 4 deletions src/openedx_content/applets/publishing/models/container.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions src/openedx_content/applets/sections/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/openedx_content/applets/subsections/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/openedx_content/applets/units/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions src/openedx_content/migrations/0004_componenttype_constraint.py
Original file line number Diff line number Diff line change
@@ -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"),
),
]
82 changes: 82 additions & 0 deletions src/openedx_content/migrations/0005_containertypes.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
Loading