Skip to content
Merged
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
6 changes: 6 additions & 0 deletions src/openedx_content/applets/backup_restore/zipper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 8 additions & 5 deletions src/openedx_content/applets/containers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,20 +89,23 @@ 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",
"created_by",
"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:
Expand Down Expand Up @@ -184,7 +187,7 @@ class ContainerVersionInlineForEntityList(admin.TabularInline):
fields = [
"pk",
"version_num",
"container_key",
"container_code",
"title",
"created",
"created_by",
Expand All @@ -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):
Expand Down
19 changes: 13 additions & 6 deletions src/openedx_content/applets/containers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
*,
Expand All @@ -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`)
Expand All @@ -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
Expand Down Expand Up @@ -339,7 +345,7 @@ def create_container_version(

def create_container_and_version(
learning_package_id: LearningPackage.ID,
key: str,
container_code: str,
Comment thread
bradenmacdonald marked this conversation as resolved.
*,
title: str,
container_cls: type[ContainerModel],
Expand All @@ -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
Expand All @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion src/openedx_content/applets/containers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Comment thread
bradenmacdonald marked this conversation as resolved.

# The type of the container. Cannot be changed once the container is created.
container_type = models.ForeignKey(
ContainerType,
Expand All @@ -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.
Comment on lines +190 to +191
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will requiring this code to be unique across all containers in a [course] bite us later when we try to migrate old courses to Learning Core, since they may have e.g. a section and unit with the same container_code ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hard to say.

On one hand, we'll likely have multiple course runs per learning package. So, we'll need to handle collisions between container block url_names anyway. I'm imagining that there'll have to be a table which maps CourseRunUsages to Entities, rather than a simple mapping function between {component,container}_{type,code} and CourseRunUsageKeys.

On the other hand, it's weird to me that container_code and component_code have different uniqueness constraints. I have a feeling that the inconsistency will make consuming code more complicated rather than having any benefit to us.

We're riding the line now, but maybe we can sneak a fix to this in before the cutoff.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'd prefer to make container_code work like component_code if we can.

container_code = code_field()

@property
def id(self) -> ID:
return cast(Container.ID, self.publishable_entity_id)
Expand All @@ -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",
),
Comment thread
kdmccormick marked this conversation as resolved.
code_field_check("container_code", name="oel_container_code_regex"),
]

@classmethod
def validate_entity(cls, entity: PublishableEntity) -> None:
"""
Expand Down
4 changes: 2 additions & 2 deletions src/openedx_content/applets/sections/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/openedx_content/applets/subsections/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/openedx_content/applets/units/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
97 changes: 97 additions & 0 deletions src/openedx_content/migrations/0010_add_container_code.py
Original file line number Diff line number Diff line change
@@ -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"])
Comment on lines +18 to +22
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Comment, no action needed] It's really unfortunate that we can't use F() across relations. I feel like this is one of the few cases where I'd be tempted to use raw SQL.



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,
Comment thread
kdmccormick marked this conversation as resolved.
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.',
),
),
]
2 changes: 1 addition & 1 deletion src/openedx_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
"""

# The version for the entire repository
__version__ = "0.41.0"
__version__ = "0.42.0"
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion tests/openedx_content/applets/collections/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading