From 9225c6807fac6b6c1c4ed6bee1fd00d9dd4f4143 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 2 Mar 2026 15:42:12 -0800 Subject: [PATCH 01/14] fix: add missing constraint to ComponentType --- .../applets/components/models.py | 20 +++++++++---------- .../0004_componenttype_constraint.py | 16 +++++++++++++++ 2 files changed, 26 insertions(+), 10 deletions(-) create mode 100644 src/openedx_content/migrations/0004_componenttype_constraint.py diff --git a/src/openedx_content/applets/components/models.py b/src/openedx_content/applets/components/models.py index e34a3977..3ec181fa 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/migrations/0004_componenttype_constraint.py b/src/openedx_content/migrations/0004_componenttype_constraint.py new file mode 100644 index 00000000..f556d14b --- /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"), + ), + ] From c6a9e0d5634d53bb3f692354692ee5f3d125704f Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 2 Mar 2026 15:45:18 -0800 Subject: [PATCH 02/14] docs: remove outdated comment --- src/openedx_content/applets/publishing/models/container.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/openedx_content/applets/publishing/models/container.py b/src/openedx_content/applets/publishing/models/container.py index e34bb6a7..a1503812 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/publishing/models/container.py @@ -18,10 +18,6 @@ class Container(PublishableEntityMixin): 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. """ From 034cca9d26c39fc63582627775e6f941fd018039 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 2 Mar 2026 17:25:15 -0800 Subject: [PATCH 03/14] feat: add new ContainerType model, refactor Containers implementation --- src/openedx_content/admin.py | 3 - .../applets/backup_restore/zipper.py | 168 +- .../applets/collections/api.py | 15 +- src/openedx_content/applets/publishing/api.py | 527 +++-- .../applets/publishing/models/__init__.py | 2 +- .../applets/publishing/models/container.py | 113 +- src/openedx_content/applets/sections/admin.py | 48 - src/openedx_content/applets/sections/api.py | 299 +-- .../applets/sections/models.py | 32 +- .../applets/subsections/admin.py | 48 - .../applets/subsections/api.py | 296 +-- .../applets/subsections/models.py | 28 +- src/openedx_content/applets/units/admin.py | 48 - src/openedx_content/applets/units/api.py | 291 +-- src/openedx_content/applets/units/models.py | 24 +- .../migrations/0005_containertypes.py | 83 + test_settings.py | 26 + .../applets/backup_restore/test_backup.py | 5 +- .../applets/backup_restore/test_restore.py | 6 +- .../applets/collections/test_api.py | 23 +- .../applets/components/test_api.py | 15 +- .../applets/publishing/test_api.py | 410 ---- .../applets/publishing/test_containers.py | 1985 +++++++++++++++++ .../applets/sections/test_api.py | 1160 +--------- .../applets/subsections/test_api.py | 1151 +--------- .../openedx_content/applets/units/test_api.py | 1108 +-------- tests/test_django_app/__init__.py | 0 tests/test_django_app/apps.py | 47 + .../migrations/0001_initial.py | 125 ++ tests/test_django_app/migrations/__init__.py | 0 tests/test_django_app/models.py | 100 + 31 files changed, 3429 insertions(+), 4757 deletions(-) delete mode 100644 src/openedx_content/applets/sections/admin.py delete mode 100644 src/openedx_content/applets/subsections/admin.py delete mode 100644 src/openedx_content/applets/units/admin.py create mode 100644 src/openedx_content/migrations/0005_containertypes.py create mode 100644 tests/openedx_content/applets/publishing/test_containers.py create mode 100644 tests/test_django_app/__init__.py create mode 100644 tests/test_django_app/apps.py create mode 100644 tests/test_django_app/migrations/0001_initial.py create mode 100644 tests/test_django_app/migrations/__init__.py create mode 100644 tests/test_django_app/models.py diff --git a/src/openedx_content/admin.py b/src/openedx_content/admin.py index c6ec4063..02dad806 100644 --- a/src/openedx_content/admin.py +++ b/src/openedx_content/admin.py @@ -8,6 +8,3 @@ from .applets.components.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/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 1bb8bbba..1861ba5b 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -33,9 +33,9 @@ from ..components import api as components_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 +804,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_type: publishing_api.ContainerType, + container_map: dict, + children_map: dict, + ): + """Internal logic for _save_units, _save_subsections, and _save_sections""" + type_code = container_type.type_code # e.g. "unit" + for data in containers.get(type_code, []): + entity_key = data.get("key") + container = publishing_api.create_container( + learning_package.id, + **data, # should this be allowed to override any of the following fields? + created_by=self.user_id, + container_type=container_type, + ) + 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], + publishing_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_type=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_type=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_type=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 +888,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_type: publishing_api.ContainerType, + container_map: dict, + children_map: dict, + ): + for valid_draft in containers.get(f"{container_type.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"] + publishing_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 f23b6fbc..7dd11fb0 100644 --- a/src/openedx_content/applets/collections/api.py +++ b/src/openedx_content/applets/collections/api.py @@ -9,7 +9,7 @@ from django.db.models import QuerySet from ..publishing import api as publishing_api -from ..publishing.models import PublishableEntity +from ..publishing.models import Container, PublishableEntity from .models import Collection, CollectionPublishableEntity # The public API that will be re-exported by openedx_content.api @@ -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/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 64b190a0..2ff5fb00 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -4,17 +4,20 @@ 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, Iterable, Optional from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models import F, Prefetch, Q, QuerySet from django.db.transaction import atomic +from django.db.utils import IntegrityError +from typing_extensions import TypeVar # for 'default=...' from openedx_django_lib.fields import create_hash_digest @@ -39,13 +42,14 @@ PublishLogRecord, PublishSideEffect, ) +from .models.container import ContainerImplementationMissingError, ContainerTypeRecord 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) +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 @@ -78,17 +82,22 @@ "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.) + "ContainerType", + "ContainerImplementationMissingError", "create_container", "create_container_version", + "create_container_and_version", "create_next_container_version", "get_container", + "get_container_version", "get_container_by_key", + "get_container_type_code", + "get_container_type", "get_containers", - "get_collection_containers", "ChildrenEntitiesAction", "ContainerEntityListEntry", - "ContainerEntityRow", "get_entities_in_container", + "get_entities_in_container_as_of", "contains_unpublished_changes", "get_containers_with_entity", "get_container_children_count", @@ -1344,22 +1353,89 @@ 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() + 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 +######################################################################################################################## + +@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 +] +ContainerType = 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_type: type[ContainerModel], 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 ] @@ -1370,13 +1446,13 @@ def create_container( key: The key of the container. created: The date and time the container was created. created_by: The ID of the user who created the container + container_type: The type of container to create (e.g. Unit) 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) + assert issubclass(container_type, Container) with atomic(): publishable_entity = create_publishable_entity( learning_package_id, @@ -1385,8 +1461,9 @@ def create_container( created_by, can_stand_alone=can_stand_alone, ) - container = container_cls.objects.create( + container = container_type.objects.create( publishable_entity=publishable_entity, + container_type_record=container_type.get_type_record(), ) return container @@ -1404,7 +1481,7 @@ def create_entity_list() -> EntityList: def create_entity_list_with_rows( - entity_rows: list[ContainerEntityRow], + parsed_entities: list[ParsedEntityReference], *, learning_package_id: int | None, ) -> EntityList: @@ -1413,7 +1490,11 @@ def create_entity_list_with_rows( 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). + 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. @@ -1422,26 +1503,17 @@ def create_entity_list_with_rows( """ # 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(): + 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.") - # 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( @@ -1452,7 +1524,7 @@ def create_entity_list_with_rows( order_num=order_num, entity_version_id=entity.version_pk, ) - for order_num, entity in enumerate(entity_rows) + for order_num, entity in enumerate(parsed_entities) ] ) return entity_list @@ -1466,13 +1538,31 @@ def _create_container_version( entity_list: EntityList, created: datetime, created_by: int | None, - container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment] -) -> ContainerVersionModel: +) -> ContainerVersion: """ Private internal method for logic shared by create_container_version() and create_next_container_version(). """ - assert issubclass(container_version_cls, ContainerVersion) + # validate entity_list using the type implementation: + try: + container_type = Container.subclass_for_type_code(container.container_type_record.type_code) + except ContainerTypeRecord.DoesNotExist as exc: + raise IntegrityError( + "Existing ContainerTypeRecord 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_type) + for entity_row in entity_list.rows: + try: + container_type.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_type.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 = create_publishable_entity_version( container.publishable_entity_id, @@ -1480,16 +1570,13 @@ def _create_container_version( 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() - ] + dependencies=[entity_row.entity_id for entity_row in entity_list.rows if entity_row.is_unpinned()], ) - container_version = container_version_cls.objects.create( + 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 @@ -1500,11 +1587,10 @@ def create_container_version( version_num: int, *, title: str, - entity_rows: list[ContainerEntityRow], + entities: EntityListInput, created: datetime, created_by: int | None, - container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment] -) -> ContainerVersionModel: +) -> ContainerVersion: """ [ 🛑 UNSTABLE ] Create a new container version. @@ -1513,24 +1599,25 @@ def create_container_version( 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). + 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. - 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 + assert entities 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, - ) + 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, @@ -1538,12 +1625,59 @@ def create_container_version( entity_list=entity_list, created=created, created_by=created_by, - container_version_cls=container_version_cls, ) return container_version +def create_container_and_version( + learning_package_id: int, + key: str, + *, + title: str, + container_type: 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_type: The type 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_type=container_type, + ) + 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""" @@ -1555,7 +1689,7 @@ class ChildrenEntitiesAction(Enum): def create_next_entity_list( learning_package_id: int, last_version: ContainerVersion, - entity_rows: list[ContainerEntityRow], + entities: EntityListInput, entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE, ) -> EntityList: """ @@ -1564,59 +1698,64 @@ def create_next_entity_list( 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: 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") + 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, - ) + parsed_entities = [ + ParsedEntityReference(entity_pk=entity.entity_id, version_pk=entity.entity_version_id) for entity in last_entities - ] + entity_rows + ] + parsed_entities 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() + # 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( - entity_rows=entity_rows, - learning_package_id=learning_package_id, - ) + return create_entity_list_with_rows(parsed_entities, learning_package_id=learning_package_id) def create_next_container_version( - container_pk: int, + container: Container | int, + /, *, - title: str | None, - entity_rows: list[ContainerEntityRow] | None, + title: str | None = None, + entities: EntityListInput | None = 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: +) -> ContainerVersion: """ [ 🛑 UNSTABLE ] Create the next version of a container. A new version of the container is created @@ -1631,17 +1770,19 @@ def create_next_container_version( 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. + 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. - 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. + 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. @@ -1649,9 +1790,10 @@ def create_next_container_version( 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) + 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: @@ -1662,15 +1804,12 @@ def create_next_container_version( if force_version_num is not None: next_version_num = force_version_num - if entity_rows is None and last_version is not None: + 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, - entity_rows if entity_rows is not None else [], - entities_action + entity.learning_package_id, last_version, entities if entities is not None else [], entities_action ) next_container_version = _create_container_version( @@ -1680,9 +1819,12 @@ def create_next_container_version( entity_list=next_entity_list, created=created, created_by=created_by, - container_version_cls=container_version_cls, ) + # 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 @@ -1691,6 +1833,8 @@ 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. @@ -1700,6 +1844,20 @@ def get_container(pk: int) -> Container: 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 ] @@ -1712,81 +1870,61 @@ def get_container_by_key(learning_package_id: int, /, key: str) -> 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, - ) + 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_container_type_code(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_record.type_code + + +def get_container_type(container: Container | int, /) -> ContainerType: + """ + 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(container) + return Container.subclass_for_type_code(type_code) def get_containers( learning_package_id: int, - container_cls: type[ContainerModel] = Container, # type: ignore[assignment] include_deleted: bool | None = False, -) -> QuerySet[ContainerModel]: +) -> QuerySet[Container]: """ [ 🛑 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) + 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_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 + return container_qset.order_by("pk") def get_entities_in_container( @@ -1812,7 +1950,8 @@ def get_entities_in_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) + "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: @@ -1820,7 +1959,8 @@ def get_entities_in_container( 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) + "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: @@ -1837,16 +1977,55 @@ def get_entities_in_container( 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, - )) + 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: +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 = 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 = 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. @@ -1864,11 +2043,15 @@ def contains_unpublished_changes(container_id: int) -> bool: 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) + 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 @@ -1913,8 +2096,7 @@ def get_containers_with_entity( # 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" + f"publishable_entity__{branch}__version__containerversion__entity_list__entitylistrow__entity_id" ): publishable_entity_pk, ( f"publishable_entity__{branch}__version__" @@ -1925,8 +2107,7 @@ def get_containers_with_entity( else: filter_dict = { ( - f"publishable_entity__{branch}__version__" - "containerversion__entity_list__entitylistrow__entity_id" + f"publishable_entity__{branch}__version__containerversion__entity_list__entitylistrow__entity_id" ): publishable_entity_pk } qs = Container.objects.filter(**filter_dict) @@ -1970,9 +2151,7 @@ def get_container_children_entities_keys(container_version: ContainerVersion) -> 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") + container_version.entity_list.entitylistrow_set.values_list("entity__key", flat=True).order_by("order_num") ) diff --git a/src/openedx_content/applets/publishing/models/__init__.py b/src/openedx_content/applets/publishing/models/__init__.py index 32a73b21..be648dff 100644 --- a/src/openedx_content/applets/publishing/models/__init__.py +++ b/src/openedx_content/applets/publishing/models/__init__.py @@ -13,7 +13,7 @@ * Storing and querying publish history. """ -from .container import Container, ContainerVersion +from .container import Container, ContainerVersion # Note: ContainerTypeRecord is private. from .draft_log import Draft, DraftChangeLog, DraftChangeLogRecord, DraftSideEffect from .entity_list import EntityList, EntityListRow from .learning_package import LearningPackage diff --git a/src/openedx_content/applets/publishing/models/container.py b/src/openedx_content/applets/publishing/models/container.py index a1503812..7a9a2499 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/publishing/models/container.py @@ -1,11 +1,56 @@ """ Container and ContainerVersion models """ + +from __future__ import annotations + +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 .entity_list import EntityList -from .publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin +from .publishable_entity import PublishableEntity, PublishableEntityMixin, PublishableEntityVersionMixin + +_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 ContainerTypeRecord(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_containertyperecord_type_code_rx", + ), + ] + + def __str__(self) -> str: # pylint: disable=invalid-str-returned + return self.type_code class Container(PublishableEntityMixin): @@ -20,6 +65,72 @@ class Container(PublishableEntityMixin): entities for different learners or at different times. """ + type_code: str # Subclasses must override this, e.g. "unit" + _type_instance: ContainerTypeRecord # Cache used by get_type_record() + + # The type of the container. Cannot be changed once the container is created. + container_type_record = models.ForeignKey( + ContainerTypeRecord, + 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_type_record(cls) -> ContainerTypeRecord: + """ + Get the ContainerTypeRecord for this type of container, auto-creating it + if need be. + """ + if cls is Container: + raise TypeError('Manipulating "naked" Containers is not allowed. Use a specific Container type 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, _ = ContainerTypeRecord.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_type_record(), which will be + invalid after the ContainerTypeRecord table is truncated. + """ + for cls in _registered_container_types.values(): + if hasattr(cls, "_type_instance"): + del cls._type_instance + + @staticmethod + def register_subclass(container_type: type[Container]): + """ + Register a Container subclass + """ + assert container_type.type_code, "Container subclasses must override type_code" + assert container_type.type_code not in _registered_container_types, ( + f"{container_type.type_code} already registered" + ) + _registered_container_types[container_type.type_code] = container_type + return container_type + + @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 + class ContainerVersion(PublishableEntityVersionMixin): """ diff --git a/src/openedx_content/applets/sections/admin.py b/src/openedx_content/applets/sections/admin.py deleted file mode 100644 index e0c08105..00000000 --- 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 05e0d614..4000c9ef 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 django.db.transaction import atomic +from typing import Iterable from ..publishing import api as publishing_api +from ..publishing.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. - - 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 get_section(section_id: int, /): + """Get a section""" + return Section.objects.select_related("container").get(pk=section_id) -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 = publishing_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_type=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. - - Returns: - The newly created SectionVersion. + See documentation of content_api.create_next_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. - - 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 = publishing_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 = publishing_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 afcb0ae0..391cdc07 100644 --- a/src/openedx_content/applets/sections/models.py +++ b/src/openedx_content/applets/sections/models.py @@ -1,9 +1,15 @@ """ 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 ..publishing.api import get_container_type +from ..publishing.models import Container, ContainerVersion, PublishableEntity +from ..subsections.models import Subsection __all__ = [ "Section", @@ -11,13 +17,17 @@ ] +@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" + container = models.OneToOneField( Container, on_delete=models.CASCADE, @@ -25,14 +35,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_type(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 +62,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 d9d197b3..00000000 --- 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 d39c5700..5cbdf8c4 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 django.db.transaction import atomic +from typing import Iterable from ..publishing import api as publishing_api +from ..publishing.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. - - 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 get_subsection(subsection_id: int, /): + """Get a subsection""" + return Subsection.objects.select_related("container").get(pk=subsection_id) -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 = publishing_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_type=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. - - Returns: - The newly created subsection version. + See documentation of content_api.create_next_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. - - 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 = publishing_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 = publishing_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 8d662ed4..8997bf31 100644 --- a/src/openedx_content/applets/subsections/models.py +++ b/src/openedx_content/applets/subsections/models.py @@ -1,9 +1,15 @@ """ 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 ..publishing.api import get_container_type +from ..publishing.models import Container, ContainerVersion, PublishableEntity +from ..units.models import Unit __all__ = [ "Subsection", @@ -11,6 +17,7 @@ ] +@Container.register_subclass class Subsection(Container): """ A Subsection is type of Container that holds Units. @@ -18,6 +25,9 @@ class Subsection(Container): Via Container and its PublishableEntityMixin, Subsections are also publishable entities and can be added to other containers. """ + + type_code = "subsection" + container = models.OneToOneField( Container, on_delete=models.CASCADE, @@ -25,6 +35,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_type(container) is not Unit: + raise ValidationError("Only Units can be added as children of a Subsection") + class SubsectionVersion(ContainerVersion): """ @@ -33,6 +52,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 +62,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 d079875f..00000000 --- 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 779b5b3d..1aa5227b 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 ..publishing.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. - - 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 get_unit(unit_id: int, /): + """Get a unit""" + return Unit.objects.select_related("container").get(pk=unit_id) -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 = publishing_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_type=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 = publishing_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 = publishing_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 0c525584..809ffb73 100644 --- a/src/openedx_content/applets/units/models.py +++ b/src/openedx_content/applets/units/models.py @@ -1,9 +1,12 @@ """ Models that implement units """ + +from typing import override + from django.db import models -from ..publishing.models import Container, ContainerVersion +from ..publishing.models import Container, ContainerVersion, PublishableEntity __all__ = [ "Unit", @@ -11,6 +14,7 @@ ] +@Container.register_subclass class Unit(Container): """ A Unit is type of Container that holds Components. @@ -18,6 +22,9 @@ class Unit(Container): Via Container and its PublishableEntityMixin, Units are also publishable entities and can be added to other containers. """ + + type_code = "unit" + container = models.OneToOneField( Container, on_delete=models.CASCADE, @@ -25,6 +32,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 +48,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 +58,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/0005_containertypes.py b/src/openedx_content/migrations/0005_containertypes.py new file mode 100644 index 00000000..a477860d --- /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") + ContainerTypeRecord = apps.get_model("openedx_content", "ContainerTypeRecord") + section_type, _ = ContainerTypeRecord.objects.get_or_create(type_code="section") + subsection_type, _ = ContainerTypeRecord.objects.get_or_create(type_code="subsection") + unit_type, _ = ContainerTypeRecord.objects.get_or_create(type_code="unit") + + containers_to_update = Container.objects.filter(container_type_record=None) + + containers_to_update.exclude(section=None).update(container_type_record=section_type) + containers_to_update.exclude(subsection=None).update(container_type_record=subsection_type) + containers_to_update.exclude(unit=None).update(container_type_record=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 ContainerTypeRecord model + migrations.CreateModel( + name="ContainerTypeRecord", + 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_containertyperecord_type_code_rx", + ) + ], + }, + ), + # 2. Define the ForeignKey from Container to ContainerType + migrations.AddField( + model_name="container", + name="container_type_record", + field=models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.RESTRICT, + to="openedx_content.containertyperecord", + ), + ), + # 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_record", + field=models.ForeignKey( + editable=False, + null=False, + on_delete=django.db.models.deletion.RESTRICT, + to="openedx_content.containertyperecord", + ), + ), + ] diff --git a/test_settings.py b/test_settings.py index 371903ad..e1e25013 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 74175395..c7df5957 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_type=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 cd3ac83c..055e9759 100644 --- a/tests/openedx_content/applets/backup_restore/test_restore.py +++ b/tests/openedx_content/applets/backup_restore/test_restore.py @@ -66,21 +66,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 publishing_api.get_container_type_code(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 publishing_api.get_container_type_code(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 publishing_api.get_container_type_code(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 faf6352a..4fe970af 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_type=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 759aa25d..a9af5f65 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/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index c0f11378..bb7a1387 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -13,12 +13,9 @@ from openedx_content.applets.publishing import api as publishing_api from openedx_content.applets.publishing.models import ( - Container, - ContainerVersion, Draft, DraftChangeLog, DraftChangeLogRecord, - DraftSideEffect, LearningPackage, PublishableEntity, PublishLog, @@ -938,413 +935,6 @@ def test_simple_publish_log(self) -> None: assert e1_pub_record.new_version == entity1_v2 -class ContainerTestCase(TestCase): - """ - Test basic operations with Drafts. - """ - now: datetime - learning_package: LearningPackage - - @classmethod - def setUpTestData(cls) -> None: - cls.now = datetime(2024, 1, 28, 16, 45, 30, tzinfo=timezone.utc) - cls.learning_package = publishing_api.create_learning_package( - "containers_package_key", - "Container Testing LearningPackage 🔥 1", - created=cls.now, - ) - - 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( - self.learning_package.id, - "child_1", - created=self.now, - created_by=None, - ) - child_1_v1 = publishing_api.create_publishable_entity_version( - child_1.id, - version_num=1, - title="Child 1 🌴", - created=self.now, - created_by=None, - ) - child_2 = publishing_api.create_publishable_entity( - self.learning_package.id, - "child_2", - created=self.now, - created_by=None, - ) - publishing_api.create_publishable_entity_version( - child_2.id, - version_num=1, - title="Child 2 🌴", - created=self.now, - created_by=None, - ) - container: Container = publishing_api.create_container( - self.learning_package.id, - "my_container", - created=self.now, - created_by=None, - ) - container_v1: ContainerVersion = publishing_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), - ], - created=self.now, - created_by=None, - ) - - # All this was just set up. Now that we have our container-child - # relationships, altering a child should add the parent container to - # the DraftChangeLog. - child_1_v2 = publishing_api.create_publishable_entity_version( - child_1.id, - version_num=2, - title="Child 1 v2", - created=self.now, - created_by=None, - ) - 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) - assert child_1_change.old_version == child_1_v1 - assert child_1_change.new_version == child_1_v2 - - # 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 - ) - assert container_change.old_version == container_v1.publishable_entity_version - assert container_change.new_version == container_v1.publishable_entity_version - - # Exactly one side-effect should have been created because we changed - # child_1 after it was part of a container. - side_effects = DraftSideEffect.objects.all() - assert side_effects.count() == 1 - side_effect = side_effects.first() - assert side_effect is not None - assert side_effect.cause == child_1_change - assert side_effect.effect == container_change - - def test_bulk_parent_child_side_effects(self) -> None: - """Test that modifying a child has side-effects on its parent. (bulk version)""" - with publishing_api.bulk_draft_changes_for(self.learning_package.id): - child_1 = publishing_api.create_publishable_entity( - self.learning_package.id, - "child_1", - created=self.now, - created_by=None, - ) - publishing_api.create_publishable_entity_version( - child_1.id, - version_num=1, - title="Child 1 🌴", - created=self.now, - created_by=None, - ) - child_2 = publishing_api.create_publishable_entity( - self.learning_package.id, - "child_2", - created=self.now, - created_by=None, - ) - child_2_v1 = publishing_api.create_publishable_entity_version( - child_2.id, - version_num=1, - title="Child 2 🌴", - created=self.now, - created_by=None, - ) - container: Container = publishing_api.create_container( - self.learning_package.id, - "my_container", - created=self.now, - created_by=None, - ) - container_v1: ContainerVersion = publishing_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), - ], - created=self.now, - created_by=None, - ) - - # All this was just set up. Now that we have our container-child - # relationships, altering a child should add the parent container to - # the DraftChangeLog. - child_1_v2 = publishing_api.create_publishable_entity_version( - child_1.id, - version_num=2, - title="Child 1 v2", - created=self.now, - created_by=None, - ) - - # Because we're doing it in bulk, there's only one DraftChangeLog entry. - assert DraftChangeLog.objects.count() == 1 - last_change_log = DraftChangeLog.objects.first() - assert last_change_log is not None - # There's only ever one change entry per publishable entity - assert last_change_log.records.count() == 3 - - child_1_change = last_change_log.records.get(entity=child_1) - assert child_1_change.old_version is None - assert child_1_change.new_version == child_1_v2 - - child_2_change = last_change_log.records.get(entity=child_2) - 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 - ) - assert container_change.old_version is None - assert container_change.new_version == container_v1.publishable_entity_version - - # There are two side effects here, because we grouped our draft edits - # together using bulk_draft_changes_for, so changes to both children - # count towards side-effects on the container. - side_effects = DraftSideEffect.objects.all() - assert side_effects.count() == 2 - caused_by_child_1 = side_effects.get(cause=child_1_change) - caused_by_child_2 = side_effects.get(cause=child_2_change) - assert caused_by_child_1.effect == container_change - assert caused_by_child_2.effect == container_change - - def test_draft_dependency_multiple_parents(self): - """ - Test that a change in a draft component affects multiple parents. - - This is the scenario where one Component is contained by multiple Units. - """ - # 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, - ) - publishing_api.create_publishable_entity_version( - 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_2 = publishing_api.create_container( - self.learning_package.id, "unit_2", created=self.now, created_by=None, - ) - for unit in [unit_1, unit_2]: - publishing_api.create_container_version( - unit.pk, - 1, - title="My Unit", - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=component.pk), - ], - created=self.now, - created_by=None, - ) - - # At this point there should be no side effects because we created - # everything from the bottom-up. - assert not DraftSideEffect.objects.all().exists() - - # 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, - ) - side_effects = DraftSideEffect.objects.all() - assert side_effects.count() == 2 - assert side_effects.filter(cause__entity=component).count() == 2 - 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): - """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, - ) - publishing_api.create_publishable_entity_version( - 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, - ) - publishing_api.create_container_version( - unit.pk, - 1, - title="My Unit", - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=component.pk), - ], - created=self.now, - created_by=None, - ) - subsection = publishing_api.create_container( - self.learning_package.id, "subsection_1", created=self.now, created_by=None, - ) - publishing_api.create_container_version( - subsection.pk, - 1, - title="My Subsection", - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=unit.pk), - ], - created=self.now, - created_by=None, - ) - - # At this point, no side-effects exist yet because we built it from the - # bottom-up using different DraftChangeLogs - assert not DraftSideEffect.objects.all().exists() - - 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, - ) - - assert DraftSideEffect.objects.count() == 2 - component_change = change_log.records.get(entity=component) - unit_change = change_log.records.get(entity=unit.publishable_entity) - subsection_change = change_log.records.get(entity=subsection.publishable_entity) - - assert not component_change.affected_by.exists() - assert unit_change.affected_by.count() == 1 - assert unit_change.affected_by.first().cause == component_change - assert subsection_change.affected_by.count() == 1 - assert subsection_change.affected_by.first().cause == unit_change - - publish_log = publishing_api.publish_all_drafts(self.learning_package.id) - 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, - ) - publish_log = publishing_api.publish_from_drafts( - self.learning_package.id, - Draft.objects.filter(entity_id=component.pk), - ) - assert publish_log.records.count() == 3 - component_publish = publish_log.records.get(entity=component) - unit_publish = publish_log.records.get(entity=unit.publishable_entity) - subsection_publish = publish_log.records.get(entity=subsection.publishable_entity) - - assert not component_publish.affected_by.exists() - assert unit_publish.affected_by.count() == 1 - assert unit_publish.affected_by.first().cause == component_publish - assert subsection_publish.affected_by.count() == 1 - assert subsection_publish.affected_by.first().cause == unit_publish - - def test_publish_all_layers(self): - """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, - ) - publishing_api.create_publishable_entity_version( - 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, - ) - publishing_api.create_container_version( - unit.pk, - 1, - title="My Unit", - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=component.pk), - ], - created=self.now, - created_by=None, - ) - subsection = publishing_api.create_container( - self.learning_package.id, "subsection_1", created=self.now, created_by=None, - ) - publishing_api.create_container_version( - subsection.pk, - 1, - title="My Subsection", - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=unit.pk), - ], - created=self.now, - created_by=None, - ) - publish_log = publishing_api.publish_from_drafts( - self.learning_package.id, - Draft.objects.filter(pk=subsection.pk), - ) - - # The component, unit, and subsection should all be accounted for in - # the publish log records. - assert publish_log.records.count() == 3 - - def test_container_next_version(self): - """Test that next_version works for containers.""" - child_1 = publishing_api.create_publishable_entity( - self.learning_package.id, - "child_1", - created=self.now, - created_by=None, - ) - container = publishing_api.create_container( - self.learning_package.id, - "my_container", - created=self.now, - created_by=None, - ) - assert container.versioning.latest is None - v1 = publishing_api.create_next_container_version( - container.pk, - title="My Container v1", - entity_rows=None, - created=self.now, - created_by=None, - ) - assert v1.version_num == 1 - assert container.versioning.latest == v1 - v2 = publishing_api.create_next_container_version( - container.pk, - title="My Container v2", - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=child_1.pk) - ], - 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( - container.pk, - title="My Container v3", - entity_rows=None, - created=self.now, - created_by=None, - ) - assert v3.version_num == 3 - assert container.versioning.latest == v3 - # Even though we didn't pass any rows, it should copy the previous version's rows - assert v2.entity_list.entitylistrow_set.count() == 1 - - class EntitiesQueryTestCase(TestCase): """ Tests for querying PublishableEntity objects. diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py new file mode 100644 index 00000000..df6acf2d --- /dev/null +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -0,0 +1,1985 @@ +""" +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.publishing import api as publishing_api +from openedx_content.applets.publishing.models import ( + Container, + ContainerVersion, + Draft, + DraftChangeLog, + DraftSideEffect, + LearningPackage, + PublishableEntity, + PublishableEntityMixin, + PublishableEntityVersionMixin, +) +from openedx_content.applets.publishing.models.container import ContainerTypeRecord +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: publishing_api.EntityListInput, title: str = "" +) -> TestContainer: + """Create a TestContainer with a draft version""" + container, _version = publishing_api.create_container_and_version( + learning_package.id, + key=key, + title=title or f"Container ({key})", + entities=entities, + container_type=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 = publishing_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_type=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 ContainerType 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, _ = publishing_api.create_container_and_version( + lp.pk, + key="abandoned-container", + title="Abandoned Container 1", + entities=[child_entity1], + container_type=TestContainer, + created=now, + ) + # Now create the plugin type (no public API for this; only do this in a test) + ctr = ContainerTypeRecord.objects.create(type_code="misc") + Container.objects.filter(pk=container.pk).update(container_type_record=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: PublishableEntity) -> TestContainer: + """An TestContainer with one child""" + other_lp_parent, _version = publishing_api.create_container_and_version( + lp2.id, + key="other_lp_parent", + title="Generic Container with One Unpinned Child Entity", + entities=[other_lp_child], + container_type=TestContainer, + created=now, + created_by=None, + ) + return other_lp_parent + + +def publish_entity(obj: PublishableEntityMixin): + """Helper method to publish a single container or other entity.""" + lp_id = obj.publishable_entity.learning_package_id + 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, +) -> publishing_api.ContainerEntityListEntry: + """Helper for quickly constructing ContainerEntityListEntry entries""" + return publishing_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 = publishing_api.create_container_and_version( + lp.pk, + key="new-container-1", + title="Test Container 1", + container_type=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 publishing_api.get_container_children_count(container, published=False) == 0 + with pytest.raises(ContainerVersion.DoesNotExist): + publishing_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_type": 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): + publishing_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): + publishing_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 = publishing_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 publishing_api.get_container_children_entities_keys( + original_version + ) == publishing_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) + publishing_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 publishing_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 = publishing_api.create_next_container_version( + parent_of_two, + entities=[child_entity3, child_entity1_v1], + created=now, + created_by=None, + entities_action=publishing_api.ChildrenEntitiesAction.APPEND, + ) + + assert parent_of_two.versioning.draft == version_2 + assert publishing_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 two" container, using the REMOVE action to remove children. + """ + #################################################################################################################### + # TODO: Note: this "REMOVE" API isn't really a great API. It needs all these tests cases to handle the case of + # duplicate entries, and pinned vs. unpinned, and we don't even use "pinning" in Open edX yet. We should consider + # dropping the APPEND/REMOVE APIs altogether and just having a simple "replace all children with this new list" API. + #################################################################################################################### + + # Before looks like this: + assert publishing_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: + publishing_api.create_next_container_version( + parent_of_six.pk, + entities=[child_entity1], + created=now, + created_by=None, + entities_action=publishing_api.ChildrenEntitiesAction.REMOVE, + ) + # Now it looks like: + + assert publishing_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 two" container, using the REMOVE action to remove children. + """ + # Before looks like this: + assert publishing_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: + publishing_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=publishing_api.ChildrenEntitiesAction.REMOVE, + ) + # Now it looks like: + + assert publishing_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 two" container, using the REMOVE action to remove children. + """ + # Before looks like this: + assert publishing_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: + publishing_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=publishing_api.ChildrenEntitiesAction.REMOVE, + ) + # Now it looks like: + + assert publishing_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 two" container, using the REMOVE action to remove children. + """ + # Before looks like this: + assert publishing_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: + publishing_api.create_next_container_version( + parent_of_six.pk, + entities=[child_entity3], + created=now, + created_by=None, + entities_action=publishing_api.ChildrenEntitiesAction.REMOVE, + ) + # Now it looks like: + + assert publishing_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.""" + publishing_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(publishing_api.ContainerImplementationMissingError): + publishing_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."): + publishing_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 = publishing_api.get_container(parent_of_two.pk) + assert result == parent_of_two.container + # Versioning data should be pre-loaded via select_related() + 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): + publishing_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 = publishing_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 = publishing_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 = publishing_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): + publishing_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 = publishing_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): + publishing_api.get_container_by_key(32874, "invalid-key") + + with pytest.raises(Container.DoesNotExist): + publishing_api.get_container_by_key(lp.pk, "invalid-key") + + +# get_container_type_code and get_container_type + + +def test_get_container_type(grandparent: ContainerContainer, parent_of_two: TestContainer, child_entity1: TestEntity): + """ + Test get_container_type_code() and get_container_type() + """ + # Grandparent is a "ContainerContainer": + assert isinstance(grandparent, ContainerContainer) + assert publishing_api.get_container_type_code(grandparent) == "test_container_container" + assert publishing_api.get_container_type(grandparent) is ContainerContainer + # The functions work even if we pass a generic "Container" object: + assert isinstance(grandparent.base_container, Container) + assert publishing_api.get_container_type_code(grandparent.base_container) == "test_container_container" + assert publishing_api.get_container_type(grandparent.base_container) is ContainerContainer + + # "Parent of Two" is a "TestContainer": + assert isinstance(parent_of_two, TestContainer) + assert publishing_api.get_container_type_code(parent_of_two) == "test_generic" + assert publishing_api.get_container_type(parent_of_two) is TestContainer + assert isinstance(parent_of_two.container, Container) + assert publishing_api.get_container_type_code(parent_of_two.container) == "test_generic" + assert publishing_api.get_container_type(parent_of_two.container) is TestContainer + + # Passing in a non-container will trigger an assert failure: + with pytest.raises(AssertionError): + publishing_api.get_container_type(child_entity1) # type: ignore + + +def test_get_container_type_deleted(container_of_uninstalled_type: Container): + """ + Get ContainerType will raise ValueError if the container type implementation + is no longer available + """ + with pytest.raises( + publishing_api.ContainerImplementationMissingError, + match='An implementation for "misc" containers is not currently installed.', + ): + publishing_api.get_container_type(container_of_uninstalled_type) + + # But get_container_type_code() should still work: + assert publishing_api.get_container_type_code(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(publishing_api.get_containers(lp.id)) + # The API always returns Container base class instances, never specific types: + assert [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(publishing_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(publishing_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(publishing_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 decsendants are unpublished drafts only. + assert grandparent.versioning.published is None + + # Tests: + with django_assert_num_queries(1): + assert publishing_api.contains_unpublished_changes(grandparent) + with django_assert_num_queries(1): + assert publishing_api.contains_unpublished_changes(grandparent.pk) + + # Publish grandparent and all its descendants: + with django_assert_num_queries(143): # TODO: investigate as this seems high! + publish_entity(grandparent) + + # Tests: + with django_assert_num_queries(1): + assert not publishing_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 publishing_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 publishing_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 publishing_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 publishing_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": + publishing_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 publishing_api.contains_unpublished_changes(parent_of_two) # Deeper check + + # Add a published entity (child_entity3, unpinned): + parent_of_two_v2 = publishing_api.create_next_container_version( + parent_of_two.pk, + entities=[child_entity3], + created=now, + created_by=None, + entities_action=publishing_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 publishing_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 publishing_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 publishing_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 publishing_api.get_entities_in_container(parent_of_two, published=False) == [ + Entry(child_entity1_v2), # new version + Entry(child_entity2_v1), # unchanged second child + ] + assert publishing_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 publishing_api.get_entities_in_container(parent_of_two, published=False) == [ + Entry(child_entity1_v2), # new version + Entry(child_entity2_v1), # unchanged second child + ] + assert publishing_api.get_entities_in_container(parent_of_two, published=True) == [ + Entry(child_entity1_v2), # new version + Entry(child_entity2_v1), # unchanged second child + ] + assert publishing_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 publishing_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 publishing_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 publishing_api.get_entities_in_container(parent_of_three, published=False) == expected_contents + assert publishing_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 publishing_api.get_entities_in_container(parent_of_three, published=False) == expected_contents + assert publishing_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, _ = publishing_api.create_container_and_version( + lp.pk, + entities=[c1, c2, c3], + title="Unit 1", + key="unit:1", + created=now, + created_by=None, + container_type=TestContainer, + ) + unit2, _ = publishing_api.create_container_and_version( + lp.pk, + entities=[c2, c4, c5], + title="Unit 2", + key="unit:2", + created=now, + created_by=None, + container_type=TestContainer, + ) + publishing_api.publish_all_drafts(lp.pk) + assert publishing_api.contains_unpublished_changes(unit1.pk) is False + assert publishing_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 publishing_api.contains_unpublished_changes(unit1.pk) + assert publishing_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 publishing_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 publishing_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 publishing_api.contains_unpublished_changes(unit1.pk) is False + assert publishing_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 publishing_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 publishing_api.contains_unpublished_changes(unit2.pk) is False + + +# 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: + publishing_api.ContainerEntityListEntry(child_entity3.versioning.draft.publishable_entity_version, pinned=True), + publishing_api.ContainerEntityListEntry(child_entity2.versioning.draft.publishable_entity_version, pinned=True), + publishing_api.ContainerEntityListEntry( + child_entity1.versioning.draft.publishable_entity_version, pinned=False + ), + ] + assert publishing_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): + publishing_api.get_entities_in_container(parent_of_three, published=True) + + publish_entity(parent_of_three) + assert publishing_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 publishing_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 publishing_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 publishing_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 publishing_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 publishing_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 publishing_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 = publishing_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") + publishing_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. + publishing_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 = publishing_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 = publishing_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 = publishing_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 = publishing_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(publishing_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(publishing_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( + publishing_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(publishing_api.get_containers_with_entity(other_lp_child.publishable_entity.pk)) == [ + other_lp_parent.container + ] + assert not list(publishing_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 publishing_api.get_container_children_count(parent_of_two, published=False) == 2 + assert publishing_api.get_container_children_count(parent_of_two, published=True) == 2 + + assert publishing_api.get_container_children_count(parent_of_three, published=False) == 3 + assert publishing_api.get_container_children_count(parent_of_three, published=True) == 3 + + assert publishing_api.get_container_children_count(parent_of_six, published=False) == 6 + assert publishing_api.get_container_children_count(parent_of_six, published=True) == 6 + # grandparent has two direct children - deeper descendants are not counted. + assert publishing_api.get_container_children_count(grandparent, published=False) == 2 + assert publishing_api.get_container_children_count(grandparent, published=True) == 2 + + # Add another container to "grandparent": + publishing_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 publishing_api.get_container_children_count(grandparent, published=False) == 3 + assert publishing_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 publishing_api.get_container_children_count(parent_of_two, published=False) == 1 + assert publishing_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 publishing_api.get_container_children_count(parent_of_six, published=False) == 4 + assert publishing_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) + with django_assert_num_queries(6): + assert publishing_api.get_container_children_count(parent_of_two, published=False) == 2 + with django_assert_num_queries(6): + assert publishing_api.get_container_children_count(parent_of_two, published=True) == 2 + with django_assert_num_queries(6): + assert publishing_api.get_container_children_count(parent_of_six, published=False) == 6 + with django_assert_num_queries(6): + assert publishing_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 publishing_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 publishing_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 + + +# Container side effects and dependencies + + +class TestContainerSideEffects: + """ + Tests related to Container side effects and dependencies + """ + + def test_parent_child_side_effects(self, lp: LearningPackage) -> None: + """Test that modifying a child has side-effects on its parent.""" + child_1 = publishing_api.create_publishable_entity( + lp.id, + "child_1", + created=now, + created_by=None, + ) + child_1_v1 = publishing_api.create_publishable_entity_version( + child_1.id, + version_num=1, + title="Child 1 🌴", + created=now, + created_by=None, + ) + child_2 = publishing_api.create_publishable_entity( + lp.id, + "child_2", + created=now, + created_by=None, + ) + publishing_api.create_publishable_entity_version( + child_2.id, + version_num=1, + title="Child 2 🌴", + created=now, + created_by=None, + ) + container: Container = publishing_api.create_container( + lp.id, + "my_container", + created=now, + created_by=None, + container_type=TestContainer, + ) + container_v1: ContainerVersion = publishing_api.create_container_version( + container.pk, + 1, + title="My Container", + entities=[ + child_1, + child_2, + ], + created=now, + created_by=None, + ) + + # All this was just set up. Now that we have our container-child + # relationships, altering a child should add the parent container to + # the DraftChangeLog. + child_1_v2 = publishing_api.create_publishable_entity_version( + child_1.id, + version_num=2, + title="Child 1 v2", + created=now, + created_by=None, + ) + 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) + assert child_1_change.old_version == child_1_v1 + assert child_1_change.new_version == child_1_v2 + + # 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) + assert container_change.old_version == container_v1.publishable_entity_version + assert container_change.new_version == container_v1.publishable_entity_version + + # Exactly one side-effect should have been created because we changed + # child_1 after it was part of a container. + side_effects = DraftSideEffect.objects.all() + assert side_effects.count() == 1 + side_effect = side_effects.first() + assert side_effect is not None + assert side_effect.cause == child_1_change + assert side_effect.effect == container_change + + def test_bulk_parent_child_side_effects(self, lp: LearningPackage) -> None: + """Test that modifying a child has side-effects on its parent. (bulk version)""" + with publishing_api.bulk_draft_changes_for(lp.id): + child_1 = publishing_api.create_publishable_entity( + lp.id, + "child_1", + created=now, + created_by=None, + ) + publishing_api.create_publishable_entity_version( + child_1.id, + version_num=1, + title="Child 1 🌴", + created=now, + created_by=None, + ) + child_2 = publishing_api.create_publishable_entity( + lp.id, + "child_2", + created=now, + created_by=None, + ) + child_2_v1 = publishing_api.create_publishable_entity_version( + child_2.id, + version_num=1, + title="Child 2 🌴", + created=now, + created_by=None, + ) + container: Container = publishing_api.create_container( + lp.id, + "my_container", + created=now, + created_by=None, + container_type=TestContainer, + ) + container_v1: ContainerVersion = publishing_api.create_container_version( + container.pk, + 1, + title="My Container", + entities=[child_1, child_2], + created=now, + created_by=None, + ) + + # All this was just set up. Now that we have our container-child + # relationships, altering a child should add the parent container to + # the DraftChangeLog. + child_1_v2 = publishing_api.create_publishable_entity_version( + child_1.id, + version_num=2, + title="Child 1 v2", + created=now, + created_by=None, + ) + + # Because we're doing it in bulk, there's only one DraftChangeLog entry. + assert DraftChangeLog.objects.count() == 1 + last_change_log = DraftChangeLog.objects.first() + assert last_change_log is not None + # There's only ever one change entry per publishable entity + assert last_change_log.records.count() == 3 + + child_1_change = last_change_log.records.get(entity=child_1) + assert child_1_change.old_version is None + assert child_1_change.new_version == child_1_v2 + + child_2_change = last_change_log.records.get(entity=child_2) + 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) + assert container_change.old_version is None + assert container_change.new_version == container_v1.publishable_entity_version + + # There are two side effects here, because we grouped our draft edits + # together using bulk_draft_changes_for, so changes to both children + # count towards side-effects on the container. + side_effects = DraftSideEffect.objects.all() + assert side_effects.count() == 2 + caused_by_child_1 = side_effects.get(cause=child_1_change) + caused_by_child_2 = side_effects.get(cause=child_2_change) + assert caused_by_child_1.effect == container_change + assert caused_by_child_2.effect == container_change + + def test_draft_dependency_multiple_parents(self, lp: LearningPackage) -> None: + """ + Test that a change in a draft component affects multiple parents. + + This is the scenario where one Component is contained by multiple Units. + """ + # Set up a Component that lives in two Units + component = publishing_api.create_publishable_entity( + lp.id, + "component_1", + created=now, + created_by=None, + ) + publishing_api.create_publishable_entity_version( + component.id, + version_num=1, + title="Component 1 🌴", + created=now, + created_by=None, + ) + unit_1 = publishing_api.create_container( + lp.id, + "unit_1", + created=now, + created_by=None, + container_type=TestContainer, + ) + unit_2 = publishing_api.create_container( + lp.id, + "unit_2", + created=now, + created_by=None, + container_type=TestContainer, + ) + for unit in [unit_1, unit_2]: + publishing_api.create_container_version( + unit.pk, + 1, + title="My Unit", + entities=[component], + created=now, + created_by=None, + ) + + # At this point there should be no side effects because we created + # everything from the bottom-up. + assert not DraftSideEffect.objects.all().exists() + + # 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=now, + created_by=None, + ) + side_effects = DraftSideEffect.objects.all() + assert side_effects.count() == 2 + assert side_effects.filter(cause__entity=component).count() == 2 + 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, lp: LearningPackage) -> 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( + lp.id, + "component_1", + created=now, + created_by=None, + ) + publishing_api.create_publishable_entity_version( + component.id, + version_num=1, + title="Component 1 🌴", + created=now, + created_by=None, + ) + unit = publishing_api.create_container( + lp.id, + "unit_1", + created=now, + created_by=None, + container_type=TestContainer, + ) + publishing_api.create_container_version( + unit.pk, + 1, + title="My Unit", + entities=[component], + created=now, + created_by=None, + ) + subsection = publishing_api.create_container( + lp.id, + "subsection_1", + created=now, + created_by=None, + container_type=TestContainer, + ) + publishing_api.create_container_version( + subsection.pk, + 1, + title="My Subsection", + entities=[unit], + created=now, + created_by=None, + ) + + # At this point, no side-effects exist yet because we built it from the + # bottom-up using different DraftChangeLogs + assert not DraftSideEffect.objects.all().exists() + + with publishing_api.bulk_draft_changes_for(lp.id) as change_log: + publishing_api.create_publishable_entity_version( + component.id, + version_num=2, + title="Component 1v2🌴", + created=now, + created_by=None, + ) + + assert DraftSideEffect.objects.count() == 2 + component_change = change_log.records.get(entity=component) + unit_change = change_log.records.get(entity=unit.publishable_entity) + subsection_change = change_log.records.get(entity=subsection.publishable_entity) + + assert not component_change.affected_by.exists() + assert unit_change.affected_by.count() == 1 + assert unit_change.affected_by.first().cause == component_change + assert subsection_change.affected_by.count() == 1 + assert subsection_change.affected_by.first().cause == unit_change + + publish_log = publishing_api.publish_all_drafts(lp.id) + assert publish_log.records.count() == 3 + + publishing_api.create_publishable_entity_version( + component.pk, + version_num=3, + title="Component v2", + created=now, + created_by=None, + ) + publish_log = publishing_api.publish_from_drafts( + lp.id, + Draft.objects.filter(entity_id=component.pk), + ) + assert publish_log.records.count() == 3 + component_publish = publish_log.records.get(entity=component) + unit_publish = publish_log.records.get(entity=unit.publishable_entity) + subsection_publish = publish_log.records.get(entity=subsection.publishable_entity) + + assert not component_publish.affected_by.exists() + assert unit_publish.affected_by.count() == 1 + 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 # type: ignore[union-attr] + + def test_publish_all_layers(self, lp: LearningPackage) -> 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( + lp.id, + "component_1", + created=now, + created_by=None, + ) + publishing_api.create_publishable_entity_version( + component.id, + version_num=1, + title="Component 1 🌴", + created=now, + created_by=None, + ) + unit = publishing_api.create_container( + lp.id, + "unit_1", + created=now, + created_by=None, + container_type=TestContainer, + ) + publishing_api.create_container_version( + unit.pk, + 1, + title="My Unit", + entities=[component], + created=now, + created_by=None, + ) + subsection = publishing_api.create_container( + lp.id, + "subsection_1", + created=now, + created_by=None, + container_type=TestContainer, + ) + publishing_api.create_container_version( + subsection.pk, + 1, + title="My Subsection", + entities=[unit], + created=now, + created_by=None, + ) + publish_log = publishing_api.publish_from_drafts( + lp.id, + Draft.objects.filter(pk=subsection.pk), + ) + + # The component, unit, and subsection should all be accounted for in + # the publish log records. + assert publish_log.records.count() == 3 + + def test_container_next_version(self, lp: LearningPackage) -> None: + """Test that next_version works for containers.""" + child_1 = publishing_api.create_publishable_entity( + lp.id, + "child_1", + created=now, + created_by=None, + ) + container = publishing_api.create_container( + lp.id, + "my_container", + created=now, + created_by=None, + container_type=TestContainer, + ) + assert container.versioning.latest is None + v1 = publishing_api.create_next_container_version( + container.pk, + title="My Container v1", + entities=None, + created=now, + created_by=None, + ) + assert v1.version_num == 1 + assert container.versioning.latest == v1 + v2 = publishing_api.create_next_container_version( + container.pk, + title="My Container v2", + entities=[child_1], + created=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( + container.pk, + title="My Container v3", + entities=None, + created=now, + created_by=None, + ) + assert v3.version_num == 3 + assert container.versioning.latest == v3 + # Even though we didn't pass any rows, it should copy the previous version's rows + assert v2.entity_list.entitylistrow_set.count() == 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 f3177113..870530b2 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,96 @@ 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(169): # TODO: this seems high? FIXME: this is 181 on MySQL but 169 on SQLite? + 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), - ] - - def test_add_remove_container_children(self): - """ - Test adding and removing children subsections from sections. - """ - 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, - ) - 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), - ] - - # 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), + Entry(self.subsection_1_v1), + Entry(self.subsection_2_v1, pinned=True), ] - def test_get_container_children_count(self): + def test_create_section_with_invalid_children(self): """ - Test get_container_children_count() + Verify that only subsections can be added to sections, and a specific exception is raised. """ - 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) + # Create a section: + section = self.create_section_with_subsections([]) 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) + # 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 content_api.get_container_children_count(section.container, published=True) == 1 + assert content_api.get_section(section.pk).versioning.draft == section_version + assert section.versioning.draft == section_version - # 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. 🫣 + # 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") diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py index 8d93e108..4de84f56 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,96 @@ 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(107): # 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), - ] - - def test_add_remove_container_children(self): - """ - Test adding and removing children units from subsections. - """ - 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, - ) - 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), - ] - - # 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), + Entry(self.unit_1_v1), + Entry(self.unit_1_v1, pinned=True), ] - def test_get_container_children_count(self): + def test_create_subsection_with_invalid_children(self): """ - Test get_container_children_count() + Verify that only units can be added to subsections, and a specific exception is raised. """ - 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) + # Create a subsection: + subsection = self.create_subsection_with_units([]) 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) + # 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 content_api.get_container_children_count(subsection.container, published=True) == 1 + assert content_api.get_subsection(subsection.pk).versioning.draft == subsection_version + assert subsection.versioning.draft == subsection_version - # 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. 🫣 + # 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") diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 588d9d83..f2e03160 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,89 @@ 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_type=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(49): # 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), - ] - - def test_add_remove_container_children(self): - """ - Test adding and removing children components from containers. - """ - 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, - ) - 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), - ] - - # 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), + Entry(self.component_1_v1), + Entry(self.component_2_v1, pinned=True), ] - def test_get_container_children_count(self): + def test_create_unit_with_invalid_children(self): """ - Test get_container_children_count() + Verify that only components can be added to units, and a specific exception is raised. """ - 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) + # Create two units: + unit = self.create_unit_with_components([]) 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) + 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 content_api.get_container_children_count(unit.container, published=True) == 1 + assert content_api.get_unit(unit.pk).versioning.draft == unit_version + assert unit.versioning.draft == unit_version - # 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. 🫣 + # 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") diff --git a/tests/test_django_app/__init__.py b/tests/test_django_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_django_app/apps.py b/tests/test_django_app/apps.py new file mode 100644 index 00000000..ac9fe8b2 --- /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 00000000..02df4410 --- /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 00000000..e69de29b diff --git a/tests/test_django_app/models.py b/tests/test_django_app/models.py new file mode 100644 index 00000000..137f0d98 --- /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 `publishing` 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 `publishing` 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, + ) From 6e60108afe6d89bfe96c708d26bcf4d0c4307d4a Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 16 Mar 2026 12:48:49 -0700 Subject: [PATCH 04/14] feat: move containers into a new applet --- .importlinter | 4 +- src/openedx_content/api.py | 1 + .../applets/backup_restore/toml.py | 4 +- .../applets/backup_restore/zipper.py | 11 +- .../applets/collections/api.py | 2 +- .../applets/containers/__init__.py | 0 .../applets/containers/admin.py | 289 ++++++ src/openedx_content/applets/containers/api.py | 858 ++++++++++++++++++ .../container.py => containers/models.py} | 100 +- .../applets/publishing/admin.py | 270 +----- src/openedx_content/applets/publishing/api.py | 832 +---------------- .../applets/publishing/models/__init__.py | 2 - .../applets/publishing/models/entity_list.py | 90 -- src/openedx_content/applets/sections/api.py | 10 +- .../applets/sections/models.py | 5 +- .../applets/subsections/api.py | 10 +- .../applets/subsections/models.py | 5 +- src/openedx_content/applets/units/api.py | 10 +- src/openedx_content/applets/units/models.py | 3 +- src/openedx_content/models.py | 3 +- src/openedx_content/models_api.py | 2 + .../applets/backup_restore/test_restore.py | 9 +- .../applets/containers/__init__.py | 0 .../test_api.py} | 750 +++------------ .../applets/publishing/test_api.py | 469 ++++++++++ tests/test_django_app/models.py | 4 +- 26 files changed, 1913 insertions(+), 1830 deletions(-) create mode 100644 src/openedx_content/applets/containers/__init__.py create mode 100644 src/openedx_content/applets/containers/admin.py create mode 100644 src/openedx_content/applets/containers/api.py rename src/openedx_content/applets/{publishing/models/container.py => containers/models.py} (62%) delete mode 100644 src/openedx_content/applets/publishing/models/entity_list.py create mode 100644 tests/openedx_content/applets/containers/__init__.py rename tests/openedx_content/applets/{publishing/test_containers.py => containers/test_api.py} (68%) diff --git a/.importlinter b/.importlinter index 1afc4c3e..5b90864c 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/api.py b/src/openedx_content/api.py index 9aaa9b7b..d8c2f0c3 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 a75e7a0a..d3986180 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 1861ba5b..f8a18271 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -31,6 +31,7 @@ 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.models import Section @@ -809,7 +810,7 @@ def _save_container( learning_package, containers, *, - container_type: publishing_api.ContainerType, + container_type: containers_api.ContainerType, container_map: dict, children_map: dict, ): @@ -817,7 +818,7 @@ def _save_container( type_code = container_type.type_code # e.g. "unit" for data in containers.get(type_code, []): entity_key = data.get("key") - container = publishing_api.create_container( + 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, @@ -831,7 +832,7 @@ def _save_container( self.all_published_entities_versions.add( (entity_key, valid_published.get('version_num')) ) # Track published version - publishing_api.create_next_container_version( + 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), @@ -889,7 +890,7 @@ def _save_draft_versions(self, components, containers, component_static_files): ) def _process_draft_containers( - container_type: publishing_api.ContainerType, + container_type: containers_api.ContainerType, container_map: dict, children_map: dict, ): @@ -900,7 +901,7 @@ def _process_draft_containers( continue children = self._resolve_children(valid_draft, children_map) del valid_draft["version_num"] - publishing_api.create_next_container_version( + 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, diff --git a/src/openedx_content/applets/collections/api.py b/src/openedx_content/applets/collections/api.py index 7dd11fb0..8ab8d9cb 100644 --- a/src/openedx_content/applets/collections/api.py +++ b/src/openedx_content/applets/collections/api.py @@ -9,7 +9,7 @@ from django.db.models import QuerySet from ..publishing import api as publishing_api -from ..publishing.models import Container, PublishableEntity +from ..publishing.models import PublishableEntity from .models import Collection, CollectionPublishableEntity # The public API that will be re-exported by openedx_content.api diff --git a/src/openedx_content/applets/containers/__init__.py b/src/openedx_content/applets/containers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/openedx_content/applets/containers/admin.py b/src/openedx_content/applets/containers/admin.py new file mode 100644 index 00000000..f68fb765 --- /dev/null +++ b/src/openedx_content/applets/containers/admin.py @@ -0,0 +1,289 @@ +""" +Django admin for containers models +""" + +from __future__ import annotations + +import functools + +from django.contrib import admin +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 .models import Container, ContainerVersion, EntityList, EntityListRow + + +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/containers/api.py b/src/openedx_content/applets/containers/api.py new file mode 100644 index 00000000..4107f76a --- /dev/null +++ b/src/openedx_content/applets/containers/api.py @@ -0,0 +1,858 @@ +""" +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, + ContainerTypeRecord, + 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.) + "ContainerType", + "ContainerImplementationMissingError", + "create_container", + "create_container_version", + "create_container_and_version", + "create_next_container_version", + "get_container", + "get_container_version", + "get_container_by_key", + "get_container_type_code", + "get_container_type", + "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 +] +ContainerType = 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_type: 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_type: The type 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_type, 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_type.objects.create( + publishable_entity=publishable_entity, + container_type_record=container_type.get_type_record(), + ) + 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_type = Container.subclass_for_type_code(container.container_type_record.type_code) + except ContainerTypeRecord.DoesNotExist as exc: + raise IntegrityError( + "Existing ContainerTypeRecord 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_type) + for entity_row in entity_list.rows: + try: + container_type.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_type.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_type: 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_type: The type 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_type=container_type, + ) + 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" + 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_container_type_code(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_record.type_code + + +def get_container_type(container: Container | int, /) -> ContainerType: + """ + 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(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/publishing/models/container.py b/src/openedx_content/applets/containers/models.py similarity index 62% rename from src/openedx_content/applets/publishing/models/container.py rename to src/openedx_content/applets/containers/models.py index 7a9a2499..88abcd52 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/containers/models.py @@ -4,6 +4,7 @@ from __future__ import annotations +from functools import cached_property from typing import final from django.core.exceptions import ValidationError @@ -11,8 +12,103 @@ from openedx_django_lib.fields import case_sensitive_char_field -from .entity_list import EntityList -from .publishable_entity import PublishableEntity, PublishableEntityMixin, PublishableEntityVersionMixin +from ..publishing.models.publishable_entity import ( + PublishableEntity, + PublishableEntityMixin, + PublishableEntityVersion, + PublishableEntityVersionMixin, +) + +__all__ = [ + "Container", + "ContainerVersion", + # ContainerTypeRecord 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]] = {} diff --git a/src/openedx_content/applets/publishing/admin.py b/src/openedx_content/applets/publishing/admin.py index 797decf8..64b0035f 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 2ff5fb00..89031ac5 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -8,29 +8,21 @@ 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, Iterable, Optional +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 -from django.db.utils import IntegrityError -from typing_extensions import TypeVar # for 'default=...' from openedx_django_lib.fields import create_hash_digest from .contextmanagers import DraftChangeLogContext from .models import ( - Container, - ContainerVersion, Draft, DraftChangeLog, DraftChangeLogRecord, DraftSideEffect, - EntityList, - EntityListRow, LearningPackage, PublishableContentModelRegistry, PublishableEntity, @@ -42,15 +34,8 @@ PublishLogRecord, PublishSideEffect, ) -from .models.container import ContainerImplementationMissingError, ContainerTypeRecord 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, 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 @@ -80,29 +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.) - "ContainerType", - "ContainerImplementationMissingError", - "create_container", - "create_container_version", - "create_container_and_version", - "create_next_container_version", - "get_container", - "get_container_version", - "get_container_by_key", - "get_container_type_code", - "get_container_type", - "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", "bulk_draft_changes_for", - "get_container_children_entities_keys", ] @@ -1364,797 +1327,6 @@ def get_published_version_as_of(entity_id: int, publish_log_id: int) -> Publisha return record.new_version if record else None -######################################################################################################################## - -@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 -] -ContainerType = 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_type: 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_type: The type 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_type, Container) - with atomic(): - publishable_entity = create_publishable_entity( - learning_package_id, - key, - created, - created_by, - can_stand_alone=can_stand_alone, - ) - container = container_type.objects.create( - publishable_entity=publishable_entity, - container_type_record=container_type.get_type_record(), - ) - 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_type = Container.subclass_for_type_code(container.container_type_record.type_code) - except ContainerTypeRecord.DoesNotExist as exc: - raise IntegrityError( - "Existing ContainerTypeRecord 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_type) - for entity_row in entity_list.rows: - try: - container_type.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_type.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 = 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_type: 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_type: The type 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_type=container_type, - ) - 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" - 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_container_type_code(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_record.type_code - - -def get_container_type(container: Container | int, /) -> ContainerType: - """ - 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(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 = 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 = 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") - ) - - def bulk_draft_changes_for( learning_package_id: int, changed_by: int | None = None, diff --git a/src/openedx_content/applets/publishing/models/__init__.py b/src/openedx_content/applets/publishing/models/__init__.py index be648dff..1cba9043 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 # Note: ContainerTypeRecord is private. 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/entity_list.py b/src/openedx_content/applets/publishing/models/entity_list.py deleted file mode 100644 index 37874ace..00000000 --- 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/api.py b/src/openedx_content/applets/sections/api.py index 4000c9ef..4b348281 100644 --- a/src/openedx_content/applets/sections/api.py +++ b/src/openedx_content/applets/sections/api.py @@ -7,8 +7,8 @@ from datetime import datetime from typing import Iterable -from ..publishing import api as publishing_api -from ..publishing.models import ContainerVersion +from ..containers import api as containers_api +from ..containers.models import ContainerVersion from ..subsections.models import Subsection, SubsectionVersion from .models import Section, SectionVersion @@ -45,7 +45,7 @@ def create_section_and_version( returned is a `SectionVersion`. In the future, if `SectionVersion` gets some fields that aren't on `ContainerVersion`, this function would be more important. """ - section, sv = publishing_api.create_container_and_version( + section, sv = containers_api.create_container_and_version( learning_package_id, key=key, title=title, @@ -77,7 +77,7 @@ def create_next_section_version( if isinstance(section, int): section = get_section(section) assert isinstance(section, Section) - sv = publishing_api.create_next_container_version( + sv = containers_api.create_next_container_version( section, title=title, entities=subsections, @@ -122,7 +122,7 @@ def get_subsections_in_section( assert isinstance(section, Section) subsections = [] try: - entries = publishing_api.get_entities_in_container( + entries = containers_api.get_entities_in_container( section, published=published, select_related_version="containerversion__subsectionversion", diff --git a/src/openedx_content/applets/sections/models.py b/src/openedx_content/applets/sections/models.py index 391cdc07..52e00c86 100644 --- a/src/openedx_content/applets/sections/models.py +++ b/src/openedx_content/applets/sections/models.py @@ -7,8 +7,9 @@ from django.core.exceptions import ValidationError from django.db import models -from ..publishing.api import get_container_type -from ..publishing.models import Container, ContainerVersion, PublishableEntity +from ..containers.api import get_container_type +from ..containers.models import Container, ContainerVersion +from ..publishing.models import PublishableEntity from ..subsections.models import Subsection __all__ = [ diff --git a/src/openedx_content/applets/subsections/api.py b/src/openedx_content/applets/subsections/api.py index 5cbdf8c4..1926827b 100644 --- a/src/openedx_content/applets/subsections/api.py +++ b/src/openedx_content/applets/subsections/api.py @@ -7,8 +7,8 @@ from datetime import datetime from typing import Iterable -from ..publishing import api as publishing_api -from ..publishing.models import ContainerVersion +from ..containers import api as containers_api +from ..containers.models import ContainerVersion from ..units.models import Unit, UnitVersion from .models import Subsection, SubsectionVersion @@ -45,7 +45,7 @@ def create_subsection_and_version( returned is a `SubsectionVersion`. In the future, if `SubsectionVersion` gets some fields that aren't on `ContainerVersion`, this function would be more important. """ - subsection, sv = publishing_api.create_container_and_version( + subsection, sv = containers_api.create_container_and_version( learning_package_id, key=key, title=title, @@ -77,7 +77,7 @@ def create_next_subsection_version( if isinstance(subsection, int): subsection = get_subsection(subsection) assert isinstance(subsection, Subsection) - sv = publishing_api.create_next_container_version( + sv = containers_api.create_next_container_version( subsection, title=title, entities=units, @@ -122,7 +122,7 @@ def get_units_in_subsection( assert isinstance(subsection, Subsection) units = [] try: - entries = publishing_api.get_entities_in_container( + entries = containers_api.get_entities_in_container( subsection, published=published, select_related_version="containerversion__unitversion", diff --git a/src/openedx_content/applets/subsections/models.py b/src/openedx_content/applets/subsections/models.py index 8997bf31..8422c091 100644 --- a/src/openedx_content/applets/subsections/models.py +++ b/src/openedx_content/applets/subsections/models.py @@ -7,8 +7,9 @@ from django.core.exceptions import ValidationError from django.db import models -from ..publishing.api import get_container_type -from ..publishing.models import Container, ContainerVersion, PublishableEntity +from ..containers.api import get_container_type +from ..containers.models import Container, ContainerVersion +from ..publishing.models import PublishableEntity from ..units.models import Unit __all__ = [ diff --git a/src/openedx_content/applets/units/api.py b/src/openedx_content/applets/units/api.py index 1aa5227b..09a94f8b 100644 --- a/src/openedx_content/applets/units/api.py +++ b/src/openedx_content/applets/units/api.py @@ -8,8 +8,8 @@ from typing import Iterable from ..components.models import Component, ComponentVersion -from ..publishing import api as publishing_api -from ..publishing.models import ContainerVersion +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 @@ -45,7 +45,7 @@ def create_unit_and_version( returned is a `UnitVersion`. In the future, if `UnitVersion` gets some fields that aren't on `ContainerVersion`, this function would be more important. """ - unit, uv = publishing_api.create_container_and_version( + unit, uv = containers_api.create_container_and_version( learning_package_id, key=key, title=title, @@ -77,7 +77,7 @@ def create_next_unit_version( if isinstance(unit, int): unit = get_unit(unit) assert isinstance(unit, Unit) - uv = publishing_api.create_next_container_version( + uv = containers_api.create_next_container_version( unit, title=title, entities=components, @@ -122,7 +122,7 @@ def get_components_in_unit( assert isinstance(unit, Unit) components = [] try: - entries = publishing_api.get_entities_in_container( + entries = containers_api.get_entities_in_container( unit, published=published, select_related_version="componentversion", diff --git a/src/openedx_content/applets/units/models.py b/src/openedx_content/applets/units/models.py index 809ffb73..9e9328f1 100644 --- a/src/openedx_content/applets/units/models.py +++ b/src/openedx_content/applets/units/models.py @@ -6,7 +6,8 @@ from django.db import models -from ..publishing.models import Container, ContainerVersion, PublishableEntity +from ..containers.models import Container, ContainerVersion +from ..publishing.models import PublishableEntity __all__ = [ "Unit", diff --git a/src/openedx_content/models.py b/src/openedx_content/models.py index 91696b5f..8d852977 100644 --- a/src/openedx_content/models.py +++ b/src/openedx_content/models.py @@ -5,11 +5,12 @@ import their modules, but that broke code introspection. """ -# pylint: disable=wildcard-import +# pylint: disable=wildcard-import,unused-import from .applets.backup_restore.models import * from .applets.collections.models import * from .applets.components.models import * +from .applets.containers.models import Container, ContainerTypeRecord, ContainerVersion # __all__ excludes TypeRecord 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 1e035b43..ee62b524 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/tests/openedx_content/applets/backup_restore/test_restore.py b/tests/openedx_content/applets/backup_restore/test_restore.py index 055e9759..d1dea46e 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 publishing_api.get_container_type_code(container) == "unit" + assert containers_api.get_container_type_code(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 publishing_api.get_container_type_code(container) == "subsection" + assert containers_api.get_container_type_code(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 publishing_api.get_container_type_code(container) == "section" + assert containers_api.get_container_type_code(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/containers/__init__.py b/tests/openedx_content/applets/containers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/containers/test_api.py similarity index 68% rename from tests/openedx_content/applets/publishing/test_containers.py rename to tests/openedx_content/applets/containers/test_api.py index df6acf2d..631c821c 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/containers/test_api.py @@ -10,19 +10,15 @@ 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, ContainerTypeRecord, ContainerVersion from openedx_content.applets.publishing import api as publishing_api from openedx_content.applets.publishing.models import ( - Container, - ContainerVersion, - Draft, - DraftChangeLog, - DraftSideEffect, LearningPackage, PublishableEntity, PublishableEntityMixin, PublishableEntityVersionMixin, ) -from openedx_content.applets.publishing.models.container import ContainerTypeRecord from tests.test_django_app.models import ( ContainerContainer, TestContainer, @@ -144,10 +140,10 @@ def _other_lp_child(lp2: LearningPackage) -> TestEntity: def create_test_container( - learning_package: LearningPackage, key: str, entities: publishing_api.EntityListInput, title: str = "" + learning_package: LearningPackage, key: str, entities: containers_api.EntityListInput, title: str = "" ) -> TestContainer: """Create a TestContainer with a draft version""" - container, _version = publishing_api.create_container_and_version( + container, _version = containers_api.create_container_and_version( learning_package.id, key=key, title=title or f"Container ({key})", @@ -217,7 +213,7 @@ def _grandparent( parent_of_three: TestContainer, ) -> ContainerContainer: """An ContainerContainer with two unpinned children""" - grandparent, _version = publishing_api.create_container_and_version( + grandparent, _version = containers_api.create_container_and_version( lp.id, key="grandparent", title="Generic Container with Two Unpinned TestContainer children", @@ -236,7 +232,7 @@ def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: TestEntit 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, _ = publishing_api.create_container_and_version( + container, _ = containers_api.create_container_and_version( lp.pk, key="abandoned-container", title="Abandoned Container 1", @@ -253,7 +249,7 @@ def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: TestEntit @pytest.fixture(name="other_lp_parent") def _other_lp_parent(lp2: LearningPackage, other_lp_child: PublishableEntity) -> TestContainer: """An TestContainer with one child""" - other_lp_parent, _version = publishing_api.create_container_and_version( + other_lp_parent, _version = containers_api.create_container_and_version( lp2.id, key="other_lp_parent", title="Generic Container with One Unpinned Child Entity", @@ -286,9 +282,9 @@ def modify_entity(obj: TestEntity, title="Newly modified entity"): def Entry( component_version: PublishableEntityVersionMixin, pinned: bool = False, -) -> publishing_api.ContainerEntityListEntry: +) -> containers_api.ContainerEntityListEntry: """Helper for quickly constructing ContainerEntityListEntry entries""" - return publishing_api.ContainerEntityListEntry(component_version.publishable_entity_version, pinned=pinned) + return containers_api.ContainerEntityListEntry(component_version.publishable_entity_version, pinned=pinned) ######################################################################################################################## @@ -303,7 +299,7 @@ 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 = publishing_api.create_container_and_version( + container, container_v1 = containers_api.create_container_and_version( lp.pk, key="new-container-1", title="Test Container 1", @@ -328,9 +324,9 @@ def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None assert container.versioning.draft.created_by == admin_user assert not container.can_stand_alone - assert publishing_api.get_container_children_count(container, published=False) == 0 + assert containers_api.get_container_children_count(container, published=False) == 0 with pytest.raises(ContainerVersion.DoesNotExist): - publishing_api.get_container_children_count(container, published=True) + containers_api.get_container_children_count(container, published=True) def test_create_container_queries(lp: LearningPackage, child_entity1: TestEntity, django_assert_num_queries) -> None: @@ -343,10 +339,10 @@ def test_create_container_queries(lp: LearningPackage, child_entity1: TestEntity } # The exact numbers here aren't too important - this is just to alert us if anything significant changes. with django_assert_num_queries(31): - publishing_api.create_container_and_version(lp.pk, key="c1", **base_args) + 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): - publishing_api.create_container_and_version(lp.pk, key="c2", **base_args, entities=[child_entity1]) + containers_api.create_container_and_version(lp.pk, key="c2", **base_args, entities=[child_entity1]) # versioning helpers @@ -382,7 +378,7 @@ def test_create_next_container_version_no_changes(parent_of_two: TestContainer, # Create a new version with no changes: v2_date = datetime.now(tz=timezone.utc) - version_2 = publishing_api.create_next_container_version( + version_2 = containers_api.create_next_container_version( parent_of_two, created=v2_date, created_by=other_user.pk, @@ -404,9 +400,9 @@ def test_create_next_container_version_no_changes(parent_of_two: TestContainer, 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 publishing_api.get_container_children_entities_keys( + assert containers_api.get_container_children_entities_keys( original_version - ) == publishing_api.get_container_children_entities_keys(version_2) + ) == containers_api.get_container_children_entities_keys(version_2) def test_create_next_container_version_with_changes( @@ -421,7 +417,7 @@ def test_create_next_container_version_with_changes( # 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) - publishing_api.create_next_container_version( + 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 @@ -438,7 +434,7 @@ def test_create_next_container_version_with_changes( 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 publishing_api.get_container_children_entities_keys(version_5) == ["child_entity2", "child_entity1"] + assert containers_api.get_container_children_entities_keys(version_5) == ["child_entity2", "child_entity1"] def test_create_next_container_version_with_append( @@ -456,16 +452,16 @@ def test_create_next_container_version_with_append( assert child_entity1_v1.version_num == 1 # Create a new version, APPENDing entity 3 and 📌 pinned entity1 (v1) - version_2 = publishing_api.create_next_container_version( + version_2 = containers_api.create_next_container_version( parent_of_two, entities=[child_entity3, child_entity1_v1], created=now, created_by=None, - entities_action=publishing_api.ChildrenEntitiesAction.APPEND, + entities_action=containers_api.ChildrenEntitiesAction.APPEND, ) assert parent_of_two.versioning.draft == version_2 - assert publishing_api.get_entities_in_container(parent_of_two, published=False) == [ + 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 @@ -489,7 +485,7 @@ def test_create_next_container_version_with_remove_1( #################################################################################################################### # Before looks like this: - assert publishing_api.get_entities_in_container(parent_of_six, published=False) == [ + 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 @@ -498,16 +494,16 @@ def test_create_next_container_version_with_remove_1( Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned ] # Remove "entity 1 unpinned" - should remove both: - publishing_api.create_next_container_version( + containers_api.create_next_container_version( parent_of_six.pk, entities=[child_entity1], created=now, created_by=None, - entities_action=publishing_api.ChildrenEntitiesAction.REMOVE, + entities_action=containers_api.ChildrenEntitiesAction.REMOVE, ) # Now it looks like: - assert publishing_api.get_entities_in_container(parent_of_six, published=False) == [ + 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 @@ -526,7 +522,7 @@ def test_create_next_container_version_with_remove_2( Test creating a new version of the "parent of two" container, using the REMOVE action to remove children. """ # Before looks like this: - assert publishing_api.get_entities_in_container(parent_of_six, published=False) == [ + 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 @@ -535,16 +531,16 @@ def test_create_next_container_version_with_remove_2( Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned ] # Remove "entity 2 pinned" - should remove both: - publishing_api.create_next_container_version( + 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=publishing_api.ChildrenEntitiesAction.REMOVE, + entities_action=containers_api.ChildrenEntitiesAction.REMOVE, ) # Now it looks like: - assert publishing_api.get_entities_in_container(parent_of_six, published=False) == [ + 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 @@ -564,7 +560,7 @@ def test_create_next_container_version_with_remove_3( Test creating a new version of the "parent of two" container, using the REMOVE action to remove children. """ # Before looks like this: - assert publishing_api.get_entities_in_container(parent_of_six, published=False) == [ + 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 @@ -573,16 +569,16 @@ def test_create_next_container_version_with_remove_3( Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned ] # Remove "entity 3 pinned" - should remove only one: - publishing_api.create_next_container_version( + 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=publishing_api.ChildrenEntitiesAction.REMOVE, + entities_action=containers_api.ChildrenEntitiesAction.REMOVE, ) # Now it looks like: - assert publishing_api.get_entities_in_container(parent_of_six, published=False) == [ + 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 @@ -602,7 +598,7 @@ def test_create_next_container_version_with_remove_4( Test creating a new version of the "parent of two" container, using the REMOVE action to remove children. """ # Before looks like this: - assert publishing_api.get_entities_in_container(parent_of_six, published=False) == [ + 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 @@ -611,16 +607,16 @@ def test_create_next_container_version_with_remove_4( Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned ] # Remove "entity 3 unpinned" - should remove only one: - publishing_api.create_next_container_version( + containers_api.create_next_container_version( parent_of_six.pk, entities=[child_entity3], created=now, created_by=None, - entities_action=publishing_api.ChildrenEntitiesAction.REMOVE, + entities_action=containers_api.ChildrenEntitiesAction.REMOVE, ) # Now it looks like: - assert publishing_api.get_entities_in_container(parent_of_six, published=False) == [ + 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 @@ -638,7 +634,7 @@ def test_create_next_container_version_with_conflicting_version(parent_of_two: T def create_v5(): """Create a new version, specifying version number 5 and changing the title and the order of the children.""" - publishing_api.create_next_container_version( + containers_api.create_next_container_version( parent_of_two.pk, title="New version - forced as v5", force_version_num=5, @@ -658,8 +654,8 @@ def test_create_next_container_version_uninstalled_plugin(container_of_uninstall 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(publishing_api.ContainerImplementationMissingError): - publishing_api.create_next_container_version( + 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, @@ -672,7 +668,7 @@ def test_create_next_container_version_other_lp(parent_of_two: TestContainer, ot 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."): - publishing_api.create_next_container_version( + containers_api.create_next_container_version( parent_of_two.pk, title="Bad Version with entities from another learning package", created=now, @@ -689,7 +685,7 @@ def test_get_container(parent_of_two: TestContainer, django_assert_num_queries) Test `get_container()` """ with django_assert_num_queries(1): - result = publishing_api.get_container(parent_of_two.pk) + result = containers_api.get_container(parent_of_two.pk) assert result == parent_of_two.container # Versioning data should be pre-loaded via select_related() with django_assert_num_queries(0): @@ -701,7 +697,7 @@ def test_get_container_nonexistent() -> None: Test `get_container()` with an invalid ID. """ with pytest.raises(Container.DoesNotExist): - publishing_api.get_container(-5000) + containers_api.get_container(-5000) def test_get_container_soft_deleted(parent_of_two: TestContainer) -> None: @@ -713,7 +709,7 @@ def test_get_container_soft_deleted(parent_of_two: TestContainer) -> None: assert parent_of_two.versioning.draft is None assert parent_of_two.versioning.published is None # Get the container - result = publishing_api.get_container(parent_of_two.pk) + result = containers_api.get_container(parent_of_two.pk) assert result == parent_of_two.container # It works fine! get_container() ignores publish/delete status. @@ -722,7 +718,7 @@ def test_get_container_uninstalled_type(container_of_uninstalled_type: Container Test `get_container()` with a container from an uninstalled plugin """ # Nothing special happens. It should work fine. - result = publishing_api.get_container(container_of_uninstalled_type.pk) + result = containers_api.get_container(container_of_uninstalled_type.pk) assert result == container_of_uninstalled_type @@ -734,7 +730,7 @@ 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 = publishing_api.get_container_version(parent_of_two.versioning.draft.pk) + cv = containers_api.get_container_version(parent_of_two.versioning.draft.pk) assert cv == parent_of_two.versioning.draft.container_version @@ -743,7 +739,7 @@ def test_get_container_version_nonexistent() -> None: Test getting a specific container version that doesn't exist """ with pytest.raises(ContainerVersion.DoesNotExist): - publishing_api.get_container_version(-500) + containers_api.get_container_version(-500) # get_container_by_key @@ -753,7 +749,7 @@ def test_get_container_by_key(lp: LearningPackage, parent_of_two: TestContainer) """ Test getting a specific container by key """ - result = publishing_api.get_container_by_key(lp.pk, parent_of_two.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 @@ -764,10 +760,10 @@ 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): - publishing_api.get_container_by_key(32874, "invalid-key") + containers_api.get_container_by_key(32874, "invalid-key") with pytest.raises(Container.DoesNotExist): - publishing_api.get_container_by_key(lp.pk, "invalid-key") + containers_api.get_container_by_key(lp.pk, "invalid-key") # get_container_type_code and get_container_type @@ -779,24 +775,24 @@ def test_get_container_type(grandparent: ContainerContainer, parent_of_two: Test """ # Grandparent is a "ContainerContainer": assert isinstance(grandparent, ContainerContainer) - assert publishing_api.get_container_type_code(grandparent) == "test_container_container" - assert publishing_api.get_container_type(grandparent) is ContainerContainer + assert containers_api.get_container_type_code(grandparent) == "test_container_container" + assert containers_api.get_container_type(grandparent) is ContainerContainer # The functions work even if we pass a generic "Container" object: assert isinstance(grandparent.base_container, Container) - assert publishing_api.get_container_type_code(grandparent.base_container) == "test_container_container" - assert publishing_api.get_container_type(grandparent.base_container) is ContainerContainer + assert containers_api.get_container_type_code(grandparent.base_container) == "test_container_container" + assert containers_api.get_container_type(grandparent.base_container) is ContainerContainer # "Parent of Two" is a "TestContainer": assert isinstance(parent_of_two, TestContainer) - assert publishing_api.get_container_type_code(parent_of_two) == "test_generic" - assert publishing_api.get_container_type(parent_of_two) is TestContainer + assert containers_api.get_container_type_code(parent_of_two) == "test_generic" + assert containers_api.get_container_type(parent_of_two) is TestContainer assert isinstance(parent_of_two.container, Container) - assert publishing_api.get_container_type_code(parent_of_two.container) == "test_generic" - assert publishing_api.get_container_type(parent_of_two.container) is TestContainer + assert containers_api.get_container_type_code(parent_of_two.container) == "test_generic" + assert containers_api.get_container_type(parent_of_two.container) is TestContainer # Passing in a non-container will trigger an assert failure: with pytest.raises(AssertionError): - publishing_api.get_container_type(child_entity1) # type: ignore + containers_api.get_container_type(child_entity1) # type: ignore def test_get_container_type_deleted(container_of_uninstalled_type: Container): @@ -805,13 +801,13 @@ def test_get_container_type_deleted(container_of_uninstalled_type: Container): is no longer available """ with pytest.raises( - publishing_api.ContainerImplementationMissingError, + containers_api.ContainerImplementationMissingError, match='An implementation for "misc" containers is not currently installed.', ): - publishing_api.get_container_type(container_of_uninstalled_type) + containers_api.get_container_type(container_of_uninstalled_type) # But get_container_type_code() should still work: - assert publishing_api.get_container_type_code(container_of_uninstalled_type) == "misc" + assert containers_api.get_container_type_code(container_of_uninstalled_type) == "misc" # get_containers @@ -828,7 +824,7 @@ def test_get_containers( """ Test that we can get all containers in a Learning Package """ - result = list(publishing_api.get_containers(lp.id)) + result = list(containers_api.get_containers(lp.id)) # The API always returns Container base class instances, never specific types: assert [c.__class__ is Container for c in result] # (we _could_ implement a get_typed_containers() API, but there's probably no need?) @@ -839,7 +835,7 @@ def test_get_containers( grandparent.base_container, ] # Now repeat with the other Learning Package, to make sure they're isolated: - assert list(publishing_api.get_containers(lp2.id)) == [ + assert list(containers_api.get_containers(lp2.id)) == [ other_lp_parent.container, ] @@ -857,13 +853,13 @@ def test_get_containers_soft_deleted( # 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(publishing_api.get_containers(lp.id)) == [ + 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(publishing_api.get_containers(lp.id, 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, @@ -882,9 +878,9 @@ def test_contains_unpublished_changes_queries( # Tests: with django_assert_num_queries(1): - assert publishing_api.contains_unpublished_changes(grandparent) + assert containers_api.contains_unpublished_changes(grandparent) with django_assert_num_queries(1): - assert publishing_api.contains_unpublished_changes(grandparent.pk) + assert containers_api.contains_unpublished_changes(grandparent.pk) # Publish grandparent and all its descendants: with django_assert_num_queries(143): # TODO: investigate as this seems high! @@ -892,7 +888,7 @@ def test_contains_unpublished_changes_queries( # Tests: with django_assert_num_queries(1): - assert not publishing_api.contains_unpublished_changes(grandparent) + 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: @@ -907,7 +903,7 @@ def test_contains_unpublished_changes_queries( assert child_entity1.versioning.has_unpublished_changes with django_assert_num_queries(1): - assert publishing_api.contains_unpublished_changes(grandparent) + assert containers_api.contains_unpublished_changes(grandparent) def test_auto_publish_children( @@ -920,7 +916,7 @@ def test_auto_publish_children( Test that publishing a container publishes its child components automatically. """ # At first, nothing is published: - assert publishing_api.contains_unpublished_changes(parent_of_two.pk) + 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 @@ -935,7 +931,7 @@ def test_auto_publish_children( 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 publishing_api.contains_unpublished_changes(parent_of_two.pk) is False # Deep check + 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: @@ -949,7 +945,7 @@ def test_no_publish_parent(parent_of_two: TestContainer, child_entity1: TestEnti 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 publishing_api.get_container_children_entities_keys(parent_of_two.versioning.draft) + 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 @@ -965,7 +961,7 @@ def test_no_publish_parent(parent_of_two: TestContainer, child_entity1: TestEnti assert parent_of_two.versioning.published is None with pytest.raises(ContainerVersion.DoesNotExist): # There is no published version of "parent_of_two": - publishing_api.get_entities_in_container(parent_of_two, published=True) + 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): @@ -980,20 +976,20 @@ def test_add_entity_after_publish(lp: LearningPackage, parent_of_two: TestContai 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 publishing_api.contains_unpublished_changes(parent_of_two) # Deeper check + assert not containers_api.contains_unpublished_changes(parent_of_two) # Deeper check # Add a published entity (child_entity3, unpinned): - parent_of_two_v2 = publishing_api.create_next_container_version( + parent_of_two_v2 = containers_api.create_next_container_version( parent_of_two.pk, entities=[child_entity3], created=now, created_by=None, - entities_action=publishing_api.ChildrenEntitiesAction.APPEND, + 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 publishing_api.contains_unpublished_changes(parent_of_two) # Deeper check + 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 @@ -1014,7 +1010,7 @@ def test_modify_unpinned_entity_after_publish( child_entity2_v1 = child_entity2.versioning.draft assert parent_of_two.versioning.has_unpublished_changes is False # Shallow check - assert publishing_api.contains_unpublished_changes(parent_of_two.pk) is False # Deeper 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): @@ -1026,30 +1022,30 @@ def test_modify_unpinned_entity_after_publish( assert ( parent_of_two.versioning.has_unpublished_changes is False ) # Shallow check should be false - container is unchanged - assert publishing_api.contains_unpublished_changes(parent_of_two.pk) # But the container DOES "contain" changes + 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 publishing_api.get_entities_in_container(parent_of_two, published=False) == [ + 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 publishing_api.get_entities_in_container(parent_of_two, published=True) == [ + 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 publishing_api.get_entities_in_container(parent_of_two, published=False) == [ + 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 publishing_api.get_entities_in_container(parent_of_two, published=True) == [ + 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 publishing_api.contains_unpublished_changes(parent_of_two) is False # No longer contains unpublished changes + assert containers_api.contains_unpublished_changes(parent_of_two) is False # No longer contains unpublished changes def test_modify_pinned_entity( @@ -1069,7 +1065,7 @@ def test_modify_pinned_entity( Entry(child_entity2.versioning.draft, pinned=True), # pinned 📌 to v1 Entry(child_entity1.versioning.draft, pinned=False), ] - assert publishing_api.get_entities_in_container(parent_of_three, published=False) == expected_contents + assert containers_api.get_entities_in_container(parent_of_three, published=False) == expected_contents # Publish everything publishing_api.publish_all_drafts(lp.id) @@ -1081,16 +1077,16 @@ def test_modify_pinned_entity( 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 publishing_api.contains_unpublished_changes(parent_of_three) is False # Deep 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 publishing_api.get_entities_in_container(parent_of_three, published=False) == expected_contents - assert publishing_api.get_entities_in_container(parent_of_three, published=True) == expected_contents + 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 publishing_api.get_entities_in_container(parent_of_three, published=False) == expected_contents - assert publishing_api.get_entities_in_container(parent_of_three, published=True) == expected_contents + 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): @@ -1110,7 +1106,7 @@ def test_publishing_shared_component(lp: LearningPackage): c3_v1 = c3.versioning.draft c4_v1 = c4.versioning.draft c5_v1 = c5.versioning.draft - unit1, _ = publishing_api.create_container_and_version( + unit1, _ = containers_api.create_container_and_version( lp.pk, entities=[c1, c2, c3], title="Unit 1", @@ -1119,7 +1115,7 @@ def test_publishing_shared_component(lp: LearningPackage): created_by=None, container_type=TestContainer, ) - unit2, _ = publishing_api.create_container_and_version( + unit2, _ = containers_api.create_container_and_version( lp.pk, entities=[c2, c4, c5], title="Unit 2", @@ -1129,14 +1125,14 @@ def test_publishing_shared_component(lp: LearningPackage): container_type=TestContainer, ) publishing_api.publish_all_drafts(lp.pk) - assert publishing_api.contains_unpublished_changes(unit1.pk) is False - assert publishing_api.contains_unpublished_changes(unit2.pk) is False + 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 publishing_api.contains_unpublished_changes(unit1.pk) - assert publishing_api.contains_unpublished_changes(unit2.pk) + 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() @@ -1150,7 +1146,7 @@ def test_publishing_shared_component(lp: LearningPackage): publish_entity(unit1) # Result: Unit 1 will show the newly published version of C2: - assert publishing_api.get_entities_in_container(unit1, published=True) == [ + assert containers_api.get_entities_in_container(unit1, published=True) == [ Entry(c1_v1), Entry(c2_v2), # new published version of C2 Entry(c3_v1), @@ -1159,25 +1155,25 @@ def test_publishing_shared_component(lp: LearningPackage): # 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 publishing_api.get_entities_in_container(unit2, published=True) == [ + 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 publishing_api.contains_unpublished_changes(unit1.pk) is False - assert publishing_api.contains_unpublished_changes(unit2.pk) + 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 publishing_api.get_entities_in_container(unit2, published=True) == [ + 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 publishing_api.contains_unpublished_changes(unit2.pk) is False + assert containers_api.contains_unpublished_changes(unit2.pk) is False # get_entities_in_container @@ -1194,19 +1190,19 @@ def test_get_entities_in_container( """ expected = [ # This particular container has three children (3, 2, 1), two of them 📌 pinned: - publishing_api.ContainerEntityListEntry(child_entity3.versioning.draft.publishable_entity_version, pinned=True), - publishing_api.ContainerEntityListEntry(child_entity2.versioning.draft.publishable_entity_version, pinned=True), - publishing_api.ContainerEntityListEntry( + 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 publishing_api.get_entities_in_container(parent_of_three, published=False) == expected + 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): - publishing_api.get_entities_in_container(parent_of_three, published=True) + containers_api.get_entities_in_container(parent_of_three, published=True) publish_entity(parent_of_three) - assert publishing_api.get_entities_in_container(parent_of_three, published=True) == expected + assert containers_api.get_entities_in_container(parent_of_three, published=True) == expected def test_get_entities_in_container_soft_deletion_unpinned( @@ -1221,7 +1217,7 @@ def test_get_entities_in_container_soft_deletion_unpinned( Entry(child_entity2.versioning.draft, pinned=True), Entry(child_entity1.versioning.draft, pinned=False), ] - assert publishing_api.get_entities_in_container(parent_of_three, published=False) == before + assert containers_api.get_entities_in_container(parent_of_three, published=False) == before # First, publish everything: publish_entity(parent_of_three) @@ -1232,14 +1228,14 @@ def test_get_entities_in_container_soft_deletion_unpinned( parent_of_three.refresh_from_db() assert not parent_of_three.versioning.has_unpublished_changes # But it "contains" a change (a deletion) - assert publishing_api.contains_unpublished_changes(parent_of_three) + 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 publishing_api.get_entities_in_container(parent_of_three, published=False) == after + assert containers_api.get_entities_in_container(parent_of_three, published=False) == after def test_get_entities_in_container_soft_deletion_pinned( @@ -1254,7 +1250,7 @@ def test_get_entities_in_container_soft_deletion_pinned( Entry(child_entity2.versioning.draft, pinned=True), Entry(child_entity1.versioning.draft, pinned=False), ] - assert publishing_api.get_entities_in_container(parent_of_three, published=False) == before + assert containers_api.get_entities_in_container(parent_of_three, published=False) == before # First, publish everything: publish_entity(parent_of_three) @@ -1264,11 +1260,11 @@ def test_get_entities_in_container_soft_deletion_pinned( # 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 publishing_api.contains_unpublished_changes(parent_of_three) + 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 publishing_api.get_entities_in_container(parent_of_three, published=False) == before + assert containers_api.get_entities_in_container(parent_of_three, published=False) == before # get_entities_in_container_as_of @@ -1281,7 +1277,7 @@ def test_snapshots_of_published_unit(lp: LearningPackage, child_entity1: TestEnt # 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 = publishing_api.get_entities_in_container_as_of(container, 0) + _, before_publish = containers_api.get_entities_in_container_as_of(container, 0) assert not before_publish # Empty list # Publish everything, creating Checkpoint 1 @@ -1298,7 +1294,7 @@ def test_snapshots_of_published_unit(lp: LearningPackage, child_entity1: TestEnt # 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") - publishing_api.create_next_container_version( + containers_api.create_next_container_version( container.pk, title="Unit title in checkpoint 3", entities=[child_entity1, child_entity2], @@ -1311,7 +1307,7 @@ def test_snapshots_of_published_unit(lp: LearningPackage, child_entity1: TestEnt # 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. - publishing_api.create_next_container_version( + containers_api.create_next_container_version( container.pk, title="Unit title in checkpoint 4", entities=[child_entity1_v1, child_entity1, child_entity2], @@ -1327,20 +1323,20 @@ def test_snapshots_of_published_unit(lp: LearningPackage, child_entity1: TestEnt modify_entity(child_entity2, title="Component 2 draft") # Now fetch the snapshots: - _, as_of_checkpoint_1 = publishing_api.get_entities_in_container_as_of(container, checkpoint_1.pk) + _, 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 = publishing_api.get_entities_in_container_as_of(container, checkpoint_2.pk) + _, 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 = publishing_api.get_entities_in_container_as_of(container, checkpoint_3.pk) + _, 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 = publishing_api.get_entities_in_container_as_of(container, checkpoint_4.pk) + _, 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 @@ -1375,7 +1371,7 @@ def test_get_containers_with_entity_draft( # "child_entity1" is found in three different containers: with django_assert_num_queries(1): - result = list(publishing_api.get_containers_with_entity(child_entity1.publishable_entity.pk)) + 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, @@ -1384,7 +1380,7 @@ def test_get_containers_with_entity_draft( # "child_entity3" is found in two different containers: with django_assert_num_queries(1): - result = list(publishing_api.get_containers_with_entity(child_entity3.publishable_entity.pk)) + 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 @@ -1395,17 +1391,17 @@ def test_get_containers_with_entity_draft( with django_assert_num_queries(1): result = list( - publishing_api.get_containers_with_entity(child_entity3.publishable_entity.pk, ignore_pinned=True) + 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(publishing_api.get_containers_with_entity(other_lp_child.publishable_entity.pk)) == [ + assert list(containers_api.get_containers_with_entity(other_lp_child.publishable_entity.pk)) == [ other_lp_parent.container ] - assert not list(publishing_api.get_containers_with_entity(other_lp_parent.publishable_entity.pk)) + assert not list(containers_api.get_containers_with_entity(other_lp_parent.publishable_entity.pk)) # get_container_children_count @@ -1420,20 +1416,20 @@ def test_get_container_children_count( ): """Test `get_container_children_count()`""" publishing_api.publish_all_drafts(lp.pk) - assert publishing_api.get_container_children_count(parent_of_two, published=False) == 2 - assert publishing_api.get_container_children_count(parent_of_two, published=True) == 2 + 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 publishing_api.get_container_children_count(parent_of_three, published=False) == 3 - assert publishing_api.get_container_children_count(parent_of_three, published=True) == 3 + 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 publishing_api.get_container_children_count(parent_of_six, published=False) == 6 - assert publishing_api.get_container_children_count(parent_of_six, published=True) == 6 + 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 publishing_api.get_container_children_count(grandparent, published=False) == 2 - assert publishing_api.get_container_children_count(grandparent, published=True) == 2 + 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": - publishing_api.create_next_container_version( + containers_api.create_next_container_version( grandparent, entities=[parent_of_two, parent_of_three, parent_of_six], created=now, @@ -1441,8 +1437,8 @@ def test_get_container_children_count( ) # Warning: this is required if 'grandparent' is passed by ID to `create_next_container_version()`: # grandparent.refresh_from_db() - assert publishing_api.get_container_children_count(grandparent, published=False) == 3 - assert publishing_api.get_container_children_count(grandparent, published=True) == 2 # published is unchanged + 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( @@ -1455,12 +1451,12 @@ def test_get_container_children_count_soft_deletion( 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 publishing_api.get_container_children_count(parent_of_two, published=False) == 1 - assert publishing_api.get_container_children_count(parent_of_two, published=True) == 2 + 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 publishing_api.get_container_children_count(parent_of_six, published=False) == 4 - assert publishing_api.get_container_children_count(parent_of_six, published=True) == 6 + 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( @@ -1472,13 +1468,13 @@ def test_get_container_children_count_queries( """Test how many database queries `get_container_children_count()` needs""" publishing_api.publish_all_drafts(lp.pk) with django_assert_num_queries(6): - assert publishing_api.get_container_children_count(parent_of_two, published=False) == 2 + assert containers_api.get_container_children_count(parent_of_two, published=False) == 2 with django_assert_num_queries(6): - assert publishing_api.get_container_children_count(parent_of_two, published=True) == 2 + assert containers_api.get_container_children_count(parent_of_two, published=True) == 2 with django_assert_num_queries(6): - assert publishing_api.get_container_children_count(parent_of_six, published=False) == 6 + assert containers_api.get_container_children_count(parent_of_six, published=False) == 6 with django_assert_num_queries(6): - assert publishing_api.get_container_children_count(parent_of_six, published=True) == 6 + assert containers_api.get_container_children_count(parent_of_six, published=True) == 6 # get_container_children_entities_keys @@ -1489,13 +1485,13 @@ def test_get_container_children_entities_keys(grandparent: ContainerContainer, p # TODO: is get_container_children_entities_keys() a useful API method? It's not used in edx-platform. - assert publishing_api.get_container_children_entities_keys(grandparent.versioning.draft) == [ + 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 publishing_api.get_container_children_entities_keys(parent_of_six.versioning.draft) == [ + assert containers_api.get_container_children_entities_keys(parent_of_six.versioning.draft) == [ "child_entity3", "child_entity2", "child_entity1", @@ -1535,451 +1531,3 @@ def test_soft_delete_container(lp: LearningPackage, parent_of_two: TestContainer child_entity1.refresh_from_db() assert child_entity1.versioning.draft == child_entity1.versioning.published assert child_entity1.versioning.draft is not None - - -# Container side effects and dependencies - - -class TestContainerSideEffects: - """ - Tests related to Container side effects and dependencies - """ - - def test_parent_child_side_effects(self, lp: LearningPackage) -> None: - """Test that modifying a child has side-effects on its parent.""" - child_1 = publishing_api.create_publishable_entity( - lp.id, - "child_1", - created=now, - created_by=None, - ) - child_1_v1 = publishing_api.create_publishable_entity_version( - child_1.id, - version_num=1, - title="Child 1 🌴", - created=now, - created_by=None, - ) - child_2 = publishing_api.create_publishable_entity( - lp.id, - "child_2", - created=now, - created_by=None, - ) - publishing_api.create_publishable_entity_version( - child_2.id, - version_num=1, - title="Child 2 🌴", - created=now, - created_by=None, - ) - container: Container = publishing_api.create_container( - lp.id, - "my_container", - created=now, - created_by=None, - container_type=TestContainer, - ) - container_v1: ContainerVersion = publishing_api.create_container_version( - container.pk, - 1, - title="My Container", - entities=[ - child_1, - child_2, - ], - created=now, - created_by=None, - ) - - # All this was just set up. Now that we have our container-child - # relationships, altering a child should add the parent container to - # the DraftChangeLog. - child_1_v2 = publishing_api.create_publishable_entity_version( - child_1.id, - version_num=2, - title="Child 1 v2", - created=now, - created_by=None, - ) - 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) - assert child_1_change.old_version == child_1_v1 - assert child_1_change.new_version == child_1_v2 - - # 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) - assert container_change.old_version == container_v1.publishable_entity_version - assert container_change.new_version == container_v1.publishable_entity_version - - # Exactly one side-effect should have been created because we changed - # child_1 after it was part of a container. - side_effects = DraftSideEffect.objects.all() - assert side_effects.count() == 1 - side_effect = side_effects.first() - assert side_effect is not None - assert side_effect.cause == child_1_change - assert side_effect.effect == container_change - - def test_bulk_parent_child_side_effects(self, lp: LearningPackage) -> None: - """Test that modifying a child has side-effects on its parent. (bulk version)""" - with publishing_api.bulk_draft_changes_for(lp.id): - child_1 = publishing_api.create_publishable_entity( - lp.id, - "child_1", - created=now, - created_by=None, - ) - publishing_api.create_publishable_entity_version( - child_1.id, - version_num=1, - title="Child 1 🌴", - created=now, - created_by=None, - ) - child_2 = publishing_api.create_publishable_entity( - lp.id, - "child_2", - created=now, - created_by=None, - ) - child_2_v1 = publishing_api.create_publishable_entity_version( - child_2.id, - version_num=1, - title="Child 2 🌴", - created=now, - created_by=None, - ) - container: Container = publishing_api.create_container( - lp.id, - "my_container", - created=now, - created_by=None, - container_type=TestContainer, - ) - container_v1: ContainerVersion = publishing_api.create_container_version( - container.pk, - 1, - title="My Container", - entities=[child_1, child_2], - created=now, - created_by=None, - ) - - # All this was just set up. Now that we have our container-child - # relationships, altering a child should add the parent container to - # the DraftChangeLog. - child_1_v2 = publishing_api.create_publishable_entity_version( - child_1.id, - version_num=2, - title="Child 1 v2", - created=now, - created_by=None, - ) - - # Because we're doing it in bulk, there's only one DraftChangeLog entry. - assert DraftChangeLog.objects.count() == 1 - last_change_log = DraftChangeLog.objects.first() - assert last_change_log is not None - # There's only ever one change entry per publishable entity - assert last_change_log.records.count() == 3 - - child_1_change = last_change_log.records.get(entity=child_1) - assert child_1_change.old_version is None - assert child_1_change.new_version == child_1_v2 - - child_2_change = last_change_log.records.get(entity=child_2) - 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) - assert container_change.old_version is None - assert container_change.new_version == container_v1.publishable_entity_version - - # There are two side effects here, because we grouped our draft edits - # together using bulk_draft_changes_for, so changes to both children - # count towards side-effects on the container. - side_effects = DraftSideEffect.objects.all() - assert side_effects.count() == 2 - caused_by_child_1 = side_effects.get(cause=child_1_change) - caused_by_child_2 = side_effects.get(cause=child_2_change) - assert caused_by_child_1.effect == container_change - assert caused_by_child_2.effect == container_change - - def test_draft_dependency_multiple_parents(self, lp: LearningPackage) -> None: - """ - Test that a change in a draft component affects multiple parents. - - This is the scenario where one Component is contained by multiple Units. - """ - # Set up a Component that lives in two Units - component = publishing_api.create_publishable_entity( - lp.id, - "component_1", - created=now, - created_by=None, - ) - publishing_api.create_publishable_entity_version( - component.id, - version_num=1, - title="Component 1 🌴", - created=now, - created_by=None, - ) - unit_1 = publishing_api.create_container( - lp.id, - "unit_1", - created=now, - created_by=None, - container_type=TestContainer, - ) - unit_2 = publishing_api.create_container( - lp.id, - "unit_2", - created=now, - created_by=None, - container_type=TestContainer, - ) - for unit in [unit_1, unit_2]: - publishing_api.create_container_version( - unit.pk, - 1, - title="My Unit", - entities=[component], - created=now, - created_by=None, - ) - - # At this point there should be no side effects because we created - # everything from the bottom-up. - assert not DraftSideEffect.objects.all().exists() - - # 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=now, - created_by=None, - ) - side_effects = DraftSideEffect.objects.all() - assert side_effects.count() == 2 - assert side_effects.filter(cause__entity=component).count() == 2 - 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, lp: LearningPackage) -> 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( - lp.id, - "component_1", - created=now, - created_by=None, - ) - publishing_api.create_publishable_entity_version( - component.id, - version_num=1, - title="Component 1 🌴", - created=now, - created_by=None, - ) - unit = publishing_api.create_container( - lp.id, - "unit_1", - created=now, - created_by=None, - container_type=TestContainer, - ) - publishing_api.create_container_version( - unit.pk, - 1, - title="My Unit", - entities=[component], - created=now, - created_by=None, - ) - subsection = publishing_api.create_container( - lp.id, - "subsection_1", - created=now, - created_by=None, - container_type=TestContainer, - ) - publishing_api.create_container_version( - subsection.pk, - 1, - title="My Subsection", - entities=[unit], - created=now, - created_by=None, - ) - - # At this point, no side-effects exist yet because we built it from the - # bottom-up using different DraftChangeLogs - assert not DraftSideEffect.objects.all().exists() - - with publishing_api.bulk_draft_changes_for(lp.id) as change_log: - publishing_api.create_publishable_entity_version( - component.id, - version_num=2, - title="Component 1v2🌴", - created=now, - created_by=None, - ) - - assert DraftSideEffect.objects.count() == 2 - component_change = change_log.records.get(entity=component) - unit_change = change_log.records.get(entity=unit.publishable_entity) - subsection_change = change_log.records.get(entity=subsection.publishable_entity) - - assert not component_change.affected_by.exists() - assert unit_change.affected_by.count() == 1 - assert unit_change.affected_by.first().cause == component_change - assert subsection_change.affected_by.count() == 1 - assert subsection_change.affected_by.first().cause == unit_change - - publish_log = publishing_api.publish_all_drafts(lp.id) - assert publish_log.records.count() == 3 - - publishing_api.create_publishable_entity_version( - component.pk, - version_num=3, - title="Component v2", - created=now, - created_by=None, - ) - publish_log = publishing_api.publish_from_drafts( - lp.id, - Draft.objects.filter(entity_id=component.pk), - ) - assert publish_log.records.count() == 3 - component_publish = publish_log.records.get(entity=component) - unit_publish = publish_log.records.get(entity=unit.publishable_entity) - subsection_publish = publish_log.records.get(entity=subsection.publishable_entity) - - assert not component_publish.affected_by.exists() - assert unit_publish.affected_by.count() == 1 - 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 # type: ignore[union-attr] - - def test_publish_all_layers(self, lp: LearningPackage) -> 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( - lp.id, - "component_1", - created=now, - created_by=None, - ) - publishing_api.create_publishable_entity_version( - component.id, - version_num=1, - title="Component 1 🌴", - created=now, - created_by=None, - ) - unit = publishing_api.create_container( - lp.id, - "unit_1", - created=now, - created_by=None, - container_type=TestContainer, - ) - publishing_api.create_container_version( - unit.pk, - 1, - title="My Unit", - entities=[component], - created=now, - created_by=None, - ) - subsection = publishing_api.create_container( - lp.id, - "subsection_1", - created=now, - created_by=None, - container_type=TestContainer, - ) - publishing_api.create_container_version( - subsection.pk, - 1, - title="My Subsection", - entities=[unit], - created=now, - created_by=None, - ) - publish_log = publishing_api.publish_from_drafts( - lp.id, - Draft.objects.filter(pk=subsection.pk), - ) - - # The component, unit, and subsection should all be accounted for in - # the publish log records. - assert publish_log.records.count() == 3 - - def test_container_next_version(self, lp: LearningPackage) -> None: - """Test that next_version works for containers.""" - child_1 = publishing_api.create_publishable_entity( - lp.id, - "child_1", - created=now, - created_by=None, - ) - container = publishing_api.create_container( - lp.id, - "my_container", - created=now, - created_by=None, - container_type=TestContainer, - ) - assert container.versioning.latest is None - v1 = publishing_api.create_next_container_version( - container.pk, - title="My Container v1", - entities=None, - created=now, - created_by=None, - ) - assert v1.version_num == 1 - assert container.versioning.latest == v1 - v2 = publishing_api.create_next_container_version( - container.pk, - title="My Container v2", - entities=[child_1], - created=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( - container.pk, - title="My Container v3", - entities=None, - created=now, - created_by=None, - ) - assert v3.version_num == 3 - assert container.versioning.latest == v3 - # Even though we didn't pass any rows, it should copy the previous version's rows - assert v2.entity_list.entitylistrow_set.count() == 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/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index bb7a1387..abbcfaa6 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -16,6 +16,7 @@ Draft, DraftChangeLog, DraftChangeLogRecord, + DraftSideEffect, LearningPackage, PublishableEntity, PublishLog, @@ -1014,3 +1015,471 @@ def test_get_publishable_entities_n_plus_problem(self) -> 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 + + @classmethod + def setUpTestData(cls) -> None: + cls.now = datetime(2024, 1, 28, 16, 45, 30, tzinfo=timezone.utc) + cls.learning_package = publishing_api.create_learning_package( + "containers_package_key", + "Container Testing LearningPackage 🔥 1", + 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( + self.learning_package.id, + "child_1", + created=self.now, + created_by=None, + ) + child_1_v1 = publishing_api.create_publishable_entity_version( + child_1.id, + version_num=1, + title="Child 1 🌴", + created=self.now, + created_by=None, + ) + child_2 = publishing_api.create_publishable_entity( + self.learning_package.id, + "child_2", + created=self.now, + created_by=None, + ) + publishing_api.create_publishable_entity_version( + child_2.id, + version_num=1, + title="Child 2 🌴", + created=self.now, + created_by=None, + ) + container = containers_api.create_container( + self.learning_package.id, + "my_container", + created=self.now, + created_by=None, + container_type=TestContainer, + ) + container_v1 = containers_api.create_container_version( + container.pk, + 1, + title="My Container", + entities=[ + child_1, + child_2, + ], + created=self.now, + created_by=None, + ) + + # All this was just set up. Now that we have our container-child + # relationships, altering a child should add the parent container to + # the DraftChangeLog. + child_1_v2 = publishing_api.create_publishable_entity_version( + child_1.id, + version_num=2, + title="Child 1 v2", + created=self.now, + created_by=None, + ) + 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) + assert child_1_change.old_version == child_1_v1 + assert child_1_change.new_version == child_1_v2 + + # 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) + assert container_change.old_version == container_v1.publishable_entity_version + assert container_change.new_version == container_v1.publishable_entity_version + + # Exactly one side-effect should have been created because we changed + # child_1 after it was part of a container. + side_effects = DraftSideEffect.objects.all() + assert side_effects.count() == 1 + side_effect = side_effects.first() + assert side_effect is not None + assert side_effect.cause == child_1_change + assert side_effect.effect == container_change + + def test_bulk_parent_child_side_effects(self) -> None: + """Test that modifying a child has side-effects on its parent. (bulk version)""" + with publishing_api.bulk_draft_changes_for(self.learning_package.id): + child_1 = publishing_api.create_publishable_entity( + self.learning_package.id, + "child_1", + created=self.now, + created_by=None, + ) + publishing_api.create_publishable_entity_version( + child_1.id, + version_num=1, + title="Child 1 🌴", + created=self.now, + created_by=None, + ) + child_2 = publishing_api.create_publishable_entity( + self.learning_package.id, + "child_2", + created=self.now, + created_by=None, + ) + child_2_v1 = publishing_api.create_publishable_entity_version( + child_2.id, + version_num=1, + title="Child 2 🌴", + created=self.now, + created_by=None, + ) + container = containers_api.create_container( + self.learning_package.id, + "my_container", + created=self.now, + created_by=None, + container_type=TestContainer, + ) + container_v1 = containers_api.create_container_version( + container.pk, + 1, + title="My Container", + entities=[child_1, child_2], + created=self.now, + created_by=None, + ) + + # All this was just set up. Now that we have our container-child + # relationships, altering a child should add the parent container to + # the DraftChangeLog. + child_1_v2 = publishing_api.create_publishable_entity_version( + child_1.id, + version_num=2, + title="Child 1 v2", + created=self.now, + created_by=None, + ) + + # Because we're doing it in bulk, there's only one DraftChangeLog entry. + assert DraftChangeLog.objects.count() == 1 + last_change_log = DraftChangeLog.objects.first() + assert last_change_log is not None + # There's only ever one change entry per publishable entity + assert last_change_log.records.count() == 3 + + child_1_change = last_change_log.records.get(entity=child_1) + assert child_1_change.old_version is None + assert child_1_change.new_version == child_1_v2 + + child_2_change = last_change_log.records.get(entity=child_2) + 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) + assert container_change.old_version is None + assert container_change.new_version == container_v1.publishable_entity_version + + # There are two side effects here, because we grouped our draft edits + # together using bulk_draft_changes_for, so changes to both children + # count towards side-effects on the container. + side_effects = DraftSideEffect.objects.all() + assert side_effects.count() == 2 + caused_by_child_1 = side_effects.get(cause=child_1_change) + caused_by_child_2 = side_effects.get(cause=child_2_change) + assert caused_by_child_1.effect == container_change + assert caused_by_child_2.effect == container_change + + def test_draft_dependency_multiple_parents(self) -> None: + """ + Test that a change in a draft component affects multiple parents. + + This is the scenario where one Component is contained by multiple Units. + """ + # 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, + ) + publishing_api.create_publishable_entity_version( + component.id, + version_num=1, + title="Component 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_type=TestContainer, + ) + unit_2 = containers_api.create_container( + self.learning_package.id, + "unit_2", + created=self.now, + created_by=None, + container_type=TestContainer, + ) + for unit in [unit_1, unit_2]: + containers_api.create_container_version( + unit.pk, + 1, + title="My Unit", + entities=[component], + created=self.now, + created_by=None, + ) + + # At this point there should be no side effects because we created + # everything from the bottom-up. + assert not DraftSideEffect.objects.all().exists() + + # 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, + ) + side_effects = DraftSideEffect.objects.all() + assert side_effects.count() == 2 + assert side_effects.filter(cause__entity=component).count() == 2 + 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) -> 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, + ) + publishing_api.create_publishable_entity_version( + component.id, + version_num=1, + title="Component 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_type=TestContainer, + ) + containers_api.create_container_version( + unit.pk, + 1, + title="My Unit", + entities=[component], + created=self.now, + created_by=None, + ) + subsection = containers_api.create_container( + self.learning_package.id, + "subsection_1", + created=self.now, + created_by=None, + container_type=TestContainer, + ) + containers_api.create_container_version( + subsection.pk, + 1, + title="My Subsection", + entities=[unit], + created=self.now, + created_by=None, + ) + + # At this point, no side-effects exist yet because we built it from the + # bottom-up using different DraftChangeLogs + assert not DraftSideEffect.objects.all().exists() + + 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, + ) + + assert DraftSideEffect.objects.count() == 2 + component_change = change_log.records.get(entity=component) + unit_change = change_log.records.get(entity=unit.publishable_entity) + subsection_change = change_log.records.get(entity=subsection.publishable_entity) + + assert not component_change.affected_by.exists() + assert unit_change.affected_by.count() == 1 + assert unit_change.affected_by.first().cause == component_change + assert subsection_change.affected_by.count() == 1 + assert subsection_change.affected_by.first().cause == unit_change + + publish_log = publishing_api.publish_all_drafts(self.learning_package.id) + 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, + ) + publish_log = publishing_api.publish_from_drafts( + self.learning_package.id, + Draft.objects.filter(entity_id=component.pk), + ) + assert publish_log.records.count() == 3 + component_publish = publish_log.records.get(entity=component) + unit_publish = publish_log.records.get(entity=unit.publishable_entity) + subsection_publish = publish_log.records.get(entity=subsection.publishable_entity) + + assert not component_publish.affected_by.exists() + assert unit_publish.affected_by.count() == 1 + 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 # type: ignore[union-attr] + + 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, + ) + publishing_api.create_publishable_entity_version( + component.id, + version_num=1, + title="Component 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_type=TestContainer, + ) + containers_api.create_container_version( + unit.pk, + 1, + title="My Unit", + entities=[component], + created=self.now, + created_by=None, + ) + subsection = containers_api.create_container( + self.learning_package.id, + "subsection_1", + created=self.now, + created_by=None, + container_type=TestContainer, + ) + containers_api.create_container_version( + subsection.pk, + 1, + title="My Subsection", + entities=[unit], + created=self.now, + created_by=None, + ) + publish_log = publishing_api.publish_from_drafts( + self.learning_package.id, + Draft.objects.filter(pk=subsection.pk), + ) + + # The component, unit, and subsection should all be accounted for in + # the publish log records. + assert publish_log.records.count() == 3 + + 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, + "child_1", + created=self.now, + created_by=None, + ) + container = containers_api.create_container( + self.learning_package.id, + "my_container", + created=self.now, + created_by=None, + container_type=TestContainer, + ) + assert container.versioning.latest is None + v1 = containers_api.create_next_container_version( + container.pk, + title="My Container v1", + entities=None, + created=self.now, + created_by=None, + ) + assert v1.version_num == 1 + assert container.versioning.latest == v1 + v2 = containers_api.create_next_container_version( + container.pk, + title="My Container v2", + 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 = containers_api.create_next_container_version( + container.pk, + title="My Container v3", + entities=None, + created=self.now, + created_by=None, + ) + assert v3.version_num == 3 + assert container.versioning.latest == v3 + # Even though we didn't pass any rows, it should copy the previous version's rows + assert v2.entity_list.entitylistrow_set.count() == 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/test_django_app/models.py b/tests/test_django_app/models.py index 137f0d98..203be32d 100644 --- a/tests/test_django_app/models.py +++ b/tests/test_django_app/models.py @@ -1,7 +1,7 @@ """ Models that are only for use in tests. -These models are specifically for testing the `publishing` API. +These models are specifically for testing the `containers` API. """ from typing import override @@ -21,7 +21,7 @@ class TestEntity(PublishableEntityMixin): """ A generic entity that's not a container. Think of it like a Component, but - for testing `publishing` APIs without using the `components` API. + 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" From 4be81b95eb938b64cf86277d6a8dd46e9d86c836 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 16 Mar 2026 17:18:10 -0700 Subject: [PATCH 05/14] test: add a "deep publish" test to the containers API --- .../applets/containers/test_api.py | 102 +++++++++++++++++- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/tests/openedx_content/applets/containers/test_api.py b/tests/openedx_content/applets/containers/test_api.py index 631c821c..9818933f 100644 --- a/tests/openedx_content/applets/containers/test_api.py +++ b/tests/openedx_content/applets/containers/test_api.py @@ -18,6 +18,8 @@ PublishableEntity, PublishableEntityMixin, PublishableEntityVersionMixin, + PublishLog, + PublishSideEffect, ) from tests.test_django_app.models import ( ContainerContainer, @@ -261,10 +263,10 @@ def _other_lp_parent(lp2: LearningPackage, other_lp_child: PublishableEntity) -> return other_lp_parent -def publish_entity(obj: PublishableEntityMixin): +def publish_entity(obj: PublishableEntityMixin) -> PublishLog: """Helper method to publish a single container or other entity.""" lp_id = obj.publishable_entity.learning_package_id - publishing_api.publish_from_drafts( + return publishing_api.publish_from_drafts( lp_id, draft_qset=publishing_api.get_all_drafts(lp_id).filter(entity=obj.publishable_entity), ) @@ -1176,6 +1178,102 @@ def test_publishing_shared_component(lp: LearningPackage): 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: TestContainer, + 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(50): + 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: TestContainer, + 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(50): + 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. + # FIXME: this is using 144 queries on MySQL vs 132 on SQLite. The disparity only happens when publishing four levels + # of hierarchy at once (e.g. publishing a Section-->Component or Great-Grandparent-->Child). i.e. if you change this + # to publish "grandparent" instead of "great_grandparent" or you pre-publish the leaves (child entities), there is + # no disparity between the databases. + with django_assert_num_queries(133): + 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", + ] + assert list( + PublishSideEffect.objects.filter(cause__publish_log=publish_log) + .values_list("cause__entity__key", "effect__entity__key") + .order_by("cause__entity__key") # Note: order_by("pk") is different on MySQL vs SQLite + ) == [ + ("child_entity2", "parent_of_two"), + ("grandparent", "great_grandparent"), + ("parent_of_three", "grandparent"), + ("parent_of_two", "grandparent"), + ] + + # get_entities_in_container From ee2da60081d6bb4ad04eecb6f2a590c9aee8f941 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 16 Mar 2026 18:31:48 -0700 Subject: [PATCH 06/14] feat: add a get_container_type() API to get a type class from type_code --- src/openedx_content/applets/containers/api.py | 26 +++++++-- .../applets/containers/models.py | 5 ++ .../applets/sections/models.py | 4 +- .../applets/subsections/models.py | 4 +- .../applets/backup_restore/test_restore.py | 6 +- .../applets/containers/test_api.py | 58 ++++++++++++++----- 6 files changed, 78 insertions(+), 25 deletions(-) diff --git a/src/openedx_content/applets/containers/api.py b/src/openedx_content/applets/containers/api.py index 4107f76a..277c72a7 100644 --- a/src/openedx_content/applets/containers/api.py +++ b/src/openedx_content/applets/containers/api.py @@ -56,8 +56,10 @@ "get_container", "get_container_version", "get_container_by_key", - "get_container_type_code", + "get_all_container_types", "get_container_type", + "get_container_type_code_of", + "get_container_type_of", "get_containers", "ChildrenEntitiesAction", "ContainerEntityListEntry", @@ -587,7 +589,23 @@ def get_container_by_key(learning_package_id: int, /, key: str) -> Container: raise -def get_container_type_code(container: Container | int, /) -> str: +def get_all_container_types() -> list[ContainerType]: + """ + Get a list of installed Container types (`Container` subclasses). + """ + return Container.all_subclasses() + + +def get_container_type(type_code: str, /) -> ContainerType: + """ + 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) @@ -595,7 +613,7 @@ def get_container_type_code(container: Container | int, /) -> str: return container.container_type_record.type_code -def get_container_type(container: Container | int, /) -> ContainerType: +def get_container_type_of(container: Container | int, /) -> ContainerType: """ Get the type of a container. @@ -604,7 +622,7 @@ def get_container_type(container: Container | int, /) -> ContainerType: Will raise a `ContainerImplementationMissingError` if the type is not currently installed. """ - type_code = get_container_type_code(container) + type_code = get_container_type_code_of(container) return Container.subclass_for_type_code(type_code) diff --git a/src/openedx_content/applets/containers/models.py b/src/openedx_content/applets/containers/models.py index 88abcd52..beaf5023 100644 --- a/src/openedx_content/applets/containers/models.py +++ b/src/openedx_content/applets/containers/models.py @@ -227,6 +227,11 @@ def subclass_for_type_code(type_code: str) -> type[Container]: "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): """ diff --git a/src/openedx_content/applets/sections/models.py b/src/openedx_content/applets/sections/models.py index 52e00c86..49a70ff2 100644 --- a/src/openedx_content/applets/sections/models.py +++ b/src/openedx_content/applets/sections/models.py @@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError from django.db import models -from ..containers.api import get_container_type +from ..containers.api import get_container_type_of from ..containers.models import Container, ContainerVersion from ..publishing.models import PublishableEntity from ..subsections.models import Subsection @@ -42,7 +42,7 @@ 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_type(container) is not Subsection: + if get_container_type_of(container) is not Subsection: raise ValidationError("Only Subsection can be added as children of a Section") diff --git a/src/openedx_content/applets/subsections/models.py b/src/openedx_content/applets/subsections/models.py index 8422c091..794c2d5b 100644 --- a/src/openedx_content/applets/subsections/models.py +++ b/src/openedx_content/applets/subsections/models.py @@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError from django.db import models -from ..containers.api import get_container_type +from ..containers.api import get_container_type_of from ..containers.models import Container, ContainerVersion from ..publishing.models import PublishableEntity from ..units.models import Unit @@ -42,7 +42,7 @@ 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_type(container) is not Unit: + if get_container_type_of(container) is not Unit: raise ValidationError("Only Units can be added as children of a Subsection") diff --git a/tests/openedx_content/applets/backup_restore/test_restore.py b/tests/openedx_content/applets/backup_restore/test_restore.py index d1dea46e..0116731c 100644 --- a/tests/openedx_content/applets/backup_restore/test_restore.py +++ b/tests/openedx_content/applets/backup_restore/test_restore.py @@ -67,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 containers_api.get_container_type_code(container) == "unit" + 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 containers_api.get_container_type_code(container) == "subsection" + 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 containers_api.get_container_type_code(container) == "section" + 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/containers/test_api.py b/tests/openedx_content/applets/containers/test_api.py index 9818933f..e10f18f1 100644 --- a/tests/openedx_content/applets/containers/test_api.py +++ b/tests/openedx_content/applets/containers/test_api.py @@ -768,33 +768,63 @@ def test_get_container_by_key_nonexistent(lp: LearningPackage) -> None: containers_api.get_container_by_key(lp.pk, "invalid-key") -# get_container_type_code and get_container_type +# get_container_type -def test_get_container_type(grandparent: ContainerContainer, parent_of_two: TestContainer, child_entity1: TestEntity): +def test_get_container_type() -> None: """ - Test get_container_type_code() and get_container_type() + Test get_container_type() + """ + assert containers_api.get_container_type("test_generic") is TestContainer + assert containers_api.get_container_type("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_type("foo") + + +# get_all_container_types +def test_get_all_container_types() -> None: + """ + Test get_all_container_types() + """ + # 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_types() if ct._meta.app_label == "test_django_app"] == [ + ContainerContainer, + TestContainer, + ] + + +# get_container_type_code_of and get_container_type_of + + +def test_get_container_type_of( + grandparent: ContainerContainer, parent_of_two: TestContainer, child_entity1: TestEntity +): + """ + Test get_container_type_code_of() and get_container_type_of() """ # Grandparent is a "ContainerContainer": assert isinstance(grandparent, ContainerContainer) - assert containers_api.get_container_type_code(grandparent) == "test_container_container" - assert containers_api.get_container_type(grandparent) is ContainerContainer + assert containers_api.get_container_type_code_of(grandparent) == "test_container_container" + assert containers_api.get_container_type_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(grandparent.base_container) == "test_container_container" - assert containers_api.get_container_type(grandparent.base_container) is ContainerContainer + assert containers_api.get_container_type_code_of(grandparent.base_container) == "test_container_container" + assert containers_api.get_container_type_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(parent_of_two) == "test_generic" - assert containers_api.get_container_type(parent_of_two) is TestContainer + assert containers_api.get_container_type_code_of(parent_of_two) == "test_generic" + assert containers_api.get_container_type_of(parent_of_two) is TestContainer assert isinstance(parent_of_two.container, Container) - assert containers_api.get_container_type_code(parent_of_two.container) == "test_generic" - assert containers_api.get_container_type(parent_of_two.container) is TestContainer + assert containers_api.get_container_type_code_of(parent_of_two.container) == "test_generic" + assert containers_api.get_container_type_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_type(child_entity1) # type: ignore + containers_api.get_container_type_of(child_entity1) # type: ignore def test_get_container_type_deleted(container_of_uninstalled_type: Container): @@ -806,10 +836,10 @@ def test_get_container_type_deleted(container_of_uninstalled_type: Container): containers_api.ContainerImplementationMissingError, match='An implementation for "misc" containers is not currently installed.', ): - containers_api.get_container_type(container_of_uninstalled_type) + containers_api.get_container_type_of(container_of_uninstalled_type) # But get_container_type_code() should still work: - assert containers_api.get_container_type_code(container_of_uninstalled_type) == "misc" + assert containers_api.get_container_type_code_of(container_of_uninstalled_type) == "misc" # get_containers From 72c3290be704a818c57adbb2a04a66cf3c5a6383 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 16 Mar 2026 18:53:35 -0700 Subject: [PATCH 07/14] feat: store `olx_tag_name` in openedx_content's core container type models --- src/openedx_content/applets/containers/models.py | 3 +++ src/openedx_content/applets/sections/models.py | 1 + src/openedx_content/applets/subsections/models.py | 1 + src/openedx_content/applets/units/models.py | 1 + tests/openedx_content/applets/sections/test_api.py | 7 +++++++ tests/openedx_content/applets/subsections/test_api.py | 7 +++++++ tests/openedx_content/applets/units/test_api.py | 7 +++++++ 7 files changed, 27 insertions(+) diff --git a/src/openedx_content/applets/containers/models.py b/src/openedx_content/applets/containers/models.py index beaf5023..d1c3055e 100644 --- a/src/openedx_content/applets/containers/models.py +++ b/src/openedx_content/applets/containers/models.py @@ -162,6 +162,9 @@ class Container(PublishableEntityMixin): """ 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: ContainerTypeRecord # Cache used by get_type_record() # The type of the container. Cannot be changed once the container is created. diff --git a/src/openedx_content/applets/sections/models.py b/src/openedx_content/applets/sections/models.py index 49a70ff2..1bb49056 100644 --- a/src/openedx_content/applets/sections/models.py +++ b/src/openedx_content/applets/sections/models.py @@ -28,6 +28,7 @@ class Section(Container): """ type_code = "section" + olx_tag_name = "chapter" # Serializes to OLX as `...`. container = models.OneToOneField( Container, diff --git a/src/openedx_content/applets/subsections/models.py b/src/openedx_content/applets/subsections/models.py index 794c2d5b..acc3f54f 100644 --- a/src/openedx_content/applets/subsections/models.py +++ b/src/openedx_content/applets/subsections/models.py @@ -28,6 +28,7 @@ class Subsection(Container): """ type_code = "subsection" + olx_tag_name = "sequential" # Serializes to OLX as `...`. container = models.OneToOneField( Container, diff --git a/src/openedx_content/applets/units/models.py b/src/openedx_content/applets/units/models.py index 9e9328f1..a3a2d36d 100644 --- a/src/openedx_content/applets/units/models.py +++ b/src/openedx_content/applets/units/models.py @@ -25,6 +25,7 @@ class Unit(Container): """ type_code = "unit" + olx_tag_name = "vertical" # Serializes to OLX as `...`. container = models.OneToOneField( Container, diff --git a/tests/openedx_content/applets/sections/test_api.py b/tests/openedx_content/applets/sections/test_api.py index 870530b2..1e97dc58 100644 --- a/tests/openedx_content/applets/sections/test_api.py +++ b/tests/openedx_content/applets/sections/test_api.py @@ -196,3 +196,10 @@ def test_create_section_with_invalid_children(self): 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_is_registered(self): + assert Section in content_api.get_all_container_types() + + def test_olx_tag_name(self): + assert content_api.get_container_type("section") is Section + assert content_api.get_container_type("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 4de84f56..f5c2a865 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -172,3 +172,10 @@ def test_create_subsection_with_invalid_children(self): 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_is_registered(self): + assert Subsection in content_api.get_all_container_types() + + def test_olx_tag_name(self): + assert content_api.get_container_type("subsection") is Subsection + assert content_api.get_container_type("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 f2e03160..473ff5c8 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -167,3 +167,10 @@ def test_create_unit_with_invalid_children(self): # 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_is_registered(self): + assert Unit in content_api.get_all_container_types() + + def test_olx_tag_name(self): + assert content_api.get_container_type("unit") is Unit + assert content_api.get_container_type("unit").olx_tag_name == "vertical" From fe1b5945ae676477ac80e67fea02cf087629f107 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 17 Mar 2026 10:17:17 -0700 Subject: [PATCH 08/14] test: minor cleanups in containers/test_api --- src/openedx_content/applets/containers/api.py | 2 +- .../applets/containers/test_api.py | 35 ++++++++++--------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/openedx_content/applets/containers/api.py b/src/openedx_content/applets/containers/api.py index 277c72a7..ee34dd00 100644 --- a/src/openedx_content/applets/containers/api.py +++ b/src/openedx_content/applets/containers/api.py @@ -388,7 +388,7 @@ class ChildrenEntitiesAction(Enum): """Possible actions for children entities""" APPEND = "append" - REMOVE = "remove" + REMOVE = "remove" # TODO: deprecated/drop/fix `REMOVE` - https://github.com/openedx/openedx-core/issues/502 REPLACE = "replace" diff --git a/tests/openedx_content/applets/containers/test_api.py b/tests/openedx_content/applets/containers/test_api.py index e10f18f1..c60a97cc 100644 --- a/tests/openedx_content/applets/containers/test_api.py +++ b/tests/openedx_content/applets/containers/test_api.py @@ -249,7 +249,7 @@ def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: TestEntit @pytest.fixture(name="other_lp_parent") -def _other_lp_parent(lp2: LearningPackage, other_lp_child: PublishableEntity) -> TestContainer: +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, @@ -478,14 +478,8 @@ def test_create_next_container_version_with_remove_1( child_entity3: TestEntity, ): """ - Test creating a new version of the "parent of two" container, using the REMOVE action to remove children. + Test creating a new version of the "parent of six" container, using the REMOVE action to remove children. """ - #################################################################################################################### - # TODO: Note: this "REMOVE" API isn't really a great API. It needs all these tests cases to handle the case of - # duplicate entries, and pinned vs. unpinned, and we don't even use "pinning" in Open edX yet. We should consider - # dropping the APPEND/REMOVE APIs altogether and just having a simple "replace all children with this new list" API. - #################################################################################################################### - # 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 @@ -521,7 +515,7 @@ def test_create_next_container_version_with_remove_2( child_entity3: TestEntity, ): """ - Test creating a new version of the "parent of two" container, using the REMOVE action to remove children. + 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) == [ @@ -559,7 +553,7 @@ def test_create_next_container_version_with_remove_3( child_entity3: TestEntity, ): """ - Test creating a new version of the "parent of two" container, using the REMOVE action to remove children. + 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) == [ @@ -597,7 +591,7 @@ def test_create_next_container_version_with_remove_4( child_entity3: TestEntity, ): """ - Test creating a new version of the "parent of two" container, using the REMOVE action to remove children. + 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) == [ @@ -689,7 +683,7 @@ def test_get_container(parent_of_two: TestContainer, django_assert_num_queries) 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 select_related() + # 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 @@ -858,7 +852,7 @@ def test_get_containers( """ result = list(containers_api.get_containers(lp.id)) # The API always returns Container base class instances, never specific types: - assert [c.__class__ is Container for c in result] + 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: @@ -905,7 +899,7 @@ 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 decsendants are unpublished drafts only. + # Setup: grandparent and all its descendants are unpublished drafts only. assert grandparent.versioning.published is None # Tests: @@ -1226,7 +1220,7 @@ def test_shallow_publish_log( def test_uninstalled_publish( lp: LearningPackage, - container_of_uninstalled_type: TestContainer, + 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""" @@ -1250,7 +1244,7 @@ def test_deep_publish_log( child_entity1: TestEntity, child_entity2: TestEntity, child_entity3: TestEntity, - container_of_uninstalled_type: TestContainer, + container_of_uninstalled_type: Container, lp2: LearningPackage, other_lp_parent: TestContainer, other_lp_child: TestEntity, @@ -1595,6 +1589,15 @@ def test_get_container_children_count_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): From 9f49bdec3a629dce979fdca54e5e0cec23a8ac51 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 17 Mar 2026 10:56:09 -0700 Subject: [PATCH 09/14] fix: query count discrepancy between MySQL and SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude explanation: In `_create_side_effects_for_change_log`, `change_log.records.all()` has no `ORDER BY`. On SQLite, this returns records in insertion order (PK order) — which is root-first, since `publish_from_drafts` inserts the initial draft first, then its dependencies layer by layer. On MySQL/InnoDB, the query uses the `oel_plr_uniq_pl_publishable` unique index on (`publish_log`, `entity`) to service the `WHERE publish_log_id = ?` filter, which returns records in `entity_id` order — i.e. leaf-first, because `child_entity2` has a lower entity PK than `parent_of_two`, `grandparent`, etc. The `processed_entity_ids` optimization on line 899–904 is order-sensitive: when you process leaf records first, `processed_entity_ids` isn't yet populated with their ancestors' IDs, so the while loop traverses all the way up the tree redundantly on each leaf --- src/openedx_content/applets/publishing/api.py | 2 +- .../applets/containers/test_api.py | 17 +---------------- .../applets/sections/test_api.py | 2 +- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 89031ac5..a25fe93b 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -810,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 ) diff --git a/tests/openedx_content/applets/containers/test_api.py b/tests/openedx_content/applets/containers/test_api.py index c60a97cc..bd20e452 100644 --- a/tests/openedx_content/applets/containers/test_api.py +++ b/tests/openedx_content/applets/containers/test_api.py @@ -19,7 +19,6 @@ PublishableEntityMixin, PublishableEntityVersionMixin, PublishLog, - PublishSideEffect, ) from tests.test_django_app.models import ( ContainerContainer, @@ -1273,11 +1272,7 @@ def test_deep_publish_log( ] # Publish great_grandparent. Should publish the whole tree. - # FIXME: this is using 144 queries on MySQL vs 132 on SQLite. The disparity only happens when publishing four levels - # of hierarchy at once (e.g. publishing a Section-->Component or Great-Grandparent-->Child). i.e. if you change this - # to publish "grandparent" instead of "great_grandparent" or you pre-publish the leaves (child entities), there is - # no disparity between the databases. - with django_assert_num_queries(133): + with django_assert_num_queries(132): publish_log = publish_entity(great_grandparent) assert list(publish_log.records.order_by("entity__pk").values_list("entity__key", flat=True)) == [ "child_entity2", @@ -1286,16 +1281,6 @@ def test_deep_publish_log( "grandparent", "great_grandparent", ] - assert list( - PublishSideEffect.objects.filter(cause__publish_log=publish_log) - .values_list("cause__entity__key", "effect__entity__key") - .order_by("cause__entity__key") # Note: order_by("pk") is different on MySQL vs SQLite - ) == [ - ("child_entity2", "parent_of_two"), - ("grandparent", "great_grandparent"), - ("parent_of_three", "grandparent"), - ("parent_of_two", "grandparent"), - ] # get_entities_in_container diff --git a/tests/openedx_content/applets/sections/test_api.py b/tests/openedx_content/applets/sections/test_api.py index 1e97dc58..e73c0fe8 100644 --- a/tests/openedx_content/applets/sections/test_api.py +++ b/tests/openedx_content/applets/sections/test_api.py @@ -154,7 +154,7 @@ def test_section_queries(self) -> None: """ with self.assertNumQueries(37): section = self.create_section_with_subsections([self.subsection_1, self.subsection_2_v1]) - with self.assertNumQueries(169): # TODO: this seems high? FIXME: this is 181 on MySQL but 169 on SQLite? + with self.assertNumQueries(169): content_api.publish_from_drafts( self.learning_package.id, draft_qset=content_api.get_all_drafts(self.learning_package.id).filter(entity=section.pk), From e6d046fb1faa5054b539f84749e574dfb9dda2ad Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 17 Mar 2026 11:27:05 -0700 Subject: [PATCH 10/14] perf: slightly reduce query count when computing publishing side effects --- src/openedx_content/applets/publishing/api.py | 8 ++++---- tests/openedx_content/applets/containers/test_api.py | 8 ++++---- tests/openedx_content/applets/sections/test_api.py | 2 +- tests/openedx_content/applets/subsections/test_api.py | 2 +- tests/openedx_content/applets/units/test_api.py | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index a25fe93b..afc59786 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -854,13 +854,13 @@ 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 = affected # 'affected' is the current Draft object + if draft_obj.draft_log_record_id != side_effect_change.pk: draft_obj.draft_log_record = side_effect_change branch_objs_to_update_with_side_effects.append(draft_obj) diff --git a/tests/openedx_content/applets/containers/test_api.py b/tests/openedx_content/applets/containers/test_api.py index bd20e452..53179159 100644 --- a/tests/openedx_content/applets/containers/test_api.py +++ b/tests/openedx_content/applets/containers/test_api.py @@ -908,7 +908,7 @@ def test_contains_unpublished_changes_queries( assert containers_api.contains_unpublished_changes(grandparent.pk) # Publish grandparent and all its descendants: - with django_assert_num_queries(143): # TODO: investigate as this seems high! + with django_assert_num_queries(135): # TODO: investigate as this seems high! publish_entity(grandparent) # Tests: @@ -1225,7 +1225,7 @@ def test_uninstalled_publish( """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(50): + 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)) == [ @@ -1263,7 +1263,7 @@ def test_deep_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(50): + 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)) == [ @@ -1272,7 +1272,7 @@ def test_deep_publish_log( ] # Publish great_grandparent. Should publish the whole tree. - with django_assert_num_queries(132): + 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", diff --git a/tests/openedx_content/applets/sections/test_api.py b/tests/openedx_content/applets/sections/test_api.py index e73c0fe8..df7076be 100644 --- a/tests/openedx_content/applets/sections/test_api.py +++ b/tests/openedx_content/applets/sections/test_api.py @@ -154,7 +154,7 @@ def test_section_queries(self) -> None: """ with self.assertNumQueries(37): section = self.create_section_with_subsections([self.subsection_1, self.subsection_2_v1]) - with self.assertNumQueries(169): + 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), diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py index f5c2a865..72ab7298 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -130,7 +130,7 @@ def test_subsection_queries(self) -> None: """ with self.assertNumQueries(37): subsection = self.create_subsection_with_units([self.unit_1, self.unit_1_v1]) - with self.assertNumQueries(107): # TODO: this seems high? + 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), diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 473ff5c8..6e2dc81f 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -131,7 +131,7 @@ def test_unit_queries(self) -> None: """ with self.assertNumQueries(35): unit = self.create_unit_with_components([self.component_1, self.component_2_v1]) - with self.assertNumQueries(49): # TODO: this seems high? + 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), From 9f619af6019083061534fa87c418ef4c82b184ac Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 17 Mar 2026 12:57:51 -0700 Subject: [PATCH 11/14] chore: ignore mypy warning --- src/openedx_content/applets/publishing/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index afc59786..88143f52 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -860,8 +860,8 @@ def _create_side_effects_for_change_log(change_log: DraftChangeLog | PublishLog) branch_objs_to_update_with_side_effects.append(published_obj) elif branch_cls == Draft: draft_obj = affected # 'affected' is the current Draft object - if draft_obj.draft_log_record_id != side_effect_change.pk: - draft_obj.draft_log_record = side_effect_change + 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 From f6f28339ae2cd01c97bbc72ed6a1d5cfa95df078 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 17 Mar 2026 22:41:27 -0700 Subject: [PATCH 12/14] refactor: ContainerType->ContainerSubclass, ContainerTypeRecord->ContainerType --- .../applets/backup_restore/zipper.py | 16 ++--- src/openedx_content/applets/containers/api.py | 48 +++++++------- .../applets/containers/models.py | 37 +++++------ src/openedx_content/applets/sections/api.py | 2 +- .../applets/sections/models.py | 4 +- .../applets/subsections/api.py | 2 +- .../applets/subsections/models.py | 4 +- src/openedx_content/applets/units/api.py | 2 +- .../migrations/0005_containertypes.py | 30 ++++----- src/openedx_content/models.py | 4 +- .../applets/backup_restore/test_backup.py | 2 +- .../applets/collections/test_api.py | 2 +- .../applets/containers/test_api.py | 65 +++++++++---------- .../applets/publishing/test_api.py | 18 ++--- .../applets/sections/test_api.py | 6 +- .../applets/subsections/test_api.py | 6 +- .../openedx_content/applets/units/test_api.py | 8 +-- 17 files changed, 127 insertions(+), 129 deletions(-) diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index f8a18271..29b9638e 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -810,19 +810,19 @@ def _save_container( learning_package, containers, *, - container_type: containers_api.ContainerType, + container_cls: containers_api.ContainerSubclass, container_map: dict, children_map: dict, ): """Internal logic for _save_units, _save_subsections, and _save_sections""" - type_code = container_type.type_code # e.g. "unit" + 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_type=container_type, + container_cls=container_cls, ) container_map[entity_key] = container # e.g. `self.units_map_by_key[entity_key] = unit` @@ -845,7 +845,7 @@ def _save_units(self, learning_package, containers): self._save_container( learning_package, containers, - container_type=Unit, + container_cls=Unit, container_map=self.units_map_by_key, children_map=self.components_map_by_key, ) @@ -855,7 +855,7 @@ def _save_subsections(self, learning_package, containers): self._save_container( learning_package, containers, - container_type=Subsection, + container_cls=Subsection, container_map=self.subsections_map_by_key, children_map=self.units_map_by_key, ) @@ -865,7 +865,7 @@ def _save_sections(self, learning_package, containers): self._save_container( learning_package, containers, - container_type=Section, + container_cls=Section, container_map=self.sections_map_by_key, children_map=self.subsections_map_by_key, ) @@ -890,11 +890,11 @@ def _save_draft_versions(self, components, containers, component_static_files): ) def _process_draft_containers( - container_type: containers_api.ContainerType, + container_cls: containers_api.ContainerSubclass, container_map: dict, children_map: dict, ): - for valid_draft in containers.get(f"{container_type.type_code}_drafts", []): + 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): diff --git a/src/openedx_content/applets/containers/api.py b/src/openedx_content/applets/containers/api.py index ee34dd00..51c00e8c 100644 --- a/src/openedx_content/applets/containers/api.py +++ b/src/openedx_content/applets/containers/api.py @@ -27,7 +27,7 @@ from .models import ( Container, ContainerImplementationMissingError, - ContainerTypeRecord, + ContainerType, ContainerVersion, EntityList, EntityListRow, @@ -47,7 +47,7 @@ __all__ = [ # 🛑 UNSTABLE: All APIs related to containers are unstable until we've figured # out our approach to dynamic content (randomized, A/B tests, etc.) - "ContainerType", + "ContainerSubclass", "ContainerImplementationMissingError", "create_container", "create_container_version", @@ -56,10 +56,10 @@ "get_container", "get_container_version", "get_container_by_key", - "get_all_container_types", - "get_container_type", + "get_all_container_subclasses", + "get_container_subclass", "get_container_type_code_of", - "get_container_type_of", + "get_container_subclass_of", "get_containers", "ChildrenEntitiesAction", "ContainerEntityListEntry", @@ -90,7 +90,7 @@ def entity(self): EntityListInput = Iterable[ PublishableEntity | PublishableEntityMixin | PublishableEntityVersion | PublishableEntityVersionMixin ] -ContainerType = type[Container] +ContainerSubclass = type[Container] @dataclass(frozen=True, kw_only=True, slots=True) @@ -140,7 +140,7 @@ def create_container( created: datetime, created_by: int | None, *, - container_type: type[ContainerModel], + container_cls: type[ContainerModel], can_stand_alone: bool = True, ) -> ContainerModel: """ @@ -152,13 +152,13 @@ def create_container( key: The key of the container. created: The date and time the container was created. created_by: The ID of the user who created the container - container_type: The type of container to create (e.g. Unit) + 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_type, Container) + assert issubclass(container_cls, Container) with atomic(): publishable_entity = publishing_api.create_publishable_entity( learning_package_id, @@ -167,9 +167,9 @@ def create_container( created_by, can_stand_alone=can_stand_alone, ) - container = container_type.objects.create( + container = container_cls.objects.create( publishable_entity=publishable_entity, - container_type_record=container_type.get_type_record(), + container_type=container_cls.get_container_type(), ) return container @@ -251,22 +251,22 @@ def _create_container_version( """ # validate entity_list using the type implementation: try: - container_type = Container.subclass_for_type_code(container.container_type_record.type_code) - except ContainerTypeRecord.DoesNotExist as exc: + container_subclass = Container.subclass_for_type_code(container.container_type.type_code) + except ContainerType.DoesNotExist as exc: raise IntegrityError( - "Existing ContainerTypeRecord is now missing. " + "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_type) + version_type = PublishableContentModelRegistry.get_versioned_model_cls(container_subclass) for entity_row in entity_list.rows: try: - container_type.validate_entity(entity_row.entity) + 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_type.type_code}" container.' + 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. @@ -341,7 +341,7 @@ def create_container_and_version( key: str, *, title: str, - container_type: type[ContainerModel], + container_cls: type[ContainerModel], entities: EntityListInput | None = None, created: datetime, created_by: int | None = None, @@ -354,7 +354,7 @@ def create_container_and_version( learning_package_id: The learning package ID. key: The key. title: The title of the new container. - container_type: The type of container to create (e.g. Unit) + 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 @@ -371,7 +371,7 @@ def create_container_and_version( created, created_by, can_stand_alone=can_stand_alone, - container_type=container_type, + container_cls=container_cls, ) container_version: ContainerVersionModel = create_container_version( # type: ignore[assignment] container.pk, @@ -589,14 +589,14 @@ def get_container_by_key(learning_package_id: int, /, key: str) -> Container: raise -def get_all_container_types() -> list[ContainerType]: +def get_all_container_subclasses() -> list[ContainerSubclass]: """ Get a list of installed Container types (`Container` subclasses). """ return Container.all_subclasses() -def get_container_type(type_code: str, /) -> ContainerType: +def get_container_subclass(type_code: str, /) -> ContainerSubclass: """ Get subclass of `Container` from its `type_code` string (e.g. `"unit"`). @@ -610,10 +610,10 @@ def get_container_type_code_of(container: Container | int, /) -> str: if isinstance(container, int): container = get_container(container) assert isinstance(container, Container) - return container.container_type_record.type_code + return container.container_type.type_code -def get_container_type_of(container: Container | int, /) -> ContainerType: +def get_container_subclass_of(container: Container | int, /) -> ContainerSubclass: """ Get the type of a container. diff --git a/src/openedx_content/applets/containers/models.py b/src/openedx_content/applets/containers/models.py index d1c3055e..e45fc7f7 100644 --- a/src/openedx_content/applets/containers/models.py +++ b/src/openedx_content/applets/containers/models.py @@ -22,7 +22,7 @@ __all__ = [ "Container", "ContainerVersion", - # ContainerTypeRecord is not public + # ContainerType is not public "EntityList", "EntityListRow", ] @@ -117,7 +117,7 @@ class ContainerImplementationMissingError(Exception): """Raised when trying to modify a container whose implementation [plugin] is no longer available.""" -class ContainerTypeRecord(models.Model): +class ContainerType(models.Model): """ Normalized representation of the type of Container. @@ -141,7 +141,7 @@ class Meta: 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_containertyperecord_type_code_rx", + name="oex_publishing_containertype_type_code_rx", ), ] @@ -165,11 +165,11 @@ class Container(PublishableEntityMixin): # 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: ContainerTypeRecord # Cache used by get_type_record() + _type_instance: ContainerType # Cache used by get_container_type() # The type of the container. Cannot be changed once the container is created. - container_type_record = models.ForeignKey( - ContainerTypeRecord, + container_type = models.ForeignKey( + ContainerType, null=False, on_delete=models.RESTRICT, editable=False, @@ -181,16 +181,15 @@ def validate_entity(cls, entity: PublishableEntity) -> None: @final @classmethod - def get_type_record(cls) -> ContainerTypeRecord: + def get_container_type(cls) -> ContainerType: """ - Get the ContainerTypeRecord for this type of container, auto-creating it - if need be. + Get the ContainerType for this type of container, auto-creating it if need be. """ if cls is Container: - raise TypeError('Manipulating "naked" Containers is not allowed. Use a specific Container type like Unit.') + 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, _ = ContainerTypeRecord.objects.get_or_create(type_code=cls.type_code) + cls._type_instance, _ = ContainerType.objects.get_or_create(type_code=cls.type_code) return cls._type_instance @final @@ -198,24 +197,24 @@ def get_type_record(cls) -> ContainerTypeRecord: def reset_cache() -> None: """ Helper for test cases that truncate the database between tests. - Call this to delete the cache used in get_type_record(), which will be - invalid after the ContainerTypeRecord table is truncated. + 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_type: type[Container]): + def register_subclass(container_subclass: type[Container]): """ Register a Container subclass """ - assert container_type.type_code, "Container subclasses must override type_code" - assert container_type.type_code not in _registered_container_types, ( - f"{container_type.type_code} already registered" + 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_type.type_code] = container_type - return container_type + _registered_container_types[container_subclass.type_code] = container_subclass + return container_subclass @staticmethod def subclass_for_type_code(type_code: str) -> type[Container]: diff --git a/src/openedx_content/applets/sections/api.py b/src/openedx_content/applets/sections/api.py index 4b348281..ff9b613d 100644 --- a/src/openedx_content/applets/sections/api.py +++ b/src/openedx_content/applets/sections/api.py @@ -53,7 +53,7 @@ def create_section_and_version( created=created, created_by=created_by, can_stand_alone=can_stand_alone, - container_type=Section, + container_cls=Section, ) assert isinstance(sv, SectionVersion) return section, sv diff --git a/src/openedx_content/applets/sections/models.py b/src/openedx_content/applets/sections/models.py index 1bb49056..a02df481 100644 --- a/src/openedx_content/applets/sections/models.py +++ b/src/openedx_content/applets/sections/models.py @@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError from django.db import models -from ..containers.api import get_container_type_of +from ..containers.api import get_container_subclass_of from ..containers.models import Container, ContainerVersion from ..publishing.models import PublishableEntity from ..subsections.models import Subsection @@ -43,7 +43,7 @@ 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_type_of(container) is not Subsection: + if get_container_subclass_of(container) is not Subsection: raise ValidationError("Only Subsection can be added as children of a Section") diff --git a/src/openedx_content/applets/subsections/api.py b/src/openedx_content/applets/subsections/api.py index 1926827b..85f3b73c 100644 --- a/src/openedx_content/applets/subsections/api.py +++ b/src/openedx_content/applets/subsections/api.py @@ -53,7 +53,7 @@ def create_subsection_and_version( created=created, created_by=created_by, can_stand_alone=can_stand_alone, - container_type=Subsection, + container_cls=Subsection, ) assert isinstance(sv, SubsectionVersion) return subsection, sv diff --git a/src/openedx_content/applets/subsections/models.py b/src/openedx_content/applets/subsections/models.py index acc3f54f..d1eefb13 100644 --- a/src/openedx_content/applets/subsections/models.py +++ b/src/openedx_content/applets/subsections/models.py @@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError from django.db import models -from ..containers.api import get_container_type_of +from ..containers.api import get_container_subclass_of from ..containers.models import Container, ContainerVersion from ..publishing.models import PublishableEntity from ..units.models import Unit @@ -43,7 +43,7 @@ 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_type_of(container) is not Unit: + if get_container_subclass_of(container) is not Unit: raise ValidationError("Only Units can be added as children of a Subsection") diff --git a/src/openedx_content/applets/units/api.py b/src/openedx_content/applets/units/api.py index 09a94f8b..4aad6dde 100644 --- a/src/openedx_content/applets/units/api.py +++ b/src/openedx_content/applets/units/api.py @@ -53,7 +53,7 @@ def create_unit_and_version( created=created, created_by=created_by, can_stand_alone=can_stand_alone, - container_type=Unit, + container_cls=Unit, ) assert isinstance(uv, UnitVersion) return unit, uv diff --git a/src/openedx_content/migrations/0005_containertypes.py b/src/openedx_content/migrations/0005_containertypes.py index a477860d..65250188 100644 --- a/src/openedx_content/migrations/0005_containertypes.py +++ b/src/openedx_content/migrations/0005_containertypes.py @@ -13,16 +13,16 @@ def backfill_container_types(apps, schema_editor): existing containers. """ Container = apps.get_model("openedx_content", "Container") - ContainerTypeRecord = apps.get_model("openedx_content", "ContainerTypeRecord") - section_type, _ = ContainerTypeRecord.objects.get_or_create(type_code="section") - subsection_type, _ = ContainerTypeRecord.objects.get_or_create(type_code="subsection") - unit_type, _ = ContainerTypeRecord.objects.get_or_create(type_code="unit") + 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_record=None) + containers_to_update = Container.objects.filter(container_type=None) - containers_to_update.exclude(section=None).update(container_type_record=section_type) - containers_to_update.exclude(subsection=None).update(container_type_record=subsection_type) - containers_to_update.exclude(unit=None).update(container_type_record=unit_type) + 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: @@ -35,9 +35,9 @@ class Migration(migrations.Migration): ] operations = [ - # 1. Create the new ContainerTypeRecord model + # 1. Create the new ContainerType model migrations.CreateModel( - name="ContainerTypeRecord", + name="ContainerType", fields=[ ("id", models.AutoField(primary_key=True, serialize=False)), ( @@ -51,7 +51,7 @@ class Migration(migrations.Migration): "constraints": [ models.CheckConstraint( condition=django.db.models.lookups.Regex(models.F("type_code"), "^[a-z0-9\\-_\\.]+$"), - name="oex_publishing_containertyperecord_type_code_rx", + name="oex_publishing_containertype_type_code_rx", ) ], }, @@ -59,12 +59,12 @@ class Migration(migrations.Migration): # 2. Define the ForeignKey from Container to ContainerType migrations.AddField( model_name="container", - name="container_type_record", + name="container_type", field=models.ForeignKey( editable=False, null=True, on_delete=django.db.models.deletion.RESTRICT, - to="openedx_content.containertyperecord", + to="openedx_content.containertype", ), ), # 3. Populate the container_type column, which is currently NULL for all existing containers @@ -72,12 +72,12 @@ class Migration(migrations.Migration): # 4. disallow NULL values from now on migrations.AlterField( model_name="container", - name="container_type_record", + name="container_type", field=models.ForeignKey( editable=False, null=False, on_delete=django.db.models.deletion.RESTRICT, - to="openedx_content.containertyperecord", + to="openedx_content.containertype", ), ), ] diff --git a/src/openedx_content/models.py b/src/openedx_content/models.py index 8d852977..6a5c696b 100644 --- a/src/openedx_content/models.py +++ b/src/openedx_content/models.py @@ -5,12 +5,12 @@ import their modules, but that broke code introspection. """ -# pylint: disable=wildcard-import,unused-import +# pylint: disable=wildcard-import from .applets.backup_restore.models import * from .applets.collections.models import * from .applets.components.models import * -from .applets.containers.models import Container, ContainerTypeRecord, ContainerVersion # __all__ excludes TypeRecord +from .applets.containers.models import * from .applets.media.models import * from .applets.publishing.models import * from .applets.sections.models import * diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py index c7df5957..e7a69cff 100644 --- a/tests/openedx_content/applets/backup_restore/test_backup.py +++ b/tests/openedx_content/applets/backup_restore/test_backup.py @@ -163,7 +163,7 @@ def setUpTestData(cls): key="unit-1", created=cls.now, created_by=cls.user.id, - container_type=Unit, + 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/collections/test_api.py b/tests/openedx_content/applets/collections/test_api.py index 4fe970af..c8d638eb 100644 --- a/tests/openedx_content/applets/collections/test_api.py +++ b/tests/openedx_content/applets/collections/test_api.py @@ -252,7 +252,7 @@ def setUpTestData(cls) -> None: key="unit-1", created=created_time, created_by=cls.user.id, - container_type=Unit, + container_cls=Unit, ) # Make and publish one Component diff --git a/tests/openedx_content/applets/containers/test_api.py b/tests/openedx_content/applets/containers/test_api.py index 53179159..50871d8c 100644 --- a/tests/openedx_content/applets/containers/test_api.py +++ b/tests/openedx_content/applets/containers/test_api.py @@ -11,7 +11,7 @@ from django.db.utils import IntegrityError from openedx_content.applets.containers import api as containers_api -from openedx_content.applets.containers.models import Container, ContainerTypeRecord, ContainerVersion +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, @@ -149,7 +149,7 @@ def create_test_container( key=key, title=title or f"Container ({key})", entities=entities, - container_type=TestContainer, + container_cls=TestContainer, created=now, created_by=None, ) @@ -219,7 +219,7 @@ def _grandparent( key="grandparent", title="Generic Container with Two Unpinned TestContainer children", entities=[parent_of_two, parent_of_three], - container_type=ContainerContainer, + container_cls=ContainerContainer, created=now, created_by=None, ) @@ -229,7 +229,7 @@ def _grandparent( @pytest.fixture(name="container_of_uninstalled_type") def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: TestEntity) -> Container: """ - A container whose ContainerType implementation is no longer available, + 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 @@ -238,12 +238,12 @@ def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: TestEntit key="abandoned-container", title="Abandoned Container 1", entities=[child_entity1], - container_type=TestContainer, + container_cls=TestContainer, created=now, ) # Now create the plugin type (no public API for this; only do this in a test) - ctr = ContainerTypeRecord.objects.create(type_code="misc") - Container.objects.filter(pk=container.pk).update(container_type_record=ctr) + 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 @@ -255,7 +255,7 @@ def _other_lp_parent(lp2: LearningPackage, other_lp_child: TestEntity) -> TestCo key="other_lp_parent", title="Generic Container with One Unpinned Child Entity", entities=[other_lp_child], - container_type=TestContainer, + container_cls=TestContainer, created=now, created_by=None, ) @@ -304,7 +304,7 @@ def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None lp.pk, key="new-container-1", title="Test Container 1", - container_type=TestContainer, + container_cls=TestContainer, created=now, created_by=admin_user.pk, can_stand_alone=False, @@ -336,7 +336,7 @@ def test_create_container_queries(lp: LearningPackage, child_entity1: TestEntity "title": "Test Container", "created": now, "created_by": None, - "container_type": TestContainer, + "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): @@ -761,75 +761,74 @@ def test_get_container_by_key_nonexistent(lp: LearningPackage) -> None: containers_api.get_container_by_key(lp.pk, "invalid-key") -# get_container_type +# get_container_subclass -def test_get_container_type() -> None: +def test_get_container_subclass() -> None: """ - Test get_container_type() + Test get_container_subclass() """ - assert containers_api.get_container_type("test_generic") is TestContainer - assert containers_api.get_container_type("test_container_container") is ContainerContainer + 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_type("foo") + containers_api.get_container_subclass("foo") -# get_all_container_types -def test_get_all_container_types() -> None: +# get_all_container_subclasses +def test_get_all_container_subclasses() -> None: """ - Test get_all_container_types() + 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_types() if ct._meta.app_label == "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_type_of +# get_container_type_code_of and get_container_subclass_of -def test_get_container_type_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_type_of() + 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_type_of(grandparent) is ContainerContainer + 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_type_of(grandparent.base_container) is ContainerContainer + 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_type_of(parent_of_two) is TestContainer + 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_type_of(parent_of_two.container) is TestContainer + 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_type_of(child_entity1) # type: ignore + containers_api.get_container_subclass_of(child_entity1) # type: ignore def test_get_container_type_deleted(container_of_uninstalled_type: Container): """ - Get ContainerType will raise ValueError if the container type implementation - is no longer available + `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_type_of(container_of_uninstalled_type) + 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" @@ -1138,7 +1137,7 @@ def test_publishing_shared_component(lp: LearningPackage): key="unit:1", created=now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) unit2, _ = containers_api.create_container_and_version( lp.pk, @@ -1147,7 +1146,7 @@ def test_publishing_shared_component(lp: LearningPackage): key="unit:2", created=now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) publishing_api.publish_all_drafts(lp.pk) assert containers_api.contains_unpublished_changes(unit1.pk) is False diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index abbcfaa6..96012633 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -1078,7 +1078,7 @@ def test_parent_child_side_effects(self) -> None: "my_container", created=self.now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) container_v1 = containers_api.create_container_version( container.pk, @@ -1158,7 +1158,7 @@ def test_bulk_parent_child_side_effects(self) -> None: "my_container", created=self.now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) container_v1 = containers_api.create_container_version( container.pk, @@ -1234,14 +1234,14 @@ def test_draft_dependency_multiple_parents(self) -> None: "unit_1", created=self.now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) unit_2 = containers_api.create_container( self.learning_package.id, "unit_2", created=self.now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) for unit in [unit_1, unit_2]: containers_api.create_container_version( @@ -1296,7 +1296,7 @@ def test_multiple_layers_of_containers(self) -> None: "unit_1", created=self.now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) containers_api.create_container_version( unit.pk, @@ -1311,7 +1311,7 @@ def test_multiple_layers_of_containers(self) -> None: "subsection_1", created=self.now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) containers_api.create_container_version( subsection.pk, @@ -1395,7 +1395,7 @@ def test_publish_all_layers(self) -> None: "unit_1", created=self.now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) containers_api.create_container_version( unit.pk, @@ -1410,7 +1410,7 @@ def test_publish_all_layers(self) -> None: "subsection_1", created=self.now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) containers_api.create_container_version( subsection.pk, @@ -1442,7 +1442,7 @@ def test_container_next_version(self) -> None: "my_container", created=self.now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) assert container.versioning.latest is None v1 = containers_api.create_next_container_version( diff --git a/tests/openedx_content/applets/sections/test_api.py b/tests/openedx_content/applets/sections/test_api.py index df7076be..ef631e88 100644 --- a/tests/openedx_content/applets/sections/test_api.py +++ b/tests/openedx_content/applets/sections/test_api.py @@ -198,8 +198,8 @@ def test_create_section_with_invalid_children(self): self.create_section_with_subsections([self.unit_1], key="unit:key3", title="Unit 3") def test_is_registered(self): - assert Section in content_api.get_all_container_types() + assert Section in content_api.get_all_container_subclasses() def test_olx_tag_name(self): - assert content_api.get_container_type("section") is Section - assert content_api.get_container_type("section").olx_tag_name == "chapter" + 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 72ab7298..8f13f2f2 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -174,8 +174,8 @@ def test_create_subsection_with_invalid_children(self): self.create_subsection_with_units([self.component_1], key="unit:key3", title="Unit 3") def test_is_registered(self): - assert Subsection in content_api.get_all_container_types() + assert Subsection in content_api.get_all_container_subclasses() def test_olx_tag_name(self): - assert content_api.get_container_type("subsection") is Subsection - assert content_api.get_container_type("subsection").olx_tag_name == "sequential" + 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 6e2dc81f..693c7ab2 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -120,7 +120,7 @@ def test_get_unit_other_container_type(self) -> None: key="test", created=self.now, created_by=None, - container_type=TestContainer, + container_cls=TestContainer, ) with pytest.raises(Unit.DoesNotExist): content_api.get_unit(other_container.pk) @@ -169,8 +169,8 @@ def test_create_unit_with_invalid_children(self): self.create_unit_with_components([unit2], key="unit:key3", title="Unit 3") def test_is_registered(self): - assert Unit in content_api.get_all_container_types() + assert Unit in content_api.get_all_container_subclasses() def test_olx_tag_name(self): - assert content_api.get_container_type("unit") is Unit - assert content_api.get_container_type("unit").olx_tag_name == "vertical" + assert content_api.get_container_subclass("unit") is Unit + assert content_api.get_container_subclass("unit").olx_tag_name == "vertical" From 7a22abdd88dfcbda21a94085d879d2b156396c20 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 17 Mar 2026 23:21:56 -0700 Subject: [PATCH 13/14] feat: improved container admin views --- src/openedx_content/admin.py | 1 + .../applets/containers/admin.py | 51 +++++++++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/openedx_content/admin.py b/src/openedx_content/admin.py index 02dad806..ae8a8812 100644 --- a/src/openedx_content/admin.py +++ b/src/openedx_content/admin.py @@ -6,5 +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 * diff --git a/src/openedx_content/applets/containers/admin.py b/src/openedx_content/applets/containers/admin.py index f68fb765..f6630475 100644 --- a/src/openedx_content/applets/containers/admin.py +++ b/src/openedx_content/applets/containers/admin.py @@ -7,12 +7,40 @@ import functools from django.contrib import admin +from django.db.models import Count +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 .models import Container, ContainerVersion, EntityList, EntityListRow +from .api import get_container_subclass, ContainerImplementationMissingError +from .models import Container, ContainerType, ContainerVersion, EntityList, EntityListRow + + +@admin.register(ContainerType) +class ContainerTypeAdmin(ReadOnlyModelAdmin): + """Very basic Django admin for ContainerType""" + + list_display = ("type_code", "num_containers", "installed") + + def get_queryset(self, request): + return super().get_queryset(request).annotate(num_containers=Count("container")) + + @admin.display(description="# of Containers") + def num_containers(self, obj: ContainerType) -> 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) -> str: + """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: @@ -55,13 +83,13 @@ class ContainerAdmin(ReadOnlyModelAdmin): Django admin configuration for Container """ - list_display = ("key", "created", "draft", "published", "see_also") + list_display = ("key", "container_type_display", "published", "draft", "created") fields = [ "pk", "publishable_entity", "learning_package", - "draft", "published", + "draft", "created", "created_by", "see_also", @@ -82,6 +110,7 @@ def get_queryset(self, request): super() .get_queryset(request) .select_related( + "container_type", "publishable_entity", "publishable_entity__learning_package", "publishable_entity__published__version", @@ -89,11 +118,27 @@ def get_queryset(self, request): ) ) + @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) ) From 5117b50fed442cbe0365bb92d302093c58c694c0 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 19 Mar 2026 09:47:32 -0700 Subject: [PATCH 14/14] chore: fix typing issues in admin views --- src/openedx_content/applets/containers/admin.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/openedx_content/applets/containers/admin.py b/src/openedx_content/applets/containers/admin.py index f6630475..f602dcfe 100644 --- a/src/openedx_content/applets/containers/admin.py +++ b/src/openedx_content/applets/containers/admin.py @@ -5,18 +5,24 @@ from __future__ import annotations import functools +from typing import TYPE_CHECKING from django.contrib import admin -from django.db.models import Count +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 get_container_subclass, ContainerImplementationMissingError +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): @@ -24,17 +30,17 @@ class ContainerTypeAdmin(ReadOnlyModelAdmin): list_display = ("type_code", "num_containers", "installed") - def get_queryset(self, request): + 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: ContainerType) -> str: + 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) -> str: + def installed(self, obj: ContainerType) -> bool: """Is the implementation of this container subclass installed?""" try: get_container_subclass(obj.type_code)