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, + )