Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9225c68
fix: add missing constraint to ComponentType
bradenmacdonald Mar 2, 2026
c6a9e0d
docs: remove outdated comment
bradenmacdonald Mar 2, 2026
034cca9
feat: add new ContainerType model, refactor Containers implementation
bradenmacdonald Mar 3, 2026
6e60108
feat: move containers into a new applet
bradenmacdonald Mar 16, 2026
4be81b9
test: add a "deep publish" test to the containers API
bradenmacdonald Mar 17, 2026
ee2da60
feat: add a get_container_type() API to get a type class from type_code
bradenmacdonald Mar 17, 2026
72c3290
feat: store `olx_tag_name` in openedx_content's core container type m…
bradenmacdonald Mar 17, 2026
fe1b594
test: minor cleanups in containers/test_api
bradenmacdonald Mar 17, 2026
9f49bde
fix: query count discrepancy between MySQL and SQLite
bradenmacdonald Mar 17, 2026
e6d046f
perf: slightly reduce query count when computing publishing side effects
bradenmacdonald Mar 17, 2026
9f619af
chore: ignore mypy warning
bradenmacdonald Mar 17, 2026
f6f2833
refactor: ContainerType->ContainerSubclass, ContainerTypeRecord->Cont…
bradenmacdonald Mar 18, 2026
7a22abd
feat: improved container admin views
bradenmacdonald Mar 18, 2026
5117b50
chore: fix typing issues in admin views
bradenmacdonald Mar 19, 2026
6969fa1
fix: add missing type annotations
bradenmacdonald Mar 20, 2026
5d0127c
chore: fix whitespace
bradenmacdonald Mar 21, 2026
7f8386d
fix: prevent creation of plain Containers, clarify return type
bradenmacdonald Mar 23, 2026
f14b2d9
docs: clarify return type, minor optimization
bradenmacdonald Mar 23, 2026
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
4 changes: 3 additions & 1 deletion .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ layers=
# Problems, Videos, and blocks of HTML text. This is also the type we would
# associate with a single "leaf" XBlock–one that is not a container type and
# has no child elements.
openedx_content.applets.components
# The "containers" app is built on top of publishing, and is a peer to
# "components" but they do not depend on each other.
openedx_content.applets.components | openedx_content.applets.containers
Comment on lines +51 to +53
Copy link
Member

Choose a reason for hiding this comment

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

did not know about this syntax. cool!


# The "media" applet stores the simplest pieces of binary and text data,
# without versioning information. These belong to a single Learning Package.
Expand Down
4 changes: 1 addition & 3 deletions src/openedx_content/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
from .applets.backup_restore.admin import *
from .applets.collections.admin import *
from .applets.components.admin import *
from .applets.containers.admin import *
from .applets.media.admin import *
from .applets.publishing.admin import *
from .applets.sections.admin import *
from .applets.subsections.admin import *
from .applets.units.admin import *
1 change: 1 addition & 0 deletions src/openedx_content/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .applets.backup_restore.api import *
from .applets.collections.api import *
from .applets.components.api import *
from .applets.containers.api import *
from .applets.media.api import *
from .applets.publishing.api import *
from .applets.sections.api import *
Expand Down
4 changes: 2 additions & 2 deletions src/openedx_content/applets/backup_restore/toml.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from django.contrib.auth.models import User as UserType # pylint: disable=imported-auth-user

from ..collections.models import Collection
from ..publishing import api as publishing_api
from ..containers import api as containers_api
from ..publishing.models import PublishableEntity, PublishableEntityVersion
from ..publishing.models.learning_package import LearningPackage

Expand Down Expand Up @@ -191,7 +191,7 @@ def toml_publishable_entity_version(version: PublishableEntityVersion) -> tomlki
if hasattr(version, 'containerversion'):
# If the version has a container version, add its children
container_table = tomlkit.table()
children = publishing_api.get_container_children_entities_keys(version.containerversion)
children = containers_api.get_container_children_entities_keys(version.containerversion)
container_table.add("children", children)
version_table.add("container", container_table)
Comment on lines 192 to 196
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I noticed that this if branch of toml_publishable_entity_version is not covered by any tests. I think we need a lot more test coverage of the backup/restore format.

return version_table
Expand Down
169 changes: 76 additions & 93 deletions src/openedx_content/applets/backup_restore/zipper.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@

from ..collections import api as collections_api
from ..components import api as components_api
from ..containers import api as containers_api
from ..media import api as media_api
from ..publishing import api as publishing_api
from ..sections import api as sections_api
from ..subsections import api as subsections_api
from ..units import api as units_api
from ..sections.models import Section
from ..subsections.models import Subsection
from ..units.models import Unit
from .serializers import (
CollectionSerializer,
ComponentSerializer,
Expand Down Expand Up @@ -804,70 +805,70 @@ def _save_components(self, learning_package, components, component_static_files)
**valid_published
)

def _save_units(self, learning_package, containers):
"""Save units and published unit versions."""
for valid_unit in containers.get("unit", []):
entity_key = valid_unit.get("key")
unit = units_api.create_unit(learning_package.id, created_by=self.user_id, **valid_unit)
self.units_map_by_key[entity_key] = unit
def _save_container(
self,
learning_package,
containers,
*,
container_cls: containers_api.ContainerSubclass,
container_map: dict,
children_map: dict,
):
"""Internal logic for _save_units, _save_subsections, and _save_sections"""
type_code = container_cls.type_code # e.g. "unit"
for data in containers.get(type_code, []):
entity_key = data.get("key")
container = containers_api.create_container(
learning_package.id,
**data, # should this be allowed to override any of the following fields?
created_by=self.user_id,
container_cls=container_cls,
)
container_map[entity_key] = container # e.g. `self.units_map_by_key[entity_key] = unit`

for valid_published in containers.get("unit_published", []):
for valid_published in containers.get(f"{type_code}_published", []):
entity_key = valid_published.pop("entity_key")
children = self._resolve_children(valid_published, self.components_map_by_key)
children = self._resolve_children(valid_published, children_map)
self.all_published_entities_versions.add(
(entity_key, valid_published.get('version_num'))
) # Track published version
units_api.create_next_unit_version(
self.units_map_by_key[entity_key],
containers_api.create_next_container_version(
container_map[entity_key],
**valid_published, # should this be allowed to override any of the following fields?
force_version_num=valid_published.pop("version_num", None),
components=children,
entities=children,
created_by=self.user_id,
**valid_published
)

def _save_units(self, learning_package, containers):
"""Save units and published unit versions."""
self._save_container(
learning_package,
containers,
container_cls=Unit,
container_map=self.units_map_by_key,
children_map=self.components_map_by_key,
)

def _save_subsections(self, learning_package, containers):
"""Save subsections and published subsection versions."""
for valid_subsection in containers.get("subsection", []):
entity_key = valid_subsection.get("key")
subsection = subsections_api.create_subsection(
learning_package.id, created_by=self.user_id, **valid_subsection
)
self.subsections_map_by_key[entity_key] = subsection

for valid_published in containers.get("subsection_published", []):
entity_key = valid_published.pop("entity_key")
children = self._resolve_children(valid_published, self.units_map_by_key)
self.all_published_entities_versions.add(
(entity_key, valid_published.get('version_num'))
) # Track published version
subsections_api.create_next_subsection_version(
self.subsections_map_by_key[entity_key],
units=children,
force_version_num=valid_published.pop("version_num", None),
created_by=self.user_id,
**valid_published
)
self._save_container(
learning_package,
containers,
container_cls=Subsection,
container_map=self.subsections_map_by_key,
children_map=self.units_map_by_key,
)

def _save_sections(self, learning_package, containers):
"""Save sections and published section versions."""
for valid_section in containers.get("section", []):
entity_key = valid_section.get("key")
section = sections_api.create_section(learning_package.id, created_by=self.user_id, **valid_section)
self.sections_map_by_key[entity_key] = section

for valid_published in containers.get("section_published", []):
entity_key = valid_published.pop("entity_key")
children = self._resolve_children(valid_published, self.subsections_map_by_key)
self.all_published_entities_versions.add(
(entity_key, valid_published.get('version_num'))
) # Track published version
sections_api.create_next_section_version(
self.sections_map_by_key[entity_key],
subsections=children,
force_version_num=valid_published.pop("version_num", None),
created_by=self.user_id,
**valid_published
)
self._save_container(
learning_package,
containers,
container_cls=Section,
container_map=self.sections_map_by_key,
children_map=self.subsections_map_by_key,
)

def _save_draft_versions(self, components, containers, component_static_files):
"""Save draft versions for all entity types."""
Expand All @@ -888,47 +889,29 @@ def _save_draft_versions(self, components, containers, component_static_files):
**valid_draft
)

for valid_draft in containers.get("unit_drafts", []):
entity_key = valid_draft.pop("entity_key")
version_num = valid_draft["version_num"] # Should exist, validated earlier
if self._is_version_already_exists(entity_key, version_num):
continue
children = self._resolve_children(valid_draft, self.components_map_by_key)
units_api.create_next_unit_version(
self.units_map_by_key[entity_key],
components=children,
force_version_num=valid_draft.pop("version_num", None),
created_by=self.user_id,
**valid_draft
)

for valid_draft in containers.get("subsection_drafts", []):
entity_key = valid_draft.pop("entity_key")
version_num = valid_draft["version_num"] # Should exist, validated earlier
if self._is_version_already_exists(entity_key, version_num):
continue
children = self._resolve_children(valid_draft, self.units_map_by_key)
subsections_api.create_next_subsection_version(
self.subsections_map_by_key[entity_key],
units=children,
force_version_num=valid_draft.pop("version_num", None),
created_by=self.user_id,
**valid_draft
)
def _process_draft_containers(
container_cls: containers_api.ContainerSubclass,
container_map: dict,
children_map: dict,
):
for valid_draft in containers.get(f"{container_cls.type_code}_drafts", []):
entity_key = valid_draft.pop("entity_key")
version_num = valid_draft["version_num"] # Should exist, validated earlier
if self._is_version_already_exists(entity_key, version_num):
continue
children = self._resolve_children(valid_draft, children_map)
del valid_draft["version_num"]
containers_api.create_next_container_version(
container_map[entity_key],
**valid_draft, # should this be allowed to override any of the following fields?
entities=children,
force_version_num=version_num,
created_by=self.user_id,
)

for valid_draft in containers.get("section_drafts", []):
entity_key = valid_draft.pop("entity_key")
version_num = valid_draft["version_num"] # Should exist, validated earlier
if self._is_version_already_exists(entity_key, version_num):
continue
children = self._resolve_children(valid_draft, self.subsections_map_by_key)
sections_api.create_next_section_version(
self.sections_map_by_key[entity_key],
subsections=children,
force_version_num=valid_draft.pop("version_num", None),
created_by=self.user_id,
**valid_draft
)
_process_draft_containers(Unit, self.units_map_by_key, children_map=self.components_map_by_key)
_process_draft_containers(Subsection, self.subsections_map_by_key, children_map=self.units_map_by_key)
_process_draft_containers(Section, self.sections_map_by_key, children_map=self.subsections_map_by_key)

# --------------------------
# Utilities
Expand Down
13 changes: 13 additions & 0 deletions src/openedx_content/applets/collections/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"get_collection",
"get_collections",
"get_entity_collections",
"get_collection_entities",
"remove_from_collection",
"restore_collection",
"update_collection",
Expand Down Expand Up @@ -195,6 +196,18 @@ def get_entity_collections(learning_package_id: int, entity_key: str) -> QuerySe
return entity.collections.filter(enabled=True).order_by("pk")


def get_collection_entities(learning_package_id: int, collection_key: str) -> QuerySet[PublishableEntity]:
"""
Returns a QuerySet of PublishableEntities in a Collection.

This is the same as `collection.entities.all()`
"""
return PublishableEntity.objects.filter(
learning_package_id=learning_package_id,
collections__key=collection_key,
).order_by("pk")


def get_collections(learning_package_id: int, enabled: bool | None = True) -> QuerySet[Collection]:
"""
Get all collections for a given learning package
Expand Down
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
Empty file.
Loading