Skip to content

Commit 1511a5b

Browse files
kdmccormickclaude
andauthored
feat!: Add Container.container_code field (#545)
Adds a container_code field (code_field) and a learning_package FK to the Container model. Also adds a UniqueConstraint on (learning_package, container_code), which is stricter than Component's constraint (no type scoping -- container codes must be unique across all container types within a given LearningPackage). For existing containers, container_code is backfilled from the entity key via a data migration. Future containers will have container_code set explicitly by the caller. BREAKING CHANGE: In create_container() and create_container_and_version(), the `key` argument has been renamed to `container_code`. The same applies to create_unit_and_version(), create_subsection_and_version(), and create_section_and_version(). Backup-restore format is unchanged. container_code will be set to the value of the entity key. This may change in a future "v2" backup format. Bumps the version to 0.42.0 Part of: #322 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cbc344b commit 1511a5b

15 files changed

Lines changed: 195 additions & 58 deletions

File tree

src/openedx_content/applets/backup_restore/zipper.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,12 @@ def _save_container(
820820
entity_key = data.get("key")
821821
container = containers_api.create_container(
822822
learning_package.id,
823+
# As of Verawood, the primary identity of a container is its
824+
# `container_code`. By convention, this equals the entity's
825+
# `key` (aka `entity_ref`). It's safe to assume that all "v1"
826+
# archives have an identical `key` and `container_code` for each
827+
# entity-container. This assumpion may not hold true v2+.
828+
container_code=data.pop("key"),
823829
**data, # should this be allowed to override any of the following fields?
824830
created_by=self.user_id,
825831
container_cls=container_cls,

src/openedx_content/applets/containers/admin.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,23 @@ class ContainerAdmin(ReadOnlyModelAdmin):
8989
Django admin configuration for Container
9090
"""
9191

92-
list_display = ("key", "container_type_display", "published", "draft", "created")
92+
list_display = ("container_code", "container_type_display", "published", "draft", "created")
9393
fields = [
9494
"pk",
9595
"publishable_entity",
9696
"learning_package",
97+
"container_code",
98+
"container_type_display",
9799
"published",
98100
"draft",
99101
"created",
100102
"created_by",
101103
"see_also",
102104
"most_recent_parent_entity_list",
103105
]
106+
# container_code is a model field; container_type_display is a method
104107
readonly_fields = fields # type: ignore[assignment]
105-
search_fields = ["publishable_entity__uuid", "publishable_entity__key"]
108+
search_fields = ["publishable_entity__uuid", "publishable_entity__key", "container_code"]
106109
inlines = [ContainerVersionInlineForContainer]
107110

108111
def learning_package(self, obj: Container) -> SafeText:
@@ -184,7 +187,7 @@ class ContainerVersionInlineForEntityList(admin.TabularInline):
184187
fields = [
185188
"pk",
186189
"version_num",
187-
"container_key",
190+
"container_code",
188191
"title",
189192
"created",
190193
"created_by",
@@ -203,8 +206,8 @@ def get_queryset(self, request):
203206
)
204207
)
205208

206-
def container_key(self, obj: ContainerVersion) -> SafeText:
207-
return model_detail_link(obj.container, obj.container.key)
209+
def container_code(self, obj: ContainerVersion) -> SafeText:
210+
return model_detail_link(obj.container, obj.container.container_code)
208211

209212

210213
class EntityListRowInline(admin.TabularInline):

src/openedx_content/applets/containers/api.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def parse(entities: EntityListInput) -> list[ParsedEntityReference]:
136136

137137
def create_container(
138138
learning_package_id: LearningPackage.ID,
139-
key: str,
139+
container_code: str,
140140
created: datetime,
141141
created_by: int | None,
142142
*,
@@ -149,7 +149,8 @@ def create_container(
149149
150150
Args:
151151
learning_package_id: The ID of the learning package that contains the container.
152-
key: The key of the container.
152+
container_code: A local slug identifier for the container, unique within
153+
the learning package (regardless of container type).
153154
created: The date and time the container was created.
154155
created_by: The ID of the user who created the container
155156
container_cls: The subclass of container to create (e.g. `Unit`)
@@ -161,15 +162,20 @@ def create_container(
161162
assert issubclass(container_cls, Container)
162163
assert container_cls is not Container, "Creating plain containers is not allowed; use a subclass of Container"
163164
with atomic():
165+
# By convention, a Container's entity_key is set to its container_code.
166+
# Do not bake this assumption into other systems. We may change it at some point.
167+
entity_key = container_code
164168
publishable_entity = publishing_api.create_publishable_entity(
165169
learning_package_id,
166-
key,
170+
entity_key,
167171
created,
168172
created_by,
169173
can_stand_alone=can_stand_alone,
170174
)
171175
container = container_cls.objects.create(
172176
publishable_entity=publishable_entity,
177+
container_code=container_code,
178+
learning_package_id=learning_package_id,
173179
container_type=container_cls.get_container_type(),
174180
)
175181
return container
@@ -339,7 +345,7 @@ def create_container_version(
339345

340346
def create_container_and_version(
341347
learning_package_id: LearningPackage.ID,
342-
key: str,
348+
container_code: str,
343349
*,
344350
title: str,
345351
container_cls: type[ContainerModel],
@@ -353,7 +359,8 @@ def create_container_and_version(
353359
354360
Args:
355361
learning_package_id: The learning package ID.
356-
key: The key.
362+
container_code: A local slug identifier for the container, unique within
363+
the learning package (regardless of container type).
357364
title: The title of the new container.
358365
container_cls: The subclass of container to create (e.g. Unit)
359366
entities: List of the entities that will comprise the entity list, in
@@ -368,7 +375,7 @@ def create_container_and_version(
368375
with atomic(savepoint=False):
369376
container = create_container(
370377
learning_package_id,
371-
key,
378+
container_code,
372379
created,
373380
created_by,
374381
can_stand_alone=can_stand_alone,

src/openedx_content/applets/containers/models.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
from django.db import models
1212
from typing_extensions import deprecated
1313

14-
from openedx_django_lib.fields import case_sensitive_char_field
14+
from openedx_django_lib.fields import case_sensitive_char_field, code_field, code_field_check
1515

16+
from ..publishing.models.learning_package import LearningPackage
1617
from ..publishing.models.publishable_entity import (
1718
PublishableEntity,
1819
PublishableEntityMixin,
@@ -171,6 +172,12 @@ class Container(PublishableEntityMixin):
171172
olx_tag_name: str = ""
172173
_type_instance: ContainerType # Cache used by get_container_type()
173174

175+
# This foreign key is technically redundant because we're already locked to
176+
# a single LearningPackage through our publishable_entity relation. However,
177+
# having this foreign key directly allows us to make indexes that efficiently
178+
# query by other Container fields within a given LearningPackage.
179+
learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE)
180+
174181
# The type of the container. Cannot be changed once the container is created.
175182
container_type = models.ForeignKey(
176183
ContainerType,
@@ -179,6 +186,11 @@ class Container(PublishableEntityMixin):
179186
editable=False,
180187
)
181188

189+
# container_code is an identifier that is local to the learning_package.
190+
# Unlike component_code, it is unique across all container types within
191+
# the same LearningPackage.
192+
container_code = code_field()
193+
182194
@property
183195
def id(self) -> ID:
184196
return cast(Container.ID, self.publishable_entity_id)
@@ -194,6 +206,15 @@ def pk(self):
194206
# override this with a deprecated marker, so it shows a warning in developer's IDEs like VS Code.
195207
return self.id
196208

209+
class Meta:
210+
constraints = [
211+
models.UniqueConstraint(
212+
fields=["learning_package", "container_code"],
213+
name="oel_container_uniq_lp_cc",
214+
),
215+
code_field_check("container_code", name="oel_container_code_regex"),
216+
]
217+
197218
@classmethod
198219
def validate_entity(cls, entity: PublishableEntity) -> None:
199220
"""

src/openedx_content/applets/sections/api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def get_section(section_id: Section.ID, /):
3131

3232
def create_section_and_version(
3333
learning_package_id: LearningPackage.ID,
34-
key: str,
34+
container_code: str,
3535
*,
3636
title: str,
3737
subsections: Iterable[Subsection | SubsectionVersion] | None = None,
@@ -48,7 +48,7 @@ def create_section_and_version(
4848
"""
4949
section, sv = containers_api.create_container_and_version(
5050
learning_package_id,
51-
key=key,
51+
container_code=container_code,
5252
title=title,
5353
entities=subsections,
5454
created=created,

src/openedx_content/applets/subsections/api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def get_subsection(subsection_id: Subsection.ID, /):
3131

3232
def create_subsection_and_version(
3333
learning_package_id: LearningPackage.ID,
34-
key: str,
34+
container_code: str,
3535
*,
3636
title: str,
3737
units: Iterable[Unit | UnitVersion] | None = None,
@@ -48,7 +48,7 @@ def create_subsection_and_version(
4848
"""
4949
subsection, sv = containers_api.create_container_and_version(
5050
learning_package_id,
51-
key=key,
51+
container_code=container_code,
5252
title=title,
5353
entities=units,
5454
created=created,

src/openedx_content/applets/units/api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def get_unit(unit_id: Unit.ID, /):
3131

3232
def create_unit_and_version(
3333
learning_package_id: LearningPackage.ID,
34-
key: str,
34+
container_code: str,
3535
*,
3636
title: str,
3737
components: Iterable[Component | ComponentVersion] | None = None,
@@ -48,7 +48,7 @@ def create_unit_and_version(
4848
"""
4949
unit, uv = containers_api.create_container_and_version(
5050
learning_package_id,
51-
key=key,
51+
container_code=container_code,
5252
title=title,
5353
entities=components,
5454
created=created,
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import re
2+
3+
import django.core.validators
4+
import django.db.models.deletion
5+
from django.db import migrations, models
6+
7+
import openedx_django_lib.fields
8+
9+
10+
def backfill_container_code(apps, schema_editor):
11+
"""
12+
Backfill container_code and learning_package from publishable_entity.
13+
14+
For existing containers, container_code is set to the entity key (the
15+
only identifier available at this point). Future containers will have
16+
container_code set by the caller.
17+
"""
18+
Container = apps.get_model("openedx_content", "Container")
19+
for container in Container.objects.select_related("publishable_entity__learning_package").all():
20+
container.learning_package = container.publishable_entity.learning_package
21+
container.container_code = container.publishable_entity.key
22+
container.save(update_fields=["learning_package", "container_code"])
23+
24+
25+
class Migration(migrations.Migration):
26+
27+
dependencies = [
28+
("openedx_content", "0009_rename_component_local_key_to_component_code"),
29+
]
30+
31+
operations = [
32+
# 1. Add learning_package FK (nullable initially for backfill)
33+
migrations.AddField(
34+
model_name="container",
35+
name="learning_package",
36+
field=models.ForeignKey(
37+
null=True,
38+
on_delete=django.db.models.deletion.CASCADE,
39+
to="openedx_content.learningpackage",
40+
),
41+
),
42+
# 2. Add container_code (nullable initially for backfill)
43+
migrations.AddField(
44+
model_name="container",
45+
name="container_code",
46+
field=openedx_django_lib.fields.MultiCollationCharField(
47+
db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"},
48+
max_length=255,
49+
null=True,
50+
),
51+
),
52+
# 3. Backfill both fields from publishable_entity
53+
migrations.RunPython(backfill_container_code, migrations.RunPython.noop),
54+
# 4. Make both fields non-nullable and add regex validation to container_code
55+
migrations.AlterField(
56+
model_name="container",
57+
name="learning_package",
58+
field=models.ForeignKey(
59+
null=False,
60+
on_delete=django.db.models.deletion.CASCADE,
61+
to="openedx_content.learningpackage",
62+
),
63+
),
64+
migrations.AlterField(
65+
model_name="container",
66+
name="container_code",
67+
field=openedx_django_lib.fields.MultiCollationCharField(
68+
db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"},
69+
max_length=255,
70+
validators=[
71+
django.core.validators.RegexValidator(
72+
re.compile(r"^[a-zA-Z0-9_.-]+\Z"),
73+
"Enter a valid \"code name\" consisting of letters, numbers, "
74+
"underscores, hyphens, or periods.",
75+
"invalid",
76+
),
77+
],
78+
),
79+
),
80+
# 5. Add uniqueness constraint
81+
migrations.AddConstraint(
82+
model_name="container",
83+
constraint=models.UniqueConstraint(
84+
fields=["learning_package", "container_code"],
85+
name="oel_container_uniq_lp_cc",
86+
),
87+
),
88+
# 6. Add db-level regex validation
89+
migrations.AddConstraint(
90+
model_name='container',
91+
constraint=models.CheckConstraint(
92+
condition=django.db.models.lookups.Regex(models.F('container_code'), '^[a-zA-Z0-9_.-]+\\Z'),
93+
name='oel_container_code_regex',
94+
violation_error_message='Enter a valid "code name" consisting of letters, numbers, underscores, hyphens, or periods.',
95+
),
96+
),
97+
]

src/openedx_core/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,4 @@
66
"""
77

88
# The version for the entire repository
9-
__version__ = "0.41.0"
9+
__version__ = "0.42.0"

tests/openedx_content/applets/backup_restore/test_backup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def setUpTestData(cls):
160160

161161
api.create_container(
162162
learning_package_id=cls.learning_package.id,
163-
key="unit-1",
163+
container_code="unit-1",
164164
created=cls.now,
165165
created_by=cls.user.id,
166166
container_cls=Unit,

0 commit comments

Comments
 (0)