diff --git a/.importlinter b/.importlinter
index 1afc4c3ec..5b90864cd 100644
--- a/.importlinter
+++ b/.importlinter
@@ -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
# The "media" applet stores the simplest pieces of binary and text data,
# without versioning information. These belong to a single Learning Package.
diff --git a/src/openedx_content/admin.py b/src/openedx_content/admin.py
index c6ec40633..ae8a88123 100644
--- a/src/openedx_content/admin.py
+++ b/src/openedx_content/admin.py
@@ -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 *
diff --git a/src/openedx_content/api.py b/src/openedx_content/api.py
index 9aaa9b7b9..d8c2f0c3b 100644
--- a/src/openedx_content/api.py
+++ b/src/openedx_content/api.py
@@ -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 *
diff --git a/src/openedx_content/applets/backup_restore/toml.py b/src/openedx_content/applets/backup_restore/toml.py
index a75e7a0ad..d39861803 100644
--- a/src/openedx_content/applets/backup_restore/toml.py
+++ b/src/openedx_content/applets/backup_restore/toml.py
@@ -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
@@ -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)
return version_table
diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py
index 1bb8bbba6..29b9638e9 100644
--- a/src/openedx_content/applets/backup_restore/zipper.py
+++ b/src/openedx_content/applets/backup_restore/zipper.py
@@ -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,
@@ -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."""
@@ -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
diff --git a/src/openedx_content/applets/collections/api.py b/src/openedx_content/applets/collections/api.py
index f23b6fbc5..8ab8d9cbf 100644
--- a/src/openedx_content/applets/collections/api.py
+++ b/src/openedx_content/applets/collections/api.py
@@ -24,6 +24,7 @@
"get_collection",
"get_collections",
"get_entity_collections",
+ "get_collection_entities",
"remove_from_collection",
"restore_collection",
"update_collection",
@@ -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
diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py
index e34a39776..3ec181fa2 100644
--- a/src/openedx_content/applets/components/models.py
+++ b/src/openedx_content/applets/components/models.py
@@ -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}"
diff --git a/src/openedx_content/applets/containers/__init__.py b/src/openedx_content/applets/containers/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/openedx_content/applets/containers/admin.py b/src/openedx_content/applets/containers/admin.py
new file mode 100644
index 000000000..f602dcfe6
--- /dev/null
+++ b/src/openedx_content/applets/containers/admin.py
@@ -0,0 +1,340 @@
+"""
+Django admin for containers models
+"""
+
+from __future__ import annotations
+
+import functools
+from typing import TYPE_CHECKING
+
+from django.contrib import admin
+from django.db.models import Count, QuerySet
+from django.urls import reverse
+from django.utils.html import format_html
+from django.utils.safestring import SafeText
+
+from openedx_django_lib.admin_utils import ReadOnlyModelAdmin, model_detail_link, one_to_one_related_model_html
+
+from .api import ContainerImplementationMissingError, get_container_subclass
+from .models import Container, ContainerType, ContainerVersion, EntityList, EntityListRow
+
+if TYPE_CHECKING:
+
+ class ContainerTypeWithNumContainers(ContainerType):
+ num_containers: int
+
+
+@admin.register(ContainerType)
+class ContainerTypeAdmin(ReadOnlyModelAdmin):
+ """Very basic Django admin for ContainerType"""
+
+ list_display = ("type_code", "num_containers", "installed")
+
+ def get_queryset(self, request) -> QuerySet[ContainerTypeWithNumContainers]:
+ return super().get_queryset(request).annotate(num_containers=Count("container"))
+
+ @admin.display(description="# of Containers")
+ def num_containers(self, obj: ContainerTypeWithNumContainers) -> str:
+ """# of containers of this type and a link to view them"""
+ url = reverse("admin:openedx_content_container_changelist") + f"?container_type={obj.pk}"
+ return format_html('{}', url, obj.num_containers)
+
+ @admin.display(boolean=True)
+ def installed(self, obj: ContainerType) -> bool:
+ """Is the implementation of this container subclass installed?"""
+ try:
+ get_container_subclass(obj.type_code)
+ return True
+ except ContainerImplementationMissingError:
+ return False
+
+
+def _entity_list_detail_link(el: EntityList) -> SafeText:
+ """
+ A link to the detail page for an EntityList which includes its PK and length.
+ """
+ num_rows = el.entitylistrow_set.count()
+ rows_noun = "row" if num_rows == 1 else "rows"
+ return model_detail_link(el, f"EntityList #{el.pk} with {num_rows} {rows_noun}")
+
+
+class ContainerVersionInlineForContainer(admin.TabularInline):
+ """
+ Inline admin view of ContainerVersions in a given Container
+ """
+
+ model = ContainerVersion
+ ordering = ["-publishable_entity_version__version_num"]
+ fields = [
+ "pk",
+ "version_num",
+ "title",
+ "children",
+ "created",
+ "created_by",
+ ]
+ readonly_fields = fields # type: ignore[assignment]
+ extra = 0
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).select_related("publishable_entity_version")
+
+ def children(self, obj: ContainerVersion):
+ return _entity_list_detail_link(obj.entity_list)
+
+
+@admin.register(Container)
+class ContainerAdmin(ReadOnlyModelAdmin):
+ """
+ Django admin configuration for Container
+ """
+
+ list_display = ("key", "container_type_display", "published", "draft", "created")
+ fields = [
+ "pk",
+ "publishable_entity",
+ "learning_package",
+ "published",
+ "draft",
+ "created",
+ "created_by",
+ "see_also",
+ "most_recent_parent_entity_list",
+ ]
+ readonly_fields = fields # type: ignore[assignment]
+ search_fields = ["publishable_entity__uuid", "publishable_entity__key"]
+ inlines = [ContainerVersionInlineForContainer]
+
+ def learning_package(self, obj: Container) -> SafeText:
+ return model_detail_link(
+ obj.publishable_entity.learning_package,
+ obj.publishable_entity.learning_package.key,
+ )
+
+ def get_queryset(self, request):
+ return (
+ super()
+ .get_queryset(request)
+ .select_related(
+ "container_type",
+ "publishable_entity",
+ "publishable_entity__learning_package",
+ "publishable_entity__published__version",
+ "publishable_entity__draft__version",
+ )
+ )
+
+ @admin.display(description="Type")
+ def container_type_display(self, obj: Container) -> str:
+ """What type of container this is"""
+ type_code = obj.container_type.type_code
+ try:
+ container_type_name = get_container_subclass(type_code).__name__
+ except ContainerImplementationMissingError:
+ container_type_name = "?????"
+ return format_html(
+ '{}
({})', container_type_name, type_code
+ )
+
+ def draft(self, obj: Container) -> str:
+ """
+ Link to this Container's draft ContainerVersion
+ """
+ if draft := obj.versioning.draft:
+ if draft.pk == obj.versioning.published.pk:
+ return format_html(
+ '{}', "(no changes from published)"
+ )
+ return format_html(
+ 'Version {} "{}" ({})', draft.version_num, draft.title, _entity_list_detail_link(draft.entity_list)
+ )
+ return "-"
+
+ def published(self, obj: Container) -> str:
+ """
+ Link to this Container's published ContainerVersion
+ """
+ if published := obj.versioning.published:
+ return format_html(
+ 'Version {} "{}" ({})',
+ published.version_num,
+ published.title,
+ _entity_list_detail_link(published.entity_list),
+ )
+ return "-"
+
+ def see_also(self, obj: Container):
+ return one_to_one_related_model_html(obj)
+
+ def most_recent_parent_entity_list(self, obj: Container) -> str:
+ if latest_row := EntityListRow.objects.filter(entity_id=obj.publishable_entity_id).order_by("-pk").first():
+ return _entity_list_detail_link(latest_row.entity_list)
+ return "-"
+
+
+class ContainerVersionInlineForEntityList(admin.TabularInline):
+ """
+ Inline admin view of ContainerVersions which use a given EntityList
+ """
+
+ model = ContainerVersion
+ verbose_name = "Container Version that references this Entity List"
+ verbose_name_plural = "Container Versions that reference this Entity List"
+ ordering = ["-pk"] # Newest first
+ fields = [
+ "pk",
+ "version_num",
+ "container_key",
+ "title",
+ "created",
+ "created_by",
+ ]
+ readonly_fields = fields # type: ignore[assignment]
+ extra = 0
+
+ def get_queryset(self, request):
+ return (
+ super()
+ .get_queryset(request)
+ .select_related(
+ "container",
+ "container__publishable_entity",
+ "publishable_entity_version",
+ )
+ )
+
+ def container_key(self, obj: ContainerVersion) -> SafeText:
+ return model_detail_link(obj.container, obj.container.key)
+
+
+class EntityListRowInline(admin.TabularInline):
+ """
+ Table of entity rows in the entitylist admin
+ """
+
+ model = EntityListRow
+ readonly_fields = [
+ "order_num",
+ "pinned_version_num",
+ "entity_models",
+ "container_models",
+ "container_children",
+ ]
+ fields = readonly_fields # type: ignore[assignment]
+
+ def get_queryset(self, request):
+ return (
+ super()
+ .get_queryset(request)
+ .select_related(
+ "entity",
+ "entity_version",
+ )
+ )
+
+ def pinned_version_num(self, obj: EntityListRow):
+ return str(obj.entity_version.version_num) if obj.entity_version else "(Unpinned)"
+
+ def entity_models(self, obj: EntityListRow):
+ return format_html(
+ "{}
",
+ model_detail_link(obj.entity, obj.entity.key),
+ one_to_one_related_model_html(obj.entity),
+ )
+
+ def container_models(self, obj: EntityListRow) -> SafeText:
+ if not hasattr(obj.entity, "container"):
+ return SafeText("(Not a Container)")
+ return format_html(
+ "{}",
+ model_detail_link(obj.entity.container, str(obj.entity.container)),
+ one_to_one_related_model_html(obj.entity.container),
+ )
+
+ def container_children(self, obj: EntityListRow) -> SafeText:
+ """
+ If this row holds a Container, then link *its* EntityList, allowing easy hierarchy browsing.
+
+ When determining which ContainerVersion to grab the EntityList from, prefer the pinned
+ version if there is one; otherwise use the Draft version.
+ """
+ if not hasattr(obj.entity, "container"):
+ return SafeText("(Not a Container)")
+ child_container_version: ContainerVersion = (
+ obj.entity_version.containerversion if obj.entity_version else obj.entity.container.versioning.draft
+ )
+ return _entity_list_detail_link(child_container_version.entity_list)
+
+
+@admin.register(EntityList)
+class EntityListAdmin(ReadOnlyModelAdmin):
+ """
+ Django admin configuration for EntityList
+ """
+
+ list_display = [
+ "entity_list",
+ "row_count",
+ "recent_container_version_num",
+ "recent_container",
+ "recent_container_package",
+ ]
+ inlines = [ContainerVersionInlineForEntityList, EntityListRowInline]
+
+ def entity_list(self, obj: EntityList) -> SafeText:
+ return model_detail_link(obj, f"EntityList #{obj.pk}")
+
+ def row_count(self, obj: EntityList) -> int:
+ return obj.entitylistrow_set.count()
+
+ def recent_container_version_num(self, obj: EntityList) -> str:
+ """
+ Number of the newest ContainerVersion that references this EntityList
+ """
+ if latest := _latest_container_version(obj):
+ return f"Version {latest.version_num}"
+ else:
+ return "-"
+
+ def recent_container(self, obj: EntityList) -> SafeText | None:
+ """
+ Link to the Container of the newest ContainerVersion that references this EntityList
+ """
+ if latest := _latest_container_version(obj):
+ return format_html("of: {}", model_detail_link(latest.container, latest.container.key))
+ else:
+ return None
+
+ def recent_container_package(self, obj: EntityList) -> SafeText | None:
+ """
+ Link to the LearningPackage of the newest ContainerVersion that references this EntityList
+ """
+ if latest := _latest_container_version(obj):
+ return format_html(
+ "in: {}",
+ model_detail_link(
+ latest.container.publishable_entity.learning_package,
+ latest.container.publishable_entity.learning_package.key,
+ ),
+ )
+ else:
+ return None
+
+ # We'd like it to appear as if these three columns are just a single
+ # nicely-formatted column, so only give the left one a description.
+ recent_container_version_num.short_description = ( # type: ignore[attr-defined]
+ "Most recent container version using this entity list"
+ )
+ recent_container.short_description = "" # type: ignore[attr-defined]
+ recent_container_package.short_description = "" # type: ignore[attr-defined]
+
+
+@functools.cache
+def _latest_container_version(obj: EntityList) -> ContainerVersion | None:
+ """
+ Any given EntityList can be used by multiple ContainerVersion (which may even
+ span multiple Containers). We only have space here to show one ContainerVersion
+ easily, so let's show the one that's most likely to be interesting to the Django
+ admin user: the most-recently-created one.
+ """
+ return obj.container_versions.order_by("-pk").first()
diff --git a/src/openedx_content/applets/containers/api.py b/src/openedx_content/applets/containers/api.py
new file mode 100644
index 000000000..51c00e8c0
--- /dev/null
+++ b/src/openedx_content/applets/containers/api.py
@@ -0,0 +1,876 @@
+"""
+Containers API (warning: UNSTABLE, in progress API)
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime
+from enum import Enum
+from typing import Iterable
+
+from django.core.exceptions import ValidationError
+from django.db.models import QuerySet
+from django.db.transaction import atomic
+from django.db.utils import IntegrityError
+from typing_extensions import TypeVar # for 'default=...'
+
+from ..publishing import api as publishing_api
+from ..publishing.models import (
+ LearningPackage,
+ PublishableContentModelRegistry,
+ PublishableEntity,
+ PublishableEntityMixin,
+ PublishableEntityVersion,
+ PublishableEntityVersionMixin,
+)
+from .models import (
+ Container,
+ ContainerImplementationMissingError,
+ ContainerType,
+ ContainerVersion,
+ EntityList,
+ EntityListRow,
+)
+
+# A few of the APIs in this file are generic and can be used for Containers in
+# general, or e.g. Units (subclass of Container) in particular. These type
+# variables are used to provide correct typing for those generic API methods.
+ContainerModel = TypeVar("ContainerModel", bound=Container)
+ContainerVersionModel = TypeVar("ContainerVersionModel", bound=ContainerVersion, default=ContainerVersion)
+
+# The public API that will be re-exported by openedx_content.api
+# is listed in the __all__ entries below. Internal helper functions that are
+# private to this module should start with an underscore. If a function does not
+# start with an underscore AND it is not in __all__, that function is considered
+# to be callable only by other apps in the authoring package.
+__all__ = [
+ # π UNSTABLE: All APIs related to containers are unstable until we've figured
+ # out our approach to dynamic content (randomized, A/B tests, etc.)
+ "ContainerSubclass",
+ "ContainerImplementationMissingError",
+ "create_container",
+ "create_container_version",
+ "create_container_and_version",
+ "create_next_container_version",
+ "get_container",
+ "get_container_version",
+ "get_container_by_key",
+ "get_all_container_subclasses",
+ "get_container_subclass",
+ "get_container_type_code_of",
+ "get_container_subclass_of",
+ "get_containers",
+ "ChildrenEntitiesAction",
+ "ContainerEntityListEntry",
+ "get_entities_in_container",
+ "get_entities_in_container_as_of",
+ "contains_unpublished_changes",
+ "get_containers_with_entity",
+ "get_container_children_count",
+ "get_container_children_entities_keys",
+]
+
+
+@dataclass(frozen=True)
+class ContainerEntityListEntry:
+ """
+ [ π UNSTABLE ]
+ Data about a single entity in a container, e.g. a component in a unit.
+ """
+
+ entity_version: PublishableEntityVersion
+ pinned: bool
+
+ @property
+ def entity(self):
+ return self.entity_version.entity
+
+
+EntityListInput = Iterable[
+ PublishableEntity | PublishableEntityMixin | PublishableEntityVersion | PublishableEntityVersionMixin
+]
+ContainerSubclass = type[Container]
+
+
+@dataclass(frozen=True, kw_only=True, slots=True)
+class ParsedEntityReference:
+ """
+ Internal format to represent an entity, and/or a specific version of an
+ entity. Used to construct entity lists.
+
+ The public API contains `ContainerEntityListEntry` which plays a similar
+ role, but is only used when reading data out, not mutating containers.
+ """
+
+ entity_pk: int
+ version_pk: int | None = None
+
+ @staticmethod
+ def parse(entities: EntityListInput) -> list[ParsedEntityReference]:
+ """
+ Helper method to create a list of entities in the correct format. If you
+ pass `*Version` objects, they will be "frozen" at that version, whereas
+ if you pass `*Entity` objects, they'll use the latest version.
+ """
+ new_list: list[ParsedEntityReference] = []
+ for obj in entities:
+ if isinstance(obj, PublishableEntityMixin):
+ try:
+ obj = obj.publishable_entity
+ except obj.__class__.publishable_entity.RelatedObjectDoesNotExist as exc: # type: ignore[union-attr]
+ # If this happens, since it's a 1:1 relationship, likely both 'obj' (e.g. "Component") and
+ # 'obj.publishable_entity' have been deleted, so give a clearer error.
+ raise obj.DoesNotExist from exc
+ elif isinstance(obj, PublishableEntityVersionMixin):
+ obj = obj.publishable_entity_version
+
+ if isinstance(obj, PublishableEntity):
+ new_list.append(ParsedEntityReference(entity_pk=obj.pk))
+ elif isinstance(obj, PublishableEntityVersion):
+ new_list.append(ParsedEntityReference(entity_pk=obj.entity_id, version_pk=obj.pk))
+ else:
+ raise TypeError(f"Unexpected entitity in list: {obj}")
+ return new_list
+
+
+def create_container(
+ learning_package_id: int,
+ key: str,
+ created: datetime,
+ created_by: int | None,
+ *,
+ container_cls: type[ContainerModel],
+ can_stand_alone: bool = True,
+) -> ContainerModel:
+ """
+ [ π UNSTABLE ]
+ Create a new container.
+
+ Args:
+ learning_package_id: The ID of the learning package that contains the 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_cls: The subclass of container to create (e.g. Unit)
+ can_stand_alone: Set to False when created as part of containers
+
+ Returns:
+ The newly created container.
+ """
+ assert issubclass(container_cls, Container)
+ with atomic():
+ publishable_entity = publishing_api.create_publishable_entity(
+ learning_package_id,
+ key,
+ created,
+ created_by,
+ can_stand_alone=can_stand_alone,
+ )
+ container = container_cls.objects.create(
+ publishable_entity=publishable_entity,
+ container_type=container_cls.get_container_type(),
+ )
+ return container
+
+
+def create_entity_list() -> EntityList:
+ """
+ [ π UNSTABLE ]
+ Create a new entity list. This is an structure that holds a list of entities
+ that will be referenced by the container.
+
+ Returns:
+ The newly created entity list.
+ """
+ return EntityList.objects.create()
+
+
+def create_entity_list_with_rows(
+ parsed_entities: list[ParsedEntityReference],
+ *,
+ learning_package_id: int | None,
+) -> EntityList:
+ """
+ [ π UNSTABLE ]
+ Create new entity list rows for an entity list.
+
+ Args:
+ entities: List of the entities that will comprise the entity list, in
+ order. Pass `PublishableEntityVersion` or objects that use
+ `PublishableEntityVersionMixin` to pin to a specific version. Pass
+ `PublishableEntity` or objects that use `PublishableEntityMixin` for
+ unpinned.
+ learning_package_id: Optional. Verify that all the entities are from
+ the specified learning package.
+
+ Returns:
+ The newly created entity list.
+ """
+ # Do a quick check that the given entities are in the right learning package:
+ if learning_package_id:
+ if (
+ PublishableEntity.objects.filter(
+ pk__in=[entity.entity_pk for entity in parsed_entities],
+ )
+ .exclude(
+ learning_package_id=learning_package_id,
+ )
+ .exists()
+ ):
+ raise ValidationError("Container entities must be from the same learning package.")
+
+ with atomic(savepoint=False):
+ entity_list = create_entity_list()
+ EntityListRow.objects.bulk_create(
+ [
+ EntityListRow(
+ entity_list=entity_list,
+ entity_id=entity.entity_pk,
+ order_num=order_num,
+ entity_version_id=entity.version_pk,
+ )
+ for order_num, entity in enumerate(parsed_entities)
+ ]
+ )
+ return entity_list
+
+
+def _create_container_version(
+ container: Container,
+ version_num: int,
+ *,
+ title: str,
+ entity_list: EntityList,
+ created: datetime,
+ created_by: int | None,
+) -> ContainerVersion:
+ """
+ Private internal method for logic shared by create_container_version() and
+ create_next_container_version().
+ """
+ # validate entity_list using the type implementation:
+ try:
+ container_subclass = Container.subclass_for_type_code(container.container_type.type_code)
+ except ContainerType.DoesNotExist as exc:
+ raise IntegrityError(
+ "Existing ContainerType is now missing. "
+ "Likely your test case needs to call Container.reset_cache() because the cache contains "
+ "a reference to a row that no longer exists after the test DB has been truncated. "
+ ) from exc
+ version_type = PublishableContentModelRegistry.get_versioned_model_cls(container_subclass)
+ for entity_row in entity_list.rows:
+ try:
+ container_subclass.validate_entity(entity_row.entity)
+ except Exception as exc:
+ # This exception is carefully worded. The validation may have failed because the entity is of the wrong
+ # type, but it _could_ be a of the correct type but otherwise invalid/corrupt, e.g. partially deleted.
+ raise ValidationError(
+ f'The entity "{entity_row.entity}" cannot be added to a "{container_subclass.type_code}" container.'
+ ) from exc
+
+ with atomic(savepoint=False): # Make sure this will happen atomically but we don't need to create a new savepoint.
+ publishable_entity_version = publishing_api.create_publishable_entity_version(
+ container.publishable_entity_id,
+ version_num=version_num,
+ title=title,
+ created=created,
+ created_by=created_by,
+ dependencies=[entity_row.entity_id for entity_row in entity_list.rows if entity_row.is_unpinned()],
+ )
+ container_version = version_type.objects.create(
+ publishable_entity_version=publishable_entity_version,
+ container_id=container.pk,
+ entity_list=entity_list,
+ # This could accept **kwargs in the future if we have additional type-specific fields?
+ )
+
+ return container_version
+
+
+def create_container_version(
+ container_id: int,
+ version_num: int,
+ *,
+ title: str,
+ entities: EntityListInput,
+ created: datetime,
+ created_by: int | None,
+) -> ContainerVersion:
+ """
+ [ π UNSTABLE ]
+ Create a new container version.
+
+ Args:
+ container_id: The ID of the container that the version belongs to.
+ version_num: The version number of the container.
+ title: The title of the container.
+ entities: List of the entities that will comprise the entity list, in
+ order. Pass `PublishableEntityVersion` or objects that use
+ `PublishableEntityVersionMixin` to pin to a specific version. Pass
+ `PublishableEntity` or objects that use `PublishableEntityMixin` for
+ unpinned.
+ created: The date and time the container version was created.
+ created_by: The ID of the user who created the container version.
+
+ Returns:
+ The newly created container version.
+ """
+ assert title is not None
+ assert entities is not None
+
+ with atomic(savepoint=False):
+ container = Container.objects.select_related("publishable_entity").get(pk=container_id)
+ entity = container.publishable_entity
+ parsed_entities = ParsedEntityReference.parse(entities)
+ entity_list = create_entity_list_with_rows(parsed_entities, learning_package_id=entity.learning_package_id)
+ container_version = _create_container_version(
+ container,
+ version_num,
+ title=title,
+ entity_list=entity_list,
+ created=created,
+ created_by=created_by,
+ )
+
+ return container_version
+
+
+def create_container_and_version(
+ learning_package_id: int,
+ key: str,
+ *,
+ title: str,
+ container_cls: type[ContainerModel],
+ entities: EntityListInput | None = None,
+ created: datetime,
+ created_by: int | None = None,
+ can_stand_alone: bool = True,
+) -> tuple[ContainerModel, ContainerVersionModel]:
+ """
+ [ π UNSTABLE ] Create a new container and its initial version.
+
+ Args:
+ learning_package_id: The learning package ID.
+ key: The key.
+ 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
+ order. Pass `PublishableEntityVersion` or objects that use
+ `PublishableEntityVersionMixin` to pin to a specific version. Pass
+ `PublishableEntity` or objects that use `PublishableEntityMixin` for
+ unpinned. Pass `None` for "no change".
+ created: The creation date.
+ created_by: The ID of the user who created the container.
+ can_stand_alone: Set to False when created as part of containers
+ """
+ with atomic(savepoint=False):
+ container = create_container(
+ learning_package_id,
+ key,
+ created,
+ created_by,
+ can_stand_alone=can_stand_alone,
+ container_cls=container_cls,
+ )
+ container_version: ContainerVersionModel = create_container_version( # type: ignore[assignment]
+ container.pk,
+ 1,
+ title=title,
+ entities=entities or [],
+ created=created,
+ created_by=created_by,
+ )
+ return container, container_version
+
+
+class ChildrenEntitiesAction(Enum):
+ """Possible actions for children entities"""
+
+ APPEND = "append"
+ REMOVE = "remove" # TODO: deprecated/drop/fix `REMOVE` - https://github.com/openedx/openedx-core/issues/502
+ REPLACE = "replace"
+
+
+def create_next_entity_list(
+ learning_package_id: int,
+ last_version: ContainerVersion,
+ entities: EntityListInput,
+ entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
+) -> EntityList:
+ """
+ Creates next entity list based on the given entities_action.
+
+ Args:
+ learning_package_id: Learning package ID
+ last_version: Last version of container.
+ entities: List of the entities that will comprise the entity list, in
+ order. Pass `PublishableEntityVersion` or objects that use
+ `PublishableEntityVersionMixin` to pin to a specific version. Pass
+ `PublishableEntity` or objects that use `PublishableEntityMixin` for
+ unpinned.
+ entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
+
+ Returns:
+ The newly created entity list.
+ """
+ parsed_entities = ParsedEntityReference.parse(entities)
+ # Do a quick check that the given entities are in the right learning package:
+ if (
+ PublishableEntity.objects.filter(pk__in=[entity.entity_pk for entity in parsed_entities])
+ .exclude(learning_package_id=learning_package_id)
+ .exists()
+ ):
+ raise ValidationError("Container entities must be from the same learning package.")
+
+ if entities_action == ChildrenEntitiesAction.APPEND:
+ # get previous entity list rows
+ last_entities = last_version.entity_list.entitylistrow_set.only("entity_id", "entity_version_id").order_by(
+ "order_num"
+ )
+ # append given entity_rows to the existing children
+ parsed_entities = [
+ ParsedEntityReference(entity_pk=entity.entity_id, version_pk=entity.entity_version_id)
+ for entity in last_entities
+ ] + parsed_entities
+ elif entities_action == ChildrenEntitiesAction.REMOVE:
+ # get previous entity list:
+ last_entities_qs = last_version.entity_list.entitylistrow_set.only("entity_id", "entity_version_id").order_by(
+ "order_num"
+ )
+ # Filter out the entities to remove:
+ for entity in parsed_entities:
+ last_entities_qs = last_entities_qs.exclude(entity_id=entity.entity_pk, entity_version_id=entity.version_pk)
+ # Create the new entity list:
+ parsed_entities = [
+ ParsedEntityReference(entity_pk=entity.entity_id, version_pk=entity.entity_version_id)
+ for entity in last_entities_qs.all()
+ ]
+
+ return create_entity_list_with_rows(parsed_entities, learning_package_id=learning_package_id)
+
+
+def create_next_container_version(
+ container: Container | int,
+ /,
+ *,
+ title: str | None = None,
+ entities: EntityListInput | None = None,
+ created: datetime,
+ created_by: int | None,
+ entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
+ force_version_num: int | None = None,
+) -> ContainerVersion:
+ """
+ [ π UNSTABLE ]
+ Create the next version of a container. A new version of the container is created
+ only when its metadata changes:
+
+ * Something was added to the Container.
+ * We re-ordered the rows in the container.
+ * Something was removed from the container.
+ * The Container's metadata changed, e.g. the title.
+ * We pin to different versions of the Container.
+
+ Args:
+ container_pk: The ID of the container to create the next version of.
+ title: The title of the container. None to keep the current title.
+ entities: List of the entities that will comprise the entity list, in
+ order. Pass `PublishableEntityVersion` or objects that use
+ `PublishableEntityVersionMixin` to pin to a specific version. Pass
+ `PublishableEntity` or objects that use `PublishableEntityMixin` for
+ unpinned. Pass `None` for "no change".
+ created: The date and time the container version was created.
+ created_by: The ID of the user who created the container version.
+ force_version_num (int, optional): If provided, overrides the automatic version number increment and sets
+ this version's number explicitly. Use this if you need to restore or import a version with a specific
+ version number, such as during data migration or when synchronizing with external systems.
+
+ Returns:
+ The newly created container version. Note: it will be a subclass of `ContainerVersion`
+
+ Why use force_version_num?
+ Normally, the version number is incremented automatically from the latest version.
+ If you need to set a specific version number (for example, when restoring from backup,
+ importing legacy data, or synchronizing with another system),
+ use force_version_num to override the default behavior.
+ """
+ with atomic():
+ if isinstance(container, int):
+ container = Container.objects.select_related("publishable_entity").get(pk=container)
+ assert isinstance(container, Container)
+ entity = container.publishable_entity
+ last_version = container.versioning.latest
+ if last_version is None:
+ next_version_num = 1
+ else:
+ next_version_num = last_version.version_num + 1
+
+ if force_version_num is not None:
+ next_version_num = force_version_num
+
+ if entities is None and last_version is not None:
+ # We're only changing metadata. Keep the same entity list.
+ next_entity_list = last_version.entity_list
+ else:
+ next_entity_list = create_next_entity_list(
+ entity.learning_package_id, last_version, entities if entities is not None else [], entities_action
+ )
+
+ next_container_version = _create_container_version(
+ container,
+ next_version_num,
+ title=title if title is not None else last_version.title,
+ entity_list=next_entity_list,
+ created=created,
+ created_by=created_by,
+ )
+
+ # reset any potentially cached 'container.versioning.draft' value on the passed 'container' instance, since we've
+ # definitely modified the draft. If 'container' is local to this function, this has no effect.
+ if PublishableEntity.draft.is_cached(container.publishable_entity): # pylint: disable=no-member
+ PublishableEntity.draft.related.delete_cached_value(container.publishable_entity) # pylint: disable=no-member
+ return next_container_version
+
+
+def get_container(pk: int) -> Container:
+ """
+ [ π UNSTABLE ]
+ Get a container by its primary key.
+
+ This returns the Container, not any specific version. It may not be published, or may have been soft deleted.
+
+ Args:
+ pk: The primary key of the container.
+
+ Returns:
+ The container with the given primary key.
+ """
+ return Container.objects.get(pk=pk)
+
+
+def get_container_version(container_version_pk: int) -> ContainerVersion:
+ """
+ [ π UNSTABLE ]
+ Get a container version by its primary key.
+
+ Args:
+ pk: The primary key of the container version.
+
+ Returns:
+ The container version with the given primary key.
+ """
+ return ContainerVersion.objects.get(pk=container_version_pk)
+
+
+def get_container_by_key(learning_package_id: int, /, key: str) -> Container:
+ """
+ [ π UNSTABLE ]
+ Get a container by its learning package and primary key.
+
+ Args:
+ learning_package_id: The ID of the learning package that contains the container.
+ key: The primary key of the container.
+
+ Returns:
+ The container with the given primary key.
+ """
+ try:
+ return Container.objects.get(
+ publishable_entity__learning_package_id=learning_package_id,
+ publishable_entity__key=key,
+ )
+ except Container.DoesNotExist:
+ # Check if it's the container or the learning package that does not exist:
+ try:
+ LearningPackage.objects.get(pk=learning_package_id)
+ except LearningPackage.DoesNotExist as lp_exc:
+ raise lp_exc # No need to "raise from" as LearningPackage nonexistence is more important
+ raise
+
+
+def get_all_container_subclasses() -> list[ContainerSubclass]:
+ """
+ Get a list of installed Container types (`Container` subclasses).
+ """
+ return Container.all_subclasses()
+
+
+def get_container_subclass(type_code: str, /) -> ContainerSubclass:
+ """
+ Get subclass of `Container` from its `type_code` string (e.g. `"unit"`).
+
+ Will raise a `ContainerImplementationMissingError` if the type is not currently installed.
+ """
+ return Container.subclass_for_type_code(type_code)
+
+
+def get_container_type_code_of(container: Container | int, /) -> str:
+ """Get the type of a container, as a string - e.g. "unit"."""
+ if isinstance(container, int):
+ container = get_container(container)
+ assert isinstance(container, Container)
+ return container.container_type.type_code
+
+
+def get_container_subclass_of(container: Container | int, /) -> ContainerSubclass:
+ """
+ Get the type of a container.
+
+ Works on either a generic `Container` instance or an instance of a specific
+ subclass like `Unit`. Accepts an instance or an integer primary key.
+
+ Will raise a `ContainerImplementationMissingError` if the type is not currently installed.
+ """
+ type_code = get_container_type_code_of(container)
+ return Container.subclass_for_type_code(type_code)
+
+
+def get_containers(
+ learning_package_id: int,
+ include_deleted: bool | None = False,
+) -> QuerySet[Container]:
+ """
+ [ π UNSTABLE ]
+ Get all containers in the given learning package.
+
+ Args:
+ learning_package_id: The primary key of the learning package
+ include_deleted: If True, include deleted containers (with no draft version) in the result.
+
+ Returns:
+ A queryset containing the container associated with the given learning package.
+ """
+ container_qset = Container.objects.filter(publishable_entity__learning_package=learning_package_id)
+ if not include_deleted:
+ container_qset = container_qset.filter(publishable_entity__draft__version__isnull=False)
+
+ return container_qset.order_by("pk")
+
+
+def get_entities_in_container(
+ container: Container,
+ *,
+ published: bool,
+ select_related_version: str | None = None,
+) -> list[ContainerEntityListEntry]:
+ """
+ [ π UNSTABLE ]
+ Get the list of entities and their versions in the current draft or
+ published version of the given container.
+
+ Args:
+ container: The Container, e.g. returned by `get_container()`
+ published: `True` if we want the published version of the container, or
+ `False` for the draft version.
+ select_related_version: An optional optimization; specify a relationship
+ on ContainerVersion, like `componentversion` or `containerversion__x`
+ to preload via select_related.
+ """
+ assert isinstance(container, Container)
+ if published:
+ # Very minor optimization: reload the container with related 1:1 entities
+ container = Container.objects.select_related(
+ "publishable_entity__published__version__containerversion__entity_list"
+ ).get(pk=container.pk)
+ container_version = container.versioning.published
+ select_related = ["entity__published__version"]
+ if select_related_version:
+ select_related.append(f"entity__published__version__{select_related_version}")
+ else:
+ # Very minor optimization: reload the container with related 1:1 entities
+ container = Container.objects.select_related(
+ "publishable_entity__draft__version__containerversion__entity_list"
+ ).get(pk=container.pk)
+ container_version = container.versioning.draft
+ select_related = ["entity__draft__version"]
+ if select_related_version:
+ select_related.append(f"entity__draft__version__{select_related_version}")
+ if container_version is None:
+ raise ContainerVersion.DoesNotExist # This container has not been published yet, or has been deleted.
+ assert isinstance(container_version, ContainerVersion)
+ entity_list: list[ContainerEntityListEntry] = []
+ for row in container_version.entity_list.entitylistrow_set.select_related(
+ "entity_version",
+ *select_related,
+ ).order_by("order_num"):
+ entity_version = row.entity_version # This will be set if pinned
+ if not entity_version: # If this entity is "unpinned", use the latest published/draft version:
+ entity_version = row.entity.published.version if published else row.entity.draft.version
+ if entity_version is not None: # As long as this hasn't been soft-deleted:
+ entity_list.append(
+ ContainerEntityListEntry(
+ entity_version=entity_version,
+ pinned=row.entity_version is not None,
+ )
+ )
+ # else we could indicate somehow a deleted item was here, e.g. by returning a ContainerEntityListEntry with
+ # deleted=True, but we don't have a use case for that yet.
+ return entity_list
+
+
+def get_entities_in_container_as_of(
+ container: Container,
+ publish_log_id: int,
+) -> tuple[ContainerVersion | None, list[ContainerEntityListEntry]]:
+ """
+ [ π UNSTABLE ]
+ Get the list of entities and their versions in the published version of the
+ given container as of the given PublishLog version (which is essentially a
+ version for the entire learning package).
+
+ Also returns the ContainerVersion so you can see the container title,
+ settings?, and any other metadata from that point in time.
+
+ TODO: optimize, perhaps by having the publishlog store a record of all
+ ancestors of every modified PublishableEntity in the publish.
+ """
+ assert isinstance(container, Container)
+ pub_entity_version = publishing_api.get_published_version_as_of(container.publishable_entity_id, publish_log_id)
+ if pub_entity_version is None:
+ return None, [] # This container was not published as of the given PublishLog ID.
+ container_version = pub_entity_version.containerversion
+
+ entity_list: list[ContainerEntityListEntry] = []
+ rows = container_version.entity_list.entitylistrow_set.order_by("order_num")
+ for row in rows:
+ if row.entity_version is not None:
+ # Pinned child entity:
+ entity_list.append(ContainerEntityListEntry(entity_version=row.entity_version, pinned=True))
+ else:
+ # Unpinned entity - figure out what its latest published version was.
+ # This is not optimized. It could be done in one query per unit rather than one query per component.
+ pub_entity_version = publishing_api.get_published_version_as_of(row.entity_id, publish_log_id)
+ if pub_entity_version:
+ entity_list.append(ContainerEntityListEntry(entity_version=pub_entity_version, pinned=False))
+ return container_version, entity_list
+
+
+def contains_unpublished_changes(container_or_pk: Container | int, /) -> bool:
+ """
+ [ π UNSTABLE ]
+ Check recursively if a container has any unpublished changes.
+
+ Note: I've preserved the API signature for now, but we probably eventually
+ want to make a more general function that operates on PublishableEntities
+ and dependencies, once we introduce those with courses and their files,
+ grading policies, etc.
+
+ Note: unlike this method, the similar-sounding
+ `container.versioning.has_unpublished_changes` property only reports
+ if the container itself has unpublished changes, not
+ if its contents do. So if you change a title or add a new child component,
+ `has_unpublished_changes` will be `True`, but if you merely edit a component
+ that's in the container, it will be `False`. This method will return `True`
+ in either case.
+ """
+ if isinstance(container_or_pk, int):
+ container_id = container_or_pk
+ else:
+ assert isinstance(container_or_pk, Container)
+ container_id = container_or_pk.pk
+ container = (
+ Container.objects.select_related("publishable_entity__draft__draft_log_record")
+ .select_related("publishable_entity__published__publish_log_record")
+ .get(pk=container_id)
+ )
+ if container.versioning.has_unpublished_changes:
+ return True
+
+ draft = container.publishable_entity.draft
+ published = container.publishable_entity.published
+
+ # Edge case: A container that was created and then immediately soft-deleted
+ # does not contain any unpublished changes.
+ if draft is None and published is None:
+ return False
+
+ # The dependencies_hash_digest captures the state of all descendants, so we
+ # can do this quick comparison instead of iterating through layers of
+ # containers.
+ draft_version_hash_digest = draft.log_record.dependencies_hash_digest
+ published_version_hash_digest = published.log_record.dependencies_hash_digest
+
+ return draft_version_hash_digest != published_version_hash_digest
+
+
+def get_containers_with_entity(
+ publishable_entity_pk: int,
+ *,
+ ignore_pinned=False,
+ published=False,
+) -> QuerySet[Container]:
+ """
+ [ π UNSTABLE ]
+ Find all draft containers that directly contain the given entity.
+
+ They will always be from the same learning package; cross-package containers
+ are not allowed.
+
+ Args:
+ publishable_entity_pk: The ID of the PublishableEntity to search for.
+ ignore_pinned: if true, ignore any pinned references to the entity.
+ """
+ branch = "published" if published else "draft"
+ if ignore_pinned:
+ filter_dict = {
+ # Note: these two conditions must be in the same filter() call,
+ # or the query won't be correct.
+ (
+ f"publishable_entity__{branch}__version__containerversion__entity_list__entitylistrow__entity_id"
+ ): publishable_entity_pk,
+ (
+ f"publishable_entity__{branch}__version__"
+ "containerversion__entity_list__entitylistrow__entity_version_id"
+ ): None,
+ }
+ qs = Container.objects.filter(**filter_dict)
+ else:
+ filter_dict = {
+ (
+ f"publishable_entity__{branch}__version__containerversion__entity_list__entitylistrow__entity_id"
+ ): publishable_entity_pk
+ }
+ qs = Container.objects.filter(**filter_dict)
+
+ return qs.order_by("pk").distinct() # Ordering is mostly for consistent test cases.
+
+
+def get_container_children_count(
+ container: Container,
+ *,
+ published: bool,
+):
+ """
+ [ π UNSTABLE ]
+ Get the count of entities in the current draft or published version of the given container.
+
+ Args:
+ container: The Container, e.g. returned by `get_container()`
+ published: `True` if we want the published version of the container, or
+ `False` for the draft version.
+ """
+ assert isinstance(container, Container)
+ container_version = container.versioning.published if published else container.versioning.draft
+ if container_version is None:
+ raise ContainerVersion.DoesNotExist # This container has not been published yet, or has been deleted.
+ assert isinstance(container_version, ContainerVersion)
+ if published:
+ filter_deleted = {"entity__published__version__isnull": False}
+ else:
+ filter_deleted = {"entity__draft__version__isnull": False}
+ return container_version.entity_list.entitylistrow_set.filter(**filter_deleted).count()
+
+
+def get_container_children_entities_keys(container_version: ContainerVersion) -> list[str]:
+ """
+ Fetch the list of entity keys for all entities in the given container version.
+
+ Args:
+ container_version: The ContainerVersion to fetch the entity keys for.
+ Returns:
+ A list of entity keys for all entities in the container version, ordered by entity key.
+ """
+ return list(
+ container_version.entity_list.entitylistrow_set.values_list("entity__key", flat=True).order_by("order_num")
+ )
diff --git a/src/openedx_content/applets/containers/models.py b/src/openedx_content/applets/containers/models.py
new file mode 100644
index 000000000..e45fc7f78
--- /dev/null
+++ b/src/openedx_content/applets/containers/models.py
@@ -0,0 +1,280 @@
+"""
+Container and ContainerVersion models
+"""
+
+from __future__ import annotations
+
+from functools import cached_property
+from typing import final
+
+from django.core.exceptions import ValidationError
+from django.db import models
+
+from openedx_django_lib.fields import case_sensitive_char_field
+
+from ..publishing.models.publishable_entity import (
+ PublishableEntity,
+ PublishableEntityMixin,
+ PublishableEntityVersion,
+ PublishableEntityVersionMixin,
+)
+
+__all__ = [
+ "Container",
+ "ContainerVersion",
+ # ContainerType is not public
+ "EntityList",
+ "EntityListRow",
+]
+
+
+class EntityList(models.Model):
+ """
+ EntityLists are a common structure to hold parent-child relations.
+
+ EntityLists are not PublishableEntities in and of themselves. That's because
+ sometimes we'll want the same kind of data structure for things that we
+ dynamically generate for individual students (e.g. Variants). EntityLists are
+ anonymous in a senseβthey're pointed to by ContainerVersions and
+ other models, rather than being looked up by their own identifiers.
+ """
+
+ @cached_property
+ def rows(self):
+ """
+ Convenience method to iterate rows.
+
+ I'd normally make this the reverse lookup name for the EntityListRow ->
+ EntityList foreign key relation, but we already have references to
+ entitylistrow_set in various places, and I thought this would be better
+ than breaking compatibility.
+ """
+ return self.entitylistrow_set.order_by("order_num")
+
+
+class EntityListRow(models.Model):
+ """
+ Each EntityListRow points to a PublishableEntity, optionally at a specific
+ version.
+
+ There is a row in this table for each member of an EntityList. The order_num
+ field is used to determine the order of the members in the list.
+ """
+
+ entity_list = models.ForeignKey(EntityList, on_delete=models.CASCADE)
+
+ # This ordering should be treated as immutableβif the ordering needs to
+ # change, we create a new EntityList and copy things over.
+ order_num = models.PositiveIntegerField()
+
+ # Simple case would use these fields with our convention that null versions
+ # means "get the latest draft or published as appropriate". These entities
+ # could be Selectors, in which case we'd need to do more work to find the right
+ # variant. The publishing app itself doesn't know anything about Selectors
+ # however, and just treats it as another PublishableEntity.
+ entity = models.ForeignKey(PublishableEntity, on_delete=models.RESTRICT)
+
+ # The version references point to the specific PublishableEntityVersion that
+ # this EntityList has for this PublishableEntity for both the draft and
+ # published states. However, we don't want to have to create new EntityList
+ # every time that a member is updated, because that would waste a lot of
+ # space and make it difficult to figure out when the metadata of something
+ # like a Unit *actually* changed, vs. when its child members were being
+ # updated. Doing so could also potentially lead to race conditions when
+ # updating multiple layers of containers.
+ #
+ # So our approach to this is to use a value of None (null) to represent an
+ # unpinned reference to a PublishableEntity. It's shorthand for "just use
+ # the latest draft or published version of this, as appropriate".
+ entity_version = models.ForeignKey(
+ PublishableEntityVersion,
+ on_delete=models.RESTRICT,
+ null=True,
+ related_name="+", # Do we need the reverse relation?
+ )
+
+ def is_pinned(self):
+ return self.entity_version_id is not None
+
+ def is_unpinned(self):
+ return self.entity_version_id is None
+
+ class Meta:
+ ordering = ["order_num"]
+ constraints = [
+ # If (entity_list, order_num) is not unique, it likely indicates a race condition - so force uniqueness.
+ models.UniqueConstraint(
+ fields=["entity_list", "order_num"],
+ name="oel_publishing_elist_row_order",
+ ),
+ ]
+
+
+_registered_container_types: dict[str, type[Container]] = {}
+
+
+class ContainerImplementationMissingError(Exception):
+ """Raised when trying to modify a container whose implementation [plugin] is no longer available."""
+
+
+class ContainerType(models.Model):
+ """
+ Normalized representation of the 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)
+
+ # type_code uniquely identifies the type of container, e.g. "unit", "subsection", etc.
+ # Plugins/apps that add their own ContainerTypes should prefix it, e.g.
+ # "myapp_custom_unit" instead of "custom_unit", to avoid collisions.
+ type_code = case_sensitive_char_field(
+ max_length=100,
+ blank=False,
+ unique=True,
+ )
+
+ class Meta:
+ constraints = [
+ models.CheckConstraint(
+ # No whitespace, uppercase, or special characters allowed in "type_code".
+ condition=models.lookups.Regex(models.F("type_code"), r"^[a-z0-9\-_\.]+$"),
+ name="oex_publishing_containertype_type_code_rx",
+ ),
+ ]
+
+ def __str__(self) -> str: # pylint: disable=invalid-str-returned
+ return self.type_code
+
+
+class Container(PublishableEntityMixin):
+ """
+ A Container is a type of PublishableEntity that holds other
+ PublishableEntities. For example, a "Unit" Container might hold several
+ Components.
+
+ For now, all containers have a static "entity list" that defines which
+ 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.
+ """
+
+ type_code: str # Subclasses must override this, e.g. "unit"
+ # olx_code: the OLX for XML serialization. Subclasses _may_ override this.
+ # Only used in openedx-platform at the moment. We'll likely have to replace this with something more sophisticated.
+ olx_tag_name: str = ""
+ _type_instance: ContainerType # Cache used by get_container_type()
+
+ # 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 validate_entity(cls, entity: PublishableEntity) -> None:
+ """Check if the given entity is allowed as a child of this Container type"""
+
+ @final
+ @classmethod
+ def get_container_type(cls) -> ContainerType:
+ """
+ Get the ContainerType for this type of container, auto-creating it if need be.
+ """
+ if cls is Container:
+ raise TypeError("Manipulating plain Containers is not allowed. Use a Container subclass, like Unit.")
+ assert cls.type_code, f"Container subclasses like {cls.__name__} must override type_code"
+ if not hasattr(cls, "_type_instance"):
+ cls._type_instance, _ = ContainerType.objects.get_or_create(type_code=cls.type_code)
+ return cls._type_instance
+
+ @final
+ @staticmethod
+ def reset_cache() -> None:
+ """
+ Helper for test cases that truncate the database between tests.
+ Call this to delete the cache used in get_container_type(), which will be invalid after the ContainerType table
+ is truncated.
+ """
+ for cls in _registered_container_types.values():
+ if hasattr(cls, "_type_instance"):
+ del cls._type_instance
+
+ @staticmethod
+ def register_subclass(container_subclass: type[Container]):
+ """
+ Register a Container subclass
+ """
+ assert container_subclass.type_code, "Container subclasses must override type_code"
+ assert container_subclass.type_code not in _registered_container_types, (
+ f"{container_subclass.type_code} already registered"
+ )
+ _registered_container_types[container_subclass.type_code] = container_subclass
+ return container_subclass
+
+ @staticmethod
+ def subclass_for_type_code(type_code: str) -> type[Container]:
+ """
+ Get the subclass for the specified container type_code.
+ """
+ try:
+ return _registered_container_types[type_code]
+ except KeyError as exc:
+ raise ContainerImplementationMissingError(
+ f'An implementation for "{type_code}" containers is not currently installed. '
+ "Such containers can be read but not modified."
+ ) from exc
+
+ @staticmethod
+ def all_subclasses() -> list[type[Container]]:
+ """Get a list of all installed container types"""
+ return sorted(_registered_container_types.values(), key=lambda ct: ct.type_code)
+
+
+class ContainerVersion(PublishableEntityVersionMixin):
+ """
+ A version of a Container.
+
+ By convention, we would only want to create new versions when the Container
+ itself changes, and not when the Container's child elements change. For
+ example:
+
+ * Something was added to the Container.
+ * We re-ordered the rows in the container.
+ * Something was removed to the container.
+ * The Container's metadata changed, e.g. the title.
+ * We pin to different versions of the Container.
+
+ The last looks a bit odd, but it's because *how we've defined the Unit* has
+ changed if we decide to explicitly pin a set of versions for the children,
+ and then later change our minds and move to a different set. It also just
+ makes things easier to reason about if we say that entity_list never
+ changes for a given ContainerVersion.
+ """
+
+ container = models.ForeignKey(
+ Container,
+ on_delete=models.CASCADE,
+ related_name="versions",
+ )
+
+ # The list of entities (frozen and/or unfrozen) in this container
+ entity_list = models.ForeignKey(
+ EntityList,
+ on_delete=models.RESTRICT,
+ null=False,
+ related_name="container_versions",
+ )
+
+ def clean(self):
+ """
+ Validate this model before saving. Not called normally, but will be
+ called if anything is edited via a ModelForm like the Django admin.
+ """
+ super().clean()
+ if self.container_id != self.publishable_entity_version.entity.container.pk: # pylint: disable=no-member
+ raise ValidationError("Inconsistent foreign keys to Container")
diff --git a/src/openedx_content/applets/publishing/admin.py b/src/openedx_content/applets/publishing/admin.py
index 797decf8a..64b0035fc 100644
--- a/src/openedx_content/applets/publishing/admin.py
+++ b/src/openedx_content/applets/publishing/admin.py
@@ -3,22 +3,15 @@
"""
from __future__ import annotations
-import functools
-
from django.contrib import admin
from django.db.models import Count, F
from django.utils.html import format_html
-from django.utils.safestring import SafeText
-from openedx_django_lib.admin_utils import ReadOnlyModelAdmin, model_detail_link, one_to_one_related_model_html
+from openedx_django_lib.admin_utils import ReadOnlyModelAdmin, one_to_one_related_model_html
from .models import (
- Container,
- ContainerVersion,
DraftChangeLog,
DraftChangeLogRecord,
- EntityList,
- EntityListRow,
LearningPackage,
PublishableEntity,
PublishableEntityVersion,
@@ -348,264 +341,3 @@ def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset.select_related("learning_package", "changed_by") \
.annotate(num_changes=Count("records"))
-
-
-def _entity_list_detail_link(el: EntityList) -> SafeText:
- """
- A link to the detail page for an EntityList which includes its PK and length.
- """
- num_rows = el.entitylistrow_set.count()
- rows_noun = "row" if num_rows == 1 else "rows"
- return model_detail_link(el, f"EntityList #{el.pk} with {num_rows} {rows_noun}")
-
-
-class ContainerVersionInlineForContainer(admin.TabularInline):
- """
- Inline admin view of ContainerVersions in a given Container
- """
- model = ContainerVersion
- ordering = ["-publishable_entity_version__version_num"]
- fields = [
- "pk",
- "version_num",
- "title",
- "children",
- "created",
- "created_by",
- ]
- readonly_fields = fields # type: ignore[assignment]
- extra = 0
-
- def get_queryset(self, request):
- return super().get_queryset(request).select_related(
- "publishable_entity_version"
- )
-
- def children(self, obj: ContainerVersion):
- return _entity_list_detail_link(obj.entity_list)
-
-
-@admin.register(Container)
-class ContainerAdmin(ReadOnlyModelAdmin):
- """
- Django admin configuration for Container
- """
- list_display = ("key", "created", "draft", "published", "see_also")
- fields = [
- "pk",
- "publishable_entity",
- "learning_package",
- "draft",
- "published",
- "created",
- "created_by",
- "see_also",
- "most_recent_parent_entity_list",
- ]
- readonly_fields = fields # type: ignore[assignment]
- search_fields = ["publishable_entity__uuid", "publishable_entity__key"]
- inlines = [ContainerVersionInlineForContainer]
-
- def learning_package(self, obj: Container) -> SafeText:
- return model_detail_link(
- obj.publishable_entity.learning_package,
- obj.publishable_entity.learning_package.key,
- )
-
- def get_queryset(self, request):
- return super().get_queryset(request).select_related(
- "publishable_entity",
- "publishable_entity__learning_package",
- "publishable_entity__published__version",
- "publishable_entity__draft__version",
- )
-
- def draft(self, obj: Container) -> str:
- """
- Link to this Container's draft ContainerVersion
- """
- if draft := obj.versioning.draft:
- return format_html(
- 'Version {} "{}" ({})', draft.version_num, draft.title, _entity_list_detail_link(draft.entity_list)
- )
- return "-"
-
- def published(self, obj: Container) -> str:
- """
- Link to this Container's published ContainerVersion
- """
- if published := obj.versioning.published:
- return format_html(
- 'Version {} "{}" ({})',
- published.version_num,
- published.title,
- _entity_list_detail_link(published.entity_list),
- )
- return "-"
-
- def see_also(self, obj: Container):
- return one_to_one_related_model_html(obj)
-
- def most_recent_parent_entity_list(self, obj: Container) -> str:
- if latest_row := EntityListRow.objects.filter(entity_id=obj.publishable_entity_id).order_by("-pk").first():
- return _entity_list_detail_link(latest_row.entity_list)
- return "-"
-
-
-class ContainerVersionInlineForEntityList(admin.TabularInline):
- """
- Inline admin view of ContainerVersions which use a given EntityList
- """
- model = ContainerVersion
- verbose_name = "Container Version that references this Entity List"
- verbose_name_plural = "Container Versions that reference this Entity List"
- ordering = ["-pk"] # Newest first
- fields = [
- "pk",
- "version_num",
- "container_key",
- "title",
- "created",
- "created_by",
- ]
- readonly_fields = fields # type: ignore[assignment]
- extra = 0
-
- def get_queryset(self, request):
- return super().get_queryset(request).select_related(
- "container",
- "container__publishable_entity",
- "publishable_entity_version",
- )
-
- def container_key(self, obj: ContainerVersion) -> SafeText:
- return model_detail_link(obj.container, obj.container.key)
-
-
-class EntityListRowInline(admin.TabularInline):
- """
- Table of entity rows in the entitylist admin
- """
- model = EntityListRow
- readonly_fields = [
- "order_num",
- "pinned_version_num",
- "entity_models",
- "container_models",
- "container_children",
- ]
- fields = readonly_fields # type: ignore[assignment]
-
- def get_queryset(self, request):
- return super().get_queryset(request).select_related(
- "entity",
- "entity_version",
- )
-
- def pinned_version_num(self, obj: EntityListRow):
- return str(obj.entity_version.version_num) if obj.entity_version else "(Unpinned)"
-
- def entity_models(self, obj: EntityListRow):
- return format_html(
- "{}",
- model_detail_link(obj.entity, obj.entity.key),
- one_to_one_related_model_html(obj.entity),
- )
-
- def container_models(self, obj: EntityListRow) -> SafeText:
- if not hasattr(obj.entity, "container"):
- return SafeText("(Not a Container)")
- return format_html(
- "{}",
- model_detail_link(obj.entity.container, str(obj.entity.container)),
- one_to_one_related_model_html(obj.entity.container),
- )
-
- def container_children(self, obj: EntityListRow) -> SafeText:
- """
- If this row holds a Container, then link *its* EntityList, allowing easy hierarchy browsing.
-
- When determining which ContainerVersion to grab the EntityList from, prefer the pinned
- version if there is one; otherwise use the Draft version.
- """
- if not hasattr(obj.entity, "container"):
- return SafeText("(Not a Container)")
- child_container_version: ContainerVersion = (
- obj.entity_version.containerversion
- if obj.entity_version
- else obj.entity.container.versioning.draft
- )
- return _entity_list_detail_link(child_container_version.entity_list)
-
-
-@admin.register(EntityList)
-class EntityListAdmin(ReadOnlyModelAdmin):
- """
- Django admin configuration for EntityList
- """
- list_display = [
- "entity_list",
- "row_count",
- "recent_container_version_num",
- "recent_container",
- "recent_container_package"
- ]
- inlines = [ContainerVersionInlineForEntityList, EntityListRowInline]
-
- def entity_list(self, obj: EntityList) -> SafeText:
- return model_detail_link(obj, f"EntityList #{obj.pk}")
-
- def row_count(self, obj: EntityList) -> int:
- return obj.entitylistrow_set.count()
-
- def recent_container_version_num(self, obj: EntityList) -> str:
- """
- Number of the newest ContainerVersion that references this EntityList
- """
- if latest := _latest_container_version(obj):
- return f"Version {latest.version_num}"
- else:
- return "-"
-
- def recent_container(self, obj: EntityList) -> SafeText | None:
- """
- Link to the Container of the newest ContainerVersion that references this EntityList
- """
- if latest := _latest_container_version(obj):
- return format_html("of: {}", model_detail_link(latest.container, latest.container.key))
- else:
- return None
-
- def recent_container_package(self, obj: EntityList) -> SafeText | None:
- """
- Link to the LearningPackage of the newest ContainerVersion that references this EntityList
- """
- if latest := _latest_container_version(obj):
- return format_html(
- "in: {}",
- model_detail_link(
- latest.container.publishable_entity.learning_package,
- latest.container.publishable_entity.learning_package.key
- )
- )
- else:
- return None
-
- # We'd like it to appear as if these three columns are just a single
- # nicely-formatted column, so only give the left one a description.
- recent_container_version_num.short_description = ( # type: ignore[attr-defined]
- "Most recent container version using this entity list"
- )
- recent_container.short_description = "" # type: ignore[attr-defined]
- recent_container_package.short_description = "" # type: ignore[attr-defined]
-
-
-@functools.cache
-def _latest_container_version(obj: EntityList) -> ContainerVersion | None:
- """
- Any given EntityList can be used by multiple ContainerVersion (which may even
- span multiple Containers). We only have space here to show one ContainerVersion
- easily, so let's show the one that's most likely to be interesting to the Django
- admin user: the most-recently-created one.
- """
- return obj.container_versions.order_by("-pk").first()
diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py
index 64b190a09..88143f520 100644
--- a/src/openedx_content/applets/publishing/api.py
+++ b/src/openedx_content/applets/publishing/api.py
@@ -4,15 +4,14 @@
Please look at the models.py file for more information about the kinds of data
are stored in this app.
"""
+
from __future__ import annotations
from contextlib import nullcontext
-from dataclasses import dataclass
from datetime import datetime, timezone
-from enum import Enum
-from typing import ContextManager, Optional, TypeVar
+from typing import ContextManager, Optional
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.exceptions import ObjectDoesNotExist
from django.db.models import F, Prefetch, Q, QuerySet
from django.db.transaction import atomic
@@ -20,14 +19,10 @@
from .contextmanagers import DraftChangeLogContext
from .models import (
- Container,
- ContainerVersion,
Draft,
DraftChangeLog,
DraftChangeLogRecord,
DraftSideEffect,
- EntityList,
- EntityListRow,
LearningPackage,
PublishableContentModelRegistry,
PublishableEntity,
@@ -41,12 +36,6 @@
)
from .models.publish_log import Published
-# A few of the APIs in this file are generic and can be used for Containers in
-# general, or e.g. Units (subclass of Container) in particular. These type
-# variables are used to provide correct typing for those generic API methods.
-ContainerModel = TypeVar('ContainerModel', bound=Container)
-ContainerVersionModel = TypeVar('ContainerVersionModel', bound=ContainerVersion)
-
# The public API that will be re-exported by openedx_content.api
# is listed in the __all__ entries below. Internal helper functions that are
# private to this module should start with an underscore. If a function does not
@@ -76,24 +65,7 @@
"reset_drafts_to_published",
"register_publishable_models",
"filter_publishable_entities",
- # π UNSTABLE: All APIs related to containers are unstable until we've figured
- # out our approach to dynamic content (randomized, A/B tests, etc.)
- "create_container",
- "create_container_version",
- "create_next_container_version",
- "get_container",
- "get_container_by_key",
- "get_containers",
- "get_collection_containers",
- "ChildrenEntitiesAction",
- "ContainerEntityListEntry",
- "ContainerEntityRow",
- "get_entities_in_container",
- "contains_unpublished_changes",
- "get_containers_with_entity",
- "get_container_children_count",
"bulk_draft_changes_for",
- "get_container_children_entities_keys",
]
@@ -838,7 +810,7 @@ def _create_side_effects_for_change_log(change_log: DraftChangeLog | PublishLog)
# It also guards against infinite parent-child relationship loops, though
# those aren't *supposed* to happen anyhow.
processed_entity_ids: set[int] = set()
- for original_change in change_log.records.all():
+ for original_change in change_log.records.order_by("pk"):
affected_by_original_change = branch_cls.objects.filter(
version__dependencies=original_change.entity
)
@@ -882,14 +854,14 @@ def _create_side_effects_for_change_log(change_log: DraftChangeLog | PublishLog)
# Update the current branch pointer (Draft or Published) for this
# entity to point to the side_effect_change (if it's not already).
if branch_cls == Published:
- published_obj = affected.entity.published
- if published_obj.publish_log_record != side_effect_change:
+ published_obj = affected # 'affected' is the current Published object
+ if published_obj.publish_log_record_id != side_effect_change.pk:
published_obj.publish_log_record = side_effect_change
branch_objs_to_update_with_side_effects.append(published_obj)
elif branch_cls == Draft:
- draft_obj = affected.entity.draft
- if draft_obj.draft_log_record != side_effect_change:
- draft_obj.draft_log_record = side_effect_change
+ draft_obj = affected # 'affected' is the current Draft object
+ if draft_obj.draft_log_record_id != side_effect_change.pk: # type: ignore[union-attr]
+ draft_obj.draft_log_record = side_effect_change # type: ignore[union-attr]
branch_objs_to_update_with_side_effects.append(draft_obj)
# Create a new side effect (DraftSideEffect or PublishSideEffect) to
@@ -1344,636 +1316,15 @@ def get_published_version_as_of(entity_id: int, publish_log_id: int) -> Publisha
This is a semi-private function, only available to other apps in the
authoring package.
"""
- record = PublishLogRecord.objects.filter(
- entity_id=entity_id,
- publish_log_id__lte=publish_log_id,
- ).order_by('-publish_log_id').first()
- return record.new_version if record else None
-
-
-def create_container(
- learning_package_id: int,
- key: str,
- created: datetime,
- created_by: int | None,
- *,
- can_stand_alone: bool = True,
- # The types on the following line are correct, but mypy will complain - https://github.com/python/mypy/issues/3737
- container_cls: type[ContainerModel] = Container, # type: ignore[assignment]
-) -> ContainerModel:
- """
- [ π UNSTABLE ]
- Create a new container.
-
- Args:
- learning_package_id: The ID of the learning package that contains the 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
- can_stand_alone: Set to False when created as part of containers
- container_cls: The subclass of Container to use, if applicable
-
- Returns:
- The newly created container.
- """
- assert issubclass(container_cls, Container)
- with atomic():
- publishable_entity = create_publishable_entity(
- learning_package_id,
- key,
- created,
- created_by,
- can_stand_alone=can_stand_alone,
- )
- container = container_cls.objects.create(
- publishable_entity=publishable_entity,
- )
- return container
-
-
-def create_entity_list() -> EntityList:
- """
- [ π UNSTABLE ]
- Create a new entity list. This is an structure that holds a list of entities
- that will be referenced by the container.
-
- Returns:
- The newly created entity list.
- """
- return EntityList.objects.create()
-
-
-def create_entity_list_with_rows(
- entity_rows: list[ContainerEntityRow],
- *,
- learning_package_id: int | None,
-) -> EntityList:
- """
- [ π UNSTABLE ]
- Create new entity list rows for an entity list.
-
- Args:
- entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
- learning_package_id: Optional. Verify that all the entities are from
- the specified learning package.
-
- Returns:
- The newly created entity list.
- """
- # Do a quick check that the given entities are in the right learning package:
- if learning_package_id:
- if PublishableEntity.objects.filter(
- pk__in=[entity.entity_pk for entity in entity_rows],
- ).exclude(
- learning_package_id=learning_package_id,
- ).exists():
- raise ValidationError("Container entities must be from the same learning package.")
-
- # Ensure that any pinned entity versions are linked to the correct entity
- pinned_entities = {
- entity.version_pk: entity.entity_pk
- for entity in entity_rows if entity.pinned
- }
- if pinned_entities:
- entity_versions = PublishableEntityVersion.objects.filter(
- pk__in=pinned_entities.keys(),
- ).only('pk', 'entity_id')
- for entity_version in entity_versions:
- if pinned_entities[entity_version.pk] != entity_version.entity_id:
- raise ValidationError("Container entity versions must belong to the specified entity.")
-
- with atomic(savepoint=False):
- entity_list = create_entity_list()
- EntityListRow.objects.bulk_create(
- [
- EntityListRow(
- entity_list=entity_list,
- entity_id=entity.entity_pk,
- order_num=order_num,
- entity_version_id=entity.version_pk,
- )
- for order_num, entity in enumerate(entity_rows)
- ]
- )
- return entity_list
-
-
-def _create_container_version(
- container: Container,
- version_num: int,
- *,
- title: str,
- entity_list: EntityList,
- created: datetime,
- created_by: int | None,
- container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
-) -> ContainerVersionModel:
- """
- Private internal method for logic shared by create_container_version() and
- create_next_container_version().
- """
- assert issubclass(container_version_cls, ContainerVersion)
- with atomic(savepoint=False): # Make sure this will happen atomically but we don't need to create a new savepoint.
- publishable_entity_version = create_publishable_entity_version(
- container.publishable_entity_id,
- version_num=version_num,
- title=title,
- created=created,
- created_by=created_by,
- dependencies=[
- entity_row.entity_id
- for entity_row in entity_list.rows
- if entity_row.is_unpinned()
- ]
- )
- container_version = container_version_cls.objects.create(
- publishable_entity_version=publishable_entity_version,
- container_id=container.pk,
- entity_list=entity_list,
- )
-
- return container_version
-
-
-def create_container_version(
- container_id: int,
- version_num: int,
- *,
- title: str,
- entity_rows: list[ContainerEntityRow],
- created: datetime,
- created_by: int | None,
- container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
-) -> ContainerVersionModel:
- """
- [ π UNSTABLE ]
- Create a new container version.
-
- Args:
- container_id: The ID of the container that the version belongs to.
- version_num: The version number of the container.
- title: The title of the container.
- entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
- created: The date and time the container version was created.
- created_by: The ID of the user who created the container version.
- container_version_cls: The subclass of ContainerVersion to use, if applicable.
-
- Returns:
- The newly created container version.
- """
- assert title is not None
- assert entity_rows is not None
-
- with atomic(savepoint=False):
- container = Container.objects.select_related("publishable_entity").get(pk=container_id)
- entity = container.publishable_entity
- entity_list = create_entity_list_with_rows(
- entity_rows=entity_rows,
- learning_package_id=entity.learning_package_id,
- )
- container_version = _create_container_version(
- container,
- version_num,
- title=title,
- entity_list=entity_list,
- created=created,
- created_by=created_by,
- container_version_cls=container_version_cls,
- )
-
- return container_version
-
-
-class ChildrenEntitiesAction(Enum):
- """Possible actions for children entities"""
-
- APPEND = "append"
- REMOVE = "remove"
- REPLACE = "replace"
-
-
-def create_next_entity_list(
- learning_package_id: int,
- last_version: ContainerVersion,
- entity_rows: list[ContainerEntityRow],
- entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
-) -> EntityList:
- """
- Creates next entity list based on the given entities_action.
-
- Args:
- learning_package_id: Learning package ID
- last_version: Last version of container.
- entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
- entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
-
- Returns:
- The newly created entity list.
- """
- if entities_action == ChildrenEntitiesAction.APPEND:
- # get previous entity list rows
- last_entities = last_version.entity_list.entitylistrow_set.only(
- "entity_id",
- "entity_version_id"
- ).order_by("order_num")
- # append given entity_rows to the existing children
- entity_rows = [
- ContainerEntityRow(
- entity_pk=entity.entity_id,
- version_pk=entity.entity_version_id,
- )
- for entity in last_entities
- ] + entity_rows
- elif entities_action == ChildrenEntitiesAction.REMOVE:
- # get previous entity list, excluding the entities in entity_rows
- last_entities = last_version.entity_list.entitylistrow_set.only(
- "entity_id",
- "entity_version_id"
- ).exclude(
- entity_id__in=[entity.entity_pk for entity in entity_rows]
- ).order_by("order_num")
- entity_rows = [
- ContainerEntityRow(
- entity_pk=entity.entity_id,
- version_pk=entity.entity_version_id,
- )
- for entity in last_entities.all()
- ]
-
- return create_entity_list_with_rows(
- entity_rows=entity_rows,
- learning_package_id=learning_package_id,
- )
-
-
-def create_next_container_version(
- container_pk: int,
- *,
- title: str | None,
- entity_rows: list[ContainerEntityRow] | None,
- created: datetime,
- created_by: int | None,
- container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment]
- entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
- force_version_num: int | None = None,
-) -> ContainerVersionModel:
- """
- [ π UNSTABLE ]
- Create the next version of a container. A new version of the container is created
- only when its metadata changes:
-
- * Something was added to the Container.
- * We re-ordered the rows in the container.
- * Something was removed from the container.
- * The Container's metadata changed, e.g. the title.
- * We pin to different versions of the Container.
-
- Args:
- container_pk: The ID of the container to create the next version of.
- title: The title of the container. None to keep the current title.
- entity_rows: List of ContainerEntityRows specifying the publishable entity ID and version ID (if pinned).
- Or None for no change.
- created: The date and time the container version was created.
- created_by: The ID of the user who created the container version.
- container_version_cls: The subclass of ContainerVersion to use, if applicable.
- force_version_num (int, optional): If provided, overrides the automatic version number increment and sets
- this version's number explicitly. Use this if you need to restore or import a version with a specific
- version number, such as during data migration or when synchronizing with external systems.
-
- Returns:
- The newly created container version.
-
- Why use force_version_num?
- Normally, the version number is incremented automatically from the latest version.
- If you need to set a specific version number (for example, when restoring from backup,
- importing legacy data, or synchronizing with another system),
- use force_version_num to override the default behavior.
- """
- assert issubclass(container_version_cls, ContainerVersion)
- with atomic():
- container = Container.objects.select_related("publishable_entity").get(pk=container_pk)
- entity = container.publishable_entity
- last_version = container.versioning.latest
- if last_version is None:
- next_version_num = 1
- else:
- next_version_num = last_version.version_num + 1
-
- if force_version_num is not None:
- next_version_num = force_version_num
-
- if entity_rows is None and last_version is not None:
- # We're only changing metadata. Keep the same entity list.
- next_entity_list = last_version.entity_list
- else:
- next_entity_list = create_next_entity_list(
- entity.learning_package_id,
- last_version,
- entity_rows if entity_rows is not None else [],
- entities_action
- )
-
- next_container_version = _create_container_version(
- container,
- next_version_num,
- title=title if title is not None else last_version.title,
- entity_list=next_entity_list,
- created=created,
- created_by=created_by,
- container_version_cls=container_version_cls,
+ record = (
+ PublishLogRecord.objects.filter(
+ entity_id=entity_id,
+ publish_log_id__lte=publish_log_id,
)
-
- return next_container_version
-
-
-def get_container(pk: int) -> Container:
- """
- [ π UNSTABLE ]
- Get a container by its primary key.
-
- Args:
- pk: The primary key of the container.
-
- Returns:
- The container with the given primary key.
- """
- return Container.objects.get(pk=pk)
-
-
-def get_container_by_key(learning_package_id: int, /, key: str) -> Container:
- """
- [ π UNSTABLE ]
- Get a container by its learning package and primary key.
-
- Args:
- learning_package_id: The ID of the learning package that contains the container.
- key: The primary key of the container.
-
- Returns:
- The container with the given primary key.
- """
- return Container.objects.get(
- publishable_entity__learning_package_id=learning_package_id,
- publishable_entity__key=key,
- )
-
-
-def get_containers(
- learning_package_id: int,
- container_cls: type[ContainerModel] = Container, # type: ignore[assignment]
- include_deleted: bool | None = False,
-) -> QuerySet[ContainerModel]:
- """
- [ π UNSTABLE ]
- Get all containers in the given learning package.
-
- Args:
- learning_package_id: The primary key of the learning package
- container_cls: The subclass of Container to use, if applicable
- include_deleted: If True, include deleted containers (with no draft version) in the result.
-
- Returns:
- A queryset containing the container associated with the given learning package.
- """
- assert issubclass(container_cls, Container)
- container_qset = container_cls.objects.filter(publishable_entity__learning_package=learning_package_id)
- if not include_deleted:
- container_qset = container_qset.filter(publishable_entity__draft__version__isnull=False)
-
- return container_qset.order_by('pk')
-
-
-def get_collection_containers(
- learning_package_id: int,
- collection_key: str,
-) -> QuerySet[Container]:
- """
- Returns a QuerySet of Containers relating to the PublishableEntities in a Collection.
-
- Containers have a one-to-one relationship with PublishableEntity, but the reverse may not always be true.
- """
- return Container.objects.filter(
- publishable_entity__learning_package_id=learning_package_id,
- publishable_entity__collections__key=collection_key,
- ).order_by('pk')
-
-
-@dataclass(frozen=True)
-class ContainerEntityListEntry:
- """
- [ π UNSTABLE ]
- Data about a single entity in a container, e.g. a component in a unit.
- """
- entity_version: PublishableEntityVersion
- pinned: bool
-
- @property
- def entity(self):
- return self.entity_version.entity
-
-
-@dataclass(frozen=True, kw_only=True, slots=True)
-class ContainerEntityRow:
- """
- [ π UNSTABLE ]
- Used to specify the primary key of PublishableEntity and optional PublishableEntityVersion.
-
- If version_pk is None (default), then the entity is considered "unpinned",
- meaning that the latest version of the entity will be used.
- """
- entity_pk: int
- version_pk: int | None = None
-
- @property
- def pinned(self):
- return self.entity_pk and self.version_pk is not None
-
-
-def get_entities_in_container(
- container: Container,
- *,
- published: bool,
- select_related_version: str | None = None,
-) -> list[ContainerEntityListEntry]:
- """
- [ π UNSTABLE ]
- Get the list of entities and their versions in the current draft or
- published version of the given container.
-
- Args:
- container: The Container, e.g. returned by `get_container()`
- published: `True` if we want the published version of the container, or
- `False` for the draft version.
- select_related_version: An optional optimization; specify a relationship
- on ContainerVersion, like `componentversion` or `containerversion__x`
- to preload via select_related.
- """
- assert isinstance(container, Container)
- if published:
- # Very minor optimization: reload the container with related 1:1 entities
- container = Container.objects.select_related(
- "publishable_entity__published__version__containerversion__entity_list").get(pk=container.pk)
- container_version = container.versioning.published
- select_related = ["entity__published__version"]
- if select_related_version:
- select_related.append(f"entity__published__version__{select_related_version}")
- else:
- # Very minor optimization: reload the container with related 1:1 entities
- container = Container.objects.select_related(
- "publishable_entity__draft__version__containerversion__entity_list").get(pk=container.pk)
- container_version = container.versioning.draft
- select_related = ["entity__draft__version"]
- if select_related_version:
- select_related.append(f"entity__draft__version__{select_related_version}")
- if container_version is None:
- raise ContainerVersion.DoesNotExist # This container has not been published yet, or has been deleted.
- assert isinstance(container_version, ContainerVersion)
- entity_list: list[ContainerEntityListEntry] = []
- for row in container_version.entity_list.entitylistrow_set.select_related(
- "entity_version",
- *select_related,
- ).order_by("order_num"):
- entity_version = row.entity_version # This will be set if pinned
- if not entity_version: # If this entity is "unpinned", use the latest published/draft version:
- entity_version = row.entity.published.version if published else row.entity.draft.version
- if entity_version is not None: # As long as this hasn't been soft-deleted:
- entity_list.append(ContainerEntityListEntry(
- entity_version=entity_version,
- pinned=row.entity_version is not None,
- ))
- # else we could indicate somehow a deleted item was here, e.g. by returning a ContainerEntityListEntry with
- # deleted=True, but we don't have a use case for that yet.
- return entity_list
-
-
-def contains_unpublished_changes(container_id: int) -> bool:
- """
- [ π UNSTABLE ]
- Check recursively if a container has any unpublished changes.
-
- Note: I've preserved the API signature for now, but we probably eventually
- want to make a more general function that operates on PublishableEntities
- and dependencies, once we introduce those with courses and their files,
- grading policies, etc.
-
- Note: unlike this method, the similar-sounding
- `container.versioning.has_unpublished_changes` property only reports
- if the container itself has unpublished changes, not
- if its contents do. So if you change a title or add a new child component,
- `has_unpublished_changes` will be `True`, but if you merely edit a component
- that's in the container, it will be `False`. This method will return `True`
- in either case.
- """
- container = (
- Container.objects
- .select_related('publishable_entity__draft__draft_log_record')
- .select_related('publishable_entity__published__publish_log_record')
- .get(pk=container_id)
- )
- if container.versioning.has_unpublished_changes:
- return True
-
- draft = container.publishable_entity.draft
- published = container.publishable_entity.published
-
- # Edge case: A container that was created and then immediately soft-deleted
- # does not contain any unpublished changes.
- if draft is None and published is None:
- return False
-
- # The dependencies_hash_digest captures the state of all descendants, so we
- # can do this quick comparison instead of iterating through layers of
- # containers.
- draft_version_hash_digest = draft.log_record.dependencies_hash_digest
- published_version_hash_digest = published.log_record.dependencies_hash_digest
-
- return draft_version_hash_digest != published_version_hash_digest
-
-
-def get_containers_with_entity(
- publishable_entity_pk: int,
- *,
- ignore_pinned=False,
- published=False,
-) -> QuerySet[Container]:
- """
- [ π UNSTABLE ]
- Find all draft containers that directly contain the given entity.
-
- They will always be from the same learning package; cross-package containers
- are not allowed.
-
- Args:
- publishable_entity_pk: The ID of the PublishableEntity to search for.
- ignore_pinned: if true, ignore any pinned references to the entity.
- """
- branch = "published" if published else "draft"
- if ignore_pinned:
- filter_dict = {
- # Note: these two conditions must be in the same filter() call,
- # or the query won't be correct.
- (
- f"publishable_entity__{branch}__version__"
- "containerversion__entity_list__entitylistrow__entity_id"
- ): publishable_entity_pk,
- (
- f"publishable_entity__{branch}__version__"
- "containerversion__entity_list__entitylistrow__entity_version_id"
- ): None,
- }
- qs = Container.objects.filter(**filter_dict)
- else:
- filter_dict = {
- (
- f"publishable_entity__{branch}__version__"
- "containerversion__entity_list__entitylistrow__entity_id"
- ): publishable_entity_pk
- }
- qs = Container.objects.filter(**filter_dict)
-
- return qs.order_by("pk").distinct() # Ordering is mostly for consistent test cases.
-
-
-def get_container_children_count(
- container: Container,
- *,
- published: bool,
-):
- """
- [ π UNSTABLE ]
- Get the count of entities in the current draft or published version of the given container.
-
- Args:
- container: The Container, e.g. returned by `get_container()`
- published: `True` if we want the published version of the container, or
- `False` for the draft version.
- """
- assert isinstance(container, Container)
- container_version = container.versioning.published if published else container.versioning.draft
- if container_version is None:
- raise ContainerVersion.DoesNotExist # This container has not been published yet, or has been deleted.
- assert isinstance(container_version, ContainerVersion)
- if published:
- filter_deleted = {"entity__published__version__isnull": False}
- else:
- filter_deleted = {"entity__draft__version__isnull": False}
- return container_version.entity_list.entitylistrow_set.filter(**filter_deleted).count()
-
-
-def get_container_children_entities_keys(container_version: ContainerVersion) -> list[str]:
- """
- Fetch the list of entity keys for all entities in the given container version.
-
- Args:
- container_version: The ContainerVersion to fetch the entity keys for.
- Returns:
- A list of entity keys for all entities in the container version, ordered by entity key.
- """
- return list(
- container_version.entity_list.entitylistrow_set
- .values_list("entity__key", flat=True)
- .order_by("order_num")
+ .order_by("-publish_log_id")
+ .first()
)
+ return record.new_version if record else None
def bulk_draft_changes_for(
diff --git a/src/openedx_content/applets/publishing/models/__init__.py b/src/openedx_content/applets/publishing/models/__init__.py
index 32a73b214..1cba90431 100644
--- a/src/openedx_content/applets/publishing/models/__init__.py
+++ b/src/openedx_content/applets/publishing/models/__init__.py
@@ -13,9 +13,7 @@
* Storing and querying publish history.
"""
-from .container import Container, ContainerVersion
from .draft_log import Draft, DraftChangeLog, DraftChangeLogRecord, DraftSideEffect
-from .entity_list import EntityList, EntityListRow
from .learning_package import LearningPackage
from .publish_log import Published, PublishLog, PublishLogRecord, PublishSideEffect
from .publishable_entity import (
diff --git a/src/openedx_content/applets/publishing/models/container.py b/src/openedx_content/applets/publishing/models/container.py
deleted file mode 100644
index e34bb6a7e..000000000
--- a/src/openedx_content/applets/publishing/models/container.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
-Container and ContainerVersion models
-"""
-from django.core.exceptions import ValidationError
-from django.db import models
-
-from .entity_list import EntityList
-from .publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin
-
-
-class Container(PublishableEntityMixin):
- """
- A Container is a type of PublishableEntity that holds other
- PublishableEntities. For example, a "Unit" Container might hold several
- Components.
-
- For now, all containers have a static "entity list" that defines which
- 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.
- """
-
-
-class ContainerVersion(PublishableEntityVersionMixin):
- """
- A version of a Container.
-
- By convention, we would only want to create new versions when the Container
- itself changes, and not when the Container's child elements change. For
- example:
-
- * Something was added to the Container.
- * We re-ordered the rows in the container.
- * Something was removed to the container.
- * The Container's metadata changed, e.g. the title.
- * We pin to different versions of the Container.
-
- The last looks a bit odd, but it's because *how we've defined the Unit* has
- changed if we decide to explicitly pin a set of versions for the children,
- and then later change our minds and move to a different set. It also just
- makes things easier to reason about if we say that entity_list never
- changes for a given ContainerVersion.
- """
-
- container = models.ForeignKey(
- Container,
- on_delete=models.CASCADE,
- related_name="versions",
- )
-
- # The list of entities (frozen and/or unfrozen) in this container
- entity_list = models.ForeignKey(
- EntityList,
- on_delete=models.RESTRICT,
- null=False,
- related_name="container_versions",
- )
-
- def clean(self):
- """
- Validate this model before saving. Not called normally, but will be
- called if anything is edited via a ModelForm like the Django admin.
- """
- super().clean()
- if self.container_id != self.publishable_entity_version.entity.container.pk: # pylint: disable=no-member
- raise ValidationError("Inconsistent foreign keys to Container")
diff --git a/src/openedx_content/applets/publishing/models/entity_list.py b/src/openedx_content/applets/publishing/models/entity_list.py
deleted file mode 100644
index 37874acee..000000000
--- a/src/openedx_content/applets/publishing/models/entity_list.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""
-Entity List models
-"""
-from functools import cached_property
-
-from django.db import models
-
-from .publishable_entity import PublishableEntity, PublishableEntityVersion
-
-
-class EntityList(models.Model):
- """
- EntityLists are a common structure to hold parent-child relations.
-
- EntityLists are not PublishableEntities in and of themselves. That's because
- sometimes we'll want the same kind of data structure for things that we
- dynamically generate for individual students (e.g. Variants). EntityLists are
- anonymous in a senseβthey're pointed to by ContainerVersions and
- other models, rather than being looked up by their own identifiers.
- """
-
- @cached_property
- def rows(self):
- """
- Convenience method to iterate rows.
-
- I'd normally make this the reverse lookup name for the EntityListRow ->
- EntityList foreign key relation, but we already have references to
- entitylistrow_set in various places, and I thought this would be better
- than breaking compatibility.
- """
- return self.entitylistrow_set.order_by("order_num")
-
-
-class EntityListRow(models.Model):
- """
- Each EntityListRow points to a PublishableEntity, optionally at a specific
- version.
-
- There is a row in this table for each member of an EntityList. The order_num
- field is used to determine the order of the members in the list.
- """
-
- entity_list = models.ForeignKey(EntityList, on_delete=models.CASCADE)
-
- # This ordering should be treated as immutableβif the ordering needs to
- # change, we create a new EntityList and copy things over.
- order_num = models.PositiveIntegerField()
-
- # Simple case would use these fields with our convention that null versions
- # means "get the latest draft or published as appropriate". These entities
- # could be Selectors, in which case we'd need to do more work to find the right
- # variant. The publishing app itself doesn't know anything about Selectors
- # however, and just treats it as another PublishableEntity.
- entity = models.ForeignKey(PublishableEntity, on_delete=models.RESTRICT)
-
- # The version references point to the specific PublishableEntityVersion that
- # this EntityList has for this PublishableEntity for both the draft and
- # published states. However, we don't want to have to create new EntityList
- # every time that a member is updated, because that would waste a lot of
- # space and make it difficult to figure out when the metadata of something
- # like a Unit *actually* changed, vs. when its child members were being
- # updated. Doing so could also potentially lead to race conditions when
- # updating multiple layers of containers.
- #
- # So our approach to this is to use a value of None (null) to represent an
- # unpinned reference to a PublishableEntity. It's shorthand for "just use
- # the latest draft or published version of this, as appropriate".
- entity_version = models.ForeignKey(
- PublishableEntityVersion,
- on_delete=models.RESTRICT,
- null=True,
- related_name="+", # Do we need the reverse relation?
- )
-
- def is_pinned(self):
- return self.entity_version_id is not None
-
- def is_unpinned(self):
- return self.entity_version_id is None
-
- class Meta:
- ordering = ["order_num"]
- constraints = [
- # If (entity_list, order_num) is not unique, it likely indicates a race condition - so force uniqueness.
- models.UniqueConstraint(
- fields=["entity_list", "order_num"],
- name="oel_publishing_elist_row_order",
- ),
- ]
diff --git a/src/openedx_content/applets/sections/admin.py b/src/openedx_content/applets/sections/admin.py
deleted file mode 100644
index e0c081059..000000000
--- a/src/openedx_content/applets/sections/admin.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-Django admin for sections models
-"""
-from django.contrib import admin
-from django.utils.safestring import SafeText
-
-from openedx_django_lib.admin_utils import ReadOnlyModelAdmin, model_detail_link
-
-from ..publishing.models import ContainerVersion
-from .models import Section, SectionVersion
-
-
-class SectionVersionInline(admin.TabularInline):
- """
- Minimal table for section versions in a section.
-
- (Generally, this information is useless, because each SectionVersion should have a
- matching ContainerVersion, shown in much more detail on the Container detail page.
- But we've hit at least one bug where ContainerVersions were being created without
- their connected SectionVersions, so we'll leave this table here for debugging
- at least until we've made the APIs more robust against that sort of data corruption.)
- """
- model = SectionVersion
- fields = ["pk"]
- readonly_fields = ["pk"]
- ordering = ["-pk"] # Newest first
-
- def pk(self, obj: ContainerVersion) -> SafeText:
- return obj.pk
-
-
-@admin.register(Section)
-class SectionAdmin(ReadOnlyModelAdmin):
- """
- Very minimal interface... just direct the admin user's attention towards the related Container model admin.
- """
- inlines = [SectionVersionInline]
- list_display = ["pk", "key"]
- fields = ["key"]
- readonly_fields = ["key"]
-
- def key(self, obj: Section) -> SafeText:
- return model_detail_link(obj.container, obj.key)
-
- def get_form(self, request, obj=None, change=False, **kwargs):
- help_texts = {'key': f'For more details of this {self.model.__name__}, click above to see its Container view'}
- kwargs.update({'help_texts': help_texts})
- return super().get_form(request, obj, **kwargs)
diff --git a/src/openedx_content/applets/sections/api.py b/src/openedx_content/applets/sections/api.py
index 05e0d6141..ff9b613de 100644
--- a/src/openedx_content/applets/sections/api.py
+++ b/src/openedx_content/applets/sections/api.py
@@ -2,252 +2,100 @@
This module provides functions to manage sections.
"""
+
from dataclasses import dataclass
from datetime import datetime
+from typing import Iterable
-from django.db.transaction import atomic
-
-from ..publishing import api as publishing_api
+from ..containers import api as containers_api
+from ..containers.models import ContainerVersion
from ..subsections.models import Subsection, SubsectionVersion
from .models import Section, SectionVersion
# π UNSTABLE: All APIs related to containers are unstable until we've figured
# out our approach to dynamic content (randomized, A/B tests, etc.)
__all__ = [
- "create_section",
- "create_section_version",
- "create_next_section_version",
- "create_section_and_version",
"get_section",
- "get_section_version",
- "get_latest_section_version",
+ "create_section_and_version",
+ "create_next_section_version",
"SectionListEntry",
"get_subsections_in_section",
- "get_subsections_in_published_section_as_of",
]
-def create_section(
- learning_package_id: int,
- key: str,
- created: datetime,
- created_by: int | None,
- *,
- can_stand_alone: bool = True,
-) -> Section:
- """
- [ π UNSTABLE ] Create a new section.
+def get_section(section_id: int, /):
+ """Get a section"""
+ return Section.objects.select_related("container").get(pk=section_id)
- Args:
- learning_package_id: The learning package ID.
- key: The key.
- created: The creation date.
- created_by: The user who created the section.
- can_stand_alone: Set to False when created as part of containers
- """
- return publishing_api.create_container(
- learning_package_id,
- key,
- created,
- created_by,
- can_stand_alone=can_stand_alone,
- container_cls=Section,
- )
-
-def create_section_version(
- section: Section,
- version_num: int,
+def create_section_and_version(
+ learning_package_id: int,
+ key: str,
*,
title: str,
- entity_rows: list[publishing_api.ContainerEntityRow],
+ subsections: Iterable[Subsection | SubsectionVersion] | None = None,
created: datetime,
created_by: int | None = None,
-) -> SectionVersion:
+ can_stand_alone: bool = True,
+) -> tuple[Section, SectionVersion]:
"""
- [ π UNSTABLE ] Create a new section version.
-
- This is a very low-level API, likely only needed for import/export. In
- general, you will use `create_section_and_version()` and
- `create_next_section_version()` instead.
+ See documentation of `content_api.create_container_and_version()`
- Args:
- section_pk: The section ID.
- version_num: The version number.
- title: The title.
- entity_rows: child entities/versions
- created: The creation date.
- created_by: The user who created the section.
+ The only real purpose of this function is to rename `entities` to `subsections`, and to specify that the version
+ returned is a `SectionVersion`. In the future, if `SectionVersion` gets some fields that aren't on
+ `ContainerVersion`, this function would be more important.
"""
- return publishing_api.create_container_version(
- section.pk,
- version_num,
+ section, sv = containers_api.create_container_and_version(
+ learning_package_id,
+ key=key,
title=title,
- entity_rows=entity_rows,
+ entities=subsections,
created=created,
created_by=created_by,
- container_version_cls=SectionVersion,
+ can_stand_alone=can_stand_alone,
+ container_cls=Section,
)
-
-
-def _pub_entities_for_subsections(
- subsections: list[Subsection | SubsectionVersion] | None,
-) -> list[publishing_api.ContainerEntityRow] | None:
- """
- Helper method: given a list of Subsection | SubsectionVersion, return the
- lists of publishable_entities_pks and entity_version_pks needed for the
- base container APIs.
-
- SubsectionVersion is passed when we want to pin a specific version, otherwise
- Subsection is used for unpinned.
- """
- if subsections is None:
- # When these are None, that means don't change the entities in the list.
- return None
- for u in subsections:
- if not isinstance(u, (Subsection, SubsectionVersion)):
- raise TypeError("Section subsections must be either Subsection or SubsectionVersion.")
- return [
- (
- publishing_api.ContainerEntityRow(
- entity_pk=s.container.publishable_entity_id,
- version_pk=None,
- ) if isinstance(s, Subsection)
- else publishing_api.ContainerEntityRow(
- entity_pk=s.subsection.container.publishable_entity_id,
- version_pk=s.container_version.publishable_entity_version_id,
- )
- )
- for s in subsections
- ]
+ assert isinstance(sv, SectionVersion)
+ return section, sv
def create_next_section_version(
- section: Section,
+ section: Section | int,
*,
title: str | None = None,
- subsections: list[Subsection | SubsectionVersion] | None = None,
+ subsections: Iterable[Subsection | SubsectionVersion] | None = None,
created: datetime,
- created_by: int | None = None,
- entities_action: publishing_api.ChildrenEntitiesAction = publishing_api.ChildrenEntitiesAction.REPLACE,
- force_version_num: int | None = None,
+ created_by: int | None,
) -> SectionVersion:
"""
- [ π UNSTABLE ] Create the next section version.
-
- Args:
- section_pk: The section ID.
- title: The title. Leave as None to keep the current title.
- subsections: The subsections, as a list of Subsections (unpinned) and/or SubsectionVersions (pinned).
- Passing None will leave the existing subsections unchanged.
- created: The creation date.
- created_by: The user who created the section.
- force_version_num (int, optional): If provided, overrides the automatic version number increment and sets
- this version's number explicitly. Use this if you need to restore or import a version with a specific
- version number, such as during data migration or when synchronizing with external systems.
+ See documentation of content_api.create_next_container_version()
- Returns:
- The newly created SectionVersion.
-
- Why use force_version_num?
- Normally, the version number is incremented automatically from the latest version.
- If you need to set a specific version number (for example, when restoring from backup,
- importing legacy data, or synchronizing with another system),
- use force_version_num to override the default behavior.
-
- Why not use create_component_version?
- The main reason is that we want to reuse the logic for adding entities to this container.
+ The only real purpose of this function is to rename `entities` to `subsections`, and to specify that the version
+ returned is a `SectionVersion`. In the future, if `SectionVersion` gets some fields that aren't on
+ `ContainerVersion`, this function would be more important.
"""
- entity_rows = _pub_entities_for_subsections(subsections)
- section_version = publishing_api.create_next_container_version(
- section.pk,
+ if isinstance(section, int):
+ section = get_section(section)
+ assert isinstance(section, Section)
+ sv = containers_api.create_next_container_version(
+ section,
title=title,
- entity_rows=entity_rows,
+ entities=subsections,
created=created,
created_by=created_by,
- container_version_cls=SectionVersion,
- entities_action=entities_action,
- force_version_num=force_version_num,
+ # For now, `entities_action` and `force_version_num` are unsupported but we could add them in the future.
)
- return section_version
-
-
-def create_section_and_version(
- learning_package_id: int,
- key: str,
- *,
- title: str,
- subsections: list[Subsection | SubsectionVersion] | None = None,
- created: datetime,
- created_by: int | None = None,
- can_stand_alone: bool = True,
-) -> tuple[Section, SectionVersion]:
- """
- [ π UNSTABLE ] Create a new section and its version.
-
- Args:
- learning_package_id: The learning package ID.
- key: The key.
- created: The creation date.
- created_by: The user who created the section.
- can_stand_alone: Set to False when created as part of containers
- """
- entity_rows = _pub_entities_for_subsections(subsections)
- with atomic():
- section = create_section(
- learning_package_id,
- key,
- created,
- created_by,
- can_stand_alone=can_stand_alone,
- )
- section_version = create_section_version(
- section,
- 1,
- title=title,
- entity_rows=entity_rows or [],
- created=created,
- created_by=created_by,
- )
- return section, section_version
-
-
-def get_section(section_pk: int) -> Section:
- """
- [ π UNSTABLE ] Get a section.
-
- Args:
- section_pk: The section ID.
- """
- return Section.objects.get(pk=section_pk)
-
-
-def get_section_version(section_version_pk: int) -> SectionVersion:
- """
- [ π UNSTABLE ] Get a section version.
-
- Args:
- section_version_pk: The section version ID.
- """
- return SectionVersion.objects.get(pk=section_version_pk)
-
-
-def get_latest_section_version(section_pk: int) -> SectionVersion:
- """
- [ π UNSTABLE ] Get the latest section version.
-
- Args:
- section_pk: The section ID.
- """
- return Section.objects.get(pk=section_pk).versioning.latest
+ assert isinstance(sv, SectionVersion)
+ return sv
@dataclass(frozen=True)
class SectionListEntry:
"""
[ π UNSTABLE ]
- Data about a single entity in a container, e.g. a subsection in a section.
+ Data about a single subsection in a section.
"""
+
subsection_version: SubsectionVersion
pinned: bool = False
@@ -267,64 +115,23 @@ def get_subsections_in_section(
version of the given Section.
Args:
- section: The Section, e.g. returned by `get_section()`
+ section: The section, e.g. returned by `get_section()`
published: `True` if we want the published version of the section, or
`False` for the draft version.
"""
assert isinstance(section, Section)
subsections = []
- entries = publishing_api.get_entities_in_container(
- section,
- published=published,
- select_related_version="containerversion__subsectionversion",
- )
+ try:
+ entries = containers_api.get_entities_in_container(
+ section,
+ published=published,
+ select_related_version="containerversion__subsectionversion",
+ )
+ except ContainerVersion.DoesNotExist as exc:
+ raise SectionVersion.DoesNotExist() from exc # Make the exception more specific
for entry in entries:
# Convert from generic PublishableEntityVersion to SubsectionVersion:
subsection_version = entry.entity_version.containerversion.subsectionversion
assert isinstance(subsection_version, SubsectionVersion)
subsections.append(SectionListEntry(subsection_version=subsection_version, pinned=entry.pinned))
return subsections
-
-
-def get_subsections_in_published_section_as_of(
- section: Section,
- publish_log_id: int,
-) -> list[SectionListEntry] | None:
- """
- [ π UNSTABLE ]
- Get the list of entities and their versions in the published version of the
- given container as of the given PublishLog version (which is essentially a
- version for the entire learning package).
-
- TODO: This API should be updated to also return the SectionVersion so we can
- see the section title and any other metadata from that point in time.
- TODO: accept a publish log UUID, not just int ID?
- TODO: move the implementation to be a generic 'containers' implementation
- that this sections function merely wraps.
- TODO: optimize, perhaps by having the publishlog store a record of all
- ancestors of every modified PublishableEntity in the publish.
- """
- assert isinstance(section, Section)
- section_pub_entity_version = publishing_api.get_published_version_as_of(
- section.publishable_entity_id, publish_log_id
- )
- if section_pub_entity_version is None:
- return None # This section was not published as of the given PublishLog ID.
- container_version = section_pub_entity_version.containerversion
-
- entity_list = []
- rows = container_version.entity_list.entitylistrow_set.order_by("order_num")
- for row in rows:
- if row.entity_version is not None:
- subsection_version = row.entity_version.containerversion.subsectionversion
- assert isinstance(subsection_version, SubsectionVersion)
- entity_list.append(SectionListEntry(subsection_version=subsection_version, pinned=True))
- else:
- # Unpinned subsection - figure out what its latest published version was.
- # This is not optimized. It could be done in one query per section rather than one query per subsection.
- pub_entity_version = publishing_api.get_published_version_as_of(row.entity_id, publish_log_id)
- if pub_entity_version:
- entity_list.append(SectionListEntry(
- subsection_version=pub_entity_version.containerversion.subsectionversion, pinned=False
- ))
- return entity_list
diff --git a/src/openedx_content/applets/sections/models.py b/src/openedx_content/applets/sections/models.py
index afcb0ae0c..a02df4816 100644
--- a/src/openedx_content/applets/sections/models.py
+++ b/src/openedx_content/applets/sections/models.py
@@ -1,9 +1,16 @@
"""
Models that implement sections
"""
+
+from typing import override
+
+from django.core.exceptions import ValidationError
from django.db import models
-from ..publishing.models import Container, ContainerVersion
+from ..containers.api import get_container_subclass_of
+from ..containers.models import Container, ContainerVersion
+from ..publishing.models import PublishableEntity
+from ..subsections.models import Subsection
__all__ = [
"Section",
@@ -11,13 +18,18 @@
]
+@Container.register_subclass
class Section(Container):
"""
- A Section is type of Container that holds Units.
+ A Section is type of Container that holds Subsections.
Via Container and its PublishableEntityMixin, Sections are also publishable
entities and can be added to other containers.
"""
+
+ type_code = "section"
+ olx_tag_name = "chapter" # Serializes to OLX as `...`.
+
container = models.OneToOneField(
Container,
on_delete=models.CASCADE,
@@ -25,14 +37,24 @@ class Section(Container):
primary_key=True,
)
+ @override
+ @classmethod
+ def validate_entity(cls, entity: PublishableEntity) -> None:
+ """Check if the given entity is allowed as a child of a Section"""
+ # Sections only allow Subsections as children, so the entity must be 1:1 with Container:
+ container = entity.container # Could raise PublishableEntity.container.RelatedObjectDoesNotExist
+ if get_container_subclass_of(container) is not Subsection:
+ raise ValidationError("Only Subsection can be added as children of a Section")
+
class SectionVersion(ContainerVersion):
"""
A SectionVersion is a specific version of a Section.
- Via ContainerVersion and its EntityList, it defines the list of Units
+ Via ContainerVersion and its EntityList, it defines the list of Subsections
in this version of the Section.
"""
+
container_version = models.OneToOneField(
ContainerVersion,
on_delete=models.CASCADE,
@@ -42,9 +64,5 @@ class SectionVersion(ContainerVersion):
@property
def section(self):
- """ Convenience accessor to the Section this version is associated with """
+ """Convenience accessor to the Section this version is associated with"""
return self.container_version.container.section # pylint: disable=no-member
-
- # Note: the 'publishable_entity_version' field is inherited and will appear on this model, but does not exist
- # in the underlying database table. It only exists in the ContainerVersion table.
- # You can verify this by running 'python manage.py sqlmigrate oel_sections 0001_initial'
diff --git a/src/openedx_content/applets/subsections/admin.py b/src/openedx_content/applets/subsections/admin.py
deleted file mode 100644
index d9d197b3c..000000000
--- a/src/openedx_content/applets/subsections/admin.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-Django admin for subsection models
-"""
-from django.contrib import admin
-from django.utils.safestring import SafeText
-
-from openedx_django_lib.admin_utils import ReadOnlyModelAdmin, model_detail_link
-
-from ..publishing.models import ContainerVersion
-from .models import Subsection, SubsectionVersion
-
-
-class SubsectionVersionInline(admin.TabularInline):
- """
- Minimal table for subsection versions in a subsection.
-
- (Generally, this information is useless, because each SubsectionVersion should have a
- matching ContainerVersion, shown in much more detail on the Container detail page.
- But we've hit at least one bug where ContainerVersions were being created without
- their connected SubsectionVersions, so we'll leave this table here for debugging
- at least until we've made the APIs more robust against that sort of data corruption.)
- """
- model = SubsectionVersion
- fields = ["pk"]
- readonly_fields = ["pk"]
- ordering = ["-pk"] # Newest first
-
- def pk(self, obj: ContainerVersion) -> SafeText:
- return obj.pk
-
-
-@admin.register(Subsection)
-class SubsectionAdmin(ReadOnlyModelAdmin):
- """
- Very minimal interface... just direct the admin user's attention towards the related Container model admin.
- """
- inlines = [SubsectionVersionInline]
- list_display = ["pk", "key"]
- fields = ["key"]
- readonly_fields = ["key"]
-
- def key(self, obj: Subsection) -> SafeText:
- return model_detail_link(obj.container, obj.key)
-
- def get_form(self, request, obj=None, change=False, **kwargs):
- help_texts = {'key': f'For more details of this {self.model.__name__}, click above to see its Container view'}
- kwargs.update({'help_texts': help_texts})
- return super().get_form(request, obj, **kwargs)
diff --git a/src/openedx_content/applets/subsections/api.py b/src/openedx_content/applets/subsections/api.py
index d39c5700a..85f3b73ca 100644
--- a/src/openedx_content/applets/subsections/api.py
+++ b/src/openedx_content/applets/subsections/api.py
@@ -2,251 +2,100 @@
This module provides functions to manage subsections.
"""
+
from dataclasses import dataclass
from datetime import datetime
+from typing import Iterable
-from django.db.transaction import atomic
-
-from ..publishing import api as publishing_api
+from ..containers import api as containers_api
+from ..containers.models import ContainerVersion
from ..units.models import Unit, UnitVersion
from .models import Subsection, SubsectionVersion
# π UNSTABLE: All APIs related to containers are unstable until we've figured
# out our approach to dynamic content (randomized, A/B tests, etc.)
__all__ = [
- "create_subsection",
- "create_subsection_version",
- "create_next_subsection_version",
- "create_subsection_and_version",
"get_subsection",
- "get_subsection_version",
- "get_latest_subsection_version",
+ "create_subsection_and_version",
+ "create_next_subsection_version",
"SubsectionListEntry",
"get_units_in_subsection",
- "get_units_in_published_subsection_as_of",
]
-def create_subsection(
- learning_package_id: int,
- key: str,
- created: datetime,
- created_by: int | None,
- *,
- can_stand_alone: bool = True,
-) -> Subsection:
- """
- [ π UNSTABLE ] Create a new subsection.
+def get_subsection(subsection_id: int, /):
+ """Get a subsection"""
+ return Subsection.objects.select_related("container").get(pk=subsection_id)
- Args:
- learning_package_id: The learning package ID.
- key: The key.
- created: The creation date.
- created_by: The user who created the subsection.
- can_stand_alone: Set to False when created as part of containers
- """
- return publishing_api.create_container(
- learning_package_id,
- key,
- created,
- created_by,
- can_stand_alone=can_stand_alone,
- container_cls=Subsection,
- )
-
-def create_subsection_version(
- subsection: Subsection,
- version_num: int,
+def create_subsection_and_version(
+ learning_package_id: int,
+ key: str,
*,
title: str,
- entity_rows: list[publishing_api.ContainerEntityRow],
+ units: Iterable[Unit | UnitVersion] | None = None,
created: datetime,
created_by: int | None = None,
-) -> SubsectionVersion:
+ can_stand_alone: bool = True,
+) -> tuple[Subsection, SubsectionVersion]:
"""
- [ π UNSTABLE ] Create a new subsection version.
-
- This is a very low-level API, likely only needed for import/export. In
- general, you will use `create_subsection_and_version()` and
- `create_next_subsection_version()` instead.
+ See documentation of `content_api.create_container_and_version()`
- Args:
- subsection_pk: The subsection ID.
- version_num: The version number.
- title: The title.
- entity_rows: child entities/versions
- created: The creation date.
- created_by: The user who created the subsection.
+ The only real purpose of this function is to rename `entities` to `units`, and to specify that the version
+ returned is a `SubsectionVersion`. In the future, if `SubsectionVersion` gets some fields that aren't on
+ `ContainerVersion`, this function would be more important.
"""
- return publishing_api.create_container_version(
- subsection.pk,
- version_num,
+ subsection, sv = containers_api.create_container_and_version(
+ learning_package_id,
+ key=key,
title=title,
- entity_rows=entity_rows,
+ entities=units,
created=created,
created_by=created_by,
- container_version_cls=SubsectionVersion,
+ can_stand_alone=can_stand_alone,
+ container_cls=Subsection,
)
-
-
-def _pub_entities_for_units(
- units: list[Unit | UnitVersion] | None,
-) -> list[publishing_api.ContainerEntityRow] | None:
- """
- Helper method: given a list of Unit | UnitVersion, return the
- list of ContainerEntityRows needed for the base container APIs.
-
- UnitVersion is passed when we want to pin a specific version, otherwise
- Unit is used for unpinned.
- """
- if units is None:
- # When these are None, that means don't change the entities in the list.
- return None
- for u in units:
- if not isinstance(u, (Unit, UnitVersion)):
- raise TypeError("Subsection units must be either Unit or UnitVersion.")
- return [
- (
- publishing_api.ContainerEntityRow(
- entity_pk=u.container.publishable_entity_id,
- version_pk=None,
- ) if isinstance(u, Unit)
- else publishing_api.ContainerEntityRow(
- entity_pk=u.unit.container.publishable_entity_id,
- version_pk=u.container_version.publishable_entity_version_id,
- )
- )
- for u in units
- ]
+ assert isinstance(sv, SubsectionVersion)
+ return subsection, sv
def create_next_subsection_version(
- subsection: Subsection,
+ subsection: Subsection | int,
*,
title: str | None = None,
- units: list[Unit | UnitVersion] | None = None,
+ units: Iterable[Unit | UnitVersion] | None = None,
created: datetime,
- created_by: int | None = None,
- entities_action: publishing_api.ChildrenEntitiesAction = publishing_api.ChildrenEntitiesAction.REPLACE,
- force_version_num: int | None = None,
+ created_by: int | None,
) -> SubsectionVersion:
"""
- [ π UNSTABLE ] Create the next subsection version.
-
- Args:
- subsection_pk: The subsection ID.
- title: The title. Leave as None to keep the current title.
- units: The units, as a list of Units (unpinned) and/or UnitVersions (pinned). Passing None
- will leave the existing units unchanged.
- created: The creation date.
- created_by: The user who created the subsection.
- force_version_num (int, optional): If provided, overrides the automatic version number increment and sets
- this version's number explicitly. Use this if you need to restore or import a version with a specific
- version number, such as during data migration or when synchronizing with external systems.
+ See documentation of content_api.create_next_container_version()
- Returns:
- The newly created subsection version.
-
- Why use force_version_num?
- Normally, the version number is incremented automatically from the latest version.
- If you need to set a specific version number (for example, when restoring from backup,
- importing legacy data, or synchronizing with another system),
- use force_version_num to override the default behavior.
-
- Why not use create_component_version?
- The main reason is that we want to reuse the logic for adding entities to this container.
+ The only real purpose of this function is to rename `entities` to `units`, and to specify that the version
+ returned is a `SubsectionVersion`. In the future, if `SubsectionVersion` gets some fields that aren't on
+ `ContainerVersion`, this function would be more important.
"""
- entity_rows = _pub_entities_for_units(units)
- subsection_version = publishing_api.create_next_container_version(
- subsection.pk,
+ if isinstance(subsection, int):
+ subsection = get_subsection(subsection)
+ assert isinstance(subsection, Subsection)
+ sv = containers_api.create_next_container_version(
+ subsection,
title=title,
- entity_rows=entity_rows,
+ entities=units,
created=created,
created_by=created_by,
- container_version_cls=SubsectionVersion,
- entities_action=entities_action,
- force_version_num=force_version_num,
+ # For now, `entities_action` and `force_version_num` are unsupported but we could add them in the future.
)
- return subsection_version
-
-
-def create_subsection_and_version(
- learning_package_id: int,
- key: str,
- *,
- title: str,
- units: list[Unit | UnitVersion] | None = None,
- created: datetime,
- created_by: int | None = None,
- can_stand_alone: bool = True,
-) -> tuple[Subsection, SubsectionVersion]:
- """
- [ π UNSTABLE ] Create a new subsection and its version.
-
- Args:
- learning_package_id: The learning package ID.
- key: The key.
- created: The creation date.
- created_by: The user who created the subsection.
- can_stand_alone: Set to False when created as part of containers
- """
- entity_rows = _pub_entities_for_units(units)
- with atomic():
- subsection = create_subsection(
- learning_package_id,
- key,
- created,
- created_by,
- can_stand_alone=can_stand_alone,
- )
- subsection_version = create_subsection_version(
- subsection,
- 1,
- title=title,
- entity_rows=entity_rows or [],
- created=created,
- created_by=created_by,
- )
- return subsection, subsection_version
-
-
-def get_subsection(subsection_pk: int) -> Subsection:
- """
- [ π UNSTABLE ] Get a subsection.
-
- Args:
- subsection_pk: The subsection ID.
- """
- return Subsection.objects.get(pk=subsection_pk)
-
-
-def get_subsection_version(subsection_version_pk: int) -> SubsectionVersion:
- """
- [ π UNSTABLE ] Get a subsection version.
-
- Args:
- subsection_version_pk: The subsection version ID.
- """
- return SubsectionVersion.objects.get(pk=subsection_version_pk)
-
-
-def get_latest_subsection_version(subsection_pk: int) -> SubsectionVersion:
- """
- [ π UNSTABLE ] Get the latest subsection version.
-
- Args:
- subsection_pk: The subsection ID.
- """
- return Subsection.objects.get(pk=subsection_pk).versioning.latest
+ assert isinstance(sv, SubsectionVersion)
+ return sv
@dataclass(frozen=True)
class SubsectionListEntry:
"""
[ π UNSTABLE ]
- Data about a single entity in a container, e.g. a unit in a subsection.
+ Data about a single unit in a subsection.
"""
+
unit_version: UnitVersion
pinned: bool = False
@@ -272,58 +121,17 @@ def get_units_in_subsection(
"""
assert isinstance(subsection, Subsection)
units = []
- entries = publishing_api.get_entities_in_container(
- subsection,
- published=published,
- select_related_version="containerversion__unitversion",
- )
+ try:
+ entries = containers_api.get_entities_in_container(
+ subsection,
+ published=published,
+ select_related_version="containerversion__unitversion",
+ )
+ except ContainerVersion.DoesNotExist as exc:
+ raise SubsectionVersion.DoesNotExist() from exc # Make the exception more specific
for entry in entries:
# Convert from generic PublishableEntityVersion to UnitVersion:
unit_version = entry.entity_version.containerversion.unitversion
assert isinstance(unit_version, UnitVersion)
units.append(SubsectionListEntry(unit_version=unit_version, pinned=entry.pinned))
return units
-
-
-def get_units_in_published_subsection_as_of(
- subsection: Subsection,
- publish_log_id: int,
-) -> list[SubsectionListEntry] | None:
- """
- [ π UNSTABLE ]
- Get the list of entities and their versions in the published version of the
- given container as of the given PublishLog version (which is essentially a
- version for the entire learning package).
-
- TODO: This API should be updated to also return the SubsectionVersion so we can
- see the subsection title and any other metadata from that point in time.
- TODO: accept a publish log UUID, not just int ID?
- TODO: move the implementation to be a generic 'containers' implementation
- that this subsections function merely wraps.
- TODO: optimize, perhaps by having the publishlog store a record of all
- ancestors of every modified PublishableEntity in the publish.
- """
- assert isinstance(subsection, Subsection)
- subsection_pub_entity_version = publishing_api.get_published_version_as_of(
- subsection.publishable_entity_id, publish_log_id
- )
- if subsection_pub_entity_version is None:
- return None # This subsection was not published as of the given PublishLog ID.
- container_version = subsection_pub_entity_version.containerversion
-
- entity_list = []
- rows = container_version.entity_list.entitylistrow_set.order_by("order_num")
- for row in rows:
- if row.entity_version is not None:
- unit_version = row.entity_version.containerversion.unitversion
- assert isinstance(unit_version, UnitVersion)
- entity_list.append(SubsectionListEntry(unit_version=unit_version, pinned=True))
- else:
- # Unpinned unit - figure out what its latest published version was.
- # This is not optimized. It could be done in one query per subsection rather than one query per unit.
- pub_entity_version = publishing_api.get_published_version_as_of(row.entity_id, publish_log_id)
- if pub_entity_version:
- entity_list.append(
- SubsectionListEntry(unit_version=pub_entity_version.containerversion.unitversion, pinned=False)
- )
- return entity_list
diff --git a/src/openedx_content/applets/subsections/models.py b/src/openedx_content/applets/subsections/models.py
index 8d662ed4e..d1eefb139 100644
--- a/src/openedx_content/applets/subsections/models.py
+++ b/src/openedx_content/applets/subsections/models.py
@@ -1,9 +1,16 @@
"""
Models that implement subsections
"""
+
+from typing import override
+
+from django.core.exceptions import ValidationError
from django.db import models
-from ..publishing.models import Container, ContainerVersion
+from ..containers.api import get_container_subclass_of
+from ..containers.models import Container, ContainerVersion
+from ..publishing.models import PublishableEntity
+from ..units.models import Unit
__all__ = [
"Subsection",
@@ -11,6 +18,7 @@
]
+@Container.register_subclass
class Subsection(Container):
"""
A Subsection is type of Container that holds Units.
@@ -18,6 +26,10 @@ class Subsection(Container):
Via Container and its PublishableEntityMixin, Subsections are also publishable
entities and can be added to other containers.
"""
+
+ type_code = "subsection"
+ olx_tag_name = "sequential" # Serializes to OLX as `...`.
+
container = models.OneToOneField(
Container,
on_delete=models.CASCADE,
@@ -25,6 +37,15 @@ class Subsection(Container):
primary_key=True,
)
+ @override
+ @classmethod
+ def validate_entity(cls, entity: PublishableEntity) -> None:
+ """Check if the given entity is allowed as a child of a Subsection"""
+ # Subsections only allow Units as children, so the entity must be 1:1 with Container:
+ container = entity.container # Could raise PublishableEntity.container.RelatedObjectDoesNotExist
+ if get_container_subclass_of(container) is not Unit:
+ raise ValidationError("Only Units can be added as children of a Subsection")
+
class SubsectionVersion(ContainerVersion):
"""
@@ -33,6 +54,7 @@ class SubsectionVersion(ContainerVersion):
Via ContainerVersion and its EntityList, it defines the list of Units
in this version of the Subsection.
"""
+
container_version = models.OneToOneField(
ContainerVersion,
on_delete=models.CASCADE,
@@ -42,9 +64,5 @@ class SubsectionVersion(ContainerVersion):
@property
def subsection(self):
- """ Convenience accessor to the Subsection this version is associated with """
+ """Convenience accessor to the Subsection this version is associated with"""
return self.container_version.container.subsection # pylint: disable=no-member
-
- # Note: the 'publishable_entity_version' field is inherited and will appear on this model, but does not exist
- # in the underlying database table. It only exists in the ContainerVersion table.
- # You can verify this by running 'python manage.py sqlmigrate oel_subsections 0001_initial'
diff --git a/src/openedx_content/applets/units/admin.py b/src/openedx_content/applets/units/admin.py
deleted file mode 100644
index d079875f8..000000000
--- a/src/openedx_content/applets/units/admin.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-Django admin for units models
-"""
-from django.contrib import admin
-from django.utils.safestring import SafeText
-
-from openedx_django_lib.admin_utils import ReadOnlyModelAdmin, model_detail_link
-
-from ..publishing.models import ContainerVersion
-from .models import Unit, UnitVersion
-
-
-class UnitVersionInline(admin.TabularInline):
- """
- Minimal table for unit versions in a unit
-
- (Generally, this information is useless, because each UnitVersion should have a
- matching ContainerVersion, shown in much more detail on the Container detail page.
- But we've hit at least one bug where ContainerVersions were being created without
- their connected UnitVersions, so we'll leave this table here for debugging
- at least until we've made the APIs more robust against that sort of data corruption.)
- """
- model = UnitVersion
- fields = ["pk"]
- readonly_fields = ["pk"]
- ordering = ["-pk"] # Newest first
-
- def pk(self, obj: ContainerVersion) -> SafeText:
- return obj.pk
-
-
-@admin.register(Unit)
-class UnitAdmin(ReadOnlyModelAdmin):
- """
- Very minimal interface... just direct the admin user's attention towards the related Container model admin.
- """
- inlines = [UnitVersionInline]
- list_display = ["pk", "key"]
- fields = ["key"]
- readonly_fields = ["key"]
-
- def key(self, obj: Unit) -> SafeText:
- return model_detail_link(obj.container, obj.key)
-
- def get_form(self, request, obj=None, change=False, **kwargs):
- help_texts = {'key': f'For more details of this {self.model.__name__}, click above to see its Container view'}
- kwargs.update({'help_texts': help_texts})
- return super().get_form(request, obj, **kwargs)
diff --git a/src/openedx_content/applets/units/api.py b/src/openedx_content/applets/units/api.py
index 779b5b3d0..4aad6dde8 100644
--- a/src/openedx_content/applets/units/api.py
+++ b/src/openedx_content/applets/units/api.py
@@ -2,244 +2,91 @@
This module provides functions to manage units.
"""
+
from dataclasses import dataclass
from datetime import datetime
-
-from django.db.transaction import atomic
+from typing import Iterable
from ..components.models import Component, ComponentVersion
-from ..publishing import api as publishing_api
+from ..containers import api as containers_api
+from ..containers.models import ContainerVersion
from .models import Unit, UnitVersion
# π UNSTABLE: All APIs related to containers are unstable until we've figured
# out our approach to dynamic content (randomized, A/B tests, etc.)
__all__ = [
- "create_unit",
- "create_unit_version",
- "create_next_unit_version",
- "create_unit_and_version",
"get_unit",
- "get_unit_version",
- "get_latest_unit_version",
+ "create_unit_and_version",
+ "create_next_unit_version",
"UnitListEntry",
"get_components_in_unit",
- "get_components_in_published_unit_as_of",
]
-def create_unit(
- learning_package_id: int,
- key: str,
- created: datetime,
- created_by: int | None,
- *,
- can_stand_alone: bool = True,
-) -> Unit:
- """
- [ π UNSTABLE ] Create a new unit.
+def get_unit(unit_id: int, /):
+ """Get a unit"""
+ return Unit.objects.select_related("container").get(pk=unit_id)
- Args:
- learning_package_id: The learning package ID.
- key: The key.
- created: The creation date.
- created_by: The user who created the unit.
- can_stand_alone: Set to False when created as part of containers
- """
- return publishing_api.create_container(
- learning_package_id,
- key,
- created,
- created_by,
- can_stand_alone=can_stand_alone,
- container_cls=Unit,
- )
-
-def create_unit_version(
- unit: Unit,
- version_num: int,
+def create_unit_and_version(
+ learning_package_id: int,
+ key: str,
*,
title: str,
- entity_rows: list[publishing_api.ContainerEntityRow],
+ components: Iterable[Component | ComponentVersion] | None = None,
created: datetime,
created_by: int | None = None,
-) -> UnitVersion:
+ can_stand_alone: bool = True,
+) -> tuple[Unit, UnitVersion]:
"""
- [ π UNSTABLE ] Create a new unit version.
-
- This is a very low-level API, likely only needed for import/export. In
- general, you will use `create_unit_and_version()` and
- `create_next_unit_version()` instead.
-
- Args:
- unit: The unit object.
- version_num: The version number.
- title: The title.
- entity_rows: child entities/versions
- created: The creation date.
- created_by: The user who created the unit.
- force_version_num (int, optional): If provided, overrides the automatic version number increment and sets
- this version's number explicitly. Use this if you need to restore or import a version with a specific
- version number, such as during data migration or when synchronizing with external systems.
-
- Returns:
- UnitVersion: The newly created UnitVersion instance.
+ See documentation of `content_api.create_container_and_version()`
- Why use force_version_num?
- Normally, the version number is incremented automatically from the latest version.
- If you need to set a specific version number (for example, when restoring from backup,
- importing legacy data, or synchronizing with another system),
- use force_version_num to override the default behavior.
-
- Why not use create_component_version?
- The main reason is that we want to reuse the logic for adding entities to this container.
+ The only real purpose of this function is to rename `entities` to `components`, and to specify that the version
+ returned is a `UnitVersion`. In the future, if `UnitVersion` gets some fields that aren't on `ContainerVersion`,
+ this function would be more important.
"""
- return publishing_api.create_container_version(
- unit.pk,
- version_num,
+ unit, uv = containers_api.create_container_and_version(
+ learning_package_id,
+ key=key,
title=title,
- entity_rows=entity_rows,
+ entities=components,
created=created,
created_by=created_by,
- container_version_cls=UnitVersion,
+ can_stand_alone=can_stand_alone,
+ container_cls=Unit,
)
-
-
-def _pub_entities_for_components(
- components: list[Component | ComponentVersion] | None,
-) -> list[publishing_api.ContainerEntityRow] | None:
- """
- Helper method: given a list of Component | ComponentVersion, return the
- list of ContainerEntityRows needed for the base container APIs.
-
- ComponentVersion is passed when we want to pin a specific version, otherwise
- Component is used for unpinned.
- """
- if components is None:
- # When these are None, that means don't change the entities in the list.
- return None
- for c in components:
- if not isinstance(c, (Component, ComponentVersion)):
- raise TypeError("Unit components must be either Component or ComponentVersion.")
- return [
- (
- publishing_api.ContainerEntityRow(
- entity_pk=c.publishable_entity_id,
- version_pk=None,
- ) if isinstance(c, Component)
- else # isinstance(c, ComponentVersion)
- publishing_api.ContainerEntityRow(
- entity_pk=c.component.publishable_entity_id,
- version_pk=c.pk,
- )
- )
- for c in components
- ]
+ assert isinstance(uv, UnitVersion)
+ return unit, uv
def create_next_unit_version(
- unit: Unit,
+ unit: Unit | int,
*,
title: str | None = None,
- components: list[Component | ComponentVersion] | None = None,
+ components: Iterable[Component | ComponentVersion] | None = None,
created: datetime,
- created_by: int | None = None,
- entities_action: publishing_api.ChildrenEntitiesAction = publishing_api.ChildrenEntitiesAction.REPLACE,
- force_version_num: int | None = None,
+ created_by: int | None,
) -> UnitVersion:
"""
- [ π UNSTABLE ] Create the next unit version.
+ See documentation of content_api.create_next_container_version()
- Args:
- unit_pk: The unit ID.
- title: The title. Leave as None to keep the current title.
- components: The components, as a list of Components (unpinned) and/or ComponentVersions (pinned). Passing None
- will leave the existing components unchanged.
- created: The creation date.
- created_by: The user who created the unit.
+ The only real purpose of this function is to rename `entities` to `components`, and to specify that the version
+ returned is a `UnitVersion`. In the future, if `UnitVersion` gets some fields that aren't on `ContainerVersion`,
+ this function would be more important.
"""
- entity_rows = _pub_entities_for_components(components)
- unit_version = publishing_api.create_next_container_version(
- unit.pk,
+ if isinstance(unit, int):
+ unit = get_unit(unit)
+ assert isinstance(unit, Unit)
+ uv = containers_api.create_next_container_version(
+ unit,
title=title,
- entity_rows=entity_rows,
+ entities=components,
created=created,
created_by=created_by,
- container_version_cls=UnitVersion,
- entities_action=entities_action,
- force_version_num=force_version_num,
+ # For now, `entities_action` and `force_version_num` are unsupported but we could add them in the future.
)
- return unit_version
-
-
-def create_unit_and_version(
- learning_package_id: int,
- key: str,
- *,
- title: str,
- components: list[Component | ComponentVersion] | None = None,
- created: datetime,
- created_by: int | None = None,
- can_stand_alone: bool = True,
-) -> tuple[Unit, UnitVersion]:
- """
- [ π UNSTABLE ] Create a new unit and its version.
-
- Args:
- learning_package_id: The learning package ID.
- key: The key.
- created: The creation date.
- created_by: The user who created the unit.
- can_stand_alone: Set to False when created as part of containers
- """
- entity_rows = _pub_entities_for_components(components)
- with atomic(savepoint=False):
- unit = create_unit(
- learning_package_id,
- key,
- created,
- created_by,
- can_stand_alone=can_stand_alone,
- )
- unit_version = create_unit_version(
- unit,
- 1,
- title=title,
- entity_rows=entity_rows or [],
- created=created,
- created_by=created_by,
- )
- return unit, unit_version
-
-
-def get_unit(unit_pk: int) -> Unit:
- """
- [ π UNSTABLE ] Get a unit.
-
- Args:
- unit_pk: The unit ID.
- """
- return Unit.objects.get(pk=unit_pk)
-
-
-def get_unit_version(unit_version_pk: int) -> UnitVersion:
- """
- [ π UNSTABLE ] Get a unit version.
-
- Args:
- unit_version_pk: The unit version ID.
- """
- return UnitVersion.objects.get(pk=unit_version_pk)
-
-
-def get_latest_unit_version(unit_pk: int) -> UnitVersion:
- """
- [ π UNSTABLE ] Get the latest unit version.
-
- Args:
- unit_pk: The unit ID.
- """
- return Unit.objects.get(pk=unit_pk).versioning.latest
+ assert isinstance(uv, UnitVersion)
+ return uv
@dataclass(frozen=True)
@@ -248,6 +95,7 @@ class UnitListEntry:
[ π UNSTABLE ]
Data about a single entity in a container, e.g. a component in a unit.
"""
+
component_version: ComponentVersion
pinned: bool = False
@@ -273,54 +121,17 @@ def get_components_in_unit(
"""
assert isinstance(unit, Unit)
components = []
- entries = publishing_api.get_entities_in_container(
- unit,
- published=published,
- select_related_version="componentversion",
- )
+ try:
+ entries = containers_api.get_entities_in_container(
+ unit,
+ published=published,
+ select_related_version="componentversion",
+ )
+ except ContainerVersion.DoesNotExist as exc:
+ raise UnitVersion.DoesNotExist() from exc # Make the exception more specific
for entry in entries:
# Convert from generic PublishableEntityVersion to ComponentVersion:
component_version = entry.entity_version.componentversion
assert isinstance(component_version, ComponentVersion)
components.append(UnitListEntry(component_version=component_version, pinned=entry.pinned))
return components
-
-
-def get_components_in_published_unit_as_of(
- unit: Unit,
- publish_log_id: int,
-) -> list[UnitListEntry] | None:
- """
- [ π UNSTABLE ]
- Get the list of entities and their versions in the published version of the
- given container as of the given PublishLog version (which is essentially a
- version for the entire learning package).
-
- TODO: This API should be updated to also return the UnitVersion so we can
- see the unit title and any other metadata from that point in time.
- TODO: accept a publish log UUID, not just int ID?
- TODO: move the implementation to be a generic 'containers' implementation
- that this units function merely wraps.
- TODO: optimize, perhaps by having the publishlog store a record of all
- ancestors of every modified PublishableEntity in the publish.
- """
- assert isinstance(unit, Unit)
- unit_pub_entity_version = publishing_api.get_published_version_as_of(unit.publishable_entity_id, publish_log_id)
- if unit_pub_entity_version is None:
- return None # This unit was not published as of the given PublishLog ID.
- container_version = unit_pub_entity_version.containerversion
-
- entity_list = []
- rows = container_version.entity_list.entitylistrow_set.order_by("order_num")
- for row in rows:
- if row.entity_version is not None:
- component_version = row.entity_version.componentversion
- assert isinstance(component_version, ComponentVersion)
- entity_list.append(UnitListEntry(component_version=component_version, pinned=True))
- else:
- # Unpinned component - figure out what its latest published version was.
- # This is not optimized. It could be done in one query per unit rather than one query per component.
- pub_entity_version = publishing_api.get_published_version_as_of(row.entity_id, publish_log_id)
- if pub_entity_version:
- entity_list.append(UnitListEntry(component_version=pub_entity_version.componentversion, pinned=False))
- return entity_list
diff --git a/src/openedx_content/applets/units/models.py b/src/openedx_content/applets/units/models.py
index 0c5255846..a3a2d36d7 100644
--- a/src/openedx_content/applets/units/models.py
+++ b/src/openedx_content/applets/units/models.py
@@ -1,9 +1,13 @@
"""
Models that implement units
"""
+
+from typing import override
+
from django.db import models
-from ..publishing.models import Container, ContainerVersion
+from ..containers.models import Container, ContainerVersion
+from ..publishing.models import PublishableEntity
__all__ = [
"Unit",
@@ -11,6 +15,7 @@
]
+@Container.register_subclass
class Unit(Container):
"""
A Unit is type of Container that holds Components.
@@ -18,6 +23,10 @@ class Unit(Container):
Via Container and its PublishableEntityMixin, Units are also publishable
entities and can be added to other containers.
"""
+
+ type_code = "unit"
+ olx_tag_name = "vertical" # Serializes to OLX as `...`.
+
container = models.OneToOneField(
Container,
on_delete=models.CASCADE,
@@ -25,6 +34,14 @@ class Unit(Container):
primary_key=True,
)
+ @override
+ @classmethod
+ def validate_entity(cls, entity: PublishableEntity) -> None:
+ """Check if the given entity is allowed as a child of a Unit"""
+ # Units only allow Components as children, so the entity must be 1:1 with Component:
+ # This could raise PublishableEntity.component.RelatedObjectDoesNotExist
+ getattr(entity, "component") # pylint: disable=literal-used-as-attribute
+
class UnitVersion(ContainerVersion):
"""
@@ -33,6 +50,7 @@ class UnitVersion(ContainerVersion):
Via ContainerVersion and its EntityList, it defines the list of Components
in this version of the Unit.
"""
+
container_version = models.OneToOneField(
ContainerVersion,
on_delete=models.CASCADE,
@@ -42,9 +60,5 @@ class UnitVersion(ContainerVersion):
@property
def unit(self):
- """ Convenience accessor to the Unit this version is associated with """
+ """Convenience accessor to the Unit this version is associated with"""
return self.container_version.container.unit # pylint: disable=no-member
-
- # Note: the 'publishable_entity_version' field is inherited and will appear on this model, but does not exist
- # in the underlying database table. It only exists in the ContainerVersion table.
- # You can verify this by running 'python manage.py sqlmigrate oel_units 0001_initial'
diff --git a/src/openedx_content/migrations/0004_componenttype_constraint.py b/src/openedx_content/migrations/0004_componenttype_constraint.py
new file mode 100644
index 000000000..f556d14b8
--- /dev/null
+++ b/src/openedx_content/migrations/0004_componenttype_constraint.py
@@ -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"),
+ ),
+ ]
diff --git a/src/openedx_content/migrations/0005_containertypes.py b/src/openedx_content/migrations/0005_containertypes.py
new file mode 100644
index 000000000..65250188a
--- /dev/null
+++ b/src/openedx_content/migrations/0005_containertypes.py
@@ -0,0 +1,83 @@
+# Generated by Django 5.2.11 on 2026-03-03 00:54
+
+import django.db.models.deletion
+import django.db.models.lookups
+from django.db import migrations, models
+
+import openedx_django_lib.fields
+
+
+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(type_code="section")
+ subsection_type, _ = ContainerType.objects.get_or_create(type_code="subsection")
+ unit_type, _ = ContainerType.objects.get_or_create(type_code="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)),
+ (
+ "type_code",
+ 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("type_code"), "^[a-z0-9\\-_\\.]+$"),
+ name="oex_publishing_containertype_type_code_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",
+ ),
+ ),
+ ]
diff --git a/src/openedx_content/models.py b/src/openedx_content/models.py
index 91696b5f3..6a5c696b3 100644
--- a/src/openedx_content/models.py
+++ b/src/openedx_content/models.py
@@ -10,6 +10,7 @@
from .applets.backup_restore.models import *
from .applets.collections.models import *
from .applets.components.models import *
+from .applets.containers.models import *
from .applets.media.models import *
from .applets.publishing.models import *
from .applets.sections.models import *
diff --git a/src/openedx_content/models_api.py b/src/openedx_content/models_api.py
index 1e035b43f..ee62b524b 100644
--- a/src/openedx_content/models_api.py
+++ b/src/openedx_content/models_api.py
@@ -5,10 +5,12 @@
authoring.py to create and modify data models in a way that keeps those models
consistent.
"""
+
# These wildcard imports are okay because these modules declare __all__.
# pylint: disable=wildcard-import
from .applets.collections.models import *
from .applets.components.models import *
+from .applets.containers.models import *
from .applets.media.models import *
from .applets.publishing.models import *
from .applets.sections.models import *
diff --git a/test_settings.py b/test_settings.py
index 371903ad3..e1e250134 100644
--- a/test_settings.py
+++ b/test_settings.py
@@ -59,6 +59,8 @@ def root(*args):
"openedx_content",
"openedx_catalog",
*openedx_content_backcompat_apps_to_install(),
+ # Apps with models that are only used for testing
+ "tests.test_django_app",
]
AUTHENTICATION_BACKENDS = [
@@ -97,3 +99,27 @@ def root(*args):
}
STATIC_URL = 'static/'
+
+# Required for Django admin which is required because it's referenced by projects.urls (ROOT_URLCONF)
+TEMPLATES = [
+ {
+ 'NAME': 'django',
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ # Don't look for template source files inside installed applications.
+ # 'APP_DIRS': False,
+ # Instead, look for template source files in these dirs.
+ # 'DIRS': [],
+ 'OPTIONS': {
+ 'context_processors': [
+ 'django.template.context_processors.request',
+ 'django.contrib.messages.context_processors.messages',
+ 'django.contrib.auth.context_processors.auth',
+ ],
+ }
+ },
+]
+MIDDLEWARE = [
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+]
diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py
index 741753950..e7a69cff7 100644
--- a/tests/openedx_content/applets/backup_restore/test_backup.py
+++ b/tests/openedx_content/applets/backup_restore/test_backup.py
@@ -13,7 +13,7 @@
from openedx_content import api
from openedx_content.applets.backup_restore.zipper import LearningPackageZipper
-from openedx_content.models_api import Collection, Component, LearningPackage, Media, PublishableEntity
+from openedx_content.models_api import Collection, Component, LearningPackage, Media, PublishableEntity, Unit
User = get_user_model()
@@ -158,11 +158,12 @@ def setUpTestData(cls):
components
)
- api.create_unit(
+ api.create_container(
learning_package_id=cls.learning_package.id,
key="unit-1",
created=cls.now,
created_by=cls.user.id,
+ container_cls=Unit,
)
def check_toml_file(self, zip_path: Path, zip_member_name: Path, content_to_check: list):
diff --git a/tests/openedx_content/applets/backup_restore/test_restore.py b/tests/openedx_content/applets/backup_restore/test_restore.py
index cd3ac83cc..0116731c4 100644
--- a/tests/openedx_content/applets/backup_restore/test_restore.py
+++ b/tests/openedx_content/applets/backup_restore/test_restore.py
@@ -11,6 +11,7 @@
from openedx_content.applets.backup_restore.zipper import LearningPackageUnzipper, generate_staged_lp_key
from openedx_content.applets.collections import api as collections_api
from openedx_content.applets.components import api as components_api
+from openedx_content.applets.containers import api as containers_api
from openedx_content.applets.publishing import api as publishing_api
from test_utils.zip_file_utils import folder_to_inmemory_zip
@@ -56,7 +57,7 @@ def verify_lp(self, key):
def verify_containers(self, lp):
"""Verify the containers and their versions were restored correctly."""
- container_qs = publishing_api.get_containers(learning_package_id=lp.id)
+ container_qs = containers_api.get_containers(learning_package_id=lp.id)
expected_container_keys = ["unit1-b7eafb", "subsection1-48afa3", "section1-8ca126"]
for container in container_qs:
@@ -66,21 +67,21 @@ def verify_containers(self, lp):
assert container.created_by is not None
assert container.created_by.username == "lp_user"
if container.key == "unit1-b7eafb":
- assert getattr(container, 'unit', None) is not None
+ assert containers_api.get_container_type_code_of(container) == "unit"
assert draft_version is not None
assert draft_version.version_num == 2
assert draft_version.created_by is not None
assert draft_version.created_by.username == "lp_user"
assert published_version is None
elif container.key == "subsection1-48afa3":
- assert getattr(container, 'subsection', None) is not None
+ assert containers_api.get_container_type_code_of(container) == "subsection"
assert draft_version is not None
assert draft_version.version_num == 2
assert draft_version.created_by is not None
assert draft_version.created_by.username == "lp_user"
assert published_version is None
elif container.key == "section1-8ca126":
- assert getattr(container, 'section', None) is not None
+ assert containers_api.get_container_type_code_of(container) == "section"
assert draft_version is not None
assert draft_version.version_num == 2
assert draft_version.created_by is not None
diff --git a/tests/openedx_content/applets/collections/test_api.py b/tests/openedx_content/applets/collections/test_api.py
index faf6352aa..c8d638eb8 100644
--- a/tests/openedx_content/applets/collections/test_api.py
+++ b/tests/openedx_content/applets/collections/test_api.py
@@ -16,6 +16,7 @@
CollectionPublishableEntity,
Component,
ComponentType,
+ Container,
LearningPackage,
PublishableEntity,
Unit,
@@ -226,7 +227,7 @@ class CollectionEntitiesTestCase(CollectionsTestCase):
"""
published_component: Component
draft_component: Component
- draft_unit: Unit
+ draft_unit: Container
user: UserType
html_type: ComponentType
problem_type: ComponentType
@@ -246,11 +247,12 @@ def setUpTestData(cls) -> None:
cls.html_type = api.get_or_create_component_type("xblock.v1", "html")
cls.problem_type = api.get_or_create_component_type("xblock.v1", "problem")
created_time = datetime(2025, 4, 1, tzinfo=timezone.utc)
- cls.draft_unit = api.create_unit(
+ cls.draft_unit = api.create_container(
learning_package_id=cls.learning_package.id,
key="unit-1",
created=created_time,
created_by=cls.user.id,
+ container_cls=Unit,
)
# Make and publish one Component
@@ -438,19 +440,28 @@ def test_get_collection_components(self):
))
def test_get_collection_containers(self):
- assert not list(api.get_collection_containers(
+ """
+ Test using `get_collection_entities()` to get containers
+ """
+ def get_collection_containers(learning_package_id: int, collection_key: str):
+ return (
+ pe.container for pe in
+ api.get_collection_entities(learning_package_id, collection_key).exclude(container=None)
+ )
+
+ assert not list(get_collection_containers(
self.learning_package.id,
self.collection1.key,
))
- assert list(api.get_collection_containers(
+ assert list(get_collection_containers(
self.learning_package.id,
self.collection2.key,
)) == [self.draft_unit.container]
- assert not list(api.get_collection_containers(
+ assert not list(get_collection_containers(
self.learning_package.id,
self.collection3.key,
))
- assert not list(api.get_collection_containers(
+ assert not list(get_collection_containers(
self.learning_package.id,
self.another_library_collection.key,
))
diff --git a/tests/openedx_content/applets/components/test_api.py b/tests/openedx_content/applets/components/test_api.py
index 759aa25dc..a9af5f65d 100644
--- a/tests/openedx_content/applets/components/test_api.py
+++ b/tests/openedx_content/applets/components/test_api.py
@@ -11,7 +11,7 @@
from openedx_content.applets.collections import api as collection_api
from openedx_content.applets.collections.models import Collection
from openedx_content.applets.components import api as components_api
-from openedx_content.applets.components.models import Component, ComponentType
+from openedx_content.applets.components.models import Component, ComponentType, ComponentVersion
from openedx_content.applets.media import api as media_api
from openedx_content.applets.media.models import MediaType
from openedx_content.applets.publishing import api as publishing_api
@@ -54,6 +54,19 @@ def publish_component(self, component: Component):
),
)
+ def create_component(self, *, title: str = "Test Component", key: str = "component:1") -> tuple[
+ Component, ComponentVersion
+ ]:
+ """ Helper method to quickly create a component """
+ return components_api.create_component_and_version(
+ self.learning_package.id,
+ component_type=self.problem_type,
+ local_key=key,
+ title=title,
+ created=self.now,
+ created_by=None,
+ )
+
class PerformanceTestCase(ComponentTestCase):
"""
diff --git a/tests/openedx_content/applets/containers/__init__.py b/tests/openedx_content/applets/containers/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/openedx_content/applets/containers/test_api.py b/tests/openedx_content/applets/containers/test_api.py
new file mode 100644
index 000000000..50871d8c5
--- /dev/null
+++ b/tests/openedx_content/applets/containers/test_api.py
@@ -0,0 +1,1648 @@
+"""
+Basic tests for the publishing containers API.
+"""
+# pylint: disable=too-many-positional-arguments, unused-argument
+
+from datetime import datetime, timezone
+from typing import Any, assert_type
+
+import pytest
+from django.core.exceptions import ValidationError
+from django.db.utils import IntegrityError
+
+from openedx_content.applets.containers import api as containers_api
+from openedx_content.applets.containers.models import Container, ContainerType, ContainerVersion
+from openedx_content.applets.publishing import api as publishing_api
+from openedx_content.applets.publishing.models import (
+ LearningPackage,
+ PublishableEntity,
+ PublishableEntityMixin,
+ PublishableEntityVersionMixin,
+ PublishLog,
+)
+from tests.test_django_app.models import (
+ ContainerContainer,
+ TestContainer,
+ TestContainerVersion,
+ TestEntity,
+ TestEntityVersion,
+)
+
+# Note: to test the Publishing applet in isolation, this test suite does not import "Component", "Unit", or other models
+# from applets that build on this one. Since Containers require specific concrete container types, we use
+# "TestContainer" and "ContainerContainer" from test_django_app, which are specifically for testing the publishing
+# API.
+
+
+pytestmark = pytest.mark.django_db
+now = datetime(2026, 5, 8, tzinfo=timezone.utc)
+
+
+@pytest.fixture(autouse=True)
+def container_tear_down():
+ """
+ Reset Container's internal type cache after each test.
+ Required because the test runner truncates tables after each test, and that
+ invalidates the cached container types.
+ """
+ yield None # run the test
+ Container.reset_cache()
+
+
+########################################################################################################################
+# Fixtures:
+
+
+# The fixtures available below and their hierarchy are:
+#
+# lp (LearningPackage)
+# ββ grandparent (ContainerContainer)
+# β ββ parent_of_two (TestContainer)
+# β β ββ child_entity1 (PublishableEntity)
+# β β ββ child_entity2 (PublishableEntity)
+# β ββ parent_of_three (TestContainer)
+# β ββ child_entity3 (π pinned to v1, PublishableEntity)
+# β ββ child_entity2 (π pinned to v1, PublishableEntity)
+# β ββ child_entity1 (PublishableEntity)
+# β
+# ββ parent_of_six (TestContainer, has duplicate children)
+# β ββ child_entity3 (π pinned to v1, PublishableEntity)
+# β ββ child_entity2 (π pinned to v1, PublishableEntity)
+# β ββ child_entity1 (PublishableEntity)
+# β ββ child_entity1 (PublishableEntity)
+# β ββ child_entity2 (π pinned to v1, PublishableEntity)
+# β ββ child_entity3 (PublishableEntity)
+# β
+# ββ container_of_uninstalled_type ("misc" Container - it's specific type plugin no longer available)
+# ββ child_entity1 (PublishableEntity)
+#
+# lp2 (LearningPackage)
+# ββ other_lp_parent (TestContainer)
+# ββ other_lp_child (PublishableEntity)
+#
+# Note that the "child" entities are referenced in multiple containers
+# Everything is initially in a draft state only, with no published version.
+
+
+@pytest.fixture(name="other_user")
+def _other_user(django_user_model):
+ return django_user_model.objects.create_user(username="other", password="something")
+
+
+@pytest.fixture(name="lp")
+def _lp() -> LearningPackage:
+ """Get a Learning Package."""
+ return publishing_api.create_learning_package(key="containers-test-lp", title="Testing Containers Main LP")
+
+
+@pytest.fixture(name="lp2")
+def _lp2() -> LearningPackage:
+ """Get a Second Learning Package."""
+ return publishing_api.create_learning_package(key="containers-test-lp2", title="Testing Containers (π¦ 2)")
+
+
+def create_test_entity(learning_package: LearningPackage, key: str, title: str) -> TestEntity:
+ """Create a TestEntity with a draft version"""
+ pe = publishing_api.create_publishable_entity(learning_package.id, key, created=now, created_by=None)
+ new_entity = TestEntity.objects.create(publishable_entity=pe)
+ pev = publishing_api.create_publishable_entity_version(
+ new_entity.pk,
+ version_num=1,
+ title=title,
+ created=now,
+ created_by=None,
+ )
+ TestEntityVersion.objects.create(publishable_entity_version=pev)
+ return new_entity
+
+
+@pytest.fixture(name="child_entity1")
+def _child_entity1(lp: LearningPackage) -> TestEntity:
+ """An example entity, such as a component"""
+ return create_test_entity(lp, key="child_entity1", title="Child 1 π΄")
+
+
+@pytest.fixture(name="child_entity2")
+def _child_entity2(lp: LearningPackage) -> TestEntity:
+ """An example entity, such as a component"""
+ return create_test_entity(lp, key="child_entity2", title="Child 2 π")
+
+
+@pytest.fixture(name="child_entity3")
+def _child_entity3(lp: LearningPackage) -> TestEntity:
+ """An example entity, such as a component"""
+ return create_test_entity(lp, key="child_entity3", title="Child 3 β΅οΈ")
+
+
+@pytest.fixture(name="other_lp_child")
+def _other_lp_child(lp2: LearningPackage) -> TestEntity:
+ """An example entity, such as a component"""
+ return create_test_entity(lp2, key="other_lp_child", title="Child in other Learning Package π¦")
+
+
+def create_test_container(
+ learning_package: LearningPackage, key: str, entities: containers_api.EntityListInput, title: str = ""
+) -> TestContainer:
+ """Create a TestContainer with a draft version"""
+ container, _version = containers_api.create_container_and_version(
+ learning_package.id,
+ key=key,
+ title=title or f"Container ({key})",
+ entities=entities,
+ container_cls=TestContainer,
+ created=now,
+ created_by=None,
+ )
+ return container
+
+
+@pytest.fixture(name="parent_of_two")
+def _parent_of_two(lp: LearningPackage, child_entity1: TestEntity, child_entity2: TestEntity) -> TestContainer:
+ """An TestContainer with two children"""
+ return create_test_container(
+ lp,
+ key="parent_of_two",
+ title="Generic Container with Two Unpinned Children",
+ entities=[child_entity1, child_entity2],
+ )
+
+
+@pytest.fixture(name="parent_of_three")
+def _parent_of_three(
+ lp: LearningPackage,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+) -> TestContainer:
+ """An TestContainer with three children, two of which are pinned"""
+ return create_test_container(
+ lp,
+ key="parent_of_three",
+ title="Generic Container with Two π Pinned Children and One Unpinned",
+ entities=[child_entity3.versioning.draft, child_entity2.versioning.draft, child_entity1],
+ )
+
+
+@pytest.fixture(name="parent_of_six")
+def _parent_of_six(
+ lp: LearningPackage,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+) -> TestContainer:
+ """An TestContainer with six children, two of each entity, with different pinned combinations"""
+ return create_test_container(
+ lp,
+ key="parent_of_six",
+ title="Generic Container with Two π Pinned Children and One Unpinned",
+ entities=[
+ # 1: both unpinned, 2: both pinned, and 3: pinned and unpinned
+ child_entity3.versioning.draft,
+ child_entity2.versioning.draft,
+ child_entity1,
+ child_entity1,
+ child_entity2.versioning.draft,
+ child_entity3,
+ ],
+ )
+
+
+@pytest.fixture(name="grandparent")
+def _grandparent(
+ lp: LearningPackage,
+ parent_of_two: TestContainer,
+ parent_of_three: TestContainer,
+) -> ContainerContainer:
+ """An ContainerContainer with two unpinned children"""
+ grandparent, _version = containers_api.create_container_and_version(
+ lp.id,
+ key="grandparent",
+ title="Generic Container with Two Unpinned TestContainer children",
+ entities=[parent_of_two, parent_of_three],
+ container_cls=ContainerContainer,
+ created=now,
+ created_by=None,
+ )
+ return grandparent
+
+
+@pytest.fixture(name="container_of_uninstalled_type")
+def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: TestEntity) -> Container:
+ """
+ A container whose Container subclass implementation is no longer available,
+ e.g. leftover data from an uninstalled plugin.
+ """
+ # First create a TestContainer, then we'll modify it to simulate it being from an uninstalled plugin
+ container, _ = containers_api.create_container_and_version(
+ lp.pk,
+ key="abandoned-container",
+ title="Abandoned Container 1",
+ entities=[child_entity1],
+ container_cls=TestContainer,
+ created=now,
+ )
+ # Now create the plugin type (no public API for this; only do this in a test)
+ ctr = ContainerType.objects.create(type_code="misc")
+ Container.objects.filter(pk=container.pk).update(container_type=ctr)
+ return Container.objects.get(pk=container.pk) # Reload and just use the base Container type
+
+
+@pytest.fixture(name="other_lp_parent")
+def _other_lp_parent(lp2: LearningPackage, other_lp_child: TestEntity) -> TestContainer:
+ """An TestContainer with one child"""
+ other_lp_parent, _version = containers_api.create_container_and_version(
+ lp2.id,
+ key="other_lp_parent",
+ title="Generic Container with One Unpinned Child Entity",
+ entities=[other_lp_child],
+ container_cls=TestContainer,
+ created=now,
+ created_by=None,
+ )
+ return other_lp_parent
+
+
+def publish_entity(obj: PublishableEntityMixin) -> PublishLog:
+ """Helper method to publish a single container or other entity."""
+ lp_id = obj.publishable_entity.learning_package_id
+ return publishing_api.publish_from_drafts(
+ lp_id,
+ draft_qset=publishing_api.get_all_drafts(lp_id).filter(entity=obj.publishable_entity),
+ )
+
+
+def modify_entity(obj: TestEntity, title="Newly modified entity"):
+ """Modify a TestEntity, creating a new version with a new title"""
+ assert isinstance(obj, TestEntity)
+ new_raw_version = publishing_api.create_publishable_entity_version(
+ obj.pk, version_num=obj.versioning.latest.version_num + 1, title=title, created=now, created_by=None
+ )
+ return TestEntityVersion.objects.create(pk=new_raw_version.pk)
+
+
+def Entry(
+ component_version: PublishableEntityVersionMixin,
+ pinned: bool = False,
+) -> containers_api.ContainerEntityListEntry:
+ """Helper for quickly constructing ContainerEntityListEntry entries"""
+ return containers_api.ContainerEntityListEntry(component_version.publishable_entity_version, pinned=pinned)
+
+
+########################################################################################################################
+
+# `create_container`, and `create_container_version` are not tested directly here, but they are used indirectly by
+# `create_container_and_version`. They are also used explicitly in `ContainerSideEffectsTestCase`, below.
+
+# Basic tests of `create_container_and_version`
+
+
+def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None:
+ """
+ Creating an empty TestContainer. It will have only a draft version.
+ """
+ container, container_v1 = containers_api.create_container_and_version(
+ lp.pk,
+ key="new-container-1",
+ title="Test Container 1",
+ container_cls=TestContainer,
+ created=now,
+ created_by=admin_user.pk,
+ can_stand_alone=False,
+ )
+
+ assert_type(container, TestContainer)
+ # assert_type(container_v1, TestContainerVersion) # FIXME: seems not possible yet as of Python 3.12
+ # Note the assert_type() calls must come before 'assert isinstance()' or they'll have no effect.
+ assert isinstance(container, TestContainer)
+ assert isinstance(container_v1, TestContainerVersion)
+ assert container.versioning.draft == container_v1
+ assert container.versioning.published is None
+ assert container.key == "new-container-1"
+ assert container.versioning.draft.title == "Test Container 1"
+ assert container.created == now
+ assert container.created_by == admin_user
+ assert container.versioning.draft.created == now
+ assert container.versioning.draft.created_by == admin_user
+ assert not container.can_stand_alone
+
+ assert containers_api.get_container_children_count(container, published=False) == 0
+ with pytest.raises(ContainerVersion.DoesNotExist):
+ containers_api.get_container_children_count(container, published=True)
+
+
+def test_create_container_queries(lp: LearningPackage, child_entity1: TestEntity, django_assert_num_queries) -> None:
+ """Test how many database queries are required to create a container."""
+ base_args: dict[str, Any] = {
+ "title": "Test Container",
+ "created": now,
+ "created_by": None,
+ "container_cls": TestContainer,
+ }
+ # The exact numbers here aren't too important - this is just to alert us if anything significant changes.
+ with django_assert_num_queries(31):
+ containers_api.create_container_and_version(lp.pk, key="c1", **base_args)
+ # And try with a a container that has children:
+ with django_assert_num_queries(32):
+ containers_api.create_container_and_version(lp.pk, key="c2", **base_args, entities=[child_entity1])
+
+
+# versioning helpers
+
+
+def test_container_versioning_helpers(parent_of_two: TestContainer):
+ """
+ Test that the .versioning helper of a subclass like `TestContainer` returns a `TestContainerVersion`, and
+ same for the base class `Container` equivalent.
+ """
+ assert isinstance(parent_of_two, TestContainer)
+ base_container = parent_of_two.container
+ assert base_container.__class__ is Container
+ container_version = base_container.versioning.draft
+ assert container_version.__class__ is ContainerVersion
+ subclass_version = parent_of_two.versioning.draft
+ assert isinstance(subclass_version, TestContainerVersion)
+ assert subclass_version.container_version == container_version
+ assert subclass_version.container_version.container == base_container
+ assert subclass_version.container_version.container.testcontainer == parent_of_two
+
+
+# create_next_container_version
+
+
+def test_create_next_container_version_no_changes(parent_of_two: TestContainer, other_user):
+ """
+ Test creating a new version of the "parent of two" container, but without
+ any actual changes.
+ """
+ original_version = parent_of_two.versioning.draft
+ assert original_version.version_num == 1
+
+ # Create a new version with no changes:
+ v2_date = datetime.now(tz=timezone.utc)
+ version_2 = containers_api.create_next_container_version(
+ parent_of_two,
+ created=v2_date,
+ created_by=other_user.pk,
+ # Specify no changes at all
+ )
+
+ # assert_type(version_2, TestContainerVersion)
+ # ^ Must come before 'assert isinstance(...)'. Unfortunately, getting the subclass return type in python 3.12 is not
+ # possible unless we explicitly pass in the ContainerVersion subclass as a parameter (which makes the API less
+ # generic) or convert `create_next_container_version()` to a classmethod on Container, which is inconsistent with
+ # our convention of a function-based public API and semi-private model API.
+ assert isinstance(version_2, TestContainerVersion)
+
+ # Now it should have an incremented version number but be unchanged:
+ assert version_2 == parent_of_two.versioning.draft
+ assert version_2.version_num == 2
+ assert version_2.title == original_version.title
+ # Since we didn't change the entities, the same entity list should be re-used:
+ assert version_2.entity_list_id == original_version.entity_list_id
+ assert version_2.created == v2_date
+ assert version_2.created_by == other_user
+ assert containers_api.get_container_children_entities_keys(
+ original_version
+ ) == containers_api.get_container_children_entities_keys(version_2)
+
+
+def test_create_next_container_version_with_changes(
+ parent_of_two: TestContainer, child_entity1: TestEntity, child_entity2: TestEntity
+):
+ """
+ Test creating a new version of the "parent of two" container, changing the
+ title and swapping the order of the children
+ """
+ original_version = parent_of_two.versioning.draft
+ assert original_version.version_num == 1
+
+ # Create a new version, specifying version number 5 and changing the title and the order of the children:
+ v5_date = datetime.now(tz=timezone.utc)
+ containers_api.create_next_container_version(
+ parent_of_two,
+ title="New Title - children reversed",
+ entities=[child_entity2, child_entity1], # Reversed from original [child_entity1, child_entity2] order
+ force_version_num=5,
+ created=v5_date,
+ created_by=None,
+ )
+
+ # Now retrieve the new version:
+ version_5 = parent_of_two.versioning.draft
+ assert parent_of_two.versioning.published is None # No change to published version
+ assert version_5.version_num == 5
+ assert version_5.created == v5_date
+ assert version_5.created_by is None
+ assert version_5.title == "New Title - children reversed"
+ assert version_5.entity_list_id != original_version.entity_list_id
+ assert containers_api.get_container_children_entities_keys(version_5) == ["child_entity2", "child_entity1"]
+
+
+def test_create_next_container_version_with_append(
+ parent_of_two: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+):
+ """
+ Test creating a new version of the "parent of two" container, using the APPEND action to append new children.
+ """
+ original_version = parent_of_two.versioning.draft
+ assert original_version.version_num == 1
+ child_entity1_v1 = child_entity1.versioning.draft
+ assert child_entity1_v1.version_num == 1
+
+ # Create a new version, APPENDing entity 3 and π pinned entity1 (v1)
+ version_2 = containers_api.create_next_container_version(
+ parent_of_two,
+ entities=[child_entity3, child_entity1_v1],
+ created=now,
+ created_by=None,
+ entities_action=containers_api.ChildrenEntitiesAction.APPEND,
+ )
+
+ assert parent_of_two.versioning.draft == version_2
+ assert containers_api.get_entities_in_container(parent_of_two, published=False) == [
+ Entry(child_entity1.versioning.draft, pinned=False), # Unchanged, original first child
+ Entry(child_entity2.versioning.draft, pinned=False), # Unchanged, original second child
+ Entry(child_entity3.versioning.draft, pinned=False), # π entity 3, appended, unpinned
+ Entry(child_entity1_v1, pinned=True), # π entity 1, appended, π pinned
+ ]
+
+
+def test_create_next_container_version_with_remove_1(
+ parent_of_six: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+):
+ """
+ Test creating a new version of the "parent of six" container, using the REMOVE action to remove children.
+ """
+ # Before looks like this:
+ assert containers_api.get_entities_in_container(parent_of_six, published=False) == [
+ Entry(child_entity3.versioning.draft, pinned=True), # entity 3, π pinned
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned
+ ]
+ # Remove "entity 1 unpinned" - should remove both:
+ containers_api.create_next_container_version(
+ parent_of_six.pk,
+ entities=[child_entity1],
+ created=now,
+ created_by=None,
+ entities_action=containers_api.ChildrenEntitiesAction.REMOVE,
+ )
+ # Now it looks like:
+
+ assert containers_api.get_entities_in_container(parent_of_six, published=False) == [
+ Entry(child_entity3.versioning.draft, pinned=True), # entity 3, π pinned
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ # entity 1 unpinned x2 removed
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned
+ ]
+
+
+def test_create_next_container_version_with_remove_2(
+ parent_of_six: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+):
+ """
+ Test creating a new version of the "parent of six" container, using the REMOVE action to remove children.
+ """
+ # Before looks like this:
+ assert containers_api.get_entities_in_container(parent_of_six, published=False) == [
+ Entry(child_entity3.versioning.draft, pinned=True), # entity 3, π pinned
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned
+ ]
+ # Remove "entity 2 pinned" - should remove both:
+ containers_api.create_next_container_version(
+ parent_of_six.pk,
+ entities=[child_entity2.versioning.draft], # specify the version for "pinned"
+ created=now,
+ created_by=None,
+ entities_action=containers_api.ChildrenEntitiesAction.REMOVE,
+ )
+ # Now it looks like:
+
+ assert containers_api.get_entities_in_container(parent_of_six, published=False) == [
+ Entry(child_entity3.versioning.draft, pinned=True), # entity 3, π pinned
+ # removed
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ # removed
+ Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned
+ ]
+
+
+def test_create_next_container_version_with_remove_3(
+ parent_of_six: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+):
+ """
+ Test creating a new version of the "parent of six" container, using the REMOVE action to remove children.
+ """
+ # Before looks like this:
+ assert containers_api.get_entities_in_container(parent_of_six, published=False) == [
+ Entry(child_entity3.versioning.draft, pinned=True), # entity 3, π pinned
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned
+ ]
+ # Remove "entity 3 pinned" - should remove only one:
+ containers_api.create_next_container_version(
+ parent_of_six.pk,
+ entities=[child_entity3.versioning.draft], # specify the version for "pinned"
+ created=now,
+ created_by=None,
+ entities_action=containers_api.ChildrenEntitiesAction.REMOVE,
+ )
+ # Now it looks like:
+
+ assert containers_api.get_entities_in_container(parent_of_six, published=False) == [
+ # entity 3 pinned removed
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned so should not be removed
+ ]
+
+
+def test_create_next_container_version_with_remove_4(
+ parent_of_six: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+):
+ """
+ Test creating a new version of the "parent of six" container, using the REMOVE action to remove children.
+ """
+ # Before looks like this:
+ assert containers_api.get_entities_in_container(parent_of_six, published=False) == [
+ Entry(child_entity3.versioning.draft, pinned=True), # entity 3, π pinned
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned
+ ]
+ # Remove "entity 3 unpinned" - should remove only one:
+ containers_api.create_next_container_version(
+ parent_of_six.pk,
+ entities=[child_entity3],
+ created=now,
+ created_by=None,
+ entities_action=containers_api.ChildrenEntitiesAction.REMOVE,
+ )
+ # Now it looks like:
+
+ assert containers_api.get_entities_in_container(parent_of_six, published=False) == [
+ Entry(child_entity3.versioning.draft, pinned=True), # entity 3, π pinned so should not be removed
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity1.versioning.draft, pinned=False), # entity 1, unpinned
+ Entry(child_entity2.versioning.draft, pinned=True), # entity 2, π pinned
+ # entity 3 unpinned removed
+ ]
+
+
+def test_create_next_container_version_with_conflicting_version(parent_of_two: TestContainer):
+ """
+ Test that an appropriate error is raised when calling `create_next_container_version` and specifying a version
+ number that already exists.
+ """
+
+ def create_v5():
+ """Create a new version, specifying version number 5 and changing the title and the order of the children."""
+ containers_api.create_next_container_version(
+ parent_of_two.pk,
+ title="New version - forced as v5",
+ force_version_num=5,
+ created=now,
+ created_by=None,
+ )
+
+ # First it should work:
+ create_v5()
+ # Then it should fail:
+ with pytest.raises(IntegrityError):
+ create_v5()
+
+
+def test_create_next_container_version_uninstalled_plugin(container_of_uninstalled_type: Container):
+ """
+ Test that an appropriate error is raised when calling `create_next_container_version` for a container whose type
+ implementation is no longer installed. Such containers should still be readable but not writable.
+ """
+ with pytest.raises(containers_api.ContainerImplementationMissingError):
+ containers_api.create_next_container_version(
+ container_of_uninstalled_type.pk,
+ title="New version of the container",
+ created=now,
+ created_by=None,
+ )
+
+
+def test_create_next_container_version_other_lp(parent_of_two: TestContainer, other_lp_child: PublishableEntity):
+ """
+ Test that an appropriate error is raised when trying to add a child from another learning package to a container.
+ """
+ with pytest.raises(ValidationError, match="Container entities must be from the same learning package."):
+ containers_api.create_next_container_version(
+ parent_of_two.pk,
+ title="Bad Version with entities from another learning package",
+ created=now,
+ created_by=None,
+ entities=[other_lp_child], # <-- from "lp2" Learning Package
+ )
+
+
+# get_container
+
+
+def test_get_container(parent_of_two: TestContainer, django_assert_num_queries) -> None:
+ """
+ Test `get_container()`
+ """
+ with django_assert_num_queries(1):
+ result = containers_api.get_container(parent_of_two.pk)
+ assert result == parent_of_two.container
+ # Versioning data should be pre-loaded via the default select_related() of Container.objects used by get_container
+ with django_assert_num_queries(0):
+ assert result.versioning.has_unpublished_changes
+
+
+def test_get_container_nonexistent() -> None:
+ """
+ Test `get_container()` with an invalid ID.
+ """
+ with pytest.raises(Container.DoesNotExist):
+ containers_api.get_container(-5000)
+
+
+def test_get_container_soft_deleted(parent_of_two: TestContainer) -> None:
+ """
+ Test `get_container()` with a soft deleted container
+ """
+ publishing_api.soft_delete_draft(parent_of_two.pk, deleted_by=None)
+ parent_of_two.refresh_from_db()
+ assert parent_of_two.versioning.draft is None
+ assert parent_of_two.versioning.published is None
+ # Get the container
+ result = containers_api.get_container(parent_of_two.pk)
+ assert result == parent_of_two.container # It works fine! get_container() ignores publish/delete status.
+
+
+def test_get_container_uninstalled_type(container_of_uninstalled_type: Container) -> None:
+ """
+ Test `get_container()` with a container from an uninstalled plugin
+ """
+ # Nothing special happens. It should work fine.
+ result = containers_api.get_container(container_of_uninstalled_type.pk)
+ assert result == container_of_uninstalled_type
+
+
+# get_container_version
+
+
+def test_get_container_version(parent_of_two: TestContainer) -> None:
+ """
+ Test getting a specific container version
+ """
+ # Note: This is not a super useful API, and we're not using it anywhere.
+ cv = containers_api.get_container_version(parent_of_two.versioning.draft.pk)
+ assert cv == parent_of_two.versioning.draft.container_version
+
+
+def test_get_container_version_nonexistent() -> None:
+ """
+ Test getting a specific container version that doesn't exist
+ """
+ with pytest.raises(ContainerVersion.DoesNotExist):
+ containers_api.get_container_version(-500)
+
+
+# get_container_by_key
+
+
+def test_get_container_by_key(lp: LearningPackage, parent_of_two: TestContainer) -> None:
+ """
+ Test getting a specific container by key
+ """
+ result = containers_api.get_container_by_key(lp.pk, parent_of_two.key)
+ assert result == parent_of_two.container
+ # The API always returns "Container", not specific subclasses like TestContainer:
+ assert result.__class__ is Container
+
+
+def test_get_container_by_key_nonexistent(lp: LearningPackage) -> None:
+ """
+ Test getting a specific container by key, where the key and/or learning package is invalid
+ """
+ with pytest.raises(LearningPackage.DoesNotExist):
+ containers_api.get_container_by_key(32874, "invalid-key")
+
+ with pytest.raises(Container.DoesNotExist):
+ containers_api.get_container_by_key(lp.pk, "invalid-key")
+
+
+# get_container_subclass
+
+
+def test_get_container_subclass() -> None:
+ """
+ Test get_container_subclass()
+ """
+ assert containers_api.get_container_subclass("test_generic") is TestContainer
+ assert containers_api.get_container_subclass("test_container_container") is ContainerContainer
+ with pytest.raises(
+ containers_api.ContainerImplementationMissingError,
+ match='An implementation for "foo" containers is not currently installed.',
+ ):
+ containers_api.get_container_subclass("foo")
+
+
+# get_all_container_subclasses
+def test_get_all_container_subclasses() -> None:
+ """
+ Test get_all_container_subclasses()
+ """
+ # For test purposes, filter the list to only include containers from our "test_django_app":
+ assert [ct for ct in containers_api.get_all_container_subclasses() if ct._meta.app_label == "test_django_app"] == [
+ ContainerContainer,
+ TestContainer,
+ ]
+
+
+# get_container_type_code_of and get_container_subclass_of
+
+
+def test_get_container_subclass_of(
+ grandparent: ContainerContainer, parent_of_two: TestContainer, child_entity1: TestEntity
+):
+ """
+ Test get_container_type_code_of() and get_container_subclass_of()
+ """
+ # Grandparent is a "ContainerContainer":
+ assert isinstance(grandparent, ContainerContainer)
+ assert containers_api.get_container_type_code_of(grandparent) == "test_container_container"
+ assert containers_api.get_container_subclass_of(grandparent) is ContainerContainer
+ # The functions work even if we pass a generic "Container" object:
+ assert isinstance(grandparent.base_container, Container)
+ assert containers_api.get_container_type_code_of(grandparent.base_container) == "test_container_container"
+ assert containers_api.get_container_subclass_of(grandparent.base_container) is ContainerContainer
+
+ # "Parent of Two" is a "TestContainer":
+ assert isinstance(parent_of_two, TestContainer)
+ assert containers_api.get_container_type_code_of(parent_of_two) == "test_generic"
+ assert containers_api.get_container_subclass_of(parent_of_two) is TestContainer
+ assert isinstance(parent_of_two.container, Container)
+ assert containers_api.get_container_type_code_of(parent_of_two.container) == "test_generic"
+ assert containers_api.get_container_subclass_of(parent_of_two.container) is TestContainer
+
+ # Passing in a non-container will trigger an assert failure:
+ with pytest.raises(AssertionError):
+ containers_api.get_container_subclass_of(child_entity1) # type: ignore
+
+
+def test_get_container_type_deleted(container_of_uninstalled_type: Container):
+ """
+ `get_container_subclass_of` will raise ValueError if the container type implementation is no longer available
+ """
+ with pytest.raises(
+ containers_api.ContainerImplementationMissingError,
+ match='An implementation for "misc" containers is not currently installed.',
+ ):
+ containers_api.get_container_subclass_of(container_of_uninstalled_type)
+
+ # But get_container_type_code() should still work:
+ assert containers_api.get_container_type_code_of(container_of_uninstalled_type) == "misc"
+
+
+# get_containers
+
+
+def test_get_containers(
+ lp: LearningPackage,
+ grandparent: ContainerContainer,
+ parent_of_two: TestContainer,
+ parent_of_three: TestContainer,
+ lp2: LearningPackage,
+ other_lp_parent: TestContainer,
+):
+ """
+ Test that we can get all containers in a Learning Package
+ """
+ result = list(containers_api.get_containers(lp.id))
+ # The API always returns Container base class instances, never specific types:
+ assert all(c.__class__ is Container for c in result)
+ # (we _could_ implement a get_typed_containers() API, but there's probably no need?)
+ assert result == [
+ # Default ordering is in the order they were created:
+ parent_of_two.container,
+ parent_of_three.container,
+ grandparent.base_container,
+ ]
+ # Now repeat with the other Learning Package, to make sure they're isolated:
+ assert list(containers_api.get_containers(lp2.id)) == [
+ other_lp_parent.container,
+ ]
+
+
+def test_get_containers_soft_deleted(
+ lp: LearningPackage,
+ grandparent: ContainerContainer,
+ parent_of_two: TestContainer,
+ parent_of_three: TestContainer,
+):
+ """
+ Test that soft deleted containers are excluded from `get_containers()` by
+ default, but can be included.
+ """
+ # Soft delete `parent_of_two`:
+ publishing_api.soft_delete_draft(parent_of_two.pk)
+ # Now it should not be included in the result:
+ assert list(containers_api.get_containers(lp.id)) == [
+ # parent_of_two is not returned.
+ parent_of_three.container,
+ grandparent.base_container,
+ ]
+ # Unless we specify include_deleted=True:
+ assert list(containers_api.get_containers(lp.id, include_deleted=True)) == [
+ parent_of_two.container,
+ parent_of_three.container,
+ grandparent.base_container,
+ ]
+
+
+# General publishing tests.
+
+
+def test_contains_unpublished_changes_queries(
+ grandparent: ContainerContainer, child_entity1: TestEntity, django_assert_num_queries
+) -> None:
+ """Test that `contains_unpublished_changes()` works, and check how many queries it uses"""
+ # Setup: grandparent and all its descendants are unpublished drafts only.
+ assert grandparent.versioning.published is None
+
+ # Tests:
+ with django_assert_num_queries(1):
+ assert containers_api.contains_unpublished_changes(grandparent)
+ with django_assert_num_queries(1):
+ assert containers_api.contains_unpublished_changes(grandparent.pk)
+
+ # Publish grandparent and all its descendants:
+ with django_assert_num_queries(135): # TODO: investigate as this seems high!
+ publish_entity(grandparent)
+
+ # Tests:
+ with django_assert_num_queries(1):
+ assert not containers_api.contains_unpublished_changes(grandparent)
+
+ # Now make a tiny change to a grandchild component (not a direct child of "grandparent"), and make sure it's
+ # detected:
+ publishing_api.create_publishable_entity_version(
+ child_entity1.pk,
+ version_num=2,
+ title="Modified grandchild",
+ created=now,
+ created_by=None,
+ )
+ child_entity1.refresh_from_db()
+ assert child_entity1.versioning.has_unpublished_changes
+
+ with django_assert_num_queries(1):
+ assert containers_api.contains_unpublished_changes(grandparent)
+
+
+def test_auto_publish_children(
+ parent_of_two: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+):
+ """
+ Test that publishing a container publishes its child components automatically.
+ """
+ # At first, nothing is published:
+ assert containers_api.contains_unpublished_changes(parent_of_two.pk)
+ assert child_entity1.versioning.published is None
+ assert child_entity2.versioning.published is None
+ assert child_entity3.versioning.published is None
+ child_entity1_v1 = child_entity1.versioning.draft
+
+ # Publish ONLY the "parent_of_two" container.
+ # This should however also auto-publish components 1 & 2 since they're children
+ publish_entity(parent_of_two)
+ # Now all changes to the container and its two children are published:
+ for entity in [parent_of_two, child_entity1, child_entity2, child_entity3]:
+ entity.refresh_from_db()
+ assert parent_of_two.versioning.has_unpublished_changes is False # Shallow check
+ assert child_entity1.versioning.has_unpublished_changes is False
+ assert child_entity2.versioning.has_unpublished_changes is False
+ assert containers_api.contains_unpublished_changes(parent_of_two.pk) is False # Deep check
+ assert child_entity1.versioning.published == child_entity1_v1 # v1 is now the published version.
+
+ # But our other component that's outside the container is not affected:
+ child_entity3.refresh_from_db()
+ assert child_entity3.versioning.has_unpublished_changes
+ assert child_entity3.versioning.published is None
+
+
+def test_no_publish_parent(parent_of_two: TestContainer, child_entity1: TestEntity):
+ """
+ Test that publishing an entity does NOT publish changes to its parent containers
+ """
+ # "child_entity1" is a child of "parent_of_two"
+ assert child_entity1.key in containers_api.get_container_children_entities_keys(parent_of_two.versioning.draft)
+ # Neither are published:
+ assert child_entity1.versioning.published is None
+ assert parent_of_two.versioning.published is None
+
+ # Publish ONLY one of its child components
+ publish_entity(child_entity1)
+ child_entity1.refresh_from_db() # Clear cache on '.versioning'
+ assert child_entity1.versioning.has_unpublished_changes is False
+
+ # The container that contains that component should still be unpublished:
+ parent_of_two.refresh_from_db() # Clear cache on '.versioning'
+ assert parent_of_two.versioning.has_unpublished_changes
+ assert parent_of_two.versioning.published is None
+ with pytest.raises(ContainerVersion.DoesNotExist):
+ # There is no published version of "parent_of_two":
+ containers_api.get_entities_in_container(parent_of_two, published=True)
+
+
+def test_add_entity_after_publish(lp: LearningPackage, parent_of_two: TestContainer, child_entity3: TestEntity):
+ """
+ Adding an entity to a published container will create a new version and show that the container has unpublished
+ changes.
+ """
+ parent_of_two_v1 = parent_of_two.versioning.draft
+ assert parent_of_two_v1.version_num == 1
+ assert parent_of_two.versioning.published is None
+ # Publish everything in the learning package:
+ publishing_api.publish_all_drafts(lp.pk)
+ parent_of_two.refresh_from_db() # Reloading is necessary
+ assert not parent_of_two.versioning.has_unpublished_changes # Shallow check
+ assert not containers_api.contains_unpublished_changes(parent_of_two) # Deeper check
+
+ # Add a published entity (child_entity3, unpinned):
+ parent_of_two_v2 = containers_api.create_next_container_version(
+ parent_of_two.pk,
+ entities=[child_entity3],
+ created=now,
+ created_by=None,
+ entities_action=containers_api.ChildrenEntitiesAction.APPEND,
+ )
+ # Now the container should have unpublished changes:
+ parent_of_two.refresh_from_db() # Reloading the container is necessary
+ assert parent_of_two.versioning.has_unpublished_changes # Shallow check - adding a child changes the container
+ assert containers_api.contains_unpublished_changes(parent_of_two) # Deeper check
+ assert parent_of_two.versioning.draft == parent_of_two_v2
+ assert parent_of_two.versioning.published == parent_of_two_v1
+
+
+def test_modify_unpinned_entity_after_publish(
+ parent_of_two: TestContainer, child_entity1: TestEntity, child_entity2: TestEntity
+):
+ """
+ Modifying an unpinned entity in a published container will NOT create a new version nor show that the container has
+ unpublished changes (but it will "contain" unpublished changes). The modifications will appear in the published
+ version of the container only after the child entity is published.
+ """
+ # Use "parent_of_two" which has two unpinned child entities.
+ # Publish it and its two children:
+ publish_entity(parent_of_two)
+ parent_of_two.refresh_from_db() # Technically reloading is only needed if we accessed 'versioning' before publish
+ child_entity1_v1 = child_entity1.versioning.draft
+ child_entity2_v1 = child_entity2.versioning.draft
+
+ assert parent_of_two.versioning.has_unpublished_changes is False # Shallow check
+ assert containers_api.contains_unpublished_changes(parent_of_two.pk) is False # Deeper check
+ assert child_entity1.versioning.has_unpublished_changes is False
+
+ # Now modify the child entity (it remains a draft):
+ child_entity1_v2 = modify_entity(child_entity1)
+
+ # The component now has unpublished changes; the container doesn't directly but does contain
+ parent_of_two.refresh_from_db() # Reloading the container is necessary, or '.versioning' will be outdated
+ child_entity1.refresh_from_db()
+ assert (
+ parent_of_two.versioning.has_unpublished_changes is False
+ ) # Shallow check should be false - container is unchanged
+ assert containers_api.contains_unpublished_changes(parent_of_two.pk) # But the container DOES "contain" changes
+ assert child_entity1.versioning.has_unpublished_changes
+
+ # Since the child's changes haven't been published, they should only appear in the draft container
+ assert containers_api.get_entities_in_container(parent_of_two, published=False) == [
+ Entry(child_entity1_v2), # new version
+ Entry(child_entity2_v1), # unchanged second child
+ ]
+ assert containers_api.get_entities_in_container(parent_of_two, published=True) == [
+ Entry(child_entity1_v1), # old version
+ Entry(child_entity2_v1), # unchanged second child
+ ]
+
+ # But if we publish the child, the changes will appear in the published version of the container.
+ publish_entity(child_entity1)
+ assert containers_api.get_entities_in_container(parent_of_two, published=False) == [
+ Entry(child_entity1_v2), # new version
+ Entry(child_entity2_v1), # unchanged second child
+ ]
+ assert containers_api.get_entities_in_container(parent_of_two, published=True) == [
+ Entry(child_entity1_v2), # new version
+ Entry(child_entity2_v1), # unchanged second child
+ ]
+ assert containers_api.contains_unpublished_changes(parent_of_two) is False # No longer contains unpublished changes
+
+
+def test_modify_pinned_entity(
+ lp: LearningPackage,
+ parent_of_three: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+):
+ """
+ When a pinned π entity in a container is modified and/or published, it will have no effect on either the draft nor
+ published version of the container, which will continue to use the pinned version.
+ """
+ # Note: "parent_of_three" has two pinned children and one unpinned
+ expected_contents = [
+ Entry(child_entity3.versioning.draft, pinned=True), # pinned π to v1
+ Entry(child_entity2.versioning.draft, pinned=True), # pinned π to v1
+ Entry(child_entity1.versioning.draft, pinned=False),
+ ]
+ assert containers_api.get_entities_in_container(parent_of_three, published=False) == expected_contents
+
+ # Publish everything
+ publishing_api.publish_all_drafts(lp.id)
+
+ # Now modify the first π pinned child entity (#3) by changing its title (it remains a draft):
+ modify_entity(child_entity3)
+
+ # The component now has unpublished changes; the container is entirely unaffected
+ parent_of_three.refresh_from_db() # Reloading the container is necessary, or '.versioning' will be outdated
+ child_entity3.refresh_from_db()
+ assert parent_of_three.versioning.has_unpublished_changes is False # Shallow check
+ assert containers_api.contains_unpublished_changes(parent_of_three) is False # Deep check
+ assert child_entity3.versioning.has_unpublished_changes is True
+
+ # Neither the draft nor the published version of the container is affected
+ assert containers_api.get_entities_in_container(parent_of_three, published=False) == expected_contents
+ assert containers_api.get_entities_in_container(parent_of_three, published=True) == expected_contents
+ # Even if we publish the component, the container stays pinned to the specified version:
+ publish_entity(child_entity3)
+ assert containers_api.get_entities_in_container(parent_of_three, published=False) == expected_contents
+ assert containers_api.get_entities_in_container(parent_of_three, published=True) == expected_contents
+
+
+def test_publishing_shared_component(lp: LearningPackage):
+ """
+ A complex test case involving two units with a shared component and other non-shared components.
+
+ Note these are not actual "Unit"s nor "Components" but instead `TestContainer` and `TestEntity` standing
+ in for them.
+
+ Unit 1: components C1, C2, C3
+ Unit 2: components C2, C4, C5
+ Everything is "unpinned".
+ """
+ # 1οΈβ£ Create the units and publish them:
+ c1, c2, c3, c4, c5 = [create_test_entity(lp, key=f"C{i}", title=f"Component {i}") for i in range(1, 6)]
+ c1_v1 = c1.versioning.draft
+ c3_v1 = c3.versioning.draft
+ c4_v1 = c4.versioning.draft
+ c5_v1 = c5.versioning.draft
+ unit1, _ = containers_api.create_container_and_version(
+ lp.pk,
+ entities=[c1, c2, c3],
+ title="Unit 1",
+ key="unit:1",
+ created=now,
+ created_by=None,
+ container_cls=TestContainer,
+ )
+ unit2, _ = containers_api.create_container_and_version(
+ lp.pk,
+ entities=[c2, c4, c5],
+ title="Unit 2",
+ key="unit:2",
+ created=now,
+ created_by=None,
+ container_cls=TestContainer,
+ )
+ publishing_api.publish_all_drafts(lp.pk)
+ assert containers_api.contains_unpublished_changes(unit1.pk) is False
+ assert containers_api.contains_unpublished_changes(unit2.pk) is False
+
+ # 2οΈβ£ Then the author edits C2 inside of Unit 1 making C2v2.
+ c2_v2 = modify_entity(c2)
+ # This makes U1 and U2 both show up as Units that CONTAIN unpublished changes, because they share the component.
+ assert containers_api.contains_unpublished_changes(unit1.pk)
+ assert containers_api.contains_unpublished_changes(unit2.pk)
+ # (But the units themselves are unchanged:)
+ unit1.refresh_from_db()
+ unit2.refresh_from_db()
+ assert unit1.versioning.has_unpublished_changes is False
+ assert unit2.versioning.has_unpublished_changes is False
+
+ # 3οΈβ£ In addition to this, the author also modifies another component in Unit 2 (C5)
+ c5_v2 = modify_entity(c5)
+
+ # 4οΈβ£ The author then publishes Unit 1, and therefore everything in it.
+ publish_entity(unit1)
+
+ # Result: Unit 1 will show the newly published version of C2:
+ assert containers_api.get_entities_in_container(unit1, published=True) == [
+ Entry(c1_v1),
+ Entry(c2_v2), # new published version of C2
+ Entry(c3_v1),
+ ]
+
+ # Result: someone looking at Unit 2 should see the newly published component 2, because publishing it anywhere
+ # publishes it everywhere. But publishing C2 and Unit 1 does not affect the other components in Unit 2.
+ # (Publish propagates downward, not upward)
+ assert containers_api.get_entities_in_container(unit2, published=True) == [
+ Entry(c2_v2), # new published version of C2
+ Entry(c4_v1), # still original version of C4 (it was never modified)
+ Entry(c5_v1), # still original version of C5 (it hasn't been published)
+ ]
+
+ # Result: Unit 2 CONTAINS unpublished changes because of the modified C5. Unit 1 doesn't contain unpub changes.
+ assert containers_api.contains_unpublished_changes(unit1.pk) is False
+ assert containers_api.contains_unpublished_changes(unit2.pk)
+
+ # 5οΈβ£ Publish component C5, which should be the only thing unpublished in the learning package
+ publish_entity(c5)
+ # Result: Unit 2 shows the new version of C5 and no longer contains unpublished changes:
+ assert containers_api.get_entities_in_container(unit2, published=True) == [
+ Entry(c2_v2), # new published version of C2
+ Entry(c4_v1), # still original version of C4 (it was never modified)
+ Entry(c5_v2), # new published version of C5
+ ]
+ assert containers_api.contains_unpublished_changes(unit2.pk) is False
+
+
+def test_shallow_publish_log(
+ lp: LearningPackage,
+ grandparent: ContainerContainer, # Create grandparent so it exists during this test; it should be untouched.
+ parent_of_two: TestContainer,
+ parent_of_three: TestContainer,
+) -> None:
+ """Simple test of publishing a container plus children and reviewing the publish log"""
+ publish_log = publish_entity(parent_of_two)
+ assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [
+ # The container and its two children should be the only things published:
+ "child_entity1",
+ "child_entity2",
+ "parent_of_two",
+ ]
+
+
+def test_uninstalled_publish(
+ lp: LearningPackage,
+ container_of_uninstalled_type: Container,
+ django_assert_num_queries,
+) -> None:
+ """Simple test of publishing a container of uninstalled type, plus its child, and reviewing the publish log"""
+ # Publish container_of_uninstalled_type (and child_entity1). Should not affect anything else,
+ # but we should see "child_entity1" omitted from the subsequent publish.
+ with django_assert_num_queries(49):
+ publish_log = publish_entity(container_of_uninstalled_type)
+ # Nothing else should have been affected by the publish:
+ assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [
+ "child_entity1",
+ "abandoned-container",
+ ]
+
+
+def test_deep_publish_log(
+ lp: LearningPackage,
+ grandparent: ContainerContainer,
+ parent_of_two: TestContainer,
+ parent_of_three: TestContainer,
+ parent_of_six: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+ container_of_uninstalled_type: Container,
+ lp2: LearningPackage,
+ other_lp_parent: TestContainer,
+ other_lp_child: TestEntity,
+ django_assert_num_queries,
+) -> None:
+ """
+ With lots of entities present in a deep hierarchy, test the result of publishing different parts of the tree.
+
+ See diagram near the top of this file.
+ """
+ # Create a "great grandparent" container that contains "grandparent"
+ great_grandparent = create_test_container(
+ lp,
+ key="great_grandparent",
+ title="Great-grandparent container",
+ entities=[grandparent],
+ )
+ # Publish container_of_uninstalled_type (and child_entity1). Should not affect anything else,
+ # but we should see "child_entity1" omitted from the subsequent publish.
+ with django_assert_num_queries(49):
+ publish_log = publish_entity(container_of_uninstalled_type)
+ # Nothing else should have been affected by the publish:
+ assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [
+ "child_entity1",
+ "abandoned-container",
+ ]
+
+ # Publish great_grandparent. Should publish the whole tree.
+ with django_assert_num_queries(126):
+ publish_log = publish_entity(great_grandparent)
+ assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [
+ "child_entity2",
+ "parent_of_two",
+ "parent_of_three",
+ "grandparent",
+ "great_grandparent",
+ ]
+
+
+# get_entities_in_container
+
+
+def test_get_entities_in_container(
+ parent_of_three: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+) -> None:
+ """
+ Test `get_entities_in_container()`
+ """
+ expected = [
+ # This particular container has three children (3, 2, 1), two of them π pinned:
+ containers_api.ContainerEntityListEntry(child_entity3.versioning.draft.publishable_entity_version, pinned=True),
+ containers_api.ContainerEntityListEntry(child_entity2.versioning.draft.publishable_entity_version, pinned=True),
+ containers_api.ContainerEntityListEntry(
+ child_entity1.versioning.draft.publishable_entity_version, pinned=False
+ ),
+ ]
+ assert containers_api.get_entities_in_container(parent_of_three, published=False) == expected
+ # Asking about the published version will throw an exception, since no published version exists yet:
+ with pytest.raises(ContainerVersion.DoesNotExist):
+ containers_api.get_entities_in_container(parent_of_three, published=True)
+
+ publish_entity(parent_of_three)
+ assert containers_api.get_entities_in_container(parent_of_three, published=True) == expected
+
+
+def test_get_entities_in_container_soft_deletion_unpinned(
+ parent_of_three: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+) -> None:
+ """Test that `get_entities_in_container()` correctly handles soft deletion of child entities."""
+ before = [ # This particular container has three children (3, 2, 1), two of them π pinned:
+ Entry(child_entity3.versioning.draft, pinned=True),
+ Entry(child_entity2.versioning.draft, pinned=True),
+ Entry(child_entity1.versioning.draft, pinned=False),
+ ]
+ assert containers_api.get_entities_in_container(parent_of_three, published=False) == before
+
+ # First, publish everything:
+ publish_entity(parent_of_three)
+ # Soft delete the third, unpinned child (child_entity1):
+ publishing_api.soft_delete_draft(child_entity1.pk)
+
+ # That deletion should NOT count as a change to the container itself:
+ parent_of_three.refresh_from_db()
+ assert not parent_of_three.versioning.has_unpublished_changes
+ # But it "contains" a change (a deletion)
+ assert containers_api.contains_unpublished_changes(parent_of_three)
+
+ after = [
+ before[0], # first two children are unchanged
+ before[1],
+ # the third child (#1) has been soft deleted and doesn't appear in the draft
+ ]
+ assert containers_api.get_entities_in_container(parent_of_three, published=False) == after
+
+
+def test_get_entities_in_container_soft_deletion_pinned(
+ parent_of_three: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+) -> None:
+ """Test that `get_entities_in_container()` correctly handles soft deletion of π pinned child entities."""
+ before = [ # This particular container has three children (3, 2, 1), two of them π pinned:
+ Entry(child_entity3.versioning.draft, pinned=True),
+ Entry(child_entity2.versioning.draft, pinned=True),
+ Entry(child_entity1.versioning.draft, pinned=False),
+ ]
+ assert containers_api.get_entities_in_container(parent_of_three, published=False) == before
+
+ # First, publish everything:
+ publish_entity(parent_of_three)
+ # Soft delete child 2:
+ publishing_api.soft_delete_draft(child_entity2.pk)
+
+ # The above deletions should NOT count as a change to the container itself, in any way:
+ parent_of_three.refresh_from_db()
+ assert not parent_of_three.versioning.has_unpublished_changes
+ assert not containers_api.contains_unpublished_changes(parent_of_three)
+
+ # Since the second child was pinned to an exact version, soft deleting it doesn't affect the contents of the
+ # container at all:
+ assert containers_api.get_entities_in_container(parent_of_three, published=False) == before
+
+
+# get_entities_in_container_as_of
+
+
+def test_snapshots_of_published_unit(lp: LearningPackage, child_entity1: TestEntity, child_entity2: TestEntity):
+ """Test that we can access snapshots of the historic published version of containers and their contents."""
+ child_entity1_v1 = child_entity1.versioning.draft
+
+ # At first the container has one child (unpinned):
+ container = create_test_container(lp, key="c", entities=[child_entity1])
+ modify_entity(child_entity1, title="Component 1 as of checkpoint 1")
+ _, before_publish = containers_api.get_entities_in_container_as_of(container, 0)
+ assert not before_publish # Empty list
+
+ # Publish everything, creating Checkpoint 1
+ checkpoint_1 = publishing_api.publish_all_drafts(lp.id, message="checkpoint 1")
+
+ ########################################################################
+
+ # Now we update the title of the component.
+ modify_entity(child_entity1, title="Component 1 as of checkpoint 2")
+ # Publish everything, creating Checkpoint 2
+ checkpoint_2 = publishing_api.publish_all_drafts(lp.id, message="checkpoint 2")
+ ########################################################################
+
+ # Now add a second component to the unit:
+ modify_entity(child_entity1, title="Component 1 as of checkpoint 3")
+ modify_entity(child_entity2, title="Component 2 as of checkpoint 3")
+ containers_api.create_next_container_version(
+ container.pk,
+ title="Unit title in checkpoint 3",
+ entities=[child_entity1, child_entity2],
+ created=now,
+ created_by=None,
+ )
+ # Publish everything, creating Checkpoint 3
+ checkpoint_3 = publishing_api.publish_all_drafts(lp.id, message="checkpoint 3")
+ ########################################################################
+
+ # Now add a third component to the unit, a pinned π version of component 1.
+ # This will test pinned versions and also test adding at the beginning rather than the end of the unit.
+ containers_api.create_next_container_version(
+ container.pk,
+ title="Unit title in checkpoint 4",
+ entities=[child_entity1_v1, child_entity1, child_entity2],
+ created=now,
+ created_by=None,
+ )
+ # Publish everything, creating Checkpoint 4
+ checkpoint_4 = publishing_api.publish_all_drafts(lp.id, message="checkpoint 4")
+ ########################################################################
+
+ # Modify the drafts, but don't publish:
+ modify_entity(child_entity1, title="Component 1 draft")
+ modify_entity(child_entity2, title="Component 2 draft")
+
+ # Now fetch the snapshots:
+ _, as_of_checkpoint_1 = containers_api.get_entities_in_container_as_of(container, checkpoint_1.pk)
+ assert [ev.entity_version.title for ev in as_of_checkpoint_1] == [
+ "Component 1 as of checkpoint 1",
+ ]
+ _, as_of_checkpoint_2 = containers_api.get_entities_in_container_as_of(container, checkpoint_2.pk)
+ assert [ev.entity_version.title for ev in as_of_checkpoint_2] == [
+ "Component 1 as of checkpoint 2",
+ ]
+ _, as_of_checkpoint_3 = containers_api.get_entities_in_container_as_of(container, checkpoint_3.pk)
+ assert [ev.entity_version.title for ev in as_of_checkpoint_3] == [
+ "Component 1 as of checkpoint 3",
+ "Component 2 as of checkpoint 3",
+ ]
+ _, as_of_checkpoint_4 = containers_api.get_entities_in_container_as_of(container, checkpoint_4.pk)
+ assert [ev.entity_version.title for ev in as_of_checkpoint_4] == [
+ "Child 1 π΄", # Pinned. This title is self.component_1_v1.title (original v1 title)
+ "Component 1 as of checkpoint 3", # we didn't modify these components so they're same as in snapshot 3
+ "Component 2 as of checkpoint 3", # we didn't modify these components so they're same as in snapshot 3
+ ]
+
+
+# get_containers_with_entity
+
+
+def test_get_containers_with_entity_draft(
+ lp: LearningPackage,
+ grandparent: ContainerContainer,
+ parent_of_two: TestContainer,
+ parent_of_three: TestContainer,
+ parent_of_six: TestContainer,
+ child_entity1: TestEntity,
+ child_entity2: TestEntity,
+ child_entity3: TestEntity,
+ lp2: LearningPackage,
+ other_lp_parent: TestContainer,
+ other_lp_child: TestEntity,
+ django_assert_num_queries,
+):
+ """Test that we can efficiently get a list of all the draft containers containing a given entity."""
+
+ # Note this test uses a lot of pre-loaded fixtures. Refer to the diagram in the comments near the top of this file.
+ # The idea is to have enough variety to ensure we're testing comprehensively:
+ # - duplicate entities in the same container
+ # - pinned and unpinned entities
+ # - different learning packages
+
+ # "child_entity1" is found in three different containers:
+ with django_assert_num_queries(1):
+ result = list(containers_api.get_containers_with_entity(child_entity1.publishable_entity.pk))
+ assert result == [ # Note: ordering is in order of container creation
+ parent_of_two.container,
+ parent_of_three.container,
+ parent_of_six.container, # This should only appear once, not several times.
+ ]
+
+ # "child_entity3" is found in two different containers:
+ with django_assert_num_queries(1):
+ result = list(containers_api.get_containers_with_entity(child_entity3.publishable_entity.pk))
+ assert result == [ # Note: ordering is in order of container creation
+ parent_of_three.container, # pinned in this container
+ parent_of_six.container, # pinned and unpinned in this container
+ ]
+
+ # Test retrieving only "unpinned", for cases like potential deletion of a component, where we wouldn't care
+ # about pinned uses anyways (they would be unaffected by a delete).
+
+ with django_assert_num_queries(1):
+ result = list(
+ containers_api.get_containers_with_entity(child_entity3.publishable_entity.pk, ignore_pinned=True)
+ )
+ assert result == [ # Note: ordering is in order of container creation
+ parent_of_six.container, # it's pinned and unpinned in this container
+ ]
+
+ # Some basic tests of the other learning package:
+ assert list(containers_api.get_containers_with_entity(other_lp_child.publishable_entity.pk)) == [
+ other_lp_parent.container
+ ]
+ assert not list(containers_api.get_containers_with_entity(other_lp_parent.publishable_entity.pk))
+
+
+# get_container_children_count
+
+
+def test_get_container_children_count(
+ lp: LearningPackage,
+ parent_of_two: TestContainer,
+ parent_of_three: TestContainer,
+ parent_of_six: TestContainer,
+ grandparent: ContainerContainer,
+):
+ """Test `get_container_children_count()`"""
+ publishing_api.publish_all_drafts(lp.pk)
+ assert containers_api.get_container_children_count(parent_of_two, published=False) == 2
+ assert containers_api.get_container_children_count(parent_of_two, published=True) == 2
+
+ assert containers_api.get_container_children_count(parent_of_three, published=False) == 3
+ assert containers_api.get_container_children_count(parent_of_three, published=True) == 3
+
+ assert containers_api.get_container_children_count(parent_of_six, published=False) == 6
+ assert containers_api.get_container_children_count(parent_of_six, published=True) == 6
+ # grandparent has two direct children - deeper descendants are not counted.
+ assert containers_api.get_container_children_count(grandparent, published=False) == 2
+ assert containers_api.get_container_children_count(grandparent, published=True) == 2
+
+ # Add another container to "grandparent":
+ containers_api.create_next_container_version(
+ grandparent,
+ entities=[parent_of_two, parent_of_three, parent_of_six],
+ created=now,
+ created_by=None,
+ )
+ # Warning: this is required if 'grandparent' is passed by ID to `create_next_container_version()`:
+ # grandparent.refresh_from_db()
+ assert containers_api.get_container_children_count(grandparent, published=False) == 3
+ assert containers_api.get_container_children_count(grandparent, published=True) == 2 # published is unchanged
+
+
+def test_get_container_children_count_soft_deletion(
+ lp: LearningPackage,
+ parent_of_two: TestContainer,
+ parent_of_six: TestContainer,
+ child_entity2: TestEntity,
+):
+ """Test `get_container_children_count()` when an entity is soft deleted"""
+ publishing_api.publish_all_drafts(lp.pk)
+ publishing_api.soft_delete_draft(child_entity2.pk)
+ # "parent_of_two" contains the soft deleted child, so its draft child count is decreased by one:
+ assert containers_api.get_container_children_count(parent_of_two, published=False) == 1
+ assert containers_api.get_container_children_count(parent_of_two, published=True) == 2
+ # "parent_of_six" also contains two unpinned entries for the soft deleted child, so its draft child count is
+ # decreased by two:
+ assert containers_api.get_container_children_count(parent_of_six, published=False) == 4
+ assert containers_api.get_container_children_count(parent_of_six, published=True) == 6
+
+
+def test_get_container_children_count_queries(
+ lp: LearningPackage,
+ parent_of_two: TestContainer,
+ parent_of_six: TestContainer,
+ django_assert_num_queries,
+):
+ """Test how many database queries `get_container_children_count()` needs"""
+ publishing_api.publish_all_drafts(lp.pk)
+ # The 6 queries are:
+ # - Draft.objects.get()
+ # - PublishableEntityVersion.objects.get()
+ # - ContainerVersion.objects.get()
+ # - TestContainerVersion.objects.get()
+ # - EntityList.objects.get()
+ # - SELECT COUNT(*) from EntityListRow ... JOIN on not soft deleted...
+ # TODO: the first four/five queries are all just loading "TestContainer" and its related objects, and could be
+ # optimized into a single query with better `select_related()`. The first four queries all use the same primary key.
+ with django_assert_num_queries(6):
+ assert containers_api.get_container_children_count(parent_of_two, published=False) == 2
+ with django_assert_num_queries(6):
+ assert containers_api.get_container_children_count(parent_of_two, published=True) == 2
+ with django_assert_num_queries(6):
+ assert containers_api.get_container_children_count(parent_of_six, published=False) == 6
+ with django_assert_num_queries(6):
+ assert containers_api.get_container_children_count(parent_of_six, published=True) == 6
+
+
+# get_container_children_entities_keys
+
+
+def test_get_container_children_entities_keys(grandparent: ContainerContainer, parent_of_six: TestContainer) -> None:
+ """Test `get_container_children_entities_keys()`"""
+
+ # TODO: is get_container_children_entities_keys() a useful API method? It's not used in edx-platform.
+
+ assert containers_api.get_container_children_entities_keys(grandparent.versioning.draft) == [
+ # These are the two children of "grandparent" - see diagram near the top of this file.
+ "parent_of_two",
+ "parent_of_three",
+ ]
+
+ assert containers_api.get_container_children_entities_keys(parent_of_six.versioning.draft) == [
+ "child_entity3",
+ "child_entity2",
+ "child_entity1",
+ "child_entity1",
+ "child_entity2",
+ "child_entity3",
+ ]
+
+
+# Container deletion
+
+
+def test_soft_delete_container(lp: LearningPackage, parent_of_two: TestContainer, child_entity1: TestEntity):
+ """
+ I can delete a container without deleting the entities it contains.
+
+ See https://github.com/openedx/frontend-app-authoring/issues/1693
+ """
+ # Publish everything:
+ publish_entity(parent_of_two)
+ # Delete the container:
+ publishing_api.soft_delete_draft(parent_of_two.publishable_entity_id)
+ parent_of_two.refresh_from_db()
+ # Now the draft container is [soft] deleted, but the children, published container, and other container are
+ # unaffected:
+ assert parent_of_two.versioning.draft is None # container is soft deleted.
+ assert parent_of_two.versioning.published is not None
+ child_entity1.refresh_from_db()
+ assert child_entity1.versioning.draft is not None
+
+ # Publish the changes:
+ publishing_api.publish_all_drafts(lp.id)
+ # Now the container's published version is also deleted, but nothing else is affected.
+ parent_of_two.refresh_from_db()
+ assert parent_of_two.versioning.draft is None
+ assert parent_of_two.versioning.published is None # Now this is also None
+ child_entity1.refresh_from_db()
+ assert child_entity1.versioning.draft == child_entity1.versioning.published
+ assert child_entity1.versioning.draft is not None
diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py
index c0f113787..96012633d 100644
--- a/tests/openedx_content/applets/publishing/test_api.py
+++ b/tests/openedx_content/applets/publishing/test_api.py
@@ -13,8 +13,6 @@
from openedx_content.applets.publishing import api as publishing_api
from openedx_content.applets.publishing.models import (
- Container,
- ContainerVersion,
Draft,
DraftChangeLog,
DraftChangeLogRecord,
@@ -938,9 +936,98 @@ def test_simple_publish_log(self) -> None:
assert e1_pub_record.new_version == entity1_v2
-class ContainerTestCase(TestCase):
+class EntitiesQueryTestCase(TestCase):
"""
- Test basic operations with Drafts.
+ Tests for querying PublishableEntity objects.
+ """
+ now: datetime
+ learning_package_1: LearningPackage
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ """
+ Initialize our content data
+ """
+
+ cls.now = datetime(2025, 8, 4, 12, 00, 00, tzinfo=timezone.utc)
+ cls.learning_package_1 = publishing_api.create_learning_package(
+ "my_package_key_1",
+ "Entities Testing LearningPackage π₯ 1",
+ created=cls.now,
+ )
+
+ with publishing_api.bulk_draft_changes_for(cls.learning_package_1.id):
+ entity = publishing_api.create_publishable_entity(
+ cls.learning_package_1.id,
+ "my_entity",
+ created=cls.now,
+ created_by=None,
+ )
+ publishing_api.create_publishable_entity_version(
+ entity.id,
+ version_num=1,
+ title="An Entity π΄",
+ created=cls.now,
+ created_by=None,
+ )
+ entity2 = publishing_api.create_publishable_entity(
+ cls.learning_package_1.id,
+ "my_entity2",
+ created=cls.now,
+ created_by=None,
+ )
+ publishing_api.create_publishable_entity_version(
+ entity2.id,
+ version_num=1,
+ title="An Entity π΄ 2",
+ created=cls.now,
+ created_by=None,
+ )
+ publishing_api.publish_all_drafts(cls.learning_package_1.id)
+
+ def test_get_publishable_entities(self) -> None:
+ """
+ Test that get_entities returns all entities for a learning package.
+ """
+ entities = publishing_api.get_publishable_entities(self.learning_package_1.id)
+ assert entities.count() == 2
+ for entity in entities:
+ assert isinstance(entity, PublishableEntity)
+ assert entity.learning_package_id == self.learning_package_1.id
+ assert entity.created == self.now
+
+ def test_get_publishable_entities_n_plus_problem(self) -> None:
+ """
+ Check get_publishable_entities if N+1 query problem exists when accessing related entities.
+ """
+ entities = publishing_api.get_publishable_entities(self.learning_package_1.id)
+
+ # assert that only 1 query is made even when accessing related entities
+ with self.assertNumQueries(1):
+ # Related entities to review:
+ # - draft.version
+ # - published.version
+
+ for e in entities:
+ # Instead of just checking the version number, we verify the related query count.
+ # If an N+1 issue exists, accessing versions or other related fields would trigger more than one query.
+ draft = getattr(e, 'draft', None)
+ published = getattr(e, 'published', None)
+ assert draft and draft.version.version_num == 1
+ assert published and published.version.version_num == 1
+
+
+# TODO: refactor these tests to use a "fake" container model so there's no dependency on the containers applet?
+# All we need is a similar generic publishableentity with dependencies.
+# pylint: disable=wrong-import-position
+from openedx_content.applets.containers import api as containers_api # noqa
+from openedx_content.models_api import Container # noqa
+from tests.test_django_app.models import TestContainer # noqa
+
+
+class TestContainerSideEffects(TestCase):
+ """
+ Tests related to Container side effects and dependencies
"""
now: datetime
learning_package: LearningPackage
@@ -954,6 +1041,10 @@ def setUpTestData(cls) -> None:
created=cls.now,
)
+ def tearDown(self):
+ Container.reset_cache() # <- needed in tests involving Container subclasses
+ return super().tearDown()
+
def test_parent_child_side_effects(self) -> None:
"""Test that modifying a child has side-effects on its parent."""
child_1 = publishing_api.create_publishable_entity(
@@ -982,19 +1073,20 @@ def test_parent_child_side_effects(self) -> None:
created=self.now,
created_by=None,
)
- container: Container = publishing_api.create_container(
+ container = containers_api.create_container(
self.learning_package.id,
"my_container",
created=self.now,
created_by=None,
+ container_cls=TestContainer,
)
- container_v1: ContainerVersion = publishing_api.create_container_version(
+ container_v1 = containers_api.create_container_version(
container.pk,
1,
title="My Container",
- entity_rows=[
- publishing_api.ContainerEntityRow(entity_pk=child_1.pk),
- publishing_api.ContainerEntityRow(entity_pk=child_2.pk),
+ entities=[
+ child_1,
+ child_2,
],
created=self.now,
created_by=None,
@@ -1010,7 +1102,7 @@ def test_parent_child_side_effects(self) -> None:
created=self.now,
created_by=None,
)
- last_change_log = DraftChangeLog.objects.order_by('-id').first()
+ last_change_log = DraftChangeLog.objects.order_by("-id").first()
assert last_change_log is not None
assert last_change_log.records.count() == 2
child_1_change = last_change_log.records.get(entity=child_1)
@@ -1019,9 +1111,7 @@ def test_parent_child_side_effects(self) -> None:
# The container should be here, but the versions should be the same for
# before and after:
- container_change = last_change_log.records.get(
- entity=container.publishable_entity
- )
+ container_change = last_change_log.records.get(entity=container.publishable_entity)
assert container_change.old_version == container_v1.publishable_entity_version
assert container_change.new_version == container_v1.publishable_entity_version
@@ -1063,20 +1153,18 @@ def test_bulk_parent_child_side_effects(self) -> None:
created=self.now,
created_by=None,
)
- container: Container = publishing_api.create_container(
+ container = containers_api.create_container(
self.learning_package.id,
"my_container",
created=self.now,
created_by=None,
+ container_cls=TestContainer,
)
- container_v1: ContainerVersion = publishing_api.create_container_version(
+ container_v1 = containers_api.create_container_version(
container.pk,
1,
title="My Container",
- entity_rows=[
- publishing_api.ContainerEntityRow(entity_pk=child_1.pk),
- publishing_api.ContainerEntityRow(entity_pk=child_2.pk),
- ],
+ entities=[child_1, child_2],
created=self.now,
created_by=None,
)
@@ -1107,9 +1195,7 @@ def test_bulk_parent_child_side_effects(self) -> None:
assert child_2_change.old_version is None
assert child_2_change.new_version == child_2_v1
- container_change = last_change_log.records.get(
- entity=container.publishable_entity
- )
+ container_change = last_change_log.records.get(entity=container.publishable_entity)
assert container_change.old_version is None
assert container_change.new_version == container_v1.publishable_entity_version
@@ -1123,7 +1209,7 @@ def test_bulk_parent_child_side_effects(self) -> None:
assert caused_by_child_1.effect == container_change
assert caused_by_child_2.effect == container_change
- def test_draft_dependency_multiple_parents(self):
+ def test_draft_dependency_multiple_parents(self) -> None:
"""
Test that a change in a draft component affects multiple parents.
@@ -1131,25 +1217,38 @@ def test_draft_dependency_multiple_parents(self):
"""
# Set up a Component that lives in two Units
component = publishing_api.create_publishable_entity(
- self.learning_package.id, "component_1", created=self.now, created_by=None,
+ self.learning_package.id,
+ "component_1",
+ created=self.now,
+ created_by=None,
)
publishing_api.create_publishable_entity_version(
- component.id, version_num=1, title="Component 1 π΄", created=self.now, created_by=None,
+ component.id,
+ version_num=1,
+ title="Component 1 π΄",
+ created=self.now,
+ created_by=None,
)
- unit_1 = publishing_api.create_container(
- self.learning_package.id, "unit_1", created=self.now, created_by=None,
+ unit_1 = containers_api.create_container(
+ self.learning_package.id,
+ "unit_1",
+ created=self.now,
+ created_by=None,
+ container_cls=TestContainer,
)
- unit_2 = publishing_api.create_container(
- self.learning_package.id, "unit_2", created=self.now, created_by=None,
+ unit_2 = containers_api.create_container(
+ self.learning_package.id,
+ "unit_2",
+ created=self.now,
+ created_by=None,
+ container_cls=TestContainer,
)
for unit in [unit_1, unit_2]:
- publishing_api.create_container_version(
+ containers_api.create_container_version(
unit.pk,
1,
title="My Unit",
- entity_rows=[
- publishing_api.ContainerEntityRow(entity_pk=component.pk),
- ],
+ entities=[component],
created=self.now,
created_by=None,
)
@@ -1161,7 +1260,11 @@ def test_draft_dependency_multiple_parents(self):
# Now let's change the Component and make sure it created side-effects
# for both Units.
publishing_api.create_publishable_entity_version(
- component.id, version_num=2, title="Component 1.2 π΄", created=self.now, created_by=None,
+ component.id,
+ version_num=2,
+ title="Component 1.2 π΄",
+ created=self.now,
+ created_by=None,
)
side_effects = DraftSideEffect.objects.all()
assert side_effects.count() == 2
@@ -1169,41 +1272,52 @@ def test_draft_dependency_multiple_parents(self):
assert side_effects.filter(effect__entity=unit_1.publishable_entity).count() == 1
assert side_effects.filter(effect__entity=unit_2.publishable_entity).count() == 1
- def test_multiple_layers_of_containers(self):
+ def test_multiple_layers_of_containers(self) -> None:
"""Test stacking containers three layers deep."""
# Note that these aren't real "components" and "units". Everything being
# tested is confined to the publishing app, so those concepts shouldn't
# be imported here. They're just named this way to make it more obvious
# what the intended hierarchy is for testing container nesting.
component = publishing_api.create_publishable_entity(
- self.learning_package.id, "component_1", created=self.now, created_by=None,
+ self.learning_package.id,
+ "component_1",
+ created=self.now,
+ created_by=None,
)
publishing_api.create_publishable_entity_version(
- component.id, version_num=1, title="Component 1 π΄", created=self.now, created_by=None,
+ component.id,
+ version_num=1,
+ title="Component 1 π΄",
+ created=self.now,
+ created_by=None,
)
- unit = publishing_api.create_container(
- self.learning_package.id, "unit_1", created=self.now, created_by=None,
+ unit = containers_api.create_container(
+ self.learning_package.id,
+ "unit_1",
+ created=self.now,
+ created_by=None,
+ container_cls=TestContainer,
)
- publishing_api.create_container_version(
+ containers_api.create_container_version(
unit.pk,
1,
title="My Unit",
- entity_rows=[
- publishing_api.ContainerEntityRow(entity_pk=component.pk),
- ],
+ entities=[component],
created=self.now,
created_by=None,
)
- subsection = publishing_api.create_container(
- self.learning_package.id, "subsection_1", created=self.now, created_by=None,
+ subsection = containers_api.create_container(
+ self.learning_package.id,
+ "subsection_1",
+ created=self.now,
+ created_by=None,
+ container_cls=TestContainer,
)
- publishing_api.create_container_version(
+ containers_api.create_container_version(
subsection.pk,
1,
title="My Subsection",
- entity_rows=[
- publishing_api.ContainerEntityRow(entity_pk=unit.pk),
- ],
+ entities=[unit],
created=self.now,
created_by=None,
)
@@ -1214,7 +1328,11 @@ def test_multiple_layers_of_containers(self):
with publishing_api.bulk_draft_changes_for(self.learning_package.id) as change_log:
publishing_api.create_publishable_entity_version(
- component.id, version_num=2, title="Component 1v2π΄", created=self.now, created_by=None,
+ component.id,
+ version_num=2,
+ title="Component 1v2π΄",
+ created=self.now,
+ created_by=None,
)
assert DraftSideEffect.objects.count() == 2
@@ -1232,7 +1350,11 @@ def test_multiple_layers_of_containers(self):
assert publish_log.records.count() == 3
publishing_api.create_publishable_entity_version(
- component.pk, version_num=3, title="Component v2", created=self.now, created_by=None,
+ component.pk,
+ version_num=3,
+ title="Component v2",
+ created=self.now,
+ created_by=None,
)
publish_log = publishing_api.publish_from_drafts(
self.learning_package.id,
@@ -1245,45 +1367,56 @@ def test_multiple_layers_of_containers(self):
assert not component_publish.affected_by.exists()
assert unit_publish.affected_by.count() == 1
- assert unit_publish.affected_by.first().cause == component_publish
+ assert unit_publish.affected_by.first().cause == component_publish # type: ignore[union-attr]
assert subsection_publish.affected_by.count() == 1
- assert subsection_publish.affected_by.first().cause == unit_publish
+ assert subsection_publish.affected_by.first().cause == unit_publish # type: ignore[union-attr]
- def test_publish_all_layers(self):
+ def test_publish_all_layers(self) -> None:
"""Test that we can publish multiple layers from one root."""
# Note that these aren't real "components" and "units". Everything being
# tested is confined to the publishing app, so those concepts shouldn't
# be imported here. They're just named this way to make it more obvious
# what the intended hierarchy is for testing container nesting.
component = publishing_api.create_publishable_entity(
- self.learning_package.id, "component_1", created=self.now, created_by=None,
+ self.learning_package.id,
+ "component_1",
+ created=self.now,
+ created_by=None,
)
publishing_api.create_publishable_entity_version(
- component.id, version_num=1, title="Component 1 π΄", created=self.now, created_by=None,
+ component.id,
+ version_num=1,
+ title="Component 1 π΄",
+ created=self.now,
+ created_by=None,
)
- unit = publishing_api.create_container(
- self.learning_package.id, "unit_1", created=self.now, created_by=None,
+ unit = containers_api.create_container(
+ self.learning_package.id,
+ "unit_1",
+ created=self.now,
+ created_by=None,
+ container_cls=TestContainer,
)
- publishing_api.create_container_version(
+ containers_api.create_container_version(
unit.pk,
1,
title="My Unit",
- entity_rows=[
- publishing_api.ContainerEntityRow(entity_pk=component.pk),
- ],
+ entities=[component],
created=self.now,
created_by=None,
)
- subsection = publishing_api.create_container(
- self.learning_package.id, "subsection_1", created=self.now, created_by=None,
+ subsection = containers_api.create_container(
+ self.learning_package.id,
+ "subsection_1",
+ created=self.now,
+ created_by=None,
+ container_cls=TestContainer,
)
- publishing_api.create_container_version(
+ containers_api.create_container_version(
subsection.pk,
1,
title="My Subsection",
- entity_rows=[
- publishing_api.ContainerEntityRow(entity_pk=unit.pk),
- ],
+ entities=[unit],
created=self.now,
created_by=None,
)
@@ -1296,7 +1429,7 @@ def test_publish_all_layers(self):
# the publish log records.
assert publish_log.records.count() == 3
- def test_container_next_version(self):
+ def test_container_next_version(self) -> None:
"""Test that next_version works for containers."""
child_1 = publishing_api.create_publishable_entity(
self.learning_package.id,
@@ -1304,38 +1437,37 @@ def test_container_next_version(self):
created=self.now,
created_by=None,
)
- container = publishing_api.create_container(
+ container = containers_api.create_container(
self.learning_package.id,
"my_container",
created=self.now,
created_by=None,
+ container_cls=TestContainer,
)
assert container.versioning.latest is None
- v1 = publishing_api.create_next_container_version(
+ v1 = containers_api.create_next_container_version(
container.pk,
title="My Container v1",
- entity_rows=None,
+ entities=None,
created=self.now,
created_by=None,
)
assert v1.version_num == 1
assert container.versioning.latest == v1
- v2 = publishing_api.create_next_container_version(
+ v2 = containers_api.create_next_container_version(
container.pk,
title="My Container v2",
- entity_rows=[
- publishing_api.ContainerEntityRow(entity_pk=child_1.pk)
- ],
+ entities=[child_1],
created=self.now,
created_by=None,
)
assert v2.version_num == 2
assert container.versioning.latest == v2
assert v2.entity_list.entitylistrow_set.count() == 1
- v3 = publishing_api.create_next_container_version(
+ v3 = containers_api.create_next_container_version(
container.pk,
title="My Container v3",
- entity_rows=None,
+ entities=None,
created=self.now,
created_by=None,
)
@@ -1345,82 +1477,9 @@ def test_container_next_version(self):
assert v2.entity_list.entitylistrow_set.count() == 1
-class EntitiesQueryTestCase(TestCase):
- """
- Tests for querying PublishableEntity objects.
- """
- now: datetime
- learning_package_1: LearningPackage
-
- @classmethod
- def setUpTestData(cls) -> None:
- """
- Initialize our content data
- """
-
- cls.now = datetime(2025, 8, 4, 12, 00, 00, tzinfo=timezone.utc)
- cls.learning_package_1 = publishing_api.create_learning_package(
- "my_package_key_1",
- "Entities Testing LearningPackage π₯ 1",
- created=cls.now,
- )
-
- with publishing_api.bulk_draft_changes_for(cls.learning_package_1.id):
- entity = publishing_api.create_publishable_entity(
- cls.learning_package_1.id,
- "my_entity",
- created=cls.now,
- created_by=None,
- )
- publishing_api.create_publishable_entity_version(
- entity.id,
- version_num=1,
- title="An Entity π΄",
- created=cls.now,
- created_by=None,
- )
- entity2 = publishing_api.create_publishable_entity(
- cls.learning_package_1.id,
- "my_entity2",
- created=cls.now,
- created_by=None,
- )
- publishing_api.create_publishable_entity_version(
- entity2.id,
- version_num=1,
- title="An Entity π΄ 2",
- created=cls.now,
- created_by=None,
- )
- publishing_api.publish_all_drafts(cls.learning_package_1.id)
-
- def test_get_publishable_entities(self) -> None:
- """
- Test that get_entities returns all entities for a learning package.
- """
- entities = publishing_api.get_publishable_entities(self.learning_package_1.id)
- assert entities.count() == 2
- for entity in entities:
- assert isinstance(entity, PublishableEntity)
- assert entity.learning_package_id == self.learning_package_1.id
- assert entity.created == self.now
-
- def test_get_publishable_entities_n_plus_problem(self) -> None:
- """
- Check get_publishable_entities if N+1 query problem exists when accessing related entities.
- """
- entities = publishing_api.get_publishable_entities(self.learning_package_1.id)
-
- # assert that only 1 query is made even when accessing related entities
- with self.assertNumQueries(1):
- # Related entities to review:
- # - draft.version
- # - published.version
-
- for e in entities:
- # Instead of just checking the version number, we verify the related query count.
- # If an N+1 issue exists, accessing versions or other related fields would trigger more than one query.
- draft = getattr(e, 'draft', None)
- published = getattr(e, 'published', None)
- assert draft and draft.version.version_num == 1
- assert published and published.version.version_num == 1
+# Tests TODO:
+# Test that I can get a [PublishLog] history of a given container and all its children, including children that aren't
+# currently in the container and excluding children that are only in other containers.
+# Test that I can get a [PublishLog] history of a given container and its children, that includes changes made to the
+# child components while they were part of the container but excludes changes made to those children while they were
+# not part of the container. π«£
diff --git a/tests/openedx_content/applets/sections/test_api.py b/tests/openedx_content/applets/sections/test_api.py
index f31771131..ef631e884 100644
--- a/tests/openedx_content/applets/sections/test_api.py
+++ b/tests/openedx_content/applets/sections/test_api.py
@@ -1,56 +1,72 @@
"""
-Basic tests for the subsections API.
+Basic tests for the sections API.
"""
-import ddt # type: ignore[import]
+
+from typing import Any
+
import pytest
from django.core.exceptions import ValidationError
import openedx_content.api as content_api
-from openedx_content import models_api as authoring_models
+from openedx_content.models_api import Section, SectionVersion, Subsection, SubsectionVersion
-from ..subsections.test_api import SubSectionTestCase
+from ..components.test_api import ComponentTestCase
Entry = content_api.SectionListEntry
-# TODO: Turn SubSectionTestCase into SubSectionTestMixin and remove the
-# test-inherits-tests pylint warning below.
-# https://github.com/openedx/openedx-core/issues/308
-@ddt.ddt
-class SectionTestCase(SubSectionTestCase): # pylint: disable=test-inherits-tests
- """ Test cases for Sections (containers of subsections) """
+class SectionsTestCase(ComponentTestCase):
+ """Test cases for Sections (containers of subsections)"""
def setUp(self) -> None:
+ """Create some potential desdendants for use in these tests."""
super().setUp()
- self.subsection_1, self.subsection_1_v1 = self.create_subsection(
- key="Subsection (1)",
- title="Subsection (1)",
- )
- self.subsection_2, self.subsection_2_v1 = self.create_subsection(
- key="Subsection (2)",
- title="Subsection (2)",
- )
-
- def create_subsection(self, *, title: str = "Test Subsection", key: str = "subsection:1") -> tuple[
- authoring_models.Subsection, authoring_models.SubsectionVersion
- ]:
- """ Helper method to quickly create a subsection """
- return content_api.create_subsection_and_version(
- self.learning_package.id,
- key=key,
- title=title,
- created=self.now,
- created_by=None,
+ self.component_1, self.component_1_v1 = self.create_component(
+ key="component_1",
+ title="Great-grandchild component",
+ )
+ self.component_2, self.component_2_v1 = self.create_component(
+ key="component_2",
+ title="Great-grandchild component",
+ )
+ common_args: dict[str, Any] = {
+ "learning_package_id": self.learning_package.id,
+ "created": self.now,
+ "created_by": None,
+ }
+ self.unit_1, self.unit_1_v1 = content_api.create_unit_and_version(
+ key="unit_1",
+ title="Grandchild Unit 1",
+ components=[self.component_1, self.component_2],
+ **common_args,
+ )
+ self.unit_2, self.unit_2_v1 = content_api.create_unit_and_version(
+ key="unit_2",
+ title="Grandchild Unit 2",
+ components=[self.component_2, self.component_1], # Backwards order from Unit 1
+ **common_args,
+ )
+ self.subsection_1, self.subsection_1_v1 = content_api.create_subsection_and_version(
+ key="subsection_1",
+ title="Child Subsection 1",
+ units=[self.unit_1, self.unit_2],
+ **common_args,
+ )
+ self.subsection_2, self.subsection_2_v1 = content_api.create_subsection_and_version(
+ key="subsection_2",
+ title="Child Subsection 2",
+ units=[self.unit_2, self.unit_1], # Backwards order from subsection 1
+ **common_args,
)
def create_section_with_subsections(
self,
- subsections: list[authoring_models.Subsection | authoring_models.SubsectionVersion],
+ subsections: list[Subsection | SubsectionVersion],
*,
- title="Subsection",
- key="subsection:key",
- ) -> authoring_models.Section:
- """ Helper method to quickly create a section with some subsections """
+ title="Section",
+ key="section:key",
+ ) -> Section:
+ """Helper method to quickly create a section with some subsections"""
section, _section_v1 = content_api.create_section_and_version(
learning_package_id=self.learning_package.id,
key=key,
@@ -61,226 +77,8 @@ def create_section_with_subsections(
)
return section
- def modify_subsection(
- self,
- subsection: authoring_models.Subsection,
- *,
- title="Modified Subsection",
- timestamp=None,
- ) -> authoring_models.SubsectionVersion:
- """
- Helper method to modify a subsection for the purposes of testing subsections/drafts/pinning/publishing/etc.
- """
- return content_api.create_next_subsection_version(
- subsection,
- title=title,
- created=timestamp or self.now,
- created_by=None,
- )
-
- def publish_subsection(self, subsection: authoring_models.Subsection):
- """
- Helper method to publish a single subsection.
- """
- content_api.publish_from_drafts(
- self.learning_package.pk,
- draft_qset=content_api.get_all_drafts(self.learning_package.pk).filter(
- entity=subsection.publishable_entity,
- ),
- )
-
- def test_get_section(self):
- """
- Test get_section()
- """
- section = self.create_section_with_subsections([self.subsection_1, self.subsection_2])
- with self.assertNumQueries(1):
- result = content_api.get_section(section.pk)
- assert result == section
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result.versioning.has_unpublished_changes
-
- def test_get_section_version(self):
- """
- Test get_section_version()
- """
- section = self.create_section_with_subsections([])
- draft = section.versioning.draft
- with self.assertNumQueries(1):
- result = content_api.get_section_version(draft.pk)
- assert result == draft
-
- def test_get_latest_section_version(self):
- """
- Test test_get_latest_section_version()
- """
- section = self.create_section_with_subsections([])
- draft = section.versioning.draft
- with self.assertNumQueries(2):
- result = content_api.get_latest_section_version(section.pk)
- assert result == draft
-
- def test_get_containers(self):
- """
- Test get_containers()
- """
- section = self.create_section_with_subsections([])
- with self.assertNumQueries(1):
- result = list(content_api.get_containers(self.learning_package.id))
- self.assertCountEqual(result, [
- self.unit_1.container,
- self.unit_2.container,
- self.subsection_1.container,
- self.subsection_2.container,
- section.container,
- ])
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result[0].versioning.has_unpublished_changes
-
- def test_get_containers_deleted(self):
- """
- Test that get_containers() does not return soft-deleted sections.
- """
- section = self.create_section_with_subsections([])
- content_api.soft_delete_draft(section.pk)
-
- with self.assertNumQueries(1):
- result = list(content_api.get_containers(self.learning_package.id, include_deleted=True))
-
- assert result == [
- self.unit_1.container,
- self.unit_2.container,
- self.subsection_1.container,
- self.subsection_2.container,
- section.container,
- ]
-
- with self.assertNumQueries(1):
- result = list(content_api.get_containers(self.learning_package.id))
-
- assert result == [
- self.unit_1.container,
- self.unit_2.container,
- self.subsection_1.container,
- self.subsection_2.container,
- ]
-
- def test_get_container(self):
- """
- Test get_container()
- """
- section = self.create_section_with_subsections([self.subsection_1, self.subsection_2])
- with self.assertNumQueries(1):
- result = content_api.get_container(section.pk)
- assert result == section.container
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result.versioning.has_unpublished_changes
-
- def test_get_container_by_key(self):
- """
- Test get_container_by_key()
- """
- section = self.create_section_with_subsections([])
- with self.assertNumQueries(1):
- result = content_api.get_container_by_key(
- self.learning_package.id,
- key=section.publishable_entity.key,
- )
- assert result == section.container
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result.versioning.has_unpublished_changes
-
- def test_section_container_versioning(self):
- """
- Test that the .versioning helper of a Sebsection returns a SectionVersion, and
- same for the generic Container equivalent.
- """
- section = self.create_section_with_subsections([self.subsection_1, self.subsection_2])
- container = section.container
- container_version = container.versioning.draft
- assert isinstance(container_version, authoring_models.ContainerVersion)
- section_version = section.versioning.draft
- assert isinstance(section_version, authoring_models.SectionVersion)
- assert section_version.container_version == container_version
- assert section_version.container_version.container == container
- assert section_version.section == section
-
- def test_create_section_queries(self):
- """
- Test how many database queries are required to create a section
- """
- # The exact numbers here aren't too important - this is just to alert us if anything significant changes.
- with self.assertNumQueries(28):
- _empty_section = self.create_section_with_subsections([])
- with self.assertNumQueries(35):
- # And try with a non-empty section:
- self.create_section_with_subsections([self.subsection_1, self.subsection_2_v1], key="u2")
-
- def test_create_section_with_invalid_children(self):
- """
- Verify that only subsections can be added to sections, and a specific
- exception is raised.
- """
- # Create two sections:
- section, section_version = content_api.create_section_and_version(
- learning_package_id=self.learning_package.id,
- key="section:key",
- title="Section",
- created=self.now,
- created_by=None,
- )
- assert section.versioning.draft == section_version
- section2, _s2v1 = content_api.create_section_and_version(
- learning_package_id=self.learning_package.id,
- key="section:key2",
- title="Section 2",
- created=self.now,
- created_by=None,
- )
- # Try adding a Section to a Section
- with pytest.raises(TypeError, match="Section subsections must be either Subsection or SubsectionVersion."):
- content_api.create_next_section_version(
- section=section,
- title="Section Containing a Section",
- subsections=[section2],
- created=self.now,
- created_by=None,
- )
- # Check that a new version was not created:
- section.refresh_from_db()
- assert content_api.get_section(section.pk).versioning.draft == section_version
- assert section.versioning.draft == section_version
-
- def test_adding_external_subsections(self):
- """
- Test that subsections from another learning package cannot be added to a
- section.
- """
- learning_package2 = content_api.create_learning_package(key="other-package", title="Other Package")
- section, _section_version = content_api.create_section_and_version(
- learning_package_id=learning_package2.pk,
- key="section:key",
- title="Section",
- created=self.now,
- created_by=None,
- )
- assert self.subsection_1.container.publishable_entity.learning_package != learning_package2
- # Try adding a a subsection from LP 1 (self.learning_package) to a section from LP 2
- with pytest.raises(ValidationError, match="Container entities must be from the same learning package."):
- content_api.create_next_section_version(
- section=section,
- title="Section Containing an External Subsection",
- subsections=[self.subsection_1],
- created=self.now,
- created_by=None,
- )
-
def test_create_empty_section_and_version(self):
- """Test creating a section with no subsections.
+ """Test creating a section with no units.
Expected results:
1. A section and section version are created.
@@ -289,12 +87,14 @@ def test_create_empty_section_and_version(self):
4. There is no published version of the section.
"""
section, section_version = content_api.create_section_and_version(
- learning_package_id=self.learning_package.id,
+ learning_package_id=self.learning_package.pk,
key="section:key",
title="Section",
created=self.now,
created_by=None,
)
+ assert isinstance(section, Section)
+ assert isinstance(section_version, SectionVersion)
assert section, section_version
assert section_version.version_num == 1
assert section_version in section.versioning.versions.all()
@@ -303,834 +103,103 @@ def test_create_empty_section_and_version(self):
assert section.versioning.published is None
assert section.publishable_entity.can_stand_alone
- def test_create_next_section_version_with_two_unpinned_subsections(self):
- """Test creating a section version with two unpinned subsections.
+ def test_create_next_section_version_with_unpinned_subsections(self):
+ """Test creating a unit version with an unpinned unit.
Expected results:
1. A new section version is created.
2. The section version number is 2.
3. The section version is in the section's versions.
- 4. The subsections are in the draft section version's subsection list and are unpinned.
- """
- section, _section_version = content_api.create_section_and_version(
- learning_package_id=self.learning_package.id,
- key="section:key",
- title="Section",
- created=self.now,
- created_by=None,
- )
- section_version_v2 = content_api.create_next_section_version(
- section=section,
- title="Section",
- subsections=[self.subsection_1, self.subsection_2],
- created=self.now,
- created_by=None,
- )
- assert section_version_v2.version_num == 2
- assert section_version_v2 in section.versioning.versions.all()
- assert content_api.get_subsections_in_section(section, published=False) == [
- Entry(self.subsection_1.versioning.draft),
- Entry(self.subsection_2.versioning.draft),
- ]
- with pytest.raises(authoring_models.ContainerVersion.DoesNotExist):
- # There is no published version of the section:
- content_api.get_subsections_in_section(section, published=True)
-
- def test_create_next_section_version_with_unpinned_and_pinned_subsections(self):
+ 4. The unit is in the draft section version's subsection list and is unpinned.
"""
- Test creating a section version with one unpinned and one pinned π subsection.
- """
- section, _section_version = content_api.create_section_and_version(
- learning_package_id=self.learning_package.id,
- key="section:key",
- title="Section",
- created=self.now,
- created_by=None,
- )
+ section = self.create_section_with_subsections([])
section_version_v2 = content_api.create_next_section_version(
- section=section,
+ section,
title="Section",
- subsections=[
- self.subsection_1,
- self.subsection_2_v1
- ], # Note the "v1" pinning π the second one to version 1
+ subsections=[self.subsection_1],
created=self.now,
created_by=None,
)
+ assert isinstance(section_version_v2, SectionVersion)
assert section_version_v2.version_num == 2
assert section_version_v2 in section.versioning.versions.all()
assert content_api.get_subsections_in_section(section, published=False) == [
Entry(self.subsection_1_v1),
- Entry(self.subsection_2_v1, pinned=True), # Pinned π to v1
]
- with pytest.raises(authoring_models.ContainerVersion.DoesNotExist):
- # There is no published version of the section:
+ with pytest.raises(SectionVersion.DoesNotExist):
+ # There is no published version of the subsection:
content_api.get_subsections_in_section(section, published=True)
- def test_create_next_section_version_forcing_version_num(self):
- """
- Test creating a section version while forcing the next version number.
- """
- section, _section_version = content_api.create_section_and_version(
- learning_package_id=self.learning_package.id,
- key="section:key",
- title="Section",
- created=self.now,
- created_by=None,
- )
- section_version_v2 = content_api.create_next_section_version(
- section=section,
- title="Section",
- subsections=[self.subsection_1, self.subsection_2],
- created=self.now,
- created_by=None,
- force_version_num=5, # Forcing the next version number to be 5 (instead of the usual 2)
- )
- assert section_version_v2.version_num == 5
-
- def test_auto_publish_children(self):
- """
- Test that publishing a section publishes its child subsections automatically.
- """
- # Create a draft section with two draft subsections
+ def test_get_section(self) -> None:
+ """Test `get_section()`"""
section = self.create_section_with_subsections([self.subsection_1, self.subsection_2])
- # Also create another subsection that's not in the section at all:
- other_subsection, _os_v1 = self.create_subsection(
- title="A draft subsection not in the section", key="subsection:3"
- )
-
- assert content_api.contains_unpublished_changes(section.pk)
- assert self.subsection_1.versioning.published is None
- assert self.subsection_2.versioning.published is None
- # Publish ONLY the section. This should however also auto-publish subsections 1 & 2 since they're children
- content_api.publish_from_drafts(
- self.learning_package.pk,
- draft_qset=content_api.get_all_drafts(self.learning_package.pk).filter(entity=section.publishable_entity),
- )
- # Now all changes to the section and to subsection 1 are published:
- section.refresh_from_db()
- self.subsection_1.refresh_from_db()
- assert section.versioning.has_unpublished_changes is False # Shallow check
- assert self.subsection_1.versioning.has_unpublished_changes is False
- assert content_api.contains_unpublished_changes(section.pk) is False # Deep check
- assert self.subsection_1.versioning.published == self.subsection_1_v1 # v1 is now the published version.
-
- # But our other subsection that's outside the section is not affected:
- other_subsection.refresh_from_db()
- assert other_subsection.versioning.has_unpublished_changes
- assert other_subsection.versioning.published is None
+ section_retrieved = content_api.get_section(section.pk)
+ assert isinstance(section_retrieved, Section)
+ assert section_retrieved == section
- def test_no_publish_parent(self):
- """
- Test that publishing a subsection does NOT publish changes to its parent section
- """
- # Create a draft section with two draft subsections
- section = self.create_section_with_subsections([self.subsection_1, self.subsection_2])
- assert section.versioning.has_unpublished_changes
- # Publish ONLY one of its child subsections
- self.publish_subsection(self.subsection_1)
- self.subsection_1.refresh_from_db() # Clear cache on '.versioning'
- assert self.subsection_1.versioning.has_unpublished_changes is False
+ def test_get_section_nonexistent(self) -> None:
+ """Test `get_section()` when the subsection doesn't exist"""
+ with pytest.raises(Section.DoesNotExist):
+ content_api.get_section(-500)
- # The section that contains that subsection should still be unpublished:
- section.refresh_from_db() # Clear cache on '.versioning'
- assert section.versioning.has_unpublished_changes
- assert section.versioning.published is None
- with pytest.raises(authoring_models.ContainerVersion.DoesNotExist):
- # There is no published version of the section:
- content_api.get_subsections_in_section(section, published=True)
+ def test_get_section_other_container_type(self) -> None:
+ """Test `get_section()` when the provided PK is for a non-Subsection container"""
+ with pytest.raises(Section.DoesNotExist):
+ content_api.get_section(self.unit_1.pk)
- def test_add_subsection_after_publish(self):
+ def test_section_queries(self) -> None:
"""
- Adding a subsection to a published section will create a new version and
- show that the section has unpublished changes.
+ Test the number of queries needed for each part of the sections API
"""
- section, section_version = content_api.create_section_and_version(
- learning_package_id=self.learning_package.id,
- key="section:key",
- title="Section",
- created=self.now,
- created_by=None,
- )
- assert section.versioning.draft == section_version
- assert section.versioning.published is None
- assert section.versioning.has_unpublished_changes
- # Publish the empty section:
- content_api.publish_all_drafts(self.learning_package.id)
- section.refresh_from_db() # Reloading the section is necessary
- assert section.versioning.has_unpublished_changes is False # Shallow check for the section itself, not children
- assert content_api.contains_unpublished_changes(section.pk) is False # Deeper check
-
- # Add a published subsection (unpinned):
- assert self.subsection_1.versioning.has_unpublished_changes is False
- section_version_v2 = content_api.create_next_section_version(
- section=section,
- title=section_version.title,
- subsections=[self.subsection_1],
- created=self.now,
- created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.APPEND,
- )
- # Now the section should have unpublished changes:
- section.refresh_from_db() # Reloading the section is necessary
- assert section.versioning.has_unpublished_changes # Shallow check - adding a child is a change to the section
- assert content_api.contains_unpublished_changes(section.pk) # Deeper check
- assert section.versioning.draft == section_version_v2
- assert section.versioning.published == section_version
-
- def test_modify_unpinned_subsection_after_publish(self):
- """
- Modifying an unpinned subsection in a published section will NOT create a
- new version nor show that the section has unpublished changes (but it will
- "contain" unpublished changes). The modifications will appear in the
- published version of the section only after the subsection is published.
- """
- # Create a section with one unpinned draft subsection:
- assert self.subsection_1.versioning.has_unpublished_changes
- section = self.create_section_with_subsections([self.subsection_1])
- assert section.versioning.has_unpublished_changes
-
- # Publish the section and the subsection:
- content_api.publish_all_drafts(self.learning_package.id)
- section.refresh_from_db() # Reloading the section is necessary if we accessed 'versioning' before publish
- self.subsection_1.refresh_from_db()
- assert section.versioning.has_unpublished_changes is False # Shallow check
- assert content_api.contains_unpublished_changes(section.pk) is False # Deeper check
- assert self.subsection_1.versioning.has_unpublished_changes is False
-
- # Now modify the subsection by changing its title (it remains a draft):
- subsection_1_v2 = self.modify_subsection(self.subsection_1, title="Modified Counting Problem with new title")
-
- # The subsection now has unpublished changes; the section doesn't directly but does contain
- section.refresh_from_db() # Reloading the section is necessary, or 'section.versioning' will be outdated
- self.subsection_1.refresh_from_db()
- assert section.versioning.has_unpublished_changes is False # Shallow check should be false - section unchanged
- assert content_api.contains_unpublished_changes(section.pk) # But section DOES contain changes
- assert self.subsection_1.versioning.has_unpublished_changes
-
- # Since the subsection changes haven't been published, they should only appear in the draft section
- assert content_api.get_subsections_in_section(section, published=False) == [
- Entry(subsection_1_v2), # new version
- ]
- assert content_api.get_subsections_in_section(section, published=True) == [
- Entry(self.subsection_1_v1), # old version
- ]
-
- # But if we publish the subsection, the changes will appear in the published version of the section.
- self.publish_subsection(self.subsection_1)
- assert content_api.get_subsections_in_section(section, published=False) == [
- Entry(subsection_1_v2), # new version
- ]
- assert content_api.get_subsections_in_section(section, published=True) == [
- Entry(subsection_1_v2), # new version
- ]
- assert content_api.contains_unpublished_changes(section.pk) is False # No longer contains unpublished changes
-
- def test_modify_pinned_subsection(self):
- """
- When a pinned π subsection in section is modified and/or published, it will
- have no effect on either the draft nor published version of the section,
- which will continue to use the pinned version.
- """
- # Create a section with one subsection (pinned π to v1):
- section = self.create_section_with_subsections([self.subsection_1_v1])
-
- # Publish the section and the subsection:
- content_api.publish_all_drafts(self.learning_package.id)
- expected_section_contents = [
- Entry(self.subsection_1_v1, pinned=True), # pinned π to v1
- ]
- assert content_api.get_subsections_in_section(section, published=True) == expected_section_contents
-
- # Now modify the subsection by changing its title (it remains a draft):
- self.modify_subsection(self.subsection_1, title="Modified Counting Problem with new title")
-
- # The subsection now has unpublished changes; the section is entirely unaffected
- section.refresh_from_db() # Reloading the section is necessary, or 'section.versioning' will be outdated
- self.subsection_1.refresh_from_db()
- assert section.versioning.has_unpublished_changes is False # Shallow check
- assert content_api.contains_unpublished_changes(section.pk) is False # Deep check
- assert self.subsection_1.versioning.has_unpublished_changes is True
-
- # Neither the draft nor the published version of the section is affected
- assert content_api.get_subsections_in_section(section, published=False) == expected_section_contents
- assert content_api.get_subsections_in_section(section, published=True) == expected_section_contents
- # Even if we publish the subsection, the section stays pinned to the specified version:
- self.publish_subsection(self.subsection_1)
- assert content_api.get_subsections_in_section(section, published=False) == expected_section_contents
- assert content_api.get_subsections_in_section(section, published=True) == expected_section_contents
-
- def test_create_two_sections_with_same_subsections(self):
- """
- Test creating two sections with different combinations of the same two
- subsections in each section.
- """
- # Create a section with subsection 2 unpinned, subsection 2 pinned π, and subsection 1:
- section1 = self.create_section_with_subsections(
- [self.subsection_2, self.subsection_2_v1, self.subsection_1], key="u1"
- )
- # Create a second section with subsection 1 pinned π, subsection 2, and subsection 1 unpinned:
- section2 = self.create_section_with_subsections(
- [self.subsection_1_v1, self.subsection_2, self.subsection_1], key="u2"
- )
-
- # Check that the contents are as expected:
- assert [
- row.subsection_version for row in content_api.get_subsections_in_section(section1, published=False)
- ] == [self.subsection_2_v1, self.subsection_2_v1, self.subsection_1_v1,]
- assert [
- row.subsection_version for row in content_api.get_subsections_in_section(section2, published=False)
- ] == [self.subsection_1_v1, self.subsection_2_v1, self.subsection_1_v1,]
-
- # Modify subsection 1
- subsection_1_v2 = self.modify_subsection(self.subsection_1, title="subsection 1 v2")
- # Publish changes
- content_api.publish_all_drafts(self.learning_package.id)
- # Modify subsection 2 - only in the draft
- subsection_2_v2 = self.modify_subsection(self.subsection_2, title="subsection 2 DRAFT")
-
- # Check that the draft contents are as expected:
- assert content_api.get_subsections_in_section(section1, published=False) == [
- Entry(subsection_2_v2), # v2 in the draft version
- Entry(self.subsection_2_v1, pinned=True), # pinned π to v1
- Entry(subsection_1_v2), # v2
- ]
- assert content_api.get_subsections_in_section(section2, published=False) == [
- Entry(self.subsection_1_v1, pinned=True), # pinned π to v1
- Entry(subsection_2_v2), # v2 in the draft version
- Entry(subsection_1_v2), # v2
- ]
-
- # Check that the published contents are as expected:
- assert content_api.get_subsections_in_section(section1, published=True) == [
- Entry(self.subsection_2_v1), # v1 in the published version
- Entry(self.subsection_2_v1, pinned=True), # pinned π to v1
- Entry(subsection_1_v2), # v2
- ]
- assert content_api.get_subsections_in_section(section2, published=True) == [
- Entry(self.subsection_1_v1, pinned=True), # pinned π to v1
- Entry(self.subsection_2_v1), # v1 in the published version
- Entry(subsection_1_v2), # v2
- ]
-
- def test_publishing_shared_subsection(self):
- """
- A complex test case involving two sections with a shared subsection and
- other non-shared subsections.
-
- Section 1: subsections C1, C2, C3
- Section 2: subsections C2, C4, C5
- Everything is "unpinned".
- """
- # 1οΈβ£ Create the sections and publish them:
- (s1, s1_v1), (s2, _s2_v1), (s3, s3_v1), (s4, s4_v1), (s5, s5_v1) = [
- self.create_subsection(key=f"C{i}", title=f"Subsection {i}") for i in range(1, 6)
- ]
- section1 = self.create_section_with_subsections([s1, s2, s3], title="Section 1", key="section:1")
- section2 = self.create_section_with_subsections([s2, s4, s5], title="Section 2", key="section:2")
- content_api.publish_all_drafts(self.learning_package.id)
- assert content_api.contains_unpublished_changes(section1.pk) is False
- assert content_api.contains_unpublished_changes(section2.pk) is False
-
- # 2οΈβ£ Then the author edits S2 inside of Section 1 making S2v2.
- s2_v2 = self.modify_subsection(s2, title="U2 version 2")
- # This makes S1, S2 both show up as Sections that CONTAIN unpublished changes, because they share the subsection
- assert content_api.contains_unpublished_changes(section1.pk)
- assert content_api.contains_unpublished_changes(section2.pk)
- # (But the sections themselves are unchanged:)
- section1.refresh_from_db()
- section2.refresh_from_db()
- assert section1.versioning.has_unpublished_changes is False
- assert section2.versioning.has_unpublished_changes is False
-
- # 3οΈβ£ In addition to this, the author also modifies another subsection in Section 2 (U5)
- s5_v2 = self.modify_subsection(s5, title="S5 version 2")
-
- # 4οΈβ£ The author then publishes Section 1, and therefore everything in it.
- content_api.publish_from_drafts(
- self.learning_package.pk,
- draft_qset=content_api.get_all_drafts(self.learning_package.pk).filter(
- # Note: we only publish the section; the publishing API should auto-publish its subsections too.
- entity_id=section1.publishable_entity.id,
- ),
- )
-
- # Result: Section 1 will show the newly published version of U2:
- assert content_api.get_subsections_in_section(section1, published=True) == [
- Entry(s1_v1),
- Entry(s2_v2), # new published version of U2
- Entry(s3_v1),
- ]
-
- # Result: someone looking at Section 2 should see the newly published subsection 2,
- # because publishing it anywhere publishes it everywhere.
- # But publishing U2 and Section 1 does not affect the other subsections in Section 2.
- # (Publish propagates downward, not upward)
- assert content_api.get_subsections_in_section(section2, published=True) == [
- Entry(s2_v2), # new published version of U2
- Entry(s4_v1), # still original version of U4 (it was never modified)
- Entry(s5_v1), # still original version of U5 (it hasn't been published)
- ]
-
- # Result: Section 2 CONTAINS unpublished changes because of the modified U5.
- # Section 1 doesn't contain unpub changes.
- assert content_api.contains_unpublished_changes(section1.pk) is False
- assert content_api.contains_unpublished_changes(section2.pk)
-
- # 5οΈβ£ Publish subsection U5, which should be the only thing unpublished in the learning package
- self.publish_subsection(s5)
- # Result: Section 2 shows the new version of C5 and no longer contains unpublished changes:
- assert content_api.get_subsections_in_section(section2, published=True) == [
- Entry(s2_v2), # new published version of U2
- Entry(s4_v1), # still original version of U4 (it was never modified)
- Entry(s5_v2), # new published version of U5
- ]
- assert content_api.contains_unpublished_changes(section2.pk) is False
-
- def test_query_count_of_contains_unpublished_changes(self):
- """
- Checking for unpublished changes in a section should require a fixed number
- of queries, not get more expensive as the section gets larger.
- """
- # Add 2 subsections (unpinned)
- subsection_count = 2
- subsections = []
- for i in range(subsection_count):
- subsection, _version = self.create_subsection(
- key=f"Subsection {i}",
- title=f"Subsection {i}",
+ with self.assertNumQueries(37):
+ section = self.create_section_with_subsections([self.subsection_1, self.subsection_2_v1])
+ with self.assertNumQueries(160):
+ content_api.publish_from_drafts(
+ self.learning_package.id,
+ draft_qset=content_api.get_all_drafts(self.learning_package.id).filter(entity=section.pk),
)
- subsections.append(subsection)
- section = self.create_section_with_subsections(subsections)
- content_api.publish_all_drafts(self.learning_package.id)
- section.refresh_from_db()
- with self.assertNumQueries(1):
- assert content_api.contains_unpublished_changes(section.pk) is False
-
- # Modify the most recently created subsection:
- self.modify_subsection(subsection, title="Modified Subsection")
- with self.assertNumQueries(1):
- assert content_api.contains_unpublished_changes(section.pk) is True
-
- def test_metadata_change_doesnt_create_entity_list(self):
- """
- Test that changing a container's metadata like title will create a new
- version, but can re-use the same EntityList. API consumers generally
- shouldn't depend on this behavior; it's an optimization.
- """
- section = self.create_section_with_subsections([self.subsection_1, self.subsection_2_v1])
-
- orig_version_num = section.versioning.draft.version_num
- orig_entity_list_id = section.versioning.draft.entity_list.pk
-
- content_api.create_next_section_version(section, title="New Title", created=self.now)
-
- section.refresh_from_db()
- new_version_num = section.versioning.draft.version_num
- new_entity_list_id = section.versioning.draft.entity_list.pk
-
- assert new_version_num > orig_version_num
- assert new_entity_list_id == orig_entity_list_id
-
- def test_removing_subsection(self):
- """ Test removing a subsection from a section (but not deleting it) """
- section = self.create_section_with_subsections([self.subsection_1, self.subsection_2])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now remove subsection 2
- content_api.create_next_section_version(
- section=section,
- title="Revised with subsection 2 deleted",
- subsections=[self.subsection_2],
- created=self.now,
- entities_action=content_api.ChildrenEntitiesAction.REMOVE,
- )
-
- # Now it should not be listed in the section:
- assert content_api.get_subsections_in_section(section, published=False) == [
- Entry(self.subsection_1_v1),
- ]
- section.refresh_from_db()
- assert section.versioning.has_unpublished_changes # The section itself and its subsection list have change
- assert content_api.contains_unpublished_changes(section.pk)
- # The published version of the section is not yet affected:
- assert content_api.get_subsections_in_section(section, published=True) == [
- Entry(self.subsection_1_v1),
- Entry(self.subsection_2_v1),
- ]
-
- # But when we publish the new section version with the removal, the published version is affected:
- content_api.publish_all_drafts(self.learning_package.id)
- # FIXME: Refreshing the section is necessary here because get_entities_in_published_container() accesses
- # container.versioning.published, and .versioning is cached with the old version. But this seems like
- # a footgun? We could avoid this if get_entities_in_published_container() took only an ID instead of an object,
- # but that would involve additional database lookup(s).
- section.refresh_from_db()
- assert content_api.contains_unpublished_changes(section.pk) is False
- assert content_api.get_subsections_in_section(section, published=True) == [
- Entry(self.subsection_1_v1),
- ]
-
- def test_soft_deleting_subsection(self):
- """ Test soft deleting a subsection that's in a section (but not removing it) """
- section = self.create_section_with_subsections([self.subsection_1, self.subsection_2])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now soft delete subsection 2
- content_api.soft_delete_draft(self.subsection_2.pk)
-
- # Now it should not be listed in the section:
- assert content_api.get_subsections_in_section(section, published=False) == [
- Entry(self.subsection_1_v1),
- # subsection 2 is soft deleted from the draft.
- # TODO: should we return some kind of placeholder here, to indicate that a subsection is still listed in the
- # section's subsection list but has been soft deleted, and will be fully deleted when published,
- # or restored if reverted?
- ]
- assert section.versioning.has_unpublished_changes is False # The section and its subsection list is not changed
- assert content_api.contains_unpublished_changes(section.pk) # But it CONTAINS unpublished change (deletion)
- # The published version of the section is not yet affected:
- assert content_api.get_subsections_in_section(section, published=True) == [
- Entry(self.subsection_1_v1),
- Entry(self.subsection_2_v1),
- ]
-
- # But when we publish the deletion, the published version is affected:
- content_api.publish_all_drafts(self.learning_package.id)
- assert content_api.contains_unpublished_changes(section.pk) is False
- assert content_api.get_subsections_in_section(section, published=True) == [
- Entry(self.subsection_1_v1),
- ]
-
- def test_soft_deleting_and_removing_subsection(self):
- """ Test soft deleting a subsection that's in a section AND removing it """
- section = self.create_section_with_subsections([self.subsection_1, self.subsection_2])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now soft delete subsection 2
- content_api.soft_delete_draft(self.subsection_2.pk)
- # And remove it from the section:
- content_api.create_next_section_version(
- section=section,
- title="Revised with subsection 2 deleted",
- subsections=[self.subsection_2],
- created=self.now,
- entities_action=content_api.ChildrenEntitiesAction.REMOVE,
- )
-
- # Now it should not be listed in the section:
- assert content_api.get_subsections_in_section(section, published=False) == [
- Entry(self.subsection_1_v1),
- ]
- assert section.versioning.has_unpublished_changes is True
- assert content_api.contains_unpublished_changes(section.pk)
- # The published version of the section is not yet affected:
- assert content_api.get_subsections_in_section(section, published=True) == [
- Entry(self.subsection_1_v1),
- Entry(self.subsection_2_v1),
- ]
-
- # But when we publish the deletion, the published version is affected:
- content_api.publish_all_drafts(self.learning_package.id)
- assert content_api.contains_unpublished_changes(section.pk) is False
- assert content_api.get_subsections_in_section(section, published=True) == [
- Entry(self.subsection_1_v1),
- ]
-
- def test_soft_deleting_pinned_subsection(self):
- """ Test soft deleting a pinned π subsection that's in a section """
- section = self.create_section_with_subsections([self.subsection_1_v1, self.subsection_2_v1])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now soft delete subsection 2
- content_api.soft_delete_draft(self.subsection_2.pk)
-
- # Now it should still be listed in the section:
- assert content_api.get_subsections_in_section(section, published=False) == [
- Entry(self.subsection_1_v1, pinned=True),
- Entry(self.subsection_2_v1, pinned=True),
- ]
- assert section.versioning.has_unpublished_changes is False # The section and its subsection list is not changed
- assert content_api.contains_unpublished_changes(section.pk) is False # nor does it contain changes
- # The published version of the section is also not affected:
- assert content_api.get_subsections_in_section(section, published=True) == [
- Entry(self.subsection_1_v1, pinned=True),
- Entry(self.subsection_2_v1, pinned=True),
- ]
-
- def test_soft_delete_section(self):
- """
- I can delete a section without deleting the subsections it contains.
-
- See https://github.com/openedx/frontend-app-authoring/issues/1693
- """
- # Create two sections, one of which we will soon delete:
- section_to_delete = self.create_section_with_subsections([self.subsection_1, self.subsection_2])
- other_section = self.create_section_with_subsections([self.subsection_1], key="other")
-
- # Publish everything:
- content_api.publish_all_drafts(self.learning_package.id)
- # Delete the section:
- content_api.soft_delete_draft(section_to_delete.publishable_entity_id)
- section_to_delete.refresh_from_db()
- # Now draft section is [soft] deleted, but the subsections, published section, and other section is unaffected:
- assert section_to_delete.versioning.draft is None # Section is soft deleted.
- assert section_to_delete.versioning.published is not None
- self.subsection_1.refresh_from_db()
- assert self.subsection_1.versioning.draft is not None
- assert content_api.get_subsections_in_section(other_section, published=False) == [Entry(self.subsection_1_v1)]
-
- # Publish everything:
- content_api.publish_all_drafts(self.learning_package.id)
- # Now the section's published version is also deleted, but nothing else is affected.
- section_to_delete.refresh_from_db()
- assert section_to_delete.versioning.draft is None # Section is soft deleted.
- assert section_to_delete.versioning.published is None
- self.subsection_1.refresh_from_db()
- assert self.subsection_1.versioning.draft is not None
- assert self.subsection_1.versioning.published is not None
- assert content_api.get_subsections_in_section(other_section, published=False) == [Entry(self.subsection_1_v1)]
- assert content_api.get_subsections_in_section(other_section, published=True) == [Entry(self.subsection_1_v1)]
-
- def test_snapshots_of_published_section(self):
- """
- Test that we can access snapshots of the historic published version of
- sections and their contents.
- """
- # At first the section has one subsection (unpinned):
- section = self.create_section_with_subsections([self.subsection_1])
- self.modify_subsection(self.subsection_1, title="Subsection 1 as of checkpoint 1")
- before_publish = content_api.get_subsections_in_published_section_as_of(section, 0)
- assert before_publish is None
-
- # Publish everything, creating Checkpoint 1
- checkpoint_1 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 1")
-
- ########################################################################
-
- # Now we update the title of the subsection.
- self.modify_subsection(self.subsection_1, title="Subsection 1 as of checkpoint 2")
- # Publish everything, creating Checkpoint 2
- checkpoint_2 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 2")
- ########################################################################
-
- # Now add a second subsection to the section:
- self.modify_subsection(self.subsection_1, title="Subsection 1 as of checkpoint 3")
- self.modify_subsection(self.subsection_2, title="Subsection 2 as of checkpoint 3")
- content_api.create_next_section_version(
- section=section,
- title="Section title in checkpoint 3",
- subsections=[self.subsection_1, self.subsection_2],
- created=self.now,
- )
- # Publish everything, creating Checkpoint 3
- checkpoint_3 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 3")
- ########################################################################
-
- # Now add a third subsection to the section, a pinned π version of subsection 1.
- # This will test pinned versions and also test adding at the beginning rather than the end of the section.
- content_api.create_next_section_version(
- section=section,
- title="Section title in checkpoint 4",
- subsections=[self.subsection_1_v1, self.subsection_1, self.subsection_2],
- created=self.now,
- )
- # Publish everything, creating Checkpoint 4
- checkpoint_4 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 4")
- ########################################################################
-
- # Modify the drafts, but don't publish:
- self.modify_subsection(self.subsection_1, title="Subsection 1 draft")
- self.modify_subsection(self.subsection_2, title="Subsection 2 draft")
-
- # Now fetch the snapshots:
- as_of_checkpoint_1 = content_api.get_subsections_in_published_section_as_of(section, checkpoint_1.pk)
- assert [cv.subsection_version.title for cv in as_of_checkpoint_1] == [
- "Subsection 1 as of checkpoint 1",
- ]
- as_of_checkpoint_2 = content_api.get_subsections_in_published_section_as_of(section, checkpoint_2.pk)
- assert [cv.subsection_version.title for cv in as_of_checkpoint_2] == [
- "Subsection 1 as of checkpoint 2",
- ]
- as_of_checkpoint_3 = content_api.get_subsections_in_published_section_as_of(section, checkpoint_3.pk)
- assert [cv.subsection_version.title for cv in as_of_checkpoint_3] == [
- "Subsection 1 as of checkpoint 3",
- "Subsection 2 as of checkpoint 3",
- ]
- as_of_checkpoint_4 = content_api.get_subsections_in_published_section_as_of(section, checkpoint_4.pk)
- assert [cv.subsection_version.title for cv in as_of_checkpoint_4] == [
- "Subsection (1)", # Pinned. This title is self.subsection_1_v1.title (original v1 title)
- "Subsection 1 as of checkpoint 3", # we didn't modify these subsections so they're same as in snapshot 3
- "Subsection 2 as of checkpoint 3", # we didn't modify these subsections so they're same as in snapshot 3
- ]
-
- def test_sections_containing(self):
- """
- Test that we can efficiently get a list of all the [draft] sections
- containing a given subsection.
- """
- subsection_1_v2 = self.modify_subsection(self.subsection_1, title="modified subsection 1")
-
- # Create a few sections, some of which contain subsection 1 and others which don't:
- # Note: it is important that some of these sections contain other subsections, to ensure complex JOINs required
- # for this query are working correctly, especially in the case of ignore_pinned=True.
- # Section 1 β
has subsection 1, pinned π to V1
- section1_1pinned = self.create_section_with_subsections([self.subsection_1_v1, self.subsection_2], key="s1")
- # Section 2 β
has subsection 1, pinned π to V2
- section2_1pinned_v2 = self.create_section_with_subsections([subsection_1_v2, self.subsection_2_v1], key="s2")
- # Section 3 doesn't contain it
- _section3_no = self.create_section_with_subsections([self.subsection_2], key="s3")
- # Section 4 β
has subsection 1, unpinned
- section4_unpinned = self.create_section_with_subsections([
- self.subsection_1, self.subsection_2, self.subsection_2_v1,
- ], key="s4")
- # Sections 5/6 don't contain it
- _section5_no = self.create_section_with_subsections([self.subsection_2_v1, self.subsection_2], key="s5")
- _section6_no = self.create_section_with_subsections([], key="s6")
-
- # No need to publish anything as the get_containers_with_entity() API only considers drafts (for now).
-
- with self.assertNumQueries(1):
- result = [
- c.section for c in
- content_api.get_containers_with_entity(self.subsection_1.pk).select_related("section")
- ]
- assert result == [
- section1_1pinned,
- section2_1pinned_v2,
- section4_unpinned,
- ]
-
- # Test retrieving only "unpinned", for cases like potential deletion of a subsection, where we wouldn't care
- # about pinned uses anyways (they would be unaffected by a delete).
-
- with self.assertNumQueries(1):
- result2 = [
- c.section for c in
- content_api.get_containers_with_entity(
- self.subsection_1.pk, ignore_pinned=True
- ).select_related("section")
- ]
- assert result2 == [section4_unpinned]
-
- def test_get_subsections_in_section_queries(self):
- """
- Test the query count of get_subsections_in_section()
- This also tests the generic method get_entities_in_container()
- """
- section = self.create_section_with_subsections([
- self.subsection_1,
- self.subsection_2,
- self.subsection_2_v1,
- ])
- with self.assertNumQueries(4):
- result = content_api.get_subsections_in_section(section, published=False)
- assert result == [
- Entry(self.subsection_1.versioning.draft),
- Entry(self.subsection_2.versioning.draft),
- Entry(self.subsection_2.versioning.draft, pinned=True),
- ]
- content_api.publish_all_drafts(self.learning_package.id)
with self.assertNumQueries(4):
result = content_api.get_subsections_in_section(section, published=True)
assert result == [
- Entry(self.subsection_1.versioning.draft),
- Entry(self.subsection_2.versioning.draft),
- Entry(self.subsection_2.versioning.draft, pinned=True),
+ Entry(self.subsection_1_v1),
+ Entry(self.subsection_2_v1, pinned=True),
]
- def test_add_remove_container_children(self):
+ def test_create_section_with_invalid_children(self):
"""
- Test adding and removing children subsections from sections.
+ Verify that only subsections can be added to sections, and a specific exception is raised.
"""
- section, section_version = content_api.create_section_and_version(
- learning_package_id=self.learning_package.id,
- key="section:key",
- title="Section",
- subsections=[self.subsection_1],
- created=self.now,
- created_by=None,
- )
- assert content_api.get_subsections_in_section(section, published=False) == [
- Entry(self.subsection_1.versioning.draft),
- ]
- subsection_3, _ = self.create_subsection(
- key="Subsection (3)",
- title="Subsection (3)",
- )
- # Add subsection_2 and subsection_3
- section_version_v2 = content_api.create_next_section_version(
- section=section,
- title=section_version.title,
- subsections=[self.subsection_2, subsection_3],
- created=self.now,
- created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.APPEND,
- )
+ # Create a section:
+ section = self.create_section_with_subsections([])
+ section_version = section.versioning.draft
+ # Try adding a Unit to a Section
+ with pytest.raises(
+ ValidationError,
+ match='The entity "unit_1" cannot be added to a "section" container.',
+ ):
+ content_api.create_next_section_version(
+ section,
+ subsections=[self.unit_1],
+ created=self.now,
+ created_by=None,
+ )
+ # Check that a new version was not created:
section.refresh_from_db()
- assert section_version_v2.version_num == 2
- assert section_version_v2 in section.versioning.versions.all()
- # Verify that subsection_2 and subsection_3 is added to end
- assert content_api.get_subsections_in_section(section, published=False) == [
- Entry(self.subsection_1.versioning.draft),
- Entry(self.subsection_2.versioning.draft),
- Entry(subsection_3.versioning.draft),
- ]
+ assert content_api.get_section(section.pk).versioning.draft == section_version
+ assert section.versioning.draft == section_version
- # Remove subsection_1
- content_api.create_next_section_version(
- section=section,
- title=section_version.title,
- subsections=[self.subsection_1],
- created=self.now,
- created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.REMOVE,
- )
- section.refresh_from_db()
- # Verify that subsection_1 is removed
- assert content_api.get_subsections_in_section(section, published=False) == [
- Entry(self.subsection_2.versioning.draft),
- Entry(subsection_3.versioning.draft),
- ]
+ # Also check that `create_section_with_subsections()` has the same restriction
+ # (not just `create_next_subsection_version()`)
+ with pytest.raises(
+ ValidationError,
+ match='The entity "unit_1" cannot be added to a "section" container.',
+ ):
+ self.create_section_with_subsections([self.unit_1], key="unit:key3", title="Unit 3")
- def test_get_container_children_count(self):
- """
- Test get_container_children_count()
- """
- section = self.create_section_with_subsections([self.subsection_1])
- assert content_api.get_container_children_count(section.container, published=False) == 1
- # publish
- content_api.publish_all_drafts(self.learning_package.id)
- section_version = section.versioning.draft
- content_api.create_next_section_version(
- section=section,
- title=section_version.title,
- subsections=[self.subsection_2],
- created=self.now,
- created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.APPEND,
- )
- section.refresh_from_db()
- # Should have two subsections in draft version and 1 in published version
- assert content_api.get_container_children_count(section.container, published=False) == 2
- assert content_api.get_container_children_count(section.container, published=True) == 1
- # publish
- content_api.publish_all_drafts(self.learning_package.id)
- section.refresh_from_db()
- assert content_api.get_container_children_count(section.container, published=True) == 2
- # Soft delete subsection_1
- content_api.soft_delete_draft(self.subsection_1.pk)
- section.refresh_from_db()
- # Should contain only 1 child
- assert content_api.get_container_children_count(section.container, published=False) == 1
- content_api.publish_all_drafts(self.learning_package.id)
- section.refresh_from_db()
- assert content_api.get_container_children_count(section.container, published=True) == 1
+ def test_is_registered(self):
+ assert Section in content_api.get_all_container_subclasses()
- # Tests TODO:
- # Test that I can get a [PublishLog] history of a given section and all its children, including children that aren't
- # currently in the section and excluding children that are only in other sections.
- # Test that I can get a [PublishLog] history of a given section and its children, that includes changes made to the
- # child subsections while they were part of section but excludes changes made to those children while they were
- # not part of the section. π«£
+ def test_olx_tag_name(self):
+ assert content_api.get_container_subclass("section") is Section
+ assert content_api.get_container_subclass("section").olx_tag_name == "chapter"
diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py
index 8d93e1085..8f13f2f2c 100644
--- a/tests/openedx_content/applets/subsections/test_api.py
+++ b/tests/openedx_content/applets/subsections/test_api.py
@@ -1,59 +1,48 @@
"""
Basic tests for the subsections API.
"""
-from unittest.mock import patch
-import ddt # type: ignore[import]
import pytest
from django.core.exceptions import ValidationError
-from django.db import IntegrityError
import openedx_content.api as content_api
-from openedx_content import models_api as authoring_models
+from openedx_content.models_api import Subsection, SubsectionVersion, Unit, UnitVersion
-from ..units.test_api import UnitTestCase
+from ..components.test_api import ComponentTestCase
Entry = content_api.SubsectionListEntry
-# TODO: Turn UnitTestCase into UnitTestMixin and remove the
-# test-inherits-tests pylint warning below.
-# https://github.com/openedx/openedx-core/issues/308
-@ddt.ddt
-class SubSectionTestCase(UnitTestCase): # pylint: disable=test-inherits-tests
- """ Test cases for Subsections (containers of units) """
+class SubsectionsTestCase(ComponentTestCase):
+ """Test cases for Subsections (containers of units)"""
def setUp(self) -> None:
super().setUp()
- self.unit_1, self.unit_1_v1 = self.create_unit(
- key="Unit (1)",
- title="Unit (1)",
+ self.component_1, self.component_1_v1 = self.create_component(
+ key="Query Counting",
+ title="Querying Counting Problem",
)
- self.unit_2, self.unit_2_v1 = self.create_unit(
- key="Unit (2)",
- title="Unit (2)",
+ self.component_2, self.component_2_v1 = self.create_component(
+ key="Query Counting (2)",
+ title="Querying Counting Problem (2)",
)
-
- def create_unit(self, *, title: str = "Test Unit", key: str = "unit:1") -> tuple[
- authoring_models.Unit, authoring_models.UnitVersion
- ]:
- """ Helper method to quickly create a unit """
- return content_api.create_unit_and_version(
- self.learning_package.id,
- key=key,
- title=title,
+ self.unit_1, self.unit_1_v1 = content_api.create_unit_and_version(
+ learning_package_id=self.learning_package.id,
+ key="unit1",
+ title="Unit 1",
+ components=[self.component_1, self.component_2],
created=self.now,
created_by=None,
)
def create_subsection_with_units(
self,
- units: list[authoring_models.Unit | authoring_models.UnitVersion],
+ units: list[Unit | UnitVersion],
*,
- title="Unit",
- key="unit:key",
- ) -> authoring_models.Subsection:
- """ Helper method to quickly create a subsection with some units """
+ title="Subsection",
+ key="subsection:key",
+ ) -> Subsection:
+ """Helper method to quickly create a unit with some units"""
subsection, _subsection_v1 = content_api.create_subsection_and_version(
learning_package_id=self.learning_package.id,
key=key,
@@ -64,241 +53,6 @@ def create_subsection_with_units(
)
return subsection
- def modify_unit(
- self,
- unit: authoring_models.Unit,
- *,
- title="Modified Unit",
- timestamp=None,
- ) -> authoring_models.UnitVersion:
- """
- Helper method to modify a unit for the purposes of testing units/drafts/pinning/publishing/etc.
- """
- return content_api.create_next_unit_version(
- unit,
- title=title,
- created=timestamp or self.now,
- created_by=None,
- )
-
- def publish_unit(self, unit: authoring_models.Unit):
- """
- Helper method to publish a single unit.
- """
- content_api.publish_from_drafts(
- self.learning_package.pk,
- draft_qset=content_api.get_all_drafts(self.learning_package.pk).filter(
- entity=unit.publishable_entity,
- ),
- )
-
- def test_get_subsection(self):
- """
- Test get_subsection()
- """
- subsection = self.create_subsection_with_units([self.unit_1, self.unit_2])
- with self.assertNumQueries(1):
- result = content_api.get_subsection(subsection.pk)
- assert result == subsection
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result.versioning.has_unpublished_changes
-
- def test_get_subsection_version(self):
- """
- Test get_subsection_version()
- """
- subsection = self.create_subsection_with_units([])
- draft = subsection.versioning.draft
- with self.assertNumQueries(1):
- result = content_api.get_subsection_version(draft.pk)
- assert result == draft
-
- def test_get_latest_subsection_version(self):
- """
- Test test_get_latest_subsection_version()
- """
- subsection = self.create_subsection_with_units([])
- draft = subsection.versioning.draft
- with self.assertNumQueries(2):
- result = content_api.get_latest_subsection_version(subsection.pk)
- assert result == draft
-
- def test_get_containers(self):
- """
- Test get_containers()
- """
- subsection = self.create_subsection_with_units([])
- with self.assertNumQueries(1):
- result = list(content_api.get_containers(self.learning_package.id))
- assert result == [self.unit_1.container, self.unit_2.container, subsection.container]
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result[0].versioning.has_unpublished_changes
-
- def test_get_containers_deleted(self):
- """
- Test that get_containers() does not return soft-deleted sections.
- """
- subsection = self.create_subsection_with_units([])
- content_api.soft_delete_draft(subsection.pk)
- with self.assertNumQueries(1):
- result = list(content_api.get_containers(self.learning_package.id, include_deleted=True))
- assert result == [self.unit_1.container, self.unit_2.container, subsection.container]
-
- with self.assertNumQueries(1):
- result = list(content_api.get_containers(self.learning_package.id))
- assert result == [self.unit_1.container, self.unit_2.container]
-
- def test_get_container(self):
- """
- Test get_container()
- """
- subsection = self.create_subsection_with_units([self.unit_1, self.unit_2])
- with self.assertNumQueries(1):
- result = content_api.get_container(subsection.pk)
- assert result == subsection.container
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result.versioning.has_unpublished_changes
-
- def test_get_container_by_key(self):
- """
- Test get_container_by_key()
- """
- subsection = self.create_subsection_with_units([])
- with self.assertNumQueries(1):
- result = content_api.get_container_by_key(
- self.learning_package.id,
- key=subsection.publishable_entity.key,
- )
- assert result == subsection.container
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result.versioning.has_unpublished_changes
-
- def test_subsection_container_versioning(self):
- """
- Test that the .versioning helper of a Sebsection returns a SubsectionVersion, and
- same for the generic Container equivalent.
- """
- subsection = self.create_subsection_with_units([self.unit_1, self.unit_2])
- container = subsection.container
- container_version = container.versioning.draft
- assert isinstance(container_version, authoring_models.ContainerVersion)
- subsection_version = subsection.versioning.draft
- assert isinstance(subsection_version, authoring_models.SubsectionVersion)
- assert subsection_version.container_version == container_version
- assert subsection_version.container_version.container == container
- assert subsection_version.subsection == subsection
-
- def test_create_subsection_queries(self):
- """
- Test how many database queries are required to create a subsection
- """
- # The exact numbers here aren't too important - this is just to alert us if anything significant changes.
- with self.assertNumQueries(28):
- _empty_subsection = self.create_subsection_with_units([])
- with self.assertNumQueries(35):
- # And try with a non-empty subsection:
- self.create_subsection_with_units([self.unit_1, self.unit_2_v1], key="u2")
-
- def test_create_subsection_with_invalid_children(self):
- """
- Verify that only units can be added to subsections, and a specific
- exception is raised.
- """
- # Create two subsections:
- subsection, subsection_version = content_api.create_subsection_and_version(
- learning_package_id=self.learning_package.id,
- key="subsection:key",
- title="Subsection",
- created=self.now,
- created_by=None,
- )
- assert subsection.versioning.draft == subsection_version
- subsection2, _s2v1 = content_api.create_subsection_and_version(
- learning_package_id=self.learning_package.id,
- key="subsection:key2",
- title="Subsection 2",
- created=self.now,
- created_by=None,
- )
- # Try adding a Subsection to a Subsection
- with pytest.raises(TypeError, match="Subsection units must be either Unit or UnitVersion."):
- content_api.create_next_subsection_version(
- subsection=subsection,
- title="Subsection Containing a Subsection",
- units=[subsection2],
- created=self.now,
- created_by=None,
- )
- # Check that a new version was not created:
- subsection.refresh_from_db()
- assert content_api.get_subsection(subsection.pk).versioning.draft == subsection_version
- assert subsection.versioning.draft == subsection_version
-
- def test_adding_external_units(self):
- """
- Test that units from another learning package cannot be added to a
- subsection.
- """
- learning_package2 = content_api.create_learning_package(key="other-package", title="Other Package")
- subsection, _subsection_version = content_api.create_subsection_and_version(
- learning_package_id=learning_package2.pk,
- key="subsection:key",
- title="Subsection",
- created=self.now,
- created_by=None,
- )
- assert self.unit_1.container.publishable_entity.learning_package != learning_package2
- # Try adding a a unit from LP 1 (self.learning_package) to a subsection from LP 2
- with pytest.raises(ValidationError, match="Container entities must be from the same learning package."):
- content_api.create_next_subsection_version(
- subsection=subsection,
- title="Subsection Containing an External Unit",
- units=[self.unit_1],
- created=self.now,
- created_by=None,
- )
-
- @patch('openedx_content.applets.subsections.api._pub_entities_for_units')
- def test_adding_mismatched_versions(self, mock_entities_for_units): # pylint: disable=arguments-renamed
- """
- Test that versioned units must match their entities.
- """
- mock_entities_for_units.return_value = [
- content_api.ContainerEntityRow(
- entity_pk=self.unit_1.pk,
- version_pk=self.unit_2_v1.pk,
- ),
- ]
- # Try adding a a unit from LP 1 (self.learning_package) to a subsection from LP 2
- with pytest.raises(ValidationError, match="Container entity versions must belong to the specified entity"):
- content_api.create_subsection_and_version(
- learning_package_id=self.unit_1.container.publishable_entity.learning_package.pk,
- key="subsection:key",
- title="Subsection",
- units=[self.unit_1],
- created=self.now,
- created_by=None,
- )
-
- @ddt.data(True, False)
- @pytest.mark.skip(reason="FIXME: publishable_entity is not deleted from the database with the unit.")
- # FIXME: Also, exception is Container.DoesNotExist, not Unit.DoesNotExist
- def test_cannot_add_invalid_ids(self, pin_version):
- """
- Test that non-existent units cannot be added to subsections
- """
- self.unit_1.delete()
- if pin_version:
- units = [self.unit_1_v1]
- else:
- units = [self.unit_1]
- with pytest.raises((IntegrityError, authoring_models.Unit.DoesNotExist)):
- self.create_subsection_with_units(units)
-
def test_create_empty_subsection_and_version(self):
"""Test creating a subsection with no units.
@@ -309,12 +63,14 @@ def test_create_empty_subsection_and_version(self):
4. There is no published version of the subsection.
"""
subsection, subsection_version = content_api.create_subsection_and_version(
- learning_package_id=self.learning_package.id,
+ learning_package_id=self.learning_package.pk,
key="subsection:key",
title="Subsection",
created=self.now,
created_by=None,
)
+ assert isinstance(subsection, Subsection)
+ assert isinstance(subsection_version, SubsectionVersion)
assert subsection, subsection_version
assert subsection_version.version_num == 1
assert subsection_version in subsection.versioning.versions.all()
@@ -323,845 +79,103 @@ def test_create_empty_subsection_and_version(self):
assert subsection.versioning.published is None
assert subsection.publishable_entity.can_stand_alone
- def test_create_next_subsection_version_with_two_unpinned_units(self):
- """Test creating a subsection version with two unpinned units.
+ def test_create_next_subsection_version_with_unpinned_unit(self):
+ """Test creating a subsection version with an unpinned unit.
Expected results:
1. A new subsection version is created.
2. The subsection version number is 2.
3. The subsection version is in the subsection's versions.
- 4. The units are in the draft subsection version's unit list and are unpinned.
- """
- subsection, _subsection_version = content_api.create_subsection_and_version(
- learning_package_id=self.learning_package.id,
- key="subsection:key",
- title="Subsection",
- created=self.now,
- created_by=None,
- )
- subsection_version_v2 = content_api.create_next_subsection_version(
- subsection=subsection,
- title="Subsection",
- units=[self.unit_1, self.unit_2],
- created=self.now,
- created_by=None,
- )
- assert subsection_version_v2.version_num == 2
- assert subsection_version_v2 in subsection.versioning.versions.all()
- assert content_api.get_units_in_subsection(subsection, published=False) == [
- Entry(self.unit_1.versioning.draft),
- Entry(self.unit_2.versioning.draft),
- ]
- with pytest.raises(authoring_models.ContainerVersion.DoesNotExist):
- # There is no published version of the subsection:
- content_api.get_units_in_subsection(subsection, published=True)
-
- def test_create_next_subsection_version_forcing_version_num(self):
- """
- Test creating a subsection version while forcing the next version number.
+ 4. The unit is in the draft subsection version's unit list and is unpinned.
"""
- subsection, _subsection_version = content_api.create_subsection_and_version(
- learning_package_id=self.learning_package.id,
- key="subsection:key",
- title="Subsection",
- created=self.now,
- created_by=None,
- )
- subsection_version_v2 = content_api.create_next_subsection_version(
- subsection=subsection,
- title="Subsection",
- units=[self.unit_1, self.unit_2],
- created=self.now,
- created_by=None,
- force_version_num=4
- )
- assert subsection_version_v2.version_num == 4
-
- def test_create_next_subsection_version_with_unpinned_and_pinned_units(self):
- """
- Test creating a subsection version with one unpinned and one pinned π unit.
- """
- subsection, _subsection_version = content_api.create_subsection_and_version(
- learning_package_id=self.learning_package.id,
- key="subsection:key",
- title="Subsection",
- created=self.now,
- created_by=None,
- )
+ subsection = self.create_subsection_with_units([])
subsection_version_v2 = content_api.create_next_subsection_version(
- subsection=subsection,
+ subsection,
title="Subsection",
- units=[self.unit_1, self.unit_2_v1], # Note the "v1" pinning π the second one to version 1
+ units=[self.unit_1],
created=self.now,
created_by=None,
)
+ assert isinstance(subsection_version_v2, SubsectionVersion)
assert subsection_version_v2.version_num == 2
assert subsection_version_v2 in subsection.versioning.versions.all()
assert content_api.get_units_in_subsection(subsection, published=False) == [
Entry(self.unit_1_v1),
- Entry(self.unit_2_v1, pinned=True), # Pinned π to v1
]
- with pytest.raises(authoring_models.ContainerVersion.DoesNotExist):
- # There is no published version of the subsection:
- content_api.get_units_in_subsection(subsection, published=True)
-
- def test_auto_publish_children(self):
- """
- Test that publishing a subsection publishes its child units automatically.
- """
- # Create a draft subsection with two draft units
- subsection = self.create_subsection_with_units([self.unit_1, self.unit_2])
- # Also create another unit that's not in the subsection at all:
- other_unit, _ou_v1 = self.create_unit(title="A draft unit not in the subsection", key="unit:3")
-
- assert content_api.contains_unpublished_changes(subsection.pk)
- assert self.unit_1.versioning.published is None
- assert self.unit_2.versioning.published is None
-
- # Publish ONLY the subsection. This should however also auto-publish units 1 & 2 since they're children
- content_api.publish_from_drafts(
- self.learning_package.pk,
- draft_qset=content_api.get_all_drafts(self.learning_package.pk).filter(
- entity=subsection.publishable_entity
- ),
- )
- # Now all changes to the subsection and to unit 1 are published:
- subsection.refresh_from_db()
- self.unit_1.refresh_from_db()
- assert subsection.versioning.has_unpublished_changes is False # Shallow check
- assert self.unit_1.versioning.has_unpublished_changes is False
- assert content_api.contains_unpublished_changes(subsection.pk) is False # Deep check
- assert self.unit_1.versioning.published == self.unit_1_v1 # v1 is now the published version.
-
- # But our other unit that's outside the subsection is not affected:
- other_unit.refresh_from_db()
- assert other_unit.versioning.has_unpublished_changes
- assert other_unit.versioning.published is None
-
- def test_no_publish_parent(self):
- """
- Test that publishing a unit does NOT publish changes to its parent subsection
- """
- # Create a draft subsection with two draft units
- subsection = self.create_subsection_with_units([self.unit_1, self.unit_2])
- assert subsection.versioning.has_unpublished_changes
- # Publish ONLY one of its child units
- self.publish_unit(self.unit_1)
- self.unit_1.refresh_from_db() # Clear cache on '.versioning'
- assert self.unit_1.versioning.has_unpublished_changes is False
-
- # The subsection that contains that unit should still be unpublished:
- subsection.refresh_from_db() # Clear cache on '.versioning'
- assert subsection.versioning.has_unpublished_changes
- assert subsection.versioning.published is None
- with pytest.raises(authoring_models.ContainerVersion.DoesNotExist):
+ with pytest.raises(SubsectionVersion.DoesNotExist):
# There is no published version of the subsection:
content_api.get_units_in_subsection(subsection, published=True)
- def test_add_unit_after_publish(self):
- """
- Adding a unit to a published subsection will create a new version and
- show that the subsection has unpublished changes.
- """
- subsection, subsection_version = content_api.create_subsection_and_version(
- learning_package_id=self.learning_package.id,
- key="subsection:key",
- title="Subsection",
- created=self.now,
- created_by=None,
- )
- assert subsection.versioning.draft == subsection_version
- assert subsection.versioning.published is None
- assert subsection.versioning.has_unpublished_changes
- # Publish the empty subsection:
- content_api.publish_all_drafts(self.learning_package.id)
- subsection.refresh_from_db() # Reloading the subsection is necessary
- assert subsection.versioning.has_unpublished_changes is False # Shallow check for subsection only, not children
- assert content_api.contains_unpublished_changes(subsection.pk) is False # Deeper check
-
- # Add a published unit (unpinned):
- assert self.unit_1.versioning.has_unpublished_changes is False
- subsection_version_v2 = content_api.create_next_subsection_version(
- subsection=subsection,
- title=subsection_version.title,
- units=[self.unit_1],
- created=self.now,
- created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.APPEND,
- )
- # Now the subsection should have unpublished changes:
- subsection.refresh_from_db() # Reloading the subsection is necessary
- assert subsection.versioning.has_unpublished_changes # Shallow check: adding a child changes the subsection
- assert content_api.contains_unpublished_changes(subsection.pk) # Deeper check
- assert subsection.versioning.draft == subsection_version_v2
- assert subsection.versioning.published == subsection_version
-
- def test_modify_unpinned_unit_after_publish(self):
- """
- Modifying an unpinned unit in a published subsection will NOT create a
- new version nor show that the subsection has unpublished changes (but it will
- "contain" unpublished changes). The modifications will appear in the
- published version of the subsection only after the unit is published.
- """
- # Create a subsection with one unpinned draft unit:
- assert self.unit_1.versioning.has_unpublished_changes
+ def test_get_subsection(self) -> None:
+ """Test `get_subsection()`"""
subsection = self.create_subsection_with_units([self.unit_1])
- assert subsection.versioning.has_unpublished_changes
-
- # Publish the subsection and the unit:
- content_api.publish_all_drafts(self.learning_package.id)
- subsection.refresh_from_db() # Reloading the subsection is necessary if we accessed 'versioning' before publish
- self.unit_1.refresh_from_db()
- assert subsection.versioning.has_unpublished_changes is False # Shallow check
- assert content_api.contains_unpublished_changes(subsection.pk) is False # Deeper check
- assert self.unit_1.versioning.has_unpublished_changes is False
- # Now modify the unit by changing its title (it remains a draft):
- unit_1_v2 = self.modify_unit(self.unit_1, title="Modified Counting Problem with new title")
+ subsection_retrieved = content_api.get_subsection(subsection.pk)
+ assert isinstance(subsection_retrieved, Subsection)
+ assert subsection_retrieved == subsection
- # The unit now has unpublished changes; the subsection doesn't directly but does contain
- subsection.refresh_from_db() # Refresh to avoid stale 'versioning' cache
- self.unit_1.refresh_from_db()
- assert subsection.versioning.has_unpublished_changes is False # Shallow check: subsection unchanged
- assert content_api.contains_unpublished_changes(subsection.pk) # But subsection DOES contain changes
- assert self.unit_1.versioning.has_unpublished_changes
+ def test_get_subsection_nonexistent(self) -> None:
+ """Test `get_subsection()` when the subsection doesn't exist"""
+ with pytest.raises(Subsection.DoesNotExist):
+ content_api.get_subsection(-500)
- # Since the unit changes haven't been published, they should only appear in the draft subsection
- assert content_api.get_units_in_subsection(subsection, published=False) == [
- Entry(unit_1_v2), # new version
- ]
- assert content_api.get_units_in_subsection(subsection, published=True) == [
- Entry(self.unit_1_v1), # old version
- ]
+ def test_get_subsection_other_container_type(self) -> None:
+ """Test `get_subsection()` when the provided PK is for a non-Subsection container"""
+ with pytest.raises(Subsection.DoesNotExist):
+ content_api.get_subsection(self.unit_1.pk)
- # But if we publish the unit, the changes will appear in the published version of the subsection.
- self.publish_unit(self.unit_1)
- assert content_api.get_units_in_subsection(subsection, published=False) == [
- Entry(unit_1_v2), # new version
- ]
- assert content_api.get_units_in_subsection(subsection, published=True) == [
- Entry(unit_1_v2), # new version
- ]
- assert content_api.contains_unpublished_changes(subsection.pk) is False # No more unpublished changes
-
- def test_modify_pinned_unit(self):
+ def test_subsection_queries(self) -> None:
"""
- When a pinned π unit in subsection is modified and/or published, it will
- have no effect on either the draft nor published version of the subsection,
- which will continue to use the pinned version.
+ Test the number of queries needed for each part of the subsections API
"""
- # Create a subsection with one unit (pinned π to v1):
- subsection = self.create_subsection_with_units([self.unit_1_v1])
-
- # Publish the subsection and the unit:
- content_api.publish_all_drafts(self.learning_package.id)
- expected_subsection_contents = [
- Entry(self.unit_1_v1, pinned=True), # pinned π to v1
- ]
- assert content_api.get_units_in_subsection(subsection, published=True) == expected_subsection_contents
-
- # Now modify the unit by changing its title (it remains a draft):
- self.modify_unit(self.unit_1, title="Modified Counting Problem with new title")
-
- # The unit now has unpublished changes; the subsection is entirely unaffected
- subsection.refresh_from_db() # Refresh to avoid stale 'versioning' cache
- self.unit_1.refresh_from_db()
- assert subsection.versioning.has_unpublished_changes is False # Shallow check
- assert content_api.contains_unpublished_changes(subsection.pk) is False # Deep check
- assert self.unit_1.versioning.has_unpublished_changes is True
-
- # Neither the draft nor the published version of the subsection is affected
- assert content_api.get_units_in_subsection(subsection, published=False) == expected_subsection_contents
- assert content_api.get_units_in_subsection(subsection, published=True) == expected_subsection_contents
- # Even if we publish the unit, the subsection stays pinned to the specified version:
- self.publish_unit(self.unit_1)
- assert content_api.get_units_in_subsection(subsection, published=False) == expected_subsection_contents
- assert content_api.get_units_in_subsection(subsection, published=True) == expected_subsection_contents
-
- def test_create_two_subsections_with_same_units(self):
- """
- Test creating two subsections with different combinations of the same two
- units in each subsection.
- """
- # Create a subsection with unit 2 unpinned, unit 2 pinned π, and unit 1:
- subsection1 = self.create_subsection_with_units([self.unit_2, self.unit_2_v1, self.unit_1], key="u1")
- # Create a second subsection with unit 1 pinned π, unit 2, and unit 1 unpinned:
- subsection2 = self.create_subsection_with_units([self.unit_1_v1, self.unit_2, self.unit_1], key="u2")
-
- # Check that the contents are as expected:
- assert [row.unit_version for row in content_api.get_units_in_subsection(subsection1, published=False)] == [
- self.unit_2_v1, self.unit_2_v1, self.unit_1_v1,
- ]
- assert [row.unit_version for row in content_api.get_units_in_subsection(subsection2, published=False)] == [
- self.unit_1_v1, self.unit_2_v1, self.unit_1_v1,
- ]
-
- # Modify unit 1
- unit_1_v2 = self.modify_unit(self.unit_1, title="unit 1 v2")
- # Publish changes
- content_api.publish_all_drafts(self.learning_package.id)
- # Modify unit 2 - only in the draft
- unit_2_v2 = self.modify_unit(self.unit_2, title="unit 2 DRAFT")
-
- # Check that the draft contents are as expected:
- assert content_api.get_units_in_subsection(subsection1, published=False) == [
- Entry(unit_2_v2), # v2 in the draft version
- Entry(self.unit_2_v1, pinned=True), # pinned π to v1
- Entry(unit_1_v2), # v2
- ]
- assert content_api.get_units_in_subsection(subsection2, published=False) == [
- Entry(self.unit_1_v1, pinned=True), # pinned π to v1
- Entry(unit_2_v2), # v2 in the draft version
- Entry(unit_1_v2), # v2
- ]
-
- # Check that the published contents are as expected:
- assert content_api.get_units_in_subsection(subsection1, published=True) == [
- Entry(self.unit_2_v1), # v1 in the published version
- Entry(self.unit_2_v1, pinned=True), # pinned π to v1
- Entry(unit_1_v2), # v2
- ]
- assert content_api.get_units_in_subsection(subsection2, published=True) == [
- Entry(self.unit_1_v1, pinned=True), # pinned π to v1
- Entry(self.unit_2_v1), # v1 in the published version
- Entry(unit_1_v2), # v2
- ]
-
- def test_publishing_shared_unit(self):
- """
- A complex test case involving two subsections with a shared unit and
- other non-shared units.
-
- Subsection 1: units C1, C2, C3
- Subsection 2: units C2, C4, C5
- Everything is "unpinned".
- """
- # 1οΈβ£ Create the subsections and publish them:
- (u1, u1_v1), (u2, _u2_v1), (u3, u3_v1), (u4, u4_v1), (u5, u5_v1) = [
- self.create_unit(key=f"C{i}", title=f"Unit {i}") for i in range(1, 6)
- ]
- subsection1 = self.create_subsection_with_units([u1, u2, u3], title="Subsection 1", key="subsection:1")
- subsection2 = self.create_subsection_with_units([u2, u4, u5], title="Subsection 2", key="subsection:2")
- content_api.publish_all_drafts(self.learning_package.id)
- assert content_api.contains_unpublished_changes(subsection1.pk) is False
- assert content_api.contains_unpublished_changes(subsection2.pk) is False
-
- # 2οΈβ£ Then the author edits U2 inside of Subsection 1 making U2v2.
- u2_v2 = self.modify_unit(u2, title="U2 version 2")
- # Both S1 and S2 now contain unpublished changes since they share the unit.
- assert content_api.contains_unpublished_changes(subsection1.pk)
- assert content_api.contains_unpublished_changes(subsection2.pk)
- # (But the subsections themselves are unchanged:)
- subsection1.refresh_from_db()
- subsection2.refresh_from_db()
- assert subsection1.versioning.has_unpublished_changes is False
- assert subsection2.versioning.has_unpublished_changes is False
-
- # 3οΈβ£ In addition to this, the author also modifies another unit in Subsection 2 (U5)
- u5_v2 = self.modify_unit(u5, title="U5 version 2")
-
- # 4οΈβ£ The author then publishes Subsection 1, and therefore everything in it.
- content_api.publish_from_drafts(
- self.learning_package.pk,
- draft_qset=content_api.get_all_drafts(self.learning_package.pk).filter(
- # Note: we only publish the subsection; the publishing API should auto-publish its units too.
- entity_id=subsection1.publishable_entity.id,
- ),
- )
-
- # Result: Subsection 1 will show the newly published version of U2:
- assert content_api.get_units_in_subsection(subsection1, published=True) == [
- Entry(u1_v1),
- Entry(u2_v2), # new published version of U2
- Entry(u3_v1),
- ]
-
- # Result: someone looking at Subsection 2 should see the newly published unit 2, because publishing it anywhere
- # publishes it everywhere. But publishing U2 and Subsection 1 does not affect the other units in Subsection 2.
- # (Publish propagates downward, not upward)
- assert content_api.get_units_in_subsection(subsection2, published=True) == [
- Entry(u2_v2), # new published version of U2
- Entry(u4_v1), # still original version of U4 (it was never modified)
- Entry(u5_v1), # still original version of U5 (it hasn't been published)
- ]
-
- # Result: Subsection 2 contains unpublished changes due to modified U5; Subsection 1 does not.
- assert content_api.contains_unpublished_changes(subsection1.pk) is False
- assert content_api.contains_unpublished_changes(subsection2.pk)
-
- # 5οΈβ£ Publish unit U5, which should be the only thing unpublished in the learning package
- self.publish_unit(u5)
- # Result: Subsection 2 shows the new version of C5 and no longer contains unpublished changes:
- assert content_api.get_units_in_subsection(subsection2, published=True) == [
- Entry(u2_v2), # new published version of U2
- Entry(u4_v1), # still original version of U4 (it was never modified)
- Entry(u5_v2), # new published version of U5
- ]
- assert content_api.contains_unpublished_changes(subsection2.pk) is False
-
- def test_query_count_of_contains_unpublished_changes(self):
- """
- Checking for unpublished changes in a subsection should require a fixed number
- of queries, not get more expensive as the subsection gets larger.
- """
- # Add 2 units (unpinned)
- unit_count = 2
- units = []
- for i in range(unit_count):
- unit, _version = self.create_unit(
- key=f"Unit {i}",
- title=f"Unit {i}",
+ with self.assertNumQueries(37):
+ subsection = self.create_subsection_with_units([self.unit_1, self.unit_1_v1])
+ with self.assertNumQueries(102): # TODO: this seems high?
+ content_api.publish_from_drafts(
+ self.learning_package.id,
+ draft_qset=content_api.get_all_drafts(self.learning_package.id).filter(entity=subsection.pk),
)
- units.append(unit)
- subsection = self.create_subsection_with_units(units)
- content_api.publish_all_drafts(self.learning_package.id)
- subsection.refresh_from_db()
- with self.assertNumQueries(1):
- assert content_api.contains_unpublished_changes(subsection.pk) is False
-
- # Modify the most recently created unit:
- self.modify_unit(unit, title="Modified Unit")
- with self.assertNumQueries(1):
- assert content_api.contains_unpublished_changes(subsection.pk) is True
-
- def test_metadata_change_doesnt_create_entity_list(self):
- """
- Test that changing a container's metadata like title will create a new
- version, but can re-use the same EntityList. API consumers generally
- shouldn't depend on this behavior; it's an optimization.
- """
- subsection = self.create_subsection_with_units([self.unit_1, self.unit_2_v1])
-
- orig_version_num = subsection.versioning.draft.version_num
- orig_entity_list_id = subsection.versioning.draft.entity_list.pk
-
- content_api.create_next_subsection_version(subsection, title="New Title", created=self.now)
-
- subsection.refresh_from_db()
- new_version_num = subsection.versioning.draft.version_num
- new_entity_list_id = subsection.versioning.draft.entity_list.pk
-
- assert new_version_num > orig_version_num
- assert new_entity_list_id == orig_entity_list_id
-
- @ddt.data(True, False)
- @pytest.mark.skip(reason="FIXME: we don't yet prevent adding soft-deleted units to subsections")
- def test_cannot_add_soft_deleted_unit(self, publish_first):
- """
- Test that a soft-deleted unit cannot be added to a subsection.
-
- Although it's valid for subsections to contain soft-deleted units (by
- deleting the unit after adding it), it is likely a mistake if
- you're trying to add one to the subsection.
- """
- unit, _cv = self.create_unit(title="Deleted unit")
- if publish_first:
- # Publish the unit:
- content_api.publish_all_drafts(self.learning_package.id)
- # Now delete it. The draft version is now deleted:
- content_api.soft_delete_draft(unit.pk)
- # Now try adding that unit to a subsection:
- with pytest.raises(ValidationError, match="unit is deleted"):
- self.create_subsection_with_units([unit])
-
- def test_removing_unit(self):
- """ Test removing a unit from a subsection (but not deleting it) """
- subsection = self.create_subsection_with_units([self.unit_1, self.unit_2])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now remove unit 2
- content_api.create_next_subsection_version(
- subsection=subsection,
- title="Revised with unit 2 deleted",
- units=[self.unit_2],
- created=self.now,
- entities_action=content_api.ChildrenEntitiesAction.REMOVE,
- )
-
- # Now it should not be listed in the subsection:
- assert content_api.get_units_in_subsection(subsection, published=False) == [
- Entry(self.unit_1_v1),
- ]
- subsection.refresh_from_db()
- assert subsection.versioning.has_unpublished_changes # The subsection itself and its unit list have change
- assert content_api.contains_unpublished_changes(subsection.pk)
- # The published version of the subsection is not yet affected:
- assert content_api.get_units_in_subsection(subsection, published=True) == [
- Entry(self.unit_1_v1),
- Entry(self.unit_2_v1),
- ]
-
- # But when we publish the new subsection version with the removal, the published version is affected:
- content_api.publish_all_drafts(self.learning_package.id)
- # FIXME: Refreshing the subsection is necessary here because get_entities_in_published_container() accesses
- # container.versioning.published, and .versioning is cached with the old version. But this seems like
- # a footgun? We could avoid this if get_entities_in_published_container() took only an ID instead of an object,
- # but that would involve additional database lookup(s).
- subsection.refresh_from_db()
- assert content_api.contains_unpublished_changes(subsection.pk) is False
- assert content_api.get_units_in_subsection(subsection, published=True) == [
- Entry(self.unit_1_v1),
- ]
-
- def test_soft_deleting_unit(self):
- """ Test soft deleting a unit that's in a subsection (but not removing it) """
- subsection = self.create_subsection_with_units([self.unit_1, self.unit_2])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now soft delete unit 2
- content_api.soft_delete_draft(self.unit_2.pk)
-
- # Now it should not be listed in the subsection:
- assert content_api.get_units_in_subsection(subsection, published=False) == [
- Entry(self.unit_1_v1),
- # unit 2 is soft deleted from the draft.
- # TODO: should we return some kind of placeholder here, to indicate that a unit is still listed in the
- # subsection's unit list but has been soft deleted, and will be fully deleted when published, or restored if
- # reverted?
- ]
- assert subsection.versioning.has_unpublished_changes is False # Subsection and unit list unchanged
- assert content_api.contains_unpublished_changes(subsection.pk) # It still contains an unpublished deletion
- # The published version of the subsection is not yet affected:
- assert content_api.get_units_in_subsection(subsection, published=True) == [
- Entry(self.unit_1_v1),
- Entry(self.unit_2_v1),
- ]
-
- # But when we publish the deletion, the published version is affected:
- content_api.publish_all_drafts(self.learning_package.id)
- assert content_api.contains_unpublished_changes(subsection.pk) is False
- assert content_api.get_units_in_subsection(subsection, published=True) == [
- Entry(self.unit_1_v1),
- ]
-
- def test_soft_deleting_and_removing_unit(self):
- """ Test soft deleting a unit that's in a subsection AND removing it """
- subsection = self.create_subsection_with_units([self.unit_1, self.unit_2])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now soft delete unit 2
- content_api.soft_delete_draft(self.unit_2.pk)
- # And remove it from the subsection:
- content_api.create_next_subsection_version(
- subsection=subsection,
- title="Revised with unit 2 deleted",
- units=[self.unit_2],
- created=self.now,
- entities_action=content_api.ChildrenEntitiesAction.REMOVE,
- )
-
- # Now it should not be listed in the subsection:
- assert content_api.get_units_in_subsection(subsection, published=False) == [
- Entry(self.unit_1_v1),
- ]
- assert subsection.versioning.has_unpublished_changes is True
- assert content_api.contains_unpublished_changes(subsection.pk)
- # The published version of the subsection is not yet affected:
- assert content_api.get_units_in_subsection(subsection, published=True) == [
- Entry(self.unit_1_v1),
- Entry(self.unit_2_v1),
- ]
-
- # But when we publish the deletion, the published version is affected:
- content_api.publish_all_drafts(self.learning_package.id)
- assert content_api.contains_unpublished_changes(subsection.pk) is False
- assert content_api.get_units_in_subsection(subsection, published=True) == [
- Entry(self.unit_1_v1),
- ]
-
- def test_soft_deleting_pinned_unit(self):
- """ Test soft deleting a pinned π unit that's in a subsection """
- subsection = self.create_subsection_with_units([self.unit_1_v1, self.unit_2_v1])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now soft delete unit 2
- content_api.soft_delete_draft(self.unit_2.pk)
-
- # Now it should still be listed in the subsection:
- assert content_api.get_units_in_subsection(subsection, published=False) == [
- Entry(self.unit_1_v1, pinned=True),
- Entry(self.unit_2_v1, pinned=True),
- ]
- assert subsection.versioning.has_unpublished_changes is False # Subsection and unit list unchanged
- assert content_api.contains_unpublished_changes(subsection.pk) is False # nor does it contain changes
- # The published version of the subsection is also not affected:
- assert content_api.get_units_in_subsection(subsection, published=True) == [
- Entry(self.unit_1_v1, pinned=True),
- Entry(self.unit_2_v1, pinned=True),
- ]
-
- def test_soft_delete_subsection(self):
- """
- I can delete a subsection without deleting the units it contains.
-
- See https://github.com/openedx/frontend-app-authoring/issues/1693
- """
- # Create two subsections, one of which we will soon delete:
- subsection_to_delete = self.create_subsection_with_units([self.unit_1, self.unit_2])
- other_subsection = self.create_subsection_with_units([self.unit_1], key="other")
-
- # Publish everything:
- content_api.publish_all_drafts(self.learning_package.id)
- # Delete the subsection:
- content_api.soft_delete_draft(subsection_to_delete.publishable_entity_id)
- subsection_to_delete.refresh_from_db()
- # Now the draft subsection is soft deleted; units, published subsection, and other subsection are unaffected:
- assert subsection_to_delete.versioning.draft is None # Subsection is soft deleted.
- assert subsection_to_delete.versioning.published is not None
- self.unit_1.refresh_from_db()
- assert self.unit_1.versioning.draft is not None
- assert content_api.get_units_in_subsection(other_subsection, published=False) == [Entry(self.unit_1_v1)]
-
- # Publish everything:
- content_api.publish_all_drafts(self.learning_package.id)
- # Now the subsection's published version is also deleted, but nothing else is affected.
- subsection_to_delete.refresh_from_db()
- assert subsection_to_delete.versioning.draft is None # Subsection is soft deleted.
- assert subsection_to_delete.versioning.published is None
- self.unit_1.refresh_from_db()
- assert self.unit_1.versioning.draft is not None
- assert self.unit_1.versioning.published is not None
- assert content_api.get_units_in_subsection(other_subsection, published=False) == [Entry(self.unit_1_v1)]
- assert content_api.get_units_in_subsection(other_subsection, published=True) == [Entry(self.unit_1_v1)]
-
- def test_snapshots_of_published_subsection(self):
- """
- Test that we can access snapshots of the historic published version of
- subsections and their contents.
- """
- # At first the subsection has one unit (unpinned):
- subsection = self.create_subsection_with_units([self.unit_1])
- self.modify_unit(self.unit_1, title="Unit 1 as of checkpoint 1")
- before_publish = content_api.get_units_in_published_subsection_as_of(subsection, 0)
- assert before_publish is None
-
- # Publish everything, creating Checkpoint 1
- checkpoint_1 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 1")
-
- ########################################################################
-
- # Now we update the title of the unit.
- self.modify_unit(self.unit_1, title="Unit 1 as of checkpoint 2")
- # Publish everything, creating Checkpoint 2
- checkpoint_2 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 2")
- ########################################################################
-
- # Now add a second unit to the subsection:
- self.modify_unit(self.unit_1, title="Unit 1 as of checkpoint 3")
- self.modify_unit(self.unit_2, title="Unit 2 as of checkpoint 3")
- content_api.create_next_subsection_version(
- subsection=subsection,
- title="Subsection title in checkpoint 3",
- units=[self.unit_1, self.unit_2],
- created=self.now,
- )
- # Publish everything, creating Checkpoint 3
- checkpoint_3 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 3")
- ########################################################################
-
- # Now add a third unit to the subsection, a pinned π version of unit 1.
- # This will test pinned versions and also test adding at the beginning rather than the end of the subsection.
- content_api.create_next_subsection_version(
- subsection=subsection,
- title="Subsection title in checkpoint 4",
- units=[self.unit_1_v1, self.unit_1, self.unit_2],
- created=self.now,
- )
- # Publish everything, creating Checkpoint 4
- checkpoint_4 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 4")
- ########################################################################
-
- # Modify the drafts, but don't publish:
- self.modify_unit(self.unit_1, title="Unit 1 draft")
- self.modify_unit(self.unit_2, title="Unit 2 draft")
-
- # Now fetch the snapshots:
- as_of_checkpoint_1 = content_api.get_units_in_published_subsection_as_of(subsection, checkpoint_1.pk)
- assert [cv.unit_version.title for cv in as_of_checkpoint_1] == [
- "Unit 1 as of checkpoint 1",
- ]
- as_of_checkpoint_2 = content_api.get_units_in_published_subsection_as_of(subsection, checkpoint_2.pk)
- assert [cv.unit_version.title for cv in as_of_checkpoint_2] == [
- "Unit 1 as of checkpoint 2",
- ]
- as_of_checkpoint_3 = content_api.get_units_in_published_subsection_as_of(subsection, checkpoint_3.pk)
- assert [cv.unit_version.title for cv in as_of_checkpoint_3] == [
- "Unit 1 as of checkpoint 3",
- "Unit 2 as of checkpoint 3",
- ]
- as_of_checkpoint_4 = content_api.get_units_in_published_subsection_as_of(subsection, checkpoint_4.pk)
- assert [cv.unit_version.title for cv in as_of_checkpoint_4] == [
- "Unit (1)", # Pinned. This title is self.unit_1_v1.title (original v1 title)
- "Unit 1 as of checkpoint 3", # we didn't modify these units so they're same as in snapshot 3
- "Unit 2 as of checkpoint 3", # we didn't modify these units so they're same as in snapshot 3
- ]
-
- def test_subsections_containing(self):
- """
- Test that we can efficiently get a list of all the [draft] subsections
- containing a given unit.
- """
- unit_1_v2 = self.modify_unit(self.unit_1, title="modified unit 1")
-
- # Create a few subsections, some of which contain unit 1 and others which don't:
- # Note: it is important that some of these subsections contain other units, to ensure the complex JOINs required
- # for this query are working correctly, especially in the case of ignore_pinned=True.
- # Subsection 1 β
has unit 1, pinned π to V1
- subsection1_1pinned = self.create_subsection_with_units([self.unit_1_v1, self.unit_2], key="u1")
- # Subsection 2 β
has unit 1, pinned π to V2
- subsection2_1pinned_v2 = self.create_subsection_with_units([unit_1_v2, self.unit_2_v1], key="u2")
- # Subsection 3 doesn't contain it
- _subsection3_no = self.create_subsection_with_units([self.unit_2], key="u3")
- # Subsection 4 β
has unit 1, unpinned
- subsection4_unpinned = self.create_subsection_with_units([
- self.unit_1, self.unit_2, self.unit_2_v1,
- ], key="u4")
- # Subsections 5/6 don't contain it
- _subsection5_no = self.create_subsection_with_units([self.unit_2_v1, self.unit_2], key="u5")
- _subsection6_no = self.create_subsection_with_units([], key="u6")
-
- # No need to publish anything as the get_containers_with_entity() API only considers drafts (for now).
-
- with self.assertNumQueries(1):
- result = [
- c.subsection for c in
- content_api.get_containers_with_entity(self.unit_1.pk).select_related("subsection")
- ]
- assert result == [
- subsection1_1pinned,
- subsection2_1pinned_v2,
- subsection4_unpinned,
- ]
-
- # Test retrieving only "unpinned", for cases like potential deletion of a unit, where we wouldn't care
- # about pinned uses anyways (they would be unaffected by a delete).
-
- with self.assertNumQueries(1):
- result2 = [
- c.subsection for c in
- content_api.get_containers_with_entity(
- self.unit_1.pk, ignore_pinned=True
- ).select_related("subsection")
- ]
- assert result2 == [subsection4_unpinned]
-
- def test_get_units_in_subsection_queries(self):
- """
- Test the query count of get_units_in_subsection()
- This also tests the generic method get_entities_in_container()
- """
- subsection = self.create_subsection_with_units([
- self.unit_1,
- self.unit_2,
- self.unit_2_v1,
- ])
- with self.assertNumQueries(4):
- result = content_api.get_units_in_subsection(subsection, published=False)
- assert result == [
- Entry(self.unit_1.versioning.draft),
- Entry(self.unit_2.versioning.draft),
- Entry(self.unit_2.versioning.draft, pinned=True),
- ]
- content_api.publish_all_drafts(self.learning_package.id)
with self.assertNumQueries(4):
result = content_api.get_units_in_subsection(subsection, published=True)
assert result == [
- Entry(self.unit_1.versioning.draft),
- Entry(self.unit_2.versioning.draft),
- Entry(self.unit_2.versioning.draft, pinned=True),
+ Entry(self.unit_1_v1),
+ Entry(self.unit_1_v1, pinned=True),
]
- def test_add_remove_container_children(self):
+ def test_create_subsection_with_invalid_children(self):
"""
- Test adding and removing children units from subsections.
+ Verify that only units can be added to subsections, and a specific exception is raised.
"""
- subsection, subsection_version = content_api.create_subsection_and_version(
- learning_package_id=self.learning_package.id,
- key="subsection:key",
- title="Subsection",
- units=[self.unit_1],
- created=self.now,
- created_by=None,
- )
- assert content_api.get_units_in_subsection(subsection, published=False) == [
- Entry(self.unit_1.versioning.draft),
- ]
- unit_3, _ = self.create_unit(
- key="Unit (3)",
- title="Unit (3)",
- )
- # Add unit_2 and unit_3
- subsection_version_v2 = content_api.create_next_subsection_version(
- subsection=subsection,
- title=subsection_version.title,
- units=[self.unit_2, unit_3],
- created=self.now,
- created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.APPEND,
- )
+ # Create a subsection:
+ subsection = self.create_subsection_with_units([])
+ subsection_version = subsection.versioning.draft
+ # Try adding a Component to a Subsection
+ with pytest.raises(
+ ValidationError,
+ match='The entity "xblock.v1:problem:Query Counting" cannot be added to a "subsection" container.',
+ ):
+ content_api.create_next_subsection_version(
+ subsection,
+ units=[self.component_1],
+ created=self.now,
+ created_by=None,
+ )
+ # Check that a new version was not created:
subsection.refresh_from_db()
- assert subsection_version_v2.version_num == 2
- assert subsection_version_v2 in subsection.versioning.versions.all()
- # Verify that unit_2 and unit_3 is added to end
- assert content_api.get_units_in_subsection(subsection, published=False) == [
- Entry(self.unit_1.versioning.draft),
- Entry(self.unit_2.versioning.draft),
- Entry(unit_3.versioning.draft),
- ]
+ assert content_api.get_subsection(subsection.pk).versioning.draft == subsection_version
+ assert subsection.versioning.draft == subsection_version
- # Remove unit_1
- content_api.create_next_subsection_version(
- subsection=subsection,
- title=subsection_version.title,
- units=[self.unit_1],
- created=self.now,
- created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.REMOVE,
- )
- subsection.refresh_from_db()
- # Verify that unit_1 is removed
- assert content_api.get_units_in_subsection(subsection, published=False) == [
- Entry(self.unit_2.versioning.draft),
- Entry(unit_3.versioning.draft),
- ]
+ # Also check that `create_subsection_with_units()` has the same restriction
+ # (not just `create_next_subsection_version()`)
+ with pytest.raises(
+ ValidationError,
+ match='The entity "xblock.v1:problem:Query Counting" cannot be added to a "subsection" container.',
+ ):
+ self.create_subsection_with_units([self.component_1], key="unit:key3", title="Unit 3")
- def test_get_container_children_count(self):
- """
- Test get_container_children_count()
- """
- subsection = self.create_subsection_with_units([self.unit_1])
- assert content_api.get_container_children_count(subsection.container, published=False) == 1
- # publish
- content_api.publish_all_drafts(self.learning_package.id)
- subsection_version = subsection.versioning.draft
- content_api.create_next_subsection_version(
- subsection=subsection,
- title=subsection_version.title,
- units=[self.unit_2],
- created=self.now,
- created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.APPEND,
- )
- subsection.refresh_from_db()
- # Should have two units in draft version and 1 in published version
- assert content_api.get_container_children_count(subsection.container, published=False) == 2
- assert content_api.get_container_children_count(subsection.container, published=True) == 1
- # publish
- content_api.publish_all_drafts(self.learning_package.id)
- subsection.refresh_from_db()
- assert content_api.get_container_children_count(subsection.container, published=True) == 2
- # Soft delete unit_1
- content_api.soft_delete_draft(self.unit_1.pk)
- subsection.refresh_from_db()
- # Should contain only 1 child
- assert content_api.get_container_children_count(subsection.container, published=False) == 1
- content_api.publish_all_drafts(self.learning_package.id)
- subsection.refresh_from_db()
- assert content_api.get_container_children_count(subsection.container, published=True) == 1
+ def test_is_registered(self):
+ assert Subsection in content_api.get_all_container_subclasses()
- # Tests TODO:
- # Test that I can get a [PublishLog] history of a given subsection and all its children, including children
- # that aren't currently in the subsection and excluding children that are only in other subsections.
- # Test that I can get a [PublishLog] history of a given subsection and its children, that includes changes
- # made to the child units while they were part of the subsection but excludes changes
- # made to those children while they were not part of the subsection. π«£
+ def test_olx_tag_name(self):
+ assert content_api.get_container_subclass("subsection") is Subsection
+ assert content_api.get_container_subclass("subsection").olx_tag_name == "sequential"
diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py
index 588d9d839..693c7ab2c 100644
--- a/tests/openedx_content/applets/units/test_api.py
+++ b/tests/openedx_content/applets/units/test_api.py
@@ -1,24 +1,21 @@
"""
Basic tests for the units API.
"""
-from unittest.mock import patch
-import ddt # type: ignore[import]
import pytest
from django.core.exceptions import ValidationError
-from django.db import IntegrityError
import openedx_content.api as content_api
-from openedx_content import models_api as authoring_models
+from openedx_content.models_api import Component, ComponentVersion, Unit, UnitVersion
+from tests.test_django_app.models import TestContainer
from ..components.test_api import ComponentTestCase
Entry = content_api.UnitListEntry
-@ddt.ddt
-class UnitTestCase(ComponentTestCase):
- """ Test cases for Units (containers of components) """
+class UnitsTestCase(ComponentTestCase):
+ """Test cases for Units (containers of components)"""
def setUp(self) -> None:
super().setUp()
@@ -31,27 +28,14 @@ def setUp(self) -> None:
title="Querying Counting Problem (2)",
)
- def create_component(self, *, title: str = "Test Component", key: str = "component:1") -> tuple[
- authoring_models.Component, authoring_models.ComponentVersion
- ]:
- """ Helper method to quickly create a component """
- return content_api.create_component_and_version(
- self.learning_package.id,
- component_type=self.problem_type,
- local_key=key,
- title=title,
- created=self.now,
- created_by=None,
- )
-
def create_unit_with_components(
self,
- components: list[authoring_models.Component | authoring_models.ComponentVersion],
+ components: list[Component | ComponentVersion],
*,
title="Unit",
key="unit:key",
- ) -> authoring_models.Unit:
- """ Helper method to quickly create a unit with some components """
+ ) -> Unit:
+ """Helper method to quickly create a unit with some components"""
unit, _unit_v1 = content_api.create_unit_and_version(
learning_package_id=self.learning_package.id,
key=key,
@@ -62,229 +46,6 @@ def create_unit_with_components(
)
return unit
- def modify_component(
- self,
- component: authoring_models.Component,
- *,
- title="Modified Component",
- timestamp=None,
- ) -> authoring_models.ComponentVersion:
- """
- Helper method to modify a component for the purposes of testing units/drafts/pinning/publishing/etc.
- """
- return content_api.create_next_component_version(
- component.pk,
- media_to_replace={},
- title=title,
- created=timestamp or self.now,
- created_by=None,
- )
-
- def test_get_unit(self):
- """
- Test get_unit()
- """
- unit = self.create_unit_with_components([self.component_1, self.component_2])
- with self.assertNumQueries(1):
- result = content_api.get_unit(unit.pk)
- assert result == unit
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result.versioning.has_unpublished_changes
-
- def test_get_unit_version(self):
- """
- Test get_unit_version()
- """
- unit = self.create_unit_with_components([])
- draft = unit.versioning.draft
- with self.assertNumQueries(1):
- result = content_api.get_unit_version(draft.pk)
- assert result == draft
-
- def test_get_latest_unit_version(self):
- """
- Test test_get_latest_unit_version()
- """
- unit = self.create_unit_with_components([])
- draft = unit.versioning.draft
- with self.assertNumQueries(2):
- result = content_api.get_latest_unit_version(unit.pk)
- assert result == draft
-
- def test_get_containers(self):
- """
- Test get_containers()
- """
- unit = self.create_unit_with_components([])
- with self.assertNumQueries(1):
- result = list(content_api.get_containers(self.learning_package.id))
- assert result == [unit.container]
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result[0].versioning.has_unpublished_changes
-
- def test_get_containers_deleted(self):
- """
- Test that get_containers() does not return soft-deleted units.
- """
- unit = self.create_unit_with_components([])
- content_api.soft_delete_draft(unit.pk)
- with self.assertNumQueries(1):
- result = list(content_api.get_containers(self.learning_package.id, include_deleted=True))
- assert result == [unit.container]
-
- with self.assertNumQueries(1):
- result = list(content_api.get_containers(self.learning_package.id))
- assert not result
-
- def test_get_container(self):
- """
- Test get_container()
- """
- unit = self.create_unit_with_components([self.component_1, self.component_2])
- with self.assertNumQueries(1):
- result = content_api.get_container(unit.pk)
- assert result == unit.container
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result.versioning.has_unpublished_changes
-
- def test_get_container_by_key(self):
- """
- Test get_container_by_key()
- """
- unit = self.create_unit_with_components([])
- with self.assertNumQueries(1):
- result = content_api.get_container_by_key(
- self.learning_package.id,
- key=unit.publishable_entity.key,
- )
- assert result == unit.container
- # Versioning data should be pre-loaded via select_related()
- with self.assertNumQueries(0):
- assert result.versioning.has_unpublished_changes
-
- def test_unit_container_versioning(self):
- """
- Test that the .versioning helper of a Unit returns a UnitVersion, and
- same for the generic Container equivalent.
- """
- unit = self.create_unit_with_components([self.component_1, self.component_2])
- container = unit.container
- container_version = container.versioning.draft
- assert isinstance(container_version, authoring_models.ContainerVersion)
- unit_version = unit.versioning.draft
- assert isinstance(unit_version, authoring_models.UnitVersion)
- assert unit_version.container_version == container_version
- assert unit_version.container_version.container == container
- assert unit_version.unit == unit
-
- def test_create_unit_queries(self):
- """
- Test how many database queries are required to create a unit
- """
- # The exact numbers here aren't too important - this is just to alert us if anything significant changes.
- with self.assertNumQueries(26):
- _empty_unit = self.create_unit_with_components([])
- with self.assertNumQueries(32):
- # And try with a non-empty unit:
- self.create_unit_with_components([self.component_1, self.component_2_v1], key="u2")
-
- def test_create_unit_with_invalid_children(self):
- """
- Verify that only components can be added to units, and a specific
- exception is raised.
- """
- # Create two units:
- unit, unit_version = content_api.create_unit_and_version(
- learning_package_id=self.learning_package.id,
- key="unit:key",
- title="Unit",
- created=self.now,
- created_by=None,
- )
- assert unit.versioning.draft == unit_version
- unit2, _u2v1 = content_api.create_unit_and_version(
- learning_package_id=self.learning_package.id,
- key="unit:key2",
- title="Unit 2",
- created=self.now,
- created_by=None,
- )
- # Try adding a Unit to a Unit
- with pytest.raises(TypeError, match="Unit components must be either Component or ComponentVersion."):
- content_api.create_next_unit_version(
- unit=unit,
- title="Unit Containing a Unit",
- components=[unit2],
- created=self.now,
- created_by=None,
- )
- # Check that a new version was not created:
- unit.refresh_from_db()
- assert content_api.get_unit(unit.pk).versioning.draft == unit_version
- assert unit.versioning.draft == unit_version
-
- def test_adding_external_components(self):
- """
- Test that components from another learning package cannot be added to a
- unit.
- """
- learning_package2 = content_api.create_learning_package(key="other-package", title="Other Package")
- unit, _unit_version = content_api.create_unit_and_version(
- learning_package_id=learning_package2.pk,
- key="unit:key",
- title="Unit",
- created=self.now,
- created_by=None,
- )
- assert self.component_1.learning_package != learning_package2
- # Try adding a a component from LP 1 (self.learning_package) to a unit from LP 2
- with pytest.raises(ValidationError, match="Container entities must be from the same learning package."):
- content_api.create_next_unit_version(
- unit=unit,
- title="Unit Containing an External Component",
- components=[self.component_1],
- created=self.now,
- created_by=None,
- )
-
- @patch('openedx_content.applets.units.api._pub_entities_for_components')
- def test_adding_mismatched_versions(self, mock_entities_for_components):
- """
- Test that versioned components must match their entities.
- """
- mock_entities_for_components.return_value = [
- content_api.ContainerEntityRow(
- entity_pk=self.component_1.pk,
- version_pk=self.component_2_v1.pk,
- ),
- ]
- # Try adding a a component from LP 1 (self.learning_package) to a unit from LP 2
- with pytest.raises(ValidationError, match="Container entity versions must belong to the specified entity"):
- content_api.create_unit_and_version(
- learning_package_id=self.component_1.learning_package.id,
- key="unit:key",
- title="Unit",
- components=[self.component_1],
- created=self.now,
- created_by=None,
- )
-
- @ddt.data(True, False)
- def test_cannot_add_invalid_ids(self, pin_version):
- """
- Test that non-existent components cannot be added to units
- """
- self.component_1.delete()
- if pin_version:
- components = [self.component_1_v1]
- else:
- components = [self.component_1]
- with pytest.raises((IntegrityError, authoring_models.Component.DoesNotExist)):
- self.create_unit_with_components(components)
-
def test_create_empty_unit_and_version(self):
"""Test creating a unit with no components.
@@ -295,12 +56,14 @@ def test_create_empty_unit_and_version(self):
4. There is no published version of the unit.
"""
unit, unit_version = content_api.create_unit_and_version(
- learning_package_id=self.learning_package.id,
+ learning_package_id=self.learning_package.pk,
key="unit:key",
title="Unit",
created=self.now,
created_by=None,
)
+ assert isinstance(unit, Unit)
+ assert isinstance(unit_version, UnitVersion)
assert unit, unit_version
assert unit_version.version_num == 1
assert unit_version in unit.versioning.versions.all()
@@ -318,838 +81,96 @@ def test_create_next_unit_version_with_two_unpinned_components(self):
3. The unit version is in the unit's versions.
4. The components are in the draft unit version's component list and are unpinned.
"""
- unit, _unit_version = content_api.create_unit_and_version(
- learning_package_id=self.learning_package.id,
- key="unit:key",
- title="Unit",
- created=self.now,
- created_by=None,
- )
+ unit = self.create_unit_with_components([])
unit_version_v2 = content_api.create_next_unit_version(
- unit=unit,
+ unit,
title="Unit",
components=[self.component_1, self.component_2],
created=self.now,
created_by=None,
)
+ assert isinstance(unit_version_v2, UnitVersion)
assert unit_version_v2.version_num == 2
assert unit_version_v2 in unit.versioning.versions.all()
assert content_api.get_components_in_unit(unit, published=False) == [
Entry(self.component_1.versioning.draft),
Entry(self.component_2.versioning.draft),
]
- with pytest.raises(authoring_models.ContainerVersion.DoesNotExist):
+ with pytest.raises(UnitVersion.DoesNotExist):
# There is no published version of the unit:
content_api.get_components_in_unit(unit, published=True)
- def test_create_next_unit_version_with_unpinned_and_pinned_components(self):
- """
- Test creating a unit version with one unpinned and one pinned π component.
- """
- unit, _unit_version = content_api.create_unit_and_version(
- learning_package_id=self.learning_package.id,
- key="unit:key",
- title="Unit",
- created=self.now,
- created_by=None,
- )
- unit_version_v2 = content_api.create_next_unit_version(
- unit=unit,
- title="Unit",
- components=[self.component_1, self.component_2_v1], # Note the "v1" pinning π the second one to version 1
- created=self.now,
- created_by=None,
- )
- assert unit_version_v2.version_num == 2
- assert unit_version_v2 in unit.versioning.versions.all()
- assert content_api.get_components_in_unit(unit, published=False) == [
- Entry(self.component_1_v1),
- Entry(self.component_2_v1, pinned=True), # Pinned π to v1
- ]
- with pytest.raises(authoring_models.ContainerVersion.DoesNotExist):
- # There is no published version of the unit:
- content_api.get_components_in_unit(unit, published=True)
-
- def test_create_next_unit_version_forcing_version_num(self):
- """
- Test creating a unit version with forcing the version number.
- """
- unit, _unit_version = content_api.create_unit_and_version(
- learning_package_id=self.learning_package.id,
- key="unit:key",
- title="Unit",
- created=self.now,
- created_by=None,
- )
- unit_version_v2 = content_api.create_next_unit_version(
- unit=unit,
- title="Unit",
- components=[self.component_1, self.component_2_v1], # Note the "v1" pinning π the second one to version 1
- created=self.now,
- created_by=None,
- force_version_num=5,
- )
- assert unit_version_v2.version_num == 5
-
- def test_auto_publish_children(self):
- """
- Test that publishing a unit publishes its child components automatically.
- """
- # Create a draft unit with two draft components
- unit = self.create_unit_with_components([self.component_1, self.component_2])
- # Also create another component that's not in the unit at all:
- other_component, _oc_v1 = self.create_component(title="A draft component not in the unit", key="component:3")
-
- assert content_api.contains_unpublished_changes(unit.pk)
- assert self.component_1.versioning.published is None
- assert self.component_2.versioning.published is None
-
- # Publish ONLY the unit. This should however also auto-publish components 1 & 2 since they're children
- content_api.publish_from_drafts(
- self.learning_package.pk,
- draft_qset=content_api.get_all_drafts(self.learning_package.pk).filter(entity=unit.publishable_entity),
- )
- # Now all changes to the unit and to component 1 are published:
- unit.refresh_from_db()
- self.component_1.refresh_from_db()
- assert unit.versioning.has_unpublished_changes is False # Shallow check
- assert self.component_1.versioning.has_unpublished_changes is False
- assert content_api.contains_unpublished_changes(unit.pk) is False # Deep check
- assert self.component_1.versioning.published == self.component_1_v1 # v1 is now the published version.
-
- # But our other component that's outside the unit is not affected:
- other_component.refresh_from_db()
- assert other_component.versioning.has_unpublished_changes
- assert other_component.versioning.published is None
-
- def test_no_publish_parent(self):
- """
- Test that publishing a component does NOT publish changes to its parent unit
- """
- # Create a draft unit with two draft components
- unit = self.create_unit_with_components([self.component_1, self.component_2])
- assert unit.versioning.has_unpublished_changes
- # Publish ONLY one of its child components
- self.publish_component(self.component_1)
- self.component_1.refresh_from_db() # Clear cache on '.versioning'
- assert self.component_1.versioning.has_unpublished_changes is False
+ def test_get_unit(self) -> None:
+ """Test `get_unit()`"""
+ unit = self.create_unit_with_components([self.component_1])
- # The unit that contains that component should still be unpublished:
- unit.refresh_from_db() # Clear cache on '.versioning'
- assert unit.versioning.has_unpublished_changes
- assert unit.versioning.published is None
- with pytest.raises(authoring_models.ContainerVersion.DoesNotExist):
- # There is no published version of the unit:
- content_api.get_components_in_unit(unit, published=True)
+ unit_retrieved = content_api.get_unit(unit.pk)
+ assert isinstance(unit_retrieved, Unit)
+ assert unit_retrieved == unit
- def test_add_component_after_publish(self):
- """
- Adding a component to a published unit will create a new version and
- show that the unit has unpublished changes.
- """
- unit, unit_version = content_api.create_unit_and_version(
- learning_package_id=self.learning_package.id,
- key="unit:key",
- title="Unit",
- created=self.now,
- created_by=None,
- )
- assert unit.versioning.draft == unit_version
- assert unit.versioning.published is None
- assert unit.versioning.has_unpublished_changes
- # Publish the empty unit:
- content_api.publish_all_drafts(self.learning_package.id)
- unit.refresh_from_db() # Reloading the unit is necessary
- assert unit.versioning.has_unpublished_changes is False # Shallow check for just the unit itself, not children
- assert content_api.contains_unpublished_changes(unit.pk) is False # Deeper check
+ def test_get_unit_nonexistent(self) -> None:
+ """Test `get_unit()` when the unit doesn't exist"""
+ with pytest.raises(Unit.DoesNotExist):
+ content_api.get_unit(-500)
- # Add a published component (unpinned):
- assert self.component_1.versioning.has_unpublished_changes is False
- unit_version_v2 = content_api.create_next_unit_version(
- unit=unit,
- title=unit_version.title,
- components=[self.component_1],
+ def test_get_unit_other_container_type(self) -> None:
+ """Test `get_unit()` when the provided PK is for a non-Unit container"""
+ other_container = content_api.create_container(
+ self.learning_package.id,
+ key="test",
created=self.now,
created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.APPEND,
+ container_cls=TestContainer,
)
- # Now the unit should have unpublished changes:
- unit.refresh_from_db() # Reloading the unit is necessary
- assert unit.versioning.has_unpublished_changes # Shallow check - adding a child is a change to the unit
- assert content_api.contains_unpublished_changes(unit.pk) # Deeper check
- assert unit.versioning.draft == unit_version_v2
- assert unit.versioning.published == unit_version
-
- def test_modify_unpinned_component_after_publish(self):
- """
- Modifying an unpinned component in a published unit will NOT create a
- new version nor show that the unit has unpublished changes (but it will
- "contain" unpublished changes). The modifications will appear in the
- published version of the unit only after the component is published.
- """
- # Create a unit with one unpinned draft component:
- assert self.component_1.versioning.has_unpublished_changes
- unit = self.create_unit_with_components([self.component_1])
- assert unit.versioning.has_unpublished_changes
-
- # Publish the unit and the component:
- content_api.publish_all_drafts(self.learning_package.id)
- unit.refresh_from_db() # Reloading the unit is necessary if we accessed 'versioning' before publish
- self.component_1.refresh_from_db()
- assert unit.versioning.has_unpublished_changes is False # Shallow check
- assert content_api.contains_unpublished_changes(unit.pk) is False # Deeper check
- assert self.component_1.versioning.has_unpublished_changes is False
-
- # Now modify the component by changing its title (it remains a draft):
- component_1_v2 = self.modify_component(self.component_1, title="Modified Counting Problem with new title")
-
- # The component now has unpublished changes; the unit doesn't directly but does contain
- unit.refresh_from_db() # Reloading the unit is necessary, or 'unit.versioning' will be outdated
- self.component_1.refresh_from_db()
- assert unit.versioning.has_unpublished_changes is False # Shallow check should be false - unit is unchanged
- assert content_api.contains_unpublished_changes(unit.pk) # But unit DOES contain changes
- assert self.component_1.versioning.has_unpublished_changes
-
- # Since the component changes haven't been published, they should only appear in the draft unit
- assert content_api.get_components_in_unit(unit, published=False) == [
- Entry(component_1_v2), # new version
- ]
- assert content_api.get_components_in_unit(unit, published=True) == [
- Entry(self.component_1_v1), # old version
- ]
-
- # But if we publish the component, the changes will appear in the published version of the unit.
- self.publish_component(self.component_1)
- assert content_api.get_components_in_unit(unit, published=False) == [
- Entry(component_1_v2), # new version
- ]
- assert content_api.get_components_in_unit(unit, published=True) == [
- Entry(component_1_v2), # new version
- ]
- assert content_api.contains_unpublished_changes(unit.pk) is False # No longer contains unpublished changes
+ with pytest.raises(Unit.DoesNotExist):
+ content_api.get_unit(other_container.pk)
- def test_modify_pinned_component(self):
+ def test_unit_queries(self) -> None:
"""
- When a pinned π component in unit is modified and/or published, it will
- have no effect on either the draft nor published version of the unit,
- which will continue to use the pinned version.
+ Test the number of queries needed for each part of the units API
"""
- # Create a unit with one component (pinned π to v1):
- unit = self.create_unit_with_components([self.component_1_v1])
-
- # Publish the unit and the component:
- content_api.publish_all_drafts(self.learning_package.id)
- expected_unit_contents = [
- Entry(self.component_1_v1, pinned=True), # pinned π to v1
- ]
- assert content_api.get_components_in_unit(unit, published=True) == expected_unit_contents
-
- # Now modify the component by changing its title (it remains a draft):
- self.modify_component(self.component_1, title="Modified Counting Problem with new title")
-
- # The component now has unpublished changes; the unit is entirely unaffected
- unit.refresh_from_db() # Reloading the unit is necessary, or 'unit.versioning' will be outdated
- self.component_1.refresh_from_db()
- assert unit.versioning.has_unpublished_changes is False # Shallow check
- assert content_api.contains_unpublished_changes(unit.pk) is False # Deep check
- assert self.component_1.versioning.has_unpublished_changes is True
-
- # Neither the draft nor the published version of the unit is affected
- assert content_api.get_components_in_unit(unit, published=False) == expected_unit_contents
- assert content_api.get_components_in_unit(unit, published=True) == expected_unit_contents
- # Even if we publish the component, the unit stays pinned to the specified version:
- self.publish_component(self.component_1)
- assert content_api.get_components_in_unit(unit, published=False) == expected_unit_contents
- assert content_api.get_components_in_unit(unit, published=True) == expected_unit_contents
-
- def test_create_two_units_with_same_components(self):
- """
- Test creating two units with different combinations of the same two
- components in each unit.
- """
- # Create a unit with component 2 unpinned, component 2 pinned π, and component 1:
- unit1 = self.create_unit_with_components([self.component_2, self.component_2_v1, self.component_1], key="u1")
- # Create a second unit with component 1 pinned π, component 2, and component 1 unpinned:
- unit2 = self.create_unit_with_components([self.component_1_v1, self.component_2, self.component_1], key="u2")
-
- # Check that the contents are as expected:
- assert [row.component_version for row in content_api.get_components_in_unit(unit1, published=False)] == [
- self.component_2_v1, self.component_2_v1, self.component_1_v1,
- ]
- assert [row.component_version for row in content_api.get_components_in_unit(unit2, published=False)] == [
- self.component_1_v1, self.component_2_v1, self.component_1_v1,
- ]
-
- # Modify component 1
- component_1_v2 = self.modify_component(self.component_1, title="component 1 v2")
- # Publish changes
- content_api.publish_all_drafts(self.learning_package.id)
- # Modify component 2 - only in the draft
- component_2_v2 = self.modify_component(self.component_2, title="component 2 DRAFT")
-
- # Check that the draft contents are as expected:
- assert content_api.get_components_in_unit(unit1, published=False) == [
- Entry(component_2_v2), # v2 in the draft version
- Entry(self.component_2_v1, pinned=True), # pinned π to v1
- Entry(component_1_v2), # v2
- ]
- assert content_api.get_components_in_unit(unit2, published=False) == [
- Entry(self.component_1_v1, pinned=True), # pinned π to v1
- Entry(component_2_v2), # v2 in the draft version
- Entry(component_1_v2), # v2
- ]
-
- # Check that the published contents are as expected:
- assert content_api.get_components_in_unit(unit1, published=True) == [
- Entry(self.component_2_v1), # v1 in the published version
- Entry(self.component_2_v1, pinned=True), # pinned π to v1
- Entry(component_1_v2), # v2
- ]
- assert content_api.get_components_in_unit(unit2, published=True) == [
- Entry(self.component_1_v1, pinned=True), # pinned π to v1
- Entry(self.component_2_v1), # v1 in the published version
- Entry(component_1_v2), # v2
- ]
-
- def test_publishing_shared_component(self):
- """
- A complex test case involving two units with a shared component and
- other non-shared components.
-
- Unit 1: components C1, C2, C3
- Unit 2: components C2, C4, C5
- Everything is "unpinned".
- """
- # 1οΈβ£ Create the units and publish them:
- (c1, c1_v1), (c2, _c2_v1), (c3, c3_v1), (c4, c4_v1), (c5, c5_v1) = [
- self.create_component(key=f"C{i}", title=f"Component {i}") for i in range(1, 6)
- ]
- unit1 = self.create_unit_with_components([c1, c2, c3], title="Unit 1", key="unit:1")
- unit2 = self.create_unit_with_components([c2, c4, c5], title="Unit 2", key="unit:2")
- content_api.publish_all_drafts(self.learning_package.id)
- assert content_api.contains_unpublished_changes(unit1.pk) is False
- assert content_api.contains_unpublished_changes(unit2.pk) is False
-
- # 2οΈβ£ Then the author edits C2 inside of Unit 1 making C2v2.
- c2_v2 = self.modify_component(c2, title="C2 version 2")
- # This makes U1 and U2 both show up as Units that CONTAIN unpublished changes, because they share the component.
- assert content_api.contains_unpublished_changes(unit1.pk)
- assert content_api.contains_unpublished_changes(unit2.pk)
- # (But the units themselves are unchanged:)
- unit1.refresh_from_db()
- unit2.refresh_from_db()
- assert unit1.versioning.has_unpublished_changes is False
- assert unit2.versioning.has_unpublished_changes is False
-
- # 3οΈβ£ In addition to this, the author also modifies another component in Unit 2 (C5)
- c5_v2 = self.modify_component(c5, title="C5 version 2")
-
- # 4οΈβ£ The author then publishes Unit 1, and therefore everything in it.
- content_api.publish_from_drafts(
- self.learning_package.pk,
- draft_qset=content_api.get_all_drafts(self.learning_package.pk).filter(
- # Note: we only publish the unit; the publishing API should auto-publish its components too.
- entity_id=unit1.publishable_entity.id,
- ),
- )
-
- # Result: Unit 1 will show the newly published version of C2:
- assert content_api.get_components_in_unit(unit1, published=True) == [
- Entry(c1_v1),
- Entry(c2_v2), # new published version of C2
- Entry(c3_v1),
- ]
-
- # Result: someone looking at Unit 2 should see the newly published component 2, because publishing it anywhere
- # publishes it everywhere. But publishing C2 and Unit 1 does not affect the other components in Unit 2.
- # (Publish propagates downward, not upward)
- assert content_api.get_components_in_unit(unit2, published=True) == [
- Entry(c2_v2), # new published version of C2
- Entry(c4_v1), # still original version of C4 (it was never modified)
- Entry(c5_v1), # still original version of C5 (it hasn't been published)
- ]
-
- # Result: Unit 2 CONTAINS unpublished changes because of the modified C5. Unit 1 doesn't contain unpub changes.
- assert content_api.contains_unpublished_changes(unit1.pk) is False
- assert content_api.contains_unpublished_changes(unit2.pk)
-
- # 5οΈβ£ Publish component C5, which should be the only thing unpublished in the learning package
- self.publish_component(c5)
- # Result: Unit 2 shows the new version of C5 and no longer contains unpublished changes:
- assert content_api.get_components_in_unit(unit2, published=True) == [
- Entry(c2_v2), # new published version of C2
- Entry(c4_v1), # still original version of C4 (it was never modified)
- Entry(c5_v2), # new published version of C5
- ]
- assert content_api.contains_unpublished_changes(unit2.pk) is False
-
- def test_query_count_of_contains_unpublished_changes(self):
- """
- Checking for unpublished changes in a unit should require a fixed number
- of queries, not get more expensive as the unit gets larger.
- """
- # Add 100 components (unpinned)
- component_count = 100
- components = []
- for i in range(component_count):
- component, _version = self.create_component(
- key=f"Query Counting {i}",
- title=f"Querying Counting Problem {i}",
+ with self.assertNumQueries(35):
+ unit = self.create_unit_with_components([self.component_1, self.component_2_v1])
+ with self.assertNumQueries(48): # TODO: this seems high?
+ content_api.publish_from_drafts(
+ self.learning_package.id,
+ draft_qset=content_api.get_all_drafts(self.learning_package.id).filter(entity=unit.pk),
)
- components.append(component)
- unit = self.create_unit_with_components(components)
- content_api.publish_all_drafts(self.learning_package.id)
- unit.refresh_from_db()
- with self.assertNumQueries(1):
- assert content_api.contains_unpublished_changes(unit.pk) is False
-
- # Modify the most recently created component:
- self.modify_component(component, title="Modified Component")
- with self.assertNumQueries(1):
- assert content_api.contains_unpublished_changes(unit.pk) is True
-
- def test_metadata_change_doesnt_create_entity_list(self):
- """
- Test that changing a container's metadata like title will create a new
- version, but can re-use the same EntityList. API consumers generally
- shouldn't depend on this behavior; it's an optimization.
- """
- unit = self.create_unit_with_components([self.component_1, self.component_2_v1])
-
- orig_version_num = unit.versioning.draft.version_num
- orig_entity_list_id = unit.versioning.draft.entity_list.pk
-
- content_api.create_next_unit_version(unit, title="New Title", created=self.now)
-
- unit.refresh_from_db()
- new_version_num = unit.versioning.draft.version_num
- new_entity_list_id = unit.versioning.draft.entity_list.pk
-
- assert new_version_num > orig_version_num
- assert new_entity_list_id == orig_entity_list_id
-
- @ddt.data(True, False)
- @pytest.mark.skip(reason="FIXME: we don't yet prevent adding soft-deleted components to units")
- def test_cannot_add_soft_deleted_component(self, publish_first):
- """
- Test that a soft-deleted component cannot be added to a unit.
-
- Although it's valid for units to contain soft-deleted components (by
- deleting the component after adding it), it is likely a mistake if
- you're trying to add one to the unit.
- """
- component, _cv = self.create_component(title="Deleted component")
- if publish_first:
- # Publish the component:
- content_api.publish_all_drafts(self.learning_package.id)
- # Now delete it. The draft version is now deleted:
- content_api.soft_delete_draft(component.pk)
- # Now try adding that component to a unit:
- with pytest.raises(ValidationError, match="component is deleted"):
- self.create_unit_with_components([component])
-
- def test_removing_component(self):
- """ Test removing a component from a unit (but not deleting it) """
- unit = self.create_unit_with_components([self.component_1, self.component_2])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now remove component 2
- content_api.create_next_unit_version(
- unit=unit,
- title="Revised with component 2 deleted",
- components=[self.component_2],
- created=self.now,
- entities_action=content_api.ChildrenEntitiesAction.REMOVE,
- )
-
- # Now it should not be listed in the unit:
- assert content_api.get_components_in_unit(unit, published=False) == [
- Entry(self.component_1_v1),
- ]
- unit.refresh_from_db()
- assert unit.versioning.has_unpublished_changes # The unit itself and its component list have change
- assert content_api.contains_unpublished_changes(unit.pk)
- # The published version of the unit is not yet affected:
- assert content_api.get_components_in_unit(unit, published=True) == [
- Entry(self.component_1_v1),
- Entry(self.component_2_v1),
- ]
-
- # But when we publish the new unit version with the removal, the published version is affected:
- content_api.publish_all_drafts(self.learning_package.id)
- # FIXME: Refreshing the unit is necessary here because get_entities_in_published_container() accesses
- # container.versioning.published, and .versioning is cached with the old version. But this seems like
- # a footgun? We could avoid this if get_entities_in_published_container() took only an ID instead of an object,
- # but that would involve additional database lookup(s).
- unit.refresh_from_db()
- assert content_api.contains_unpublished_changes(unit.pk) is False
- assert content_api.get_components_in_unit(unit, published=True) == [
- Entry(self.component_1_v1),
- ]
-
- def test_soft_deleting_component(self):
- """ Test soft deleting a component that's in a unit (but not removing it) """
- unit = self.create_unit_with_components([self.component_1, self.component_2])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now soft delete component 2
- content_api.soft_delete_draft(self.component_2.pk)
-
- # Now it should not be listed in the unit:
- assert content_api.get_components_in_unit(unit, published=False) == [
- Entry(self.component_1_v1),
- # component 2 is soft deleted from the draft.
- # TODO: should we return some kind of placeholder here, to indicate that a component is still listed in the
- # unit's component list but has been soft deleted, and will be fully deleted when published, or restored if
- # reverted?
- ]
- assert unit.versioning.has_unpublished_changes is False # The unit itself and its component list is not changed
- assert content_api.contains_unpublished_changes(unit.pk) # But it CONTAINS an unpublished change (a deletion)
- # The published version of the unit is not yet affected:
- assert content_api.get_components_in_unit(unit, published=True) == [
- Entry(self.component_1_v1),
- Entry(self.component_2_v1),
- ]
-
- # But when we publish the deletion, the published version is affected:
- content_api.publish_all_drafts(self.learning_package.id)
- assert content_api.contains_unpublished_changes(unit.pk) is False
- assert content_api.get_components_in_unit(unit, published=True) == [
- Entry(self.component_1_v1),
- ]
-
- def test_soft_deleting_and_removing_component(self):
- """ Test soft deleting a component that's in a unit AND removing it """
- unit = self.create_unit_with_components([self.component_1, self.component_2])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now soft delete component 2
- content_api.soft_delete_draft(self.component_2.pk)
- # And remove it from the unit:
- content_api.create_next_unit_version(
- unit=unit,
- title="Revised with component 2 deleted",
- components=[self.component_2],
- created=self.now,
- entities_action=content_api.ChildrenEntitiesAction.REMOVE,
- )
-
- # Now it should not be listed in the unit:
- assert content_api.get_components_in_unit(unit, published=False) == [
- Entry(self.component_1_v1),
- ]
- assert unit.versioning.has_unpublished_changes is True
- assert content_api.contains_unpublished_changes(unit.pk)
- # The published version of the unit is not yet affected:
- assert content_api.get_components_in_unit(unit, published=True) == [
- Entry(self.component_1_v1),
- Entry(self.component_2_v1),
- ]
-
- # But when we publish the deletion, the published version is affected:
- content_api.publish_all_drafts(self.learning_package.id)
- assert content_api.contains_unpublished_changes(unit.pk) is False
- assert content_api.get_components_in_unit(unit, published=True) == [
- Entry(self.component_1_v1),
- ]
-
- def test_soft_deleting_pinned_component(self):
- """ Test soft deleting a pinned π component that's in a unit """
- unit = self.create_unit_with_components([self.component_1_v1, self.component_2_v1])
- content_api.publish_all_drafts(self.learning_package.id)
-
- # Now soft delete component 2
- content_api.soft_delete_draft(self.component_2.pk)
-
- # Now it should still be listed in the unit:
- assert content_api.get_components_in_unit(unit, published=False) == [
- Entry(self.component_1_v1, pinned=True),
- Entry(self.component_2_v1, pinned=True),
- ]
- assert unit.versioning.has_unpublished_changes is False # The unit itself and its component list is not changed
- assert content_api.contains_unpublished_changes(unit.pk) is False # nor does it contain changes
- # The published version of the unit is also not affected:
- assert content_api.get_components_in_unit(unit, published=True) == [
- Entry(self.component_1_v1, pinned=True),
- Entry(self.component_2_v1, pinned=True),
- ]
-
- def test_soft_delete_unit(self):
- """
- I can delete a unit without deleting the components it contains.
-
- See https://github.com/openedx/frontend-app-authoring/issues/1693
- """
- # Create two units, one of which we will soon delete:
- unit_to_delete = self.create_unit_with_components([self.component_1, self.component_2])
- other_unit = self.create_unit_with_components([self.component_1], key="other")
-
- # Publish everything:
- content_api.publish_all_drafts(self.learning_package.id)
- # Delete the unit:
- content_api.soft_delete_draft(unit_to_delete.publishable_entity_id)
- unit_to_delete.refresh_from_db()
- # Now the draft unit is [soft] deleted, but the components, published unit, and other unit is unaffected:
- assert unit_to_delete.versioning.draft is None # Unit is soft deleted.
- assert unit_to_delete.versioning.published is not None
- self.component_1.refresh_from_db()
- assert self.component_1.versioning.draft is not None
- assert content_api.get_components_in_unit(other_unit, published=False) == [Entry(self.component_1_v1)]
-
- # Publish everything:
- content_api.publish_all_drafts(self.learning_package.id)
- # Now the unit's published version is also deleted, but nothing else is affected.
- unit_to_delete.refresh_from_db()
- assert unit_to_delete.versioning.draft is None # Unit is soft deleted.
- assert unit_to_delete.versioning.published is None
- self.component_1.refresh_from_db()
- assert self.component_1.versioning.draft is not None
- assert self.component_1.versioning.published is not None
- assert content_api.get_components_in_unit(other_unit, published=False) == [Entry(self.component_1_v1)]
- assert content_api.get_components_in_unit(other_unit, published=True) == [Entry(self.component_1_v1)]
-
- def test_snapshots_of_published_unit(self):
- """
- Test that we can access snapshots of the historic published version of
- units and their contents.
- """
- # At first the unit has one component (unpinned):
- unit = self.create_unit_with_components([self.component_1])
- self.modify_component(self.component_1, title="Component 1 as of checkpoint 1")
- before_publish = content_api.get_components_in_published_unit_as_of(unit, 0)
- assert before_publish is None
-
- # Publish everything, creating Checkpoint 1
- checkpoint_1 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 1")
-
- ########################################################################
-
- # Now we update the title of the component.
- self.modify_component(self.component_1, title="Component 1 as of checkpoint 2")
- # Publish everything, creating Checkpoint 2
- checkpoint_2 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 2")
- ########################################################################
-
- # Now add a second component to the unit:
- self.modify_component(self.component_1, title="Component 1 as of checkpoint 3")
- self.modify_component(self.component_2, title="Component 2 as of checkpoint 3")
- content_api.create_next_unit_version(
- unit=unit,
- title="Unit title in checkpoint 3",
- components=[self.component_1, self.component_2],
- created=self.now,
- )
- # Publish everything, creating Checkpoint 3
- checkpoint_3 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 3")
- ########################################################################
-
- # Now add a third component to the unit, a pinned π version of component 1.
- # This will test pinned versions and also test adding at the beginning rather than the end of the unit.
- content_api.create_next_unit_version(
- unit=unit,
- title="Unit title in checkpoint 4",
- components=[self.component_1_v1, self.component_1, self.component_2],
- created=self.now,
- )
- # Publish everything, creating Checkpoint 4
- checkpoint_4 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 4")
- ########################################################################
-
- # Modify the drafts, but don't publish:
- self.modify_component(self.component_1, title="Component 1 draft")
- self.modify_component(self.component_2, title="Component 2 draft")
-
- # Now fetch the snapshots:
- as_of_checkpoint_1 = content_api.get_components_in_published_unit_as_of(unit, checkpoint_1.pk)
- assert [cv.component_version.title for cv in as_of_checkpoint_1] == [
- "Component 1 as of checkpoint 1",
- ]
- as_of_checkpoint_2 = content_api.get_components_in_published_unit_as_of(unit, checkpoint_2.pk)
- assert [cv.component_version.title for cv in as_of_checkpoint_2] == [
- "Component 1 as of checkpoint 2",
- ]
- as_of_checkpoint_3 = content_api.get_components_in_published_unit_as_of(unit, checkpoint_3.pk)
- assert [cv.component_version.title for cv in as_of_checkpoint_3] == [
- "Component 1 as of checkpoint 3",
- "Component 2 as of checkpoint 3",
- ]
- as_of_checkpoint_4 = content_api.get_components_in_published_unit_as_of(unit, checkpoint_4.pk)
- assert [cv.component_version.title for cv in as_of_checkpoint_4] == [
- "Querying Counting Problem", # Pinned. This title is self.component_1_v1.title (original v1 title)
- "Component 1 as of checkpoint 3", # we didn't modify these components so they're same as in snapshot 3
- "Component 2 as of checkpoint 3", # we didn't modify these components so they're same as in snapshot 3
- ]
-
- def test_units_containing(self):
- """
- Test that we can efficiently get a list of all the [draft] units
- containing a given component.
- """
- component_1_v2 = self.modify_component(self.component_1, title="modified component 1")
-
- # Create a few units, some of which contain component 1 and others which don't:
- # Note: it is important that some of these units contain other components, to ensure the complex JOINs required
- # for this query are working correctly, especially in the case of ignore_pinned=True.
- # Unit 1 β
has component 1, pinned π to V1
- unit1_1pinned = self.create_unit_with_components([self.component_1_v1, self.component_2], key="u1")
- # Unit 2 β
has component 1, pinned π to V2
- unit2_1pinned_v2 = self.create_unit_with_components([component_1_v2, self.component_2_v1], key="u2")
- # Unit 3 doesn't contain it
- _unit3_no = self.create_unit_with_components([self.component_2], key="u3")
- # Unit 4 β
has component 1, unpinned
- unit4_unpinned = self.create_unit_with_components([
- self.component_1, self.component_2, self.component_2_v1,
- ], key="u4")
- # Units 5/6 don't contain it
- _unit5_no = self.create_unit_with_components([self.component_2_v1, self.component_2], key="u5")
- _unit6_no = self.create_unit_with_components([], key="u6")
- # To test unique results, unit 7 β
contains several copies of component 1. Also tests matching against
- # components that aren't in the first position.
- unit7_several = self.create_unit_with_components([
- self.component_2, self.component_1, self.component_1_v1, self.component_1,
- ], key="u7")
-
- # No need to publish anything as the get_containers_with_entity() API only considers drafts (for now).
-
- with self.assertNumQueries(1):
- result = [
- c.unit for c in
- content_api.get_containers_with_entity(self.component_1.pk).select_related("unit")
- ]
- assert result == [
- unit1_1pinned,
- unit2_1pinned_v2,
- unit4_unpinned,
- unit7_several, # This should only appear once, not several times.
- ]
-
- # Test retrieving only "unpinned", for cases like potential deletion of a component, where we wouldn't care
- # about pinned uses anyways (they would be unaffected by a delete).
-
- with self.assertNumQueries(1):
- result2 = [
- c.unit for c in
- content_api.get_containers_with_entity(self.component_1.pk, ignore_pinned=True).select_related("unit")
- ]
- assert result2 == [unit4_unpinned, unit7_several]
-
- def test_get_components_in_unit_queries(self):
- """
- Test the query count of get_components_in_unit()
- This also tests the generic method get_entities_in_container()
- """
- unit = self.create_unit_with_components([
- self.component_1,
- self.component_2,
- self.component_2_v1,
- ])
- with self.assertNumQueries(3):
- result = content_api.get_components_in_unit(unit, published=False)
- assert result == [
- Entry(self.component_1.versioning.draft),
- Entry(self.component_2.versioning.draft),
- Entry(self.component_2.versioning.draft, pinned=True),
- ]
- content_api.publish_all_drafts(self.learning_package.id)
with self.assertNumQueries(3):
result = content_api.get_components_in_unit(unit, published=True)
assert result == [
- Entry(self.component_1.versioning.draft),
- Entry(self.component_2.versioning.draft),
- Entry(self.component_2.versioning.draft, pinned=True),
+ Entry(self.component_1_v1),
+ Entry(self.component_2_v1, pinned=True),
]
- def test_add_remove_container_children(self):
+ def test_create_unit_with_invalid_children(self):
"""
- Test adding and removing children components from containers.
+ Verify that only components can be added to units, and a specific exception is raised.
"""
- unit, unit_version = content_api.create_unit_and_version(
- learning_package_id=self.learning_package.id,
- key="unit:key",
- title="Unit",
- components=[self.component_1],
- created=self.now,
- created_by=None,
- )
- assert content_api.get_components_in_unit(unit, published=False) == [
- Entry(self.component_1.versioning.draft),
- ]
- component_3, _ = self.create_component(
- key="Query Counting (3)",
- title="Querying Counting Problem (3)",
- )
- # Add component_2 and component_3
- unit_version_v2 = content_api.create_next_unit_version(
- unit=unit,
- title=unit_version.title,
- components=[self.component_2, component_3],
- created=self.now,
- created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.APPEND,
- )
+ # Create two units:
+ unit = self.create_unit_with_components([])
+ unit_version = unit.versioning.draft
+ unit2 = self.create_unit_with_components([], key="unit:key2", title="Unit 2")
+ # Try adding a Unit to a Unit
+ with pytest.raises(ValidationError, match='The entity "unit:key2" cannot be added to a "unit" container.'):
+ content_api.create_next_unit_version(
+ unit,
+ components=[unit2],
+ created=self.now,
+ created_by=None,
+ )
+ # Check that a new version was not created:
unit.refresh_from_db()
- assert unit_version_v2.version_num == 2
- assert unit_version_v2 in unit.versioning.versions.all()
- # Verify that component_2 and component_3 is added to end
- assert content_api.get_components_in_unit(unit, published=False) == [
- Entry(self.component_1.versioning.draft),
- Entry(self.component_2.versioning.draft),
- Entry(component_3.versioning.draft),
- ]
+ assert content_api.get_unit(unit.pk).versioning.draft == unit_version
+ assert unit.versioning.draft == unit_version
- # Remove component_1
- content_api.create_next_unit_version(
- unit=unit,
- title=unit_version.title,
- components=[self.component_1],
- created=self.now,
- created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.REMOVE,
- )
- unit.refresh_from_db()
- # Verify that component_1 is removed
- assert content_api.get_components_in_unit(unit, published=False) == [
- Entry(self.component_2.versioning.draft),
- Entry(component_3.versioning.draft),
- ]
+ # Also check that `create_unit_and_version()` has the same restriction (not just `create_next_unit_version()`)
+ with pytest.raises(ValidationError, match='The entity "unit:key2" cannot be added to a "unit" container.'):
+ self.create_unit_with_components([unit2], key="unit:key3", title="Unit 3")
- def test_get_container_children_count(self):
- """
- Test get_container_children_count()
- """
- unit = self.create_unit_with_components([self.component_1])
- assert content_api.get_container_children_count(unit.container, published=False) == 1
- # publish
- content_api.publish_all_drafts(self.learning_package.id)
- unit_version = unit.versioning.draft
- content_api.create_next_unit_version(
- unit=unit,
- title=unit_version.title,
- components=[self.component_2],
- created=self.now,
- created_by=None,
- entities_action=content_api.ChildrenEntitiesAction.APPEND,
- )
- unit.refresh_from_db()
- # Should have two components in draft version and 1 in published version
- assert content_api.get_container_children_count(unit.container, published=False) == 2
- assert content_api.get_container_children_count(unit.container, published=True) == 1
- # publish
- content_api.publish_all_drafts(self.learning_package.id)
- unit.refresh_from_db()
- assert content_api.get_container_children_count(unit.container, published=True) == 2
- # Soft delete component_1
- content_api.soft_delete_draft(self.component_1.pk)
- unit.refresh_from_db()
- # Should contain only 1 child
- assert content_api.get_container_children_count(unit.container, published=False) == 1
- content_api.publish_all_drafts(self.learning_package.id)
- unit.refresh_from_db()
- assert content_api.get_container_children_count(unit.container, published=True) == 1
+ def test_is_registered(self):
+ assert Unit in content_api.get_all_container_subclasses()
- # Tests TODO:
- # Test that I can get a [PublishLog] history of a given unit and all its children, including children that aren't
- # currently in the unit and excluding children that are only in other units.
- # Test that I can get a [PublishLog] history of a given unit and its children, that includes changes made to the
- # child components while they were part of the unit but excludes changes made to those children while they were
- # not part of the unit. π«£
+ def test_olx_tag_name(self):
+ assert content_api.get_container_subclass("unit") is Unit
+ assert content_api.get_container_subclass("unit").olx_tag_name == "vertical"
diff --git a/tests/test_django_app/__init__.py b/tests/test_django_app/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/test_django_app/apps.py b/tests/test_django_app/apps.py
new file mode 100644
index 000000000..ac9fe8b2f
--- /dev/null
+++ b/tests/test_django_app/apps.py
@@ -0,0 +1,47 @@
+"""
+Test Django app config
+"""
+
+# pylint: disable=import-outside-toplevel
+#
+# Local imports in AppConfig.ready() are common and expected in Django, since
+# Django needs to run initialization before before we can query for things like
+# models, settings, and app config.
+
+from django.apps import AppConfig
+
+
+class TestAppConfig(AppConfig):
+ """
+ Configuration for the test Django application.
+ """
+
+ name = "tests.test_django_app"
+ label = "test_django_app"
+
+ def register_publishable_models(self):
+ """
+ Register all Publishable -> Version model pairings in our app.
+ """
+ from openedx_content.api import register_publishable_models
+
+ from .models import (
+ ContainerContainer,
+ ContainerContainerVersion,
+ TestContainer,
+ TestContainerVersion,
+ TestEntity,
+ TestEntityVersion,
+ )
+
+ register_publishable_models(TestEntity, TestEntityVersion)
+ register_publishable_models(TestContainer, TestContainerVersion)
+ register_publishable_models(ContainerContainer, ContainerContainerVersion)
+
+ def ready(self):
+ """
+ Currently used to register publishable models.
+
+ May later be used to register signal handlers as well.
+ """
+ self.register_publishable_models()
diff --git a/tests/test_django_app/migrations/0001_initial.py b/tests/test_django_app/migrations/0001_initial.py
new file mode 100644
index 000000000..02df44102
--- /dev/null
+++ b/tests/test_django_app/migrations/0001_initial.py
@@ -0,0 +1,125 @@
+# Generated by Django 5.2.11 on 2026-03-12 22:26
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ("openedx_content", "0005_containertypes"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ContainerContainer",
+ fields=[
+ (
+ "base_container",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="openedx_content.container",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("openedx_content.container",),
+ ),
+ migrations.CreateModel(
+ name="ContainerContainerVersion",
+ fields=[
+ (
+ "container_version",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="openedx_content.containerversion",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("openedx_content.containerversion",),
+ ),
+ migrations.CreateModel(
+ name="TestContainer",
+ fields=[
+ (
+ "container",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="openedx_content.container",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("openedx_content.container",),
+ ),
+ migrations.CreateModel(
+ name="TestContainerVersion",
+ fields=[
+ (
+ "container_version",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="openedx_content.containerversion",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("openedx_content.containerversion",),
+ ),
+ migrations.CreateModel(
+ name="TestEntity",
+ fields=[
+ (
+ "publishable_entity",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ primary_key=True,
+ serialize=False,
+ to="openedx_content.publishableentity",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="TestEntityVersion",
+ fields=[
+ (
+ "publishable_entity_version",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ primary_key=True,
+ serialize=False,
+ to="openedx_content.publishableentityversion",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/tests/test_django_app/migrations/__init__.py b/tests/test_django_app/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/test_django_app/models.py b/tests/test_django_app/models.py
new file mode 100644
index 000000000..203be32dd
--- /dev/null
+++ b/tests/test_django_app/models.py
@@ -0,0 +1,100 @@
+"""
+Models that are only for use in tests.
+
+These models are specifically for testing the `containers` API.
+"""
+
+from typing import override
+
+from django.core.exceptions import ValidationError
+from django.db import models
+
+from openedx_content.models_api import (
+ Container,
+ ContainerVersion,
+ PublishableEntity,
+ PublishableEntityMixin,
+ PublishableEntityVersionMixin,
+)
+
+
+class TestEntity(PublishableEntityMixin):
+ """
+ A generic entity that's not a container. Think of it like a Component, but
+ for testing `containers` APIs without using the `components` API.
+ """
+
+ __test__ = False # Tell pytest this is "an entity for testing" not "a test class for entities"
+
+
+class TestEntityVersion(PublishableEntityVersionMixin):
+ """
+ A particular version of a TestEntity.
+ """
+
+ __test__ = False
+
+
+@Container.register_subclass
+class TestContainer(Container):
+ """
+ A Test Container that can hold anything
+ """
+
+ __test__ = False # Tell pytest this is "a container for testing" not "a test class for containers"
+
+ type_code = "test_generic"
+
+ container = models.OneToOneField(Container, on_delete=models.CASCADE, parent_link=True, primary_key=True)
+
+ @override
+ @classmethod
+ def validate_entity(cls, entity: PublishableEntity) -> None:
+ """Allow any type of child"""
+
+
+class TestContainerVersion(ContainerVersion):
+ """
+ A TestContainerVersion is a specific version of a TestContainer.
+ """
+
+ __test__ = False
+
+ container_version = models.OneToOneField(
+ ContainerVersion,
+ on_delete=models.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ )
+
+
+@Container.register_subclass
+class ContainerContainer(Container):
+ """
+ A Test Container that can hold any container
+ """
+
+ type_code = "test_container_container"
+
+ # Test that we can name this field anything
+ base_container = models.OneToOneField(Container, on_delete=models.CASCADE, parent_link=True, primary_key=True)
+
+ @override
+ @classmethod
+ def validate_entity(cls, entity: PublishableEntity) -> None:
+ """Allow any container as a child"""
+ if not hasattr(entity, "container"):
+ raise ValidationError("ContainerContainer only allows containers as children.")
+
+
+class ContainerContainerVersion(ContainerVersion):
+ """
+ A ContainerContainerVersion is a specific version of a ContainerContainer.
+ """
+
+ container_version = models.OneToOneField(
+ ContainerVersion,
+ on_delete=models.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ )