Skip to content
Open
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
36 changes: 23 additions & 13 deletions src/openedx_content/applets/backup_restore/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,35 +114,45 @@ class ComponentVersionSerializer(EntityVersionSerializer): # pylint: disable=ab
class ContainerSerializer(EntitySerializer): # pylint: disable=abstract-method
"""
Serializer for containers.

Extracts container_code from the [entity.container] section.
Archives created in Verawood or later include an explicit
``container_code`` field. Archives created in Ulmo do not, so we
fall back to using the entity key as the container_code.
"""

container = serializers.DictField(required=True)

def validate_container(self, value):
"""
Custom validation logic for the container field.
Ensures that the container dict has exactly one key which is one of
"section", "subsection", or "unit" values.
Ensures that the container dict has exactly one type key ("section",
"subsection", or "unit"), optionally alongside "container_code".
"""
errors = []
if not isinstance(value, dict) or len(value) != 1:
errors.append("Container must be a dict with exactly one key.")
if len(value) == 1: # Only check the key if there is exactly one
container_type = list(value.keys())[0]
if container_type not in ("section", "subsection", "unit"):
errors.append(f"Invalid container value: {container_type}")
type_keys = [k for k in value if k in ("section", "subsection", "unit")]
if len(type_keys) != 1:
errors.append(
"Container must have exactly one type key: 'section', 'subsection', or 'unit'."
)
if errors:
raise serializers.ValidationError(errors)
return value

def validate(self, attrs):
"""
Custom validation logic:
parse the container dict to extract the container type.
Custom validation logic: extract container_type and container_code.

Archives created in Verawood or later supply an explicit
``container_code`` field inside [entity.container]. Archives created
in Ulmo do not, so we fall back to using the entity key.
"""
container = attrs["container"]
container_type = list(container.keys())[0] # It is safe to do this after validate_container
container = attrs.pop("container")
# It is safe to do this after validate_container
container_type = next(k for k in container if k in ("section", "subsection", "unit"))
attrs["container_type"] = container_type
attrs.pop("container") # Remove the container field after processing
# Verawood+: container_code is explicit. Ulmo: fall back to entity key.
attrs["container_code"] = container.get("container_code") or attrs["key"]
return attrs


Expand Down
6 changes: 5 additions & 1 deletion src/openedx_content/applets/backup_restore/toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,15 @@ def _get_toml_publishable_entity_table(
entity_table.add("component", component_table)

if hasattr(entity, "container"):
container = entity.container
container_table = tomlkit.table()
# Write container_code explicitly so that restore (Verawood and later)
# does not need to parse the entity key.
container_table.add("container_code", container.container_code)
container_types = ["section", "subsection", "unit"]

for container_type in container_types:
if hasattr(entity.container, container_type):
if hasattr(container, container_type):
container_table.add(container_type, tomlkit.table())
break # stop after the first match

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
9 changes: 9 additions & 0 deletions src/openedx_content/applets/containers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ def create_container(
created: datetime,
created_by: int | None,
*,
container_code: str,
container_cls: type[ContainerModel],
can_stand_alone: bool = True,
) -> ContainerModel:
Expand All @@ -152,6 +153,8 @@ def create_container(
key: The key of the container.
created: The date and time the container was created.
created_by: The ID of the user who created the container
container_code: A local slug identifier for the container, unique within
the learning package (regardless of container type).
container_cls: The subclass of container to create (e.g. `Unit`)
can_stand_alone: Set to False when created as part of containers

Expand All @@ -170,7 +173,9 @@ def create_container(
)
container = container_cls.objects.create(
publishable_entity=publishable_entity,
learning_package_id=learning_package_id,
container_type=container_cls.get_container_type(),
container_code=container_code,
)
return container

Expand Down Expand Up @@ -341,6 +346,7 @@ def create_container_and_version(
learning_package_id: LearningPackage.ID,
key: str,
*,
container_code: str,
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.

The fact that this has key and container_code is just from how the PRs are split up right?

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.

Yep

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.

OK, I'm not sure why we have them as separate arguments here instead of just using container_code = key and then later renaming the key arg to container_code, but it really doesn't matter if it's just for this PR and it all get sorted out in the end.

title: str,
container_cls: type[ContainerModel],
entities: EntityListInput | None = None,
Expand All @@ -354,6 +360,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 @@ -371,6 +379,7 @@ def create_container_and_version(
key,
created,
created_by,
container_code=container_code,
can_stand_alone=can_stand_alone,
container_cls=container_cls,
)
Expand Down
22 changes: 21 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

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)
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.

Very much non-blocking:

I wish we had some way to enforce the integrity of these denormalized relationships.

One idea could be in the future to write a system check that inspects the database and verifies everything. System checks which depend on the database do not normally run on startup (for performance) but can by run manually with the ./manage.py check command.


# 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.
container_code = code_field()

@property
def id(self) -> ID:
return cast(Container.ID, self.publishable_entity_id)
Expand All @@ -194,6 +206,14 @@ 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",
),
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.

Optional: Suggest adding code constraint here too.

]

@classmethod
def validate_entity(cls, entity: PublishableEntity) -> None:
"""
Expand Down
2 changes: 2 additions & 0 deletions src/openedx_content/applets/sections/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def create_section_and_version(
learning_package_id: LearningPackage.ID,
key: str,
*,
container_code: str,
title: str,
subsections: Iterable[Subsection | SubsectionVersion] | None = None,
created: datetime,
Expand All @@ -49,6 +50,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
2 changes: 2 additions & 0 deletions src/openedx_content/applets/subsections/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def create_subsection_and_version(
learning_package_id: LearningPackage.ID,
key: str,
*,
container_code: str,
title: str,
units: Iterable[Unit | UnitVersion] | None = None,
created: datetime,
Expand All @@ -49,6 +50,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
2 changes: 2 additions & 0 deletions src/openedx_content/applets/units/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def create_unit_and_version(
learning_package_id: LearningPackage.ID,
key: str,
*,
container_code: str,
Comment on lines 34 to +36
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.

Suggested change
key: str,
*,
container_code: str,
container_code: str,
*,

This would make more sense to me, and then pass key=container_code, container_code=container_code or just container_code=container_code in the part below.

But if this is definitely just a temporary thing until the next PR merges, no problem.

title: str,
components: Iterable[Component | ComponentVersion] | None = None,
created: datetime,
Expand All @@ -49,6 +50,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
88 changes: 88 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,88 @@
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,
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.

It's not necessary to explicitly add null=False here?

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.

@ormsbee oof, thank you for catching that. All of collection_code, container_code, and component_code are missing null=False on their definition and migrations. I'll fix them all.

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",
),
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ def setUpTestData(cls):
key="unit-1",
created=cls.now,
created_by=cls.user.id,
container_code="unit-1",
container_cls=Unit,
)

Expand Down
Loading