From 9225c6807fac6b6c1c4ed6bee1fd00d9dd4f4143 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 2 Mar 2026 15:42:12 -0800 Subject: [PATCH 01/34] 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/34] 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 5d781a63b9b829ce0b5ade2bfc21c7ed71232251 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 2 Mar 2026 17:25:15 -0800 Subject: [PATCH 03/34] feat: add new ContainerType model --- src/openedx_content/applets/publishing/api.py | 1 + .../applets/publishing/models/container.py | 58 +++++++++++++ .../applets/sections/models.py | 2 + .../applets/subsections/models.py | 2 + src/openedx_content/applets/units/models.py | 2 + .../migrations/0005_containertypes.py | 82 +++++++++++++++++++ 6 files changed, 147 insertions(+) create mode 100644 src/openedx_content/migrations/0005_containertypes.py diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 64b190a0..4a84db2a 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -1387,6 +1387,7 @@ def create_container( ) container = container_cls.objects.create( publishable_entity=publishable_entity, + container_type=container_cls.get_type(), ) return container diff --git a/src/openedx_content/applets/publishing/models/container.py b/src/openedx_content/applets/publishing/models/container.py index a1503812..e3af58b4 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/publishing/models/container.py @@ -1,13 +1,48 @@ """ Container and ContainerVersion models """ + 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 +class ContainerType(models.Model): + """ + Normalized representation of a 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) + + # name uniquely identifies the type of container. + # Plugins/apps that add their own ContainerTypes should prefix it, e.g. + # "myapp_custom_unit" instead of "custom_unit", to avoid collisions. + name = case_sensitive_char_field( + max_length=100, + blank=False, + unique=True, + ) + + class Meta: + constraints = [ + models.CheckConstraint( + # No whitespace, uppercase, or special characters allowed in "name". + condition=models.lookups.Regex(models.F("name"), r"^[a-z0-9\-_\.]+$"), + name="oex_publishing_containertype_name_rx", + ), + ] + + def __str__(self) -> str: + return self.name + + class Container(PublishableEntityMixin): """ A Container is a type of PublishableEntity that holds other @@ -19,6 +54,29 @@ class Container(PublishableEntityMixin): we will also add support for dynamic containers which may contain different entities for different learners or at different times. """ + CONTAINER_TYPE: str = "" # Subclasses need to override this. + + # The type of the container. Cannot be changed once the container is created. + container_type = models.ForeignKey( + ContainerType, + null=False, + on_delete=models.RESTRICT, + editable=False, + ) + + @classmethod + def get_type(cls) -> ContainerType: + """ + Helper method to get the ContainerType for a given Container subclass. + + e.g. `assert Unit.get_type().name == "unit"` + """ + if cls is Container: + raise TypeError('Creating "naked" Containers is not allowed. Use a subclass of Container like Unit.') + assert cls.CONTAINER_TYPE, f"Container subclasses like {cls.__name__} must override CONTAINER_TYPE" + if not hasattr(cls, "_type_instance"): + cls._type_instance, _ = ContainerType.objects.get_or_create(name=cls.CONTAINER_TYPE) + return cls._type_instance class ContainerVersion(PublishableEntityVersionMixin): diff --git a/src/openedx_content/applets/sections/models.py b/src/openedx_content/applets/sections/models.py index afcb0ae0..007ef624 100644 --- a/src/openedx_content/applets/sections/models.py +++ b/src/openedx_content/applets/sections/models.py @@ -18,6 +18,8 @@ class Section(Container): Via Container and its PublishableEntityMixin, Sections are also publishable entities and can be added to other containers. """ + CONTAINER_TYPE = "section" + container = models.OneToOneField( Container, on_delete=models.CASCADE, diff --git a/src/openedx_content/applets/subsections/models.py b/src/openedx_content/applets/subsections/models.py index 8d662ed4..0bd47a95 100644 --- a/src/openedx_content/applets/subsections/models.py +++ b/src/openedx_content/applets/subsections/models.py @@ -18,6 +18,8 @@ class Subsection(Container): Via Container and its PublishableEntityMixin, Subsections are also publishable entities and can be added to other containers. """ + CONTAINER_TYPE = "subsection" + container = models.OneToOneField( Container, on_delete=models.CASCADE, diff --git a/src/openedx_content/applets/units/models.py b/src/openedx_content/applets/units/models.py index 0c525584..b92db564 100644 --- a/src/openedx_content/applets/units/models.py +++ b/src/openedx_content/applets/units/models.py @@ -18,6 +18,8 @@ class Unit(Container): Via Container and its PublishableEntityMixin, Units are also publishable entities and can be added to other containers. """ + CONTAINER_TYPE = "unit" + container = models.OneToOneField( Container, on_delete=models.CASCADE, diff --git a/src/openedx_content/migrations/0005_containertypes.py b/src/openedx_content/migrations/0005_containertypes.py new file mode 100644 index 00000000..9e1b482d --- /dev/null +++ b/src/openedx_content/migrations/0005_containertypes.py @@ -0,0 +1,82 @@ +# Generated by Django 5.2.11 on 2026-03-03 00:54 + +import django.db.models.deletion +import django.db.models.lookups +import openedx_django_lib.fields +from django.db import migrations, models + + +def backfill_container_types(apps, schema_editor): + """ + Fill in the new, mandatory "container_type" foreign key field on all + existing containers. + """ + Container = apps.get_model("openedx_content", "Container") + ContainerType = apps.get_model("openedx_content", "ContainerType") + section_type, _ = ContainerType.objects.get_or_create(name="section") + subsection_type, _ = ContainerType.objects.get_or_create(name="subsection") + unit_type, _ = ContainerType.objects.get_or_create(name="unit") + + containers_to_update = Container.objects.filter(container_type=None) + + containers_to_update.exclude(section=None).update(container_type=section_type) + containers_to_update.exclude(subsection=None).update(container_type=subsection_type) + containers_to_update.exclude(unit=None).update(container_type=unit_type) + + unknown_containers = containers_to_update.all() + if unknown_containers: + raise ValueError(f"container {unknown_containers[0]} is of unknown container type. Cannot apply migration.") + + +class Migration(migrations.Migration): + dependencies = [ + ("openedx_content", "0004_componenttype_constraint"), + ] + + operations = [ + # 1. Create the new ContainerType model + migrations.CreateModel( + name="ContainerType", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ( + "name", + 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("name"), "^[a-z0-9\\-_\\.]+$"), + name="oex_publishing_containertype_name_rx", + ) + ], + }, + ), + # 2. Define the ForeignKey from Container to ContainerType + migrations.AddField( + model_name="container", + name="container_type", + field=models.ForeignKey( + editable=False, + null=True, + on_delete=django.db.models.deletion.RESTRICT, + to="openedx_content.containertype", + ), + ), + # 3. Populate the container_type column, which is currently NULL for all existing containers + migrations.RunPython(backfill_container_types), + # 4. disallow NULL values from now on + migrations.AlterField( + model_name="container", + name="container_type", + field=models.ForeignKey( + editable=False, + null=False, + on_delete=django.db.models.deletion.RESTRICT, + to="openedx_content.containertype", + ), + ), + ] From 1b9697a608e421a4d0c9908fe4049b49745c9e66 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 4 Mar 2026 11:13:29 -0800 Subject: [PATCH 04/34] test: add concrete TestContainer model --- .../applets/publishing/models/container.py | 1 + test_settings.py | 26 +++++++++++++++++++ .../applets/publishing/test_api.py | 11 ++++++++ tests/test_django_app/__init__.py | 0 tests/test_django_app/apps.py | 14 ++++++++++ tests/test_django_app/models.py | 16 ++++++++++++ 6 files changed, 68 insertions(+) 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/models.py diff --git a/src/openedx_content/applets/publishing/models/container.py b/src/openedx_content/applets/publishing/models/container.py index e3af58b4..5d6da2ad 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/publishing/models/container.py @@ -54,6 +54,7 @@ class Container(PublishableEntityMixin): we will also add support for dynamic containers which may contain different entities for different learners or at different times. """ + CONTAINER_TYPE: str = "" # Subclasses need to override this. # The type of the container. Cannot be changed once the container is created. diff --git a/test_settings.py b/test_settings.py index 371903ad..cc1a0515 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", +] \ No newline at end of file diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index c0f11378..a32bf5d4 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -27,6 +27,16 @@ User = get_user_model() +class TestContainer(Container): + """ + A fake subclass of Container used for test purposes. + """ + CONTAINER_TYPE = "fake_test" + + class Meta: + app_label = "openedx_content" + + class LearningPackageTestCase(TestCase): """ Test creating a LearningPackage @@ -1068,6 +1078,7 @@ def test_bulk_parent_child_side_effects(self) -> None: "my_container", created=self.now, created_by=None, + container_cls=TestContainer, ) container_v1: ContainerVersion = publishing_api.create_container_version( container.pk, 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..e4a20f4d --- /dev/null +++ b/tests/test_django_app/apps.py @@ -0,0 +1,14 @@ +""" +Test Django 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" diff --git a/tests/test_django_app/models.py b/tests/test_django_app/models.py new file mode 100644 index 00000000..94e16dce --- /dev/null +++ b/tests/test_django_app/models.py @@ -0,0 +1,16 @@ +""" +Models that are only for use in tests +""" + +from openedx_content.applets.publishing.models import Container + + +class TestContainer(Container): + """ + A fake subclass of Container used for test purposes. + """ + + CONTAINER_TYPE = "fake_test" + + class Meta: + app_label = "openedx_content" From 3d867fcc90ef7bb142861621f06e0969c673fb9c Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 6 Mar 2026 10:35:43 -0800 Subject: [PATCH 05/34] feat: consolidate containers implementation to use generic models only --- src/openedx_content/admin.py | 3 - src/openedx_content/api.py | 6 +- .../applets/backup_restore/zipper.py | 3 - src/openedx_content/applets/publishing/api.py | 514 +++++++++---- .../applets/publishing/models/__init__.py | 2 +- .../applets/publishing/models/container.py | 38 +- src/openedx_content/applets/sections/admin.py | 48 -- src/openedx_content/applets/sections/api.py | 330 --------- .../applets/sections/models.py | 54 +- .../applets/subsections/admin.py | 48 -- .../applets/subsections/api.py | 329 --------- .../applets/subsections/models.py | 52 +- src/openedx_content/applets/units/admin.py | 48 -- src/openedx_content/applets/units/api.py | 326 --------- src/openedx_content/applets/units/models.py | 47 +- src/openedx_content/apps.py | 9 - .../migrations/0005_containertypes.py | 34 +- .../0006_remove_empty_container_models.py | 30 + src/openedx_content/models.py | 3 - src/openedx_content/models_api.py | 4 +- test_settings.py | 2 - .../applets/publishing/container_test_case.py | 136 ++++ .../applets/sections/test_api.py | 674 +++++------------- .../applets/subsections/test_api.py | 596 +++++++--------- .../openedx_content/applets/units/test_api.py | 551 ++++++-------- tests/test_django_app/__init__.py | 0 tests/test_django_app/apps.py | 14 - tests/test_django_app/models.py | 16 - 28 files changed, 1280 insertions(+), 2637 deletions(-) delete mode 100644 src/openedx_content/applets/sections/admin.py delete mode 100644 src/openedx_content/applets/sections/api.py delete mode 100644 src/openedx_content/applets/subsections/admin.py delete mode 100644 src/openedx_content/applets/subsections/api.py delete mode 100644 src/openedx_content/applets/units/admin.py delete mode 100644 src/openedx_content/applets/units/api.py create mode 100644 src/openedx_content/migrations/0006_remove_empty_container_models.py create mode 100644 tests/openedx_content/applets/publishing/container_test_case.py delete mode 100644 tests/test_django_app/__init__.py delete mode 100644 tests/test_django_app/apps.py delete 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/api.py b/src/openedx_content/api.py index 9aaa9b7b..7daeda40 100644 --- a/src/openedx_content/api.py +++ b/src/openedx_content/api.py @@ -15,6 +15,6 @@ from .applets.components.api import * from .applets.media.api import * from .applets.publishing.api import * -from .applets.sections.api import * -from .applets.subsections.api import * -from .applets.units.api import * +from .applets.sections.models import Section # Note this isn't a model. Should we move it? +from .applets.subsections.models import Subsection # Note this isn't a model. Should we move it? +from .applets.units.models import Unit # Note this isn't a model. Should we move it? diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 1bb8bbba..4c096386 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -33,9 +33,6 @@ 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 .serializers import ( CollectionSerializer, ComponentSerializer, diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 4a84db2a..4f0341c9 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -4,13 +4,14 @@ Please look at the models.py file for more information about the kinds of data are stored in this app. """ + from __future__ import annotations from contextlib import nullcontext from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum -from typing import ContextManager, Optional, TypeVar +from typing import final, ContextManager, Optional from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models import F, Prefetch, Q, QuerySet @@ -21,6 +22,7 @@ from .contextmanagers import DraftChangeLogContext from .models import ( Container, + ContainerTypeRecord, ContainerVersion, Draft, DraftChangeLog, @@ -41,12 +43,6 @@ ) from .models.publish_log import Published -# A few of the APIs in this file are generic and can be used for Containers in -# general, or e.g. Units (subclass of Container) in particular. These type -# variables are used to provide correct typing for those generic API methods. -ContainerModel = TypeVar('ContainerModel', bound=Container) -ContainerVersionModel = TypeVar('ContainerVersionModel', bound=ContainerVersion) - # The public API that will be re-exported by openedx_content.api # is listed in the __all__ entries below. Internal helper functions that are # private to this module should start with an underscore. If a function does not @@ -78,17 +74,23 @@ "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.) + "ContainerTypeRecord", + "ContainerType", "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,13 +1346,134 @@ 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 +######################################################################################################################## + + +_registered_container_types: dict[str, ContainerTypeImplementation] = {} + + +class ContainerTypeImplementation: + """ + Abstract base class for container type implementations (Unit, Subsection, etc.) + """ + + type_code: str # e.g. "unit" + + @classmethod + def validate_entity(cls, entity: PublishableEntity) -> None: + """Check if the given entity is allowed as a child of this Container type""" + + @final + def __init__(self): + raise TypeError("ContainerType and its subclasses should not be initialized") + + @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 ContainerTypeImplementation: + raise TypeError('Manipulating "naked" Containers is not allowed. Use a specific ContainerType like Unit.') + assert cls.type_code, f"ContainerTypeImplementation 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 + + @staticmethod + def register(cti: type[ContainerTypeImplementation]): + assert cti.type_code, "ContainerTypeImplementation subclasses must override type_code" + assert cti.type_code not in _registered_container_types, f"{cti.type_code} already registered" + _registered_container_types[cti.type_code] = cti + + @staticmethod + def for_code(type_code: str) -> type[ContainerTypeImplementation]: + """ + Get the subclass for the specified container type_code. + """ + try: + return _registered_container_types[type_code] + except KeyError as exc: + raise ValueError( + 'An implementation for "{type_code}" containers is not currently installed. ' + "Such containers can be read but not modified." + ) from exc + + +@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 = list[ + PublishableEntity | PublishableEntityMixin | PublishableEntityVersion | PublishableEntityVersionMixin +] +ContainerType = type[ContainerTypeImplementation] + + +@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: + # 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, @@ -1358,9 +1481,8 @@ def create_container( created_by: int | None, *, can_stand_alone: bool = True, - # The types on the following line are correct, but mypy will complain - https://github.com/python/mypy/issues/3737 - container_cls: type[ContainerModel] = Container, # type: ignore[assignment] -) -> ContainerModel: + container_type: ContainerType, +) -> Container: """ [ 🛑 UNSTABLE ] Create a new container. @@ -1371,12 +1493,12 @@ def create_container( created: The date and time the container was created. created_by: The ID of the user who created the container can_stand_alone: Set to False when created as part of containers - container_cls: The subclass of Container to use, if applicable + container_type: The type of container to create (e.g. Unit) Returns: The newly created container. """ - assert issubclass(container_cls, Container) + assert issubclass(container_type, ContainerTypeImplementation) with atomic(): publishable_entity = create_publishable_entity( learning_package_id, @@ -1385,9 +1507,9 @@ def create_container( created_by, can_stand_alone=can_stand_alone, ) - container = container_cls.objects.create( + container = Container.objects.create( publishable_entity=publishable_entity, - container_type=container_cls.get_type(), + container_type_record=container_type.get_type_record(), ) return container @@ -1405,7 +1527,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: @@ -1414,7 +1536,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. @@ -1423,26 +1549,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( @@ -1453,7 +1570,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 @@ -1467,13 +1584,23 @@ 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: + container_type = ContainerTypeImplementation.for_code(container.container_type_record.type_code) + 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, @@ -1481,13 +1608,9 @@ 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 = ContainerVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container.pk, entity_list=entity_list, @@ -1501,11 +1624,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. @@ -1514,24 +1636,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, @@ -1539,12 +1662,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: ContainerType, + entities: EntityListInput | None = None, + created: datetime, + created_by: int | None = None, + can_stand_alone: bool = True, +) -> tuple[Container, ContainerVersion]: + """ + [ 🛑 UNSTABLE ] Create a new unit and its 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 user who created the unit. + 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 = create_container_version( + container, + 1, + title=title, + entities=entities or [], + created=created, + created_by=created_by, + ) + return container, container_version + + class ChildrenEntitiesAction(Enum): """Possible actions for children entities""" @@ -1556,7 +1726,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: """ @@ -1565,59 +1735,65 @@ 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 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.") + 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, - ) + last_entities = ( + last_version.entity_list.entitylistrow_set.only("entity_id", "entity_version_id") + .exclude(entity_id__in=[entity.entity_pk for entity in parsed_entities]) + .order_by("order_num") + ) + parsed_entities = [ + ParsedEntityReference(entity_pk=entity.entity_id, version_pk=entity.entity_version_id) for entity in last_entities.all() ] - return create_entity_list_with_rows( - entity_rows=entity_rows, - learning_package_id=learning_package_id, - ) + return create_entity_list_with_rows(parsed_entities, learning_package_id=learning_package_id) def create_next_container_version( container_pk: int, *, title: str | None, - entity_rows: list[ContainerEntityRow] | 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 @@ -1632,8 +1808,11 @@ 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. @@ -1650,7 +1829,6 @@ 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) entity = container.publishable_entity @@ -1663,15 +1841,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( @@ -1681,7 +1856,6 @@ def create_next_container_version( entity_list=next_entity_list, created=created, created_by=created_by, - container_version_cls=container_version_cls, ) return next_container_version @@ -1701,6 +1875,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 ] @@ -1719,11 +1907,27 @@ def get_container_by_key(learning_package_id: int, /, key: str) -> Container: ) +def get_container_type_code(container: Container | int, /) -> str: + """Get the type of a container, as a string - e.g. "unit".""" + if not isinstance(container, Container): + container = get_container(container) + return container.container_type_record.type_code + + +def get_container_type(container: Container | int, /) -> ContainerType: + """ + Get the type of a container. + + Will raise a ValueError if the type is not currently installed. + """ + type_code = get_container_type_code(container) + return ContainerTypeImplementation.for_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. @@ -1736,12 +1940,11 @@ def get_containers( 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') + return container_qset.order_by("pk") def get_collection_containers( @@ -1756,38 +1959,7 @@ def get_collection_containers( 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 + ).order_by("pk") def get_entities_in_container( @@ -1813,7 +1985,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: @@ -1821,7 +1994,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: @@ -1838,15 +2012,54 @@ 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 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_id: int) -> bool: """ [ 🛑 UNSTABLE ] @@ -1866,10 +2079,9 @@ def contains_unpublished_changes(container_id: int) -> bool: in either case. """ container = ( - Container.objects - .select_related('publishable_entity__draft__draft_log_record') - .select_related('publishable_entity__published__publish_log_record') - .get(pk=container_id) + 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 @@ -1914,8 +2126,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__" @@ -1926,8 +2137,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) @@ -1971,9 +2181,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..6abb16dd 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, ContainerTypeRecord, ContainerVersion 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 5d6da2ad..4fd1a68a 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/publishing/models/container.py @@ -2,6 +2,8 @@ Container and ContainerVersion models """ +from __future__ import annotations + from django.core.exceptions import ValidationError from django.db import models @@ -11,9 +13,9 @@ from .publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin -class ContainerType(models.Model): +class ContainerTypeRecord(models.Model): """ - Normalized representation of a type of Container. + Normalized representation of the type of Container. Typical container types are "unit", "subsection", and "section", but there may be others in the future. @@ -21,10 +23,10 @@ class ContainerType(models.Model): id = models.AutoField(primary_key=True) - # name uniquely identifies the type of container. + # 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. - name = case_sensitive_char_field( + type_code = case_sensitive_char_field( max_length=100, blank=False, unique=True, @@ -33,14 +35,14 @@ class ContainerType(models.Model): class Meta: constraints = [ models.CheckConstraint( - # No whitespace, uppercase, or special characters allowed in "name". - condition=models.lookups.Regex(models.F("name"), r"^[a-z0-9\-_\.]+$"), - name="oex_publishing_containertype_name_rx", + # 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: - return self.name + return self.type_code class Container(PublishableEntityMixin): @@ -55,30 +57,14 @@ class Container(PublishableEntityMixin): entities for different learners or at different times. """ - CONTAINER_TYPE: str = "" # Subclasses need to override this. - # The type of the container. Cannot be changed once the container is created. - container_type = models.ForeignKey( - ContainerType, + container_type_record = models.ForeignKey( + ContainerTypeRecord, null=False, on_delete=models.RESTRICT, editable=False, ) - @classmethod - def get_type(cls) -> ContainerType: - """ - Helper method to get the ContainerType for a given Container subclass. - - e.g. `assert Unit.get_type().name == "unit"` - """ - if cls is Container: - raise TypeError('Creating "naked" Containers is not allowed. Use a subclass of Container like Unit.') - assert cls.CONTAINER_TYPE, f"Container subclasses like {cls.__name__} must override CONTAINER_TYPE" - if not hasattr(cls, "_type_instance"): - cls._type_instance, _ = ContainerType.objects.get_or_create(name=cls.CONTAINER_TYPE) - return cls._type_instance - 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 deleted file mode 100644 index 05e0d614..00000000 --- a/src/openedx_content/applets/sections/api.py +++ /dev/null @@ -1,330 +0,0 @@ -"""Sections API. - -This module provides functions to manage sections. -""" -from dataclasses import dataclass -from datetime import datetime - -from django.db.transaction import atomic - -from ..publishing import api as publishing_api -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", - "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 create_section_version( - section: Section, - version_num: int, - *, - title: str, - entity_rows: list[publishing_api.ContainerEntityRow], - created: datetime, - created_by: int | None = None, -) -> 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. - - 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. - """ - return publishing_api.create_container_version( - section.pk, - version_num, - title=title, - entity_rows=entity_rows, - created=created, - created_by=created_by, - container_version_cls=SectionVersion, - ) - - -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 - ] - - -def create_next_section_version( - section: Section, - *, - title: str | None = None, - subsections: list[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, -) -> 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. - - 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. - """ - entity_rows = _pub_entities_for_subsections(subsections) - section_version = publishing_api.create_next_container_version( - section.pk, - title=title, - entity_rows=entity_rows, - created=created, - created_by=created_by, - container_version_cls=SectionVersion, - entities_action=entities_action, - force_version_num=force_version_num, - ) - 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 - - -@dataclass(frozen=True) -class SectionListEntry: - """ - [ 🛑 UNSTABLE ] - Data about a single entity in a container, e.g. a subsection in a section. - """ - subsection_version: SubsectionVersion - pinned: bool = False - - @property - def subsection(self): - return self.subsection_version.subsection - - -def get_subsections_in_section( - section: Section, - *, - published: bool, -) -> list[SectionListEntry]: - """ - [ 🛑 UNSTABLE ] - Get the list of entities and their versions in the draft or published - version of the given Section. - - Args: - 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", - ) - 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 007ef624..f4ee706e 100644 --- a/src/openedx_content/applets/sections/models.py +++ b/src/openedx_content/applets/sections/models.py @@ -1,52 +1,40 @@ """ Models that implement sections """ -from django.db import models -from ..publishing.models import Container, ContainerVersion +from typing import override + +from django.core.exceptions import ValidationError + +from ..publishing.api import ContainerTypeImplementation, get_container_type +from ..publishing.models import PublishableEntity +from ..subsections.models import Subsection __all__ = [ "Section", - "SectionVersion", ] -class Section(Container): +class Section(ContainerTypeImplementation): """ - 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. """ - CONTAINER_TYPE = "section" - container = models.OneToOneField( - Container, - on_delete=models.CASCADE, - parent_link=True, - primary_key=True, - ) + type_code = "section" + @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. + # validate settings - Via ContainerVersion and its EntityList, it defines the list of Units - in this version of the Section. - """ - container_version = models.OneToOneField( - ContainerVersion, - on_delete=models.CASCADE, - parent_link=True, - primary_key=True, - ) - - @property - def section(self): - """ 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' + +ContainerTypeImplementation.register(Section) 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 deleted file mode 100644 index d39c5700..00000000 --- a/src/openedx_content/applets/subsections/api.py +++ /dev/null @@ -1,329 +0,0 @@ -"""Subsections API. - -This module provides functions to manage subsections. -""" -from dataclasses import dataclass -from datetime import datetime - -from django.db.transaction import atomic - -from ..publishing import api as publishing_api -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", - "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 create_subsection_version( - subsection: Subsection, - version_num: int, - *, - title: str, - entity_rows: list[publishing_api.ContainerEntityRow], - created: datetime, - created_by: int | None = None, -) -> 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. - - 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. - """ - return publishing_api.create_container_version( - subsection.pk, - version_num, - title=title, - entity_rows=entity_rows, - created=created, - created_by=created_by, - container_version_cls=SubsectionVersion, - ) - - -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 - ] - - -def create_next_subsection_version( - subsection: Subsection, - *, - title: str | None = None, - units: list[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, -) -> 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. - - 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. - """ - entity_rows = _pub_entities_for_units(units) - subsection_version = publishing_api.create_next_container_version( - subsection.pk, - title=title, - entity_rows=entity_rows, - created=created, - created_by=created_by, - container_version_cls=SubsectionVersion, - entities_action=entities_action, - force_version_num=force_version_num, - ) - 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 - - -@dataclass(frozen=True) -class SubsectionListEntry: - """ - [ 🛑 UNSTABLE ] - Data about a single entity in a container, e.g. a unit in a subsection. - """ - unit_version: UnitVersion - pinned: bool = False - - @property - def unit(self): - return self.unit_version.unit - - -def get_units_in_subsection( - subsection: Subsection, - *, - published: bool, -) -> list[SubsectionListEntry]: - """ - [ 🛑 UNSTABLE ] - Get the list of entities and their versions in the draft or published - version of the given Subsection. - - Args: - subsection: The Subsection, e.g. returned by `get_subsection()` - published: `True` if we want the published version of the subsection, or - `False` for the draft version. - """ - assert isinstance(subsection, Subsection) - units = [] - entries = publishing_api.get_entities_in_container( - subsection, - published=published, - select_related_version="containerversion__unitversion", - ) - 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 0bd47a95..29279674 100644 --- a/src/openedx_content/applets/subsections/models.py +++ b/src/openedx_content/applets/subsections/models.py @@ -1,52 +1,40 @@ """ Models that implement subsections """ -from django.db import models -from ..publishing.models import Container, ContainerVersion +from typing import override + +from django.core.exceptions import ValidationError + +from ..publishing.api import ContainerTypeImplementation, get_container_type +from ..publishing.models import PublishableEntity +from ..units.models import Unit __all__ = [ "Subsection", - "SubsectionVersion", ] -class Subsection(Container): +class Subsection(ContainerTypeImplementation): """ A Subsection is type of Container that holds Units. Via Container and its PublishableEntityMixin, Subsections are also publishable entities and can be added to other containers. """ - CONTAINER_TYPE = "subsection" - container = models.OneToOneField( - Container, - on_delete=models.CASCADE, - parent_link=True, - primary_key=True, - ) + type_code = "subsection" + @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): - """ - A SubsectionVersion is a specific version of a Subsection. + # validate settings - 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, - parent_link=True, - primary_key=True, - ) - - @property - def subsection(self): - """ 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' + +ContainerTypeImplementation.register(Subsection) 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 deleted file mode 100644 index 779b5b3d..00000000 --- a/src/openedx_content/applets/units/api.py +++ /dev/null @@ -1,326 +0,0 @@ -"""Units API. - -This module provides functions to manage units. -""" -from dataclasses import dataclass -from datetime import datetime - -from django.db.transaction import atomic - -from ..components.models import Component, ComponentVersion -from ..publishing import api as publishing_api -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", - "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 create_unit_version( - unit: Unit, - version_num: int, - *, - title: str, - entity_rows: list[publishing_api.ContainerEntityRow], - created: datetime, - created_by: int | None = None, -) -> 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. - - 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. - """ - return publishing_api.create_container_version( - unit.pk, - version_num, - title=title, - entity_rows=entity_rows, - created=created, - created_by=created_by, - container_version_cls=UnitVersion, - ) - - -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 - ] - - -def create_next_unit_version( - unit: Unit, - *, - title: str | None = None, - components: list[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, -) -> UnitVersion: - """ - [ 🛑 UNSTABLE ] Create the next unit 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. - """ - entity_rows = _pub_entities_for_components(components) - unit_version = publishing_api.create_next_container_version( - unit.pk, - title=title, - entity_rows=entity_rows, - created=created, - created_by=created_by, - container_version_cls=UnitVersion, - entities_action=entities_action, - force_version_num=force_version_num, - ) - 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 - - -@dataclass(frozen=True) -class UnitListEntry: - """ - [ 🛑 UNSTABLE ] - Data about a single entity in a container, e.g. a component in a unit. - """ - component_version: ComponentVersion - pinned: bool = False - - @property - def component(self): - return self.component_version.component - - -def get_components_in_unit( - unit: Unit, - *, - published: bool, -) -> list[UnitListEntry]: - """ - [ 🛑 UNSTABLE ] - Get the list of entities and their versions in the draft or published - version of the given Unit. - - Args: - unit: The Unit, e.g. returned by `get_unit()` - published: `True` if we want the published version of the unit, or - `False` for the draft version. - """ - assert isinstance(unit, Unit) - components = [] - entries = publishing_api.get_entities_in_container( - unit, - published=published, - select_related_version="componentversion", - ) - 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 b92db564..686a5842 100644 --- a/src/openedx_content/applets/units/models.py +++ b/src/openedx_content/applets/units/models.py @@ -1,52 +1,35 @@ """ Models that implement units """ -from django.db import models -from ..publishing.models import Container, ContainerVersion +from typing import override + +from ..publishing.api import ContainerTypeImplementation +from ..publishing.models import PublishableEntity __all__ = [ "Unit", - "UnitVersion", ] -class Unit(Container): +class Unit(ContainerTypeImplementation): """ A Unit is type of Container that holds Components. Via Container and its PublishableEntityMixin, Units are also publishable entities and can be added to other containers. """ - CONTAINER_TYPE = "unit" - container = models.OneToOneField( - Container, - on_delete=models.CASCADE, - parent_link=True, - primary_key=True, - ) + type_code = "unit" + @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: + getattr(entity, "component") # Could raise PublishableEntity.component.RelatedObjectDoesNotExist -class UnitVersion(ContainerVersion): - """ - A UnitVersion is a specific version of a Unit. + # validate settings - 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, - parent_link=True, - primary_key=True, - ) - - @property - def unit(self): - """ 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' + +ContainerTypeImplementation.register(Unit) diff --git a/src/openedx_content/apps.py b/src/openedx_content/apps.py index 563d5d03..3f259277 100644 --- a/src/openedx_content/apps.py +++ b/src/openedx_content/apps.py @@ -30,18 +30,9 @@ def register_publishable_models(self): ComponentVersion, Container, ContainerVersion, - Section, - SectionVersion, - Subsection, - SubsectionVersion, - Unit, - UnitVersion, ) register_publishable_models(Component, ComponentVersion) register_publishable_models(Container, ContainerVersion) - register_publishable_models(Section, SectionVersion) - register_publishable_models(Subsection, SubsectionVersion) - register_publishable_models(Unit, UnitVersion) def ready(self): """ diff --git a/src/openedx_content/migrations/0005_containertypes.py b/src/openedx_content/migrations/0005_containertypes.py index 9e1b482d..5afab35d 100644 --- a/src/openedx_content/migrations/0005_containertypes.py +++ b/src/openedx_content/migrations/0005_containertypes.py @@ -12,16 +12,16 @@ def backfill_container_types(apps, schema_editor): existing containers. """ Container = apps.get_model("openedx_content", "Container") - ContainerType = apps.get_model("openedx_content", "ContainerType") - section_type, _ = ContainerType.objects.get_or_create(name="section") - subsection_type, _ = ContainerType.objects.get_or_create(name="subsection") - unit_type, _ = ContainerType.objects.get_or_create(name="unit") + 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=None) + containers_to_update = Container.objects.filter(container_type_record=None) - containers_to_update.exclude(section=None).update(container_type=section_type) - containers_to_update.exclude(subsection=None).update(container_type=subsection_type) - containers_to_update.exclude(unit=None).update(container_type=unit_type) + 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: @@ -34,13 +34,13 @@ class Migration(migrations.Migration): ] operations = [ - # 1. Create the new ContainerType model + # 1. Create the new ContainerTypeRecord model migrations.CreateModel( - name="ContainerType", + name="ContainerTypeRecord", fields=[ ("id", models.AutoField(primary_key=True, serialize=False)), ( - "name", + "type_code", openedx_django_lib.fields.MultiCollationCharField( db_collations={"mysql": "utf8mb4_bin", "sqlite": "BINARY"}, max_length=100, unique=True ), @@ -49,8 +49,8 @@ class Migration(migrations.Migration): options={ "constraints": [ models.CheckConstraint( - condition=django.db.models.lookups.Regex(models.F("name"), "^[a-z0-9\\-_\\.]+$"), - name="oex_publishing_containertype_name_rx", + condition=django.db.models.lookups.Regex(models.F("type_code"), "^[a-z0-9\\-_\\.]+$"), + name="oex_publishing_containertyperecord_type_code_rx", ) ], }, @@ -58,12 +58,12 @@ class Migration(migrations.Migration): # 2. Define the ForeignKey from Container to ContainerType migrations.AddField( model_name="container", - name="container_type", + name="container_type_record", field=models.ForeignKey( editable=False, null=True, on_delete=django.db.models.deletion.RESTRICT, - to="openedx_content.containertype", + to="openedx_content.containertyperecord", ), ), # 3. Populate the container_type column, which is currently NULL for all existing containers @@ -71,12 +71,12 @@ class Migration(migrations.Migration): # 4. disallow NULL values from now on migrations.AlterField( model_name="container", - name="container_type", + name="container_type_record", field=models.ForeignKey( editable=False, null=False, on_delete=django.db.models.deletion.RESTRICT, - to="openedx_content.containertype", + to="openedx_content.containertyperecord", ), ), ] diff --git a/src/openedx_content/migrations/0006_remove_empty_container_models.py b/src/openedx_content/migrations/0006_remove_empty_container_models.py new file mode 100644 index 00000000..7a91535e --- /dev/null +++ b/src/openedx_content/migrations/0006_remove_empty_container_models.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.11 on 2026-03-10 19:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("openedx_content", "0005_containertypes"), + ] + + operations = [ + migrations.DeleteModel( + name="Section", + ), + migrations.DeleteModel( + name="SectionVersion", + ), + migrations.DeleteModel( + name="Subsection", + ), + migrations.DeleteModel( + name="SubsectionVersion", + ), + migrations.DeleteModel( + name="Unit", + ), + migrations.DeleteModel( + name="UnitVersion", + ), + ] diff --git a/src/openedx_content/models.py b/src/openedx_content/models.py index 91696b5f..6e009e39 100644 --- a/src/openedx_content/models.py +++ b/src/openedx_content/models.py @@ -12,6 +12,3 @@ from .applets.components.models import * from .applets.media.models import * from .applets.publishing.models import * -from .applets.sections.models import * -from .applets.subsections.models import * -from .applets.units.models import * diff --git a/src/openedx_content/models_api.py b/src/openedx_content/models_api.py index 1e035b43..c46dd2d0 100644 --- a/src/openedx_content/models_api.py +++ b/src/openedx_content/models_api.py @@ -11,6 +11,4 @@ from .applets.components.models import * from .applets.media.models import * from .applets.publishing.models import * -from .applets.sections.models import * -from .applets.subsections.models import * -from .applets.units.models import * + diff --git a/test_settings.py b/test_settings.py index cc1a0515..19635329 100644 --- a/test_settings.py +++ b/test_settings.py @@ -59,8 +59,6 @@ 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 = [ diff --git a/tests/openedx_content/applets/publishing/container_test_case.py b/tests/openedx_content/applets/publishing/container_test_case.py new file mode 100644 index 00000000..7dded9f9 --- /dev/null +++ b/tests/openedx_content/applets/publishing/container_test_case.py @@ -0,0 +1,136 @@ +""" +Basic tests for the units API. +""" + +from functools import partial + +import openedx_content.api as content_api +from openedx_content import models_api as authoring_models + +from ..components.test_api import ComponentTestCase + + +def Entry( + component_version: authoring_models.PublishableEntityVersionMixin, + pinned: bool = False, +) -> content_api.ContainerEntityListEntry: + """Helper for quickly constructing ContainerEntityListEntry entries""" + return content_api.ContainerEntityListEntry(component_version.publishable_entity_version, pinned=pinned) + + +class ContainerTestCase(ComponentTestCase): + """Base class with useful functions for testing containers. Has no tests on its own.""" + + def setUp(self) -> None: + super().setUp() + self.create_unit = partial(self.create_container, container_type=content_api.Unit) + self.create_unit_and_version = partial(self.create_container_and_version, container_type=content_api.Unit) + self.create_subsection = partial(self.create_container, container_type=content_api.Subsection) + self.create_subsection_and_version = partial( + self.create_container_and_version, container_type=content_api.Subsection + ) + self.create_section = partial(self.create_container, container_type=content_api.Section) + self.create_section_and_version = partial(self.create_container_and_version, container_type=content_api.Section) + + 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_container_and_version( + self, + *, + entities: list[ + authoring_models.Component + | authoring_models.ComponentVersion + | authoring_models.Container + | authoring_models.ContainerVersion + ] + | None = None, + container_type: content_api.ContainerType, + title: str | None = None, + key: str | None = None, + ) -> authoring_models.Container: + """Helper method to quickly create a container with some child entities""" + container, version = content_api.create_container_and_version( + learning_package_id=self.learning_package.id, + key=key or f"{container_type.type_code}:key", + title=title or f"Test {container_type.type_code}", + entities=entities, + created=self.now, + created_by=None, + container_type=container_type, + ) + return container, version + + def create_container( + self, + *, + entities: list[ + authoring_models.Component + | authoring_models.ComponentVersion + | authoring_models.Container + | authoring_models.ContainerVersion + ] + | None = None, + container_type: content_api.ContainerType, + title: str | None = None, + key: str | None = None, + ) -> authoring_models.Container: + """Helper method to quickly create a container with some components""" + container, _version = self.create_container_and_version( + entities=entities, container_type=container_type, title=title, key=key + ) + return container + + 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 modify_container( + self, + container: authoring_models.Container, + *, + title="", + timestamp=None, + ) -> authoring_models.ContainerVersion: + """ + Helper method to modify a unit for the purposes of testing units/drafts/pinning/publishing/etc. + """ + return content_api.create_next_container_version( + container.pk, + title=title or f"Modified {content_api.get_container_type_code(container)}", + created=timestamp or self.now, + created_by=None, + ) + + def publish_container(self, container: authoring_models.Container): + """ + Helper method to publish a single container. + """ + content_api.publish_from_drafts( + self.learning_package.pk, + draft_qset=content_api.get_all_drafts(self.learning_package.pk).filter(entity=container.publishable_entity), + ) diff --git a/tests/openedx_content/applets/sections/test_api.py b/tests/openedx_content/applets/sections/test_api.py index f3177113..507eceb3 100644 --- a/tests/openedx_content/applets/sections/test_api.py +++ b/tests/openedx_content/applets/sections/test_api.py @@ -1,387 +1,104 @@ """ Basic tests for the subsections API. """ + import ddt # type: ignore[import] -import pytest from django.core.exceptions import ValidationError +import pytest import openedx_content.api as content_api from openedx_content import models_api as authoring_models -from ..subsections.test_api import SubSectionTestCase +from ..publishing.container_test_case import ContainerTestCase, Entry -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 SubSectionTestCase(ContainerTestCase): + """Test cases for Sections (containers of subsections)""" def setUp(self) -> None: super().setUp() - self.subsection_1, self.subsection_1_v1 = self.create_subsection( + self.subsection_1, self.subsection_1_v1 = self.create_subsection_and_version( key="Subsection (1)", title="Subsection (1)", ) - self.subsection_2, self.subsection_2_v1 = self.create_subsection( + self.subsection_2, self.subsection_2_v1 = self.create_subsection_and_version( 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, - ) - - def create_section_with_subsections( - self, - subsections: list[authoring_models.Subsection | authoring_models.SubsectionVersion], - *, - title="Subsection", - key="subsection:key", - ) -> authoring_models.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, - title=title, - subsections=subsections, - created=self.now, - created_by=None, - ) - 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, + section, section_version = self.create_section_and_version( 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, + section2, _s2v1 = self.create_section_and_version( 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, + with pytest.raises( + ValidationError, match='The entity "section:key2" cannot be added to a "section" container.' + ) as exc: + content_api.create_next_container_version( + section.pk, title="Section Containing a Section", - subsections=[section2], + entities=[section2], created=self.now, created_by=None, ) + assert "Only Subsection can be added as children of a Section" in exc.value.__cause__ # Check that a new version was not created: section.refresh_from_db() - assert content_api.get_section(section.pk).versioning.draft == section_version + assert content_api.get_container(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. - - Expected results: - 1. A section and section version are created. - 2. The section version number is 1. - 3. The section is a draft with unpublished changes. - 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, - key="section:key", - title="Section", - created=self.now, - created_by=None, - ) - assert section, section_version - assert section_version.version_num == 1 - assert section_version in section.versioning.versions.all() - assert section.versioning.has_unpublished_changes - assert section.versioning.draft == section_version - 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. - - 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): """ 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, + section, _section_version = self.create_section_and_version( key="section:key", title="Section", - created=self.now, - created_by=None, ) - section_version_v2 = content_api.create_next_section_version( - section=section, + section_version_v2 = content_api.create_next_container_version( + section.pk, title="Section", - subsections=[ + entities=[ self.subsection_1, - self.subsection_2_v1 + self.subsection_2_v1, ], # Note the "v1" pinning 📌 the second one to version 1 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) == [ + assert content_api.get_entities_in_container(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: - content_api.get_subsections_in_section(section, published=True) + content_api.get_entities_in_container(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, + section, _section_version = self.create_section_and_version( key="section:key", title="Section", - created=self.now, - created_by=None, ) - section_version_v2 = content_api.create_next_section_version( - section=section, + section_version_v2 = content_api.create_next_container_version( + section.pk, title="Section", - subsections=[self.subsection_1, self.subsection_2], + entities=[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) @@ -393,11 +110,9 @@ def test_auto_publish_children(self): Test that publishing a section publishes its child subsections automatically. """ # Create a draft section with two draft subsections - section = self.create_section_with_subsections([self.subsection_1, self.subsection_2]) + section = self.create_section(entities=[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" - ) + other_subsection = 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 @@ -426,10 +141,10 @@ 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]) + section = self.create_section(entities=[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.publish_container(self.subsection_1) self.subsection_1.refresh_from_db() # Clear cache on '.versioning' assert self.subsection_1.versioning.has_unpublished_changes is False @@ -439,19 +154,16 @@ def test_no_publish_parent(self): 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) + content_api.get_entities_in_container(section, published=True) def test_add_subsection_after_publish(self): """ Adding a subsection to a published section will create a new version and show that the section has unpublished changes. """ - section, section_version = content_api.create_section_and_version( - learning_package_id=self.learning_package.id, + section, section_version = self.create_section_and_version( key="section:key", title="Section", - created=self.now, - created_by=None, ) assert section.versioning.draft == section_version assert section.versioning.published is None @@ -464,10 +176,10 @@ def test_add_subsection_after_publish(self): # 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, + section_version_v2 = content_api.create_next_container_version( + section.pk, title=section_version.title, - subsections=[self.subsection_1], + entities=[self.subsection_1], created=self.now, created_by=None, entities_action=content_api.ChildrenEntitiesAction.APPEND, @@ -488,7 +200,7 @@ def test_modify_unpinned_subsection_after_publish(self): """ # 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]) + section = self.create_section(entities=[self.subsection_1]) assert section.versioning.has_unpublished_changes # Publish the section and the subsection: @@ -500,7 +212,7 @@ def test_modify_unpinned_subsection_after_publish(self): 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") + subsection_1_v2 = self.modify_container(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 @@ -510,19 +222,19 @@ def test_modify_unpinned_subsection_after_publish(self): 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) == [ + assert content_api.get_entities_in_container(section, published=False) == [ Entry(subsection_1_v2), # new version ] - assert content_api.get_subsections_in_section(section, published=True) == [ + assert content_api.get_entities_in_container(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) == [ + self.publish_container(self.subsection_1) + assert content_api.get_entities_in_container(section, published=False) == [ Entry(subsection_1_v2), # new version ] - assert content_api.get_subsections_in_section(section, published=True) == [ + assert content_api.get_entities_in_container(section, published=True) == [ Entry(subsection_1_v2), # new version ] assert content_api.contains_unpublished_changes(section.pk) is False # No longer contains unpublished changes @@ -534,17 +246,17 @@ def test_modify_pinned_subsection(self): 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]) + section = self.create_section(entities=[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 + assert content_api.get_entities_in_container(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") + self.modify_container(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 @@ -554,12 +266,12 @@ def test_modify_pinned_subsection(self): 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 + assert content_api.get_entities_in_container(section, published=False) == expected_section_contents + assert content_api.get_entities_in_container(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 + self.publish_container(self.subsection_1) + assert content_api.get_entities_in_container(section, published=False) == expected_section_contents + assert content_api.get_entities_in_container(section, published=True) == expected_section_contents def test_create_two_sections_with_same_subsections(self): """ @@ -567,48 +279,54 @@ def test_create_two_sections_with_same_subsections(self): 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" - ) + section1 = self.create_section(entities=[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" - ) + section2 = self.create_section(entities=[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,] + row.entity_version.containerversion + for row in content_api.get_entities_in_container(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,] + row.entity_version.containerversion + for row in content_api.get_entities_in_container(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") + subsection_1_v2 = self.modify_container(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") + subsection_2_v2 = self.modify_container(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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(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 @@ -625,16 +343,16 @@ def test_publishing_shared_subsection(self): """ # 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) + self.create_subsection_and_version(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") + section1 = self.create_section(entities=[s1, s2, s3], title="Section 1", key="section:1") + section2 = self.create_section(entities=[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") + s2_v2 = self.modify_container(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) @@ -645,7 +363,7 @@ def test_publishing_shared_subsection(self): 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") + s5_v2 = self.modify_container(s5, title="S5 version 2") # 4️⃣ The author then publishes Section 1, and therefore everything in it. content_api.publish_from_drafts( @@ -657,7 +375,7 @@ def test_publishing_shared_subsection(self): ) # Result: Section 1 will show the newly published version of U2: - assert content_api.get_subsections_in_section(section1, published=True) == [ + assert content_api.get_entities_in_container(section1, published=True) == [ Entry(s1_v1), Entry(s2_v2), # new published version of U2 Entry(s3_v1), @@ -667,7 +385,7 @@ def test_publishing_shared_subsection(self): # 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) == [ + assert content_api.get_entities_in_container(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) @@ -679,9 +397,9 @@ def test_publishing_shared_subsection(self): 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) + self.publish_container(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) == [ + assert content_api.get_entities_in_container(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 @@ -697,19 +415,19 @@ def test_query_count_of_contains_unpublished_changes(self): subsection_count = 2 subsections = [] for i in range(subsection_count): - subsection, _version = self.create_subsection( + subsection = self.create_subsection( key=f"Subsection {i}", title=f"Subsection {i}", ) subsections.append(subsection) - section = self.create_section_with_subsections(subsections) + section = self.create_section(entities=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") + self.modify_container(subsection, title="Modified Subsection") with self.assertNumQueries(1): assert content_api.contains_unpublished_changes(section.pk) is True @@ -719,12 +437,12 @@ def test_metadata_change_doesnt_create_entity_list(self): 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]) + section = self.create_section(entities=[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) + content_api.create_next_container_version(section.pk, title="New Title", created=self.now, created_by=None) section.refresh_from_db() new_version_num = section.versioning.draft.version_num @@ -734,28 +452,29 @@ def test_metadata_change_doesnt_create_entity_list(self): 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]) + """Test removing a subsection from a section (but not deleting it)""" + section = self.create_section(entities=[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, + content_api.create_next_container_version( + section.pk, title="Revised with subsection 2 deleted", - subsections=[self.subsection_2], + entities=[self.subsection_2], created=self.now, + created_by=None, 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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(section, published=True) == [ Entry(self.subsection_1_v1), Entry(self.subsection_2_v1), ] @@ -768,20 +487,20 @@ def test_removing_subsection(self): # 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) == [ + assert content_api.get_entities_in_container(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]) + """Test soft deleting a subsection that's in a section (but not removing it)""" + section = self.create_section(entities=[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) == [ + assert content_api.get_entities_in_container(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 @@ -791,7 +510,7 @@ def test_soft_deleting_subsection(self): 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) == [ + assert content_api.get_entities_in_container(section, published=True) == [ Entry(self.subsection_1_v1), Entry(self.subsection_2_v1), ] @@ -799,34 +518,35 @@ def test_soft_deleting_subsection(self): # 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) == [ + assert content_api.get_entities_in_container(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]) + """Test soft deleting a subsection that's in a section AND removing it""" + section = self.create_section(entities=[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, + content_api.create_next_container_version( + section.pk, title="Revised with subsection 2 deleted", - subsections=[self.subsection_2], + entities=[self.subsection_2], created=self.now, entities_action=content_api.ChildrenEntitiesAction.REMOVE, + created_by=None, ) # Now it should not be listed in the section: - assert content_api.get_subsections_in_section(section, published=False) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(section, published=True) == [ Entry(self.subsection_1_v1), Entry(self.subsection_2_v1), ] @@ -834,27 +554,27 @@ def test_soft_deleting_and_removing_subsection(self): # 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) == [ + assert content_api.get_entities_in_container(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]) + """Test soft deleting a pinned 📌 subsection that's in a section""" + section = self.create_section(entities=[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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(section, published=True) == [ Entry(self.subsection_1_v1, pinned=True), Entry(self.subsection_2_v1, pinned=True), ] @@ -866,8 +586,8 @@ def test_soft_delete_section(self): 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") + section_to_delete = self.create_section(entities=[self.subsection_1, self.subsection_2]) + other_section = self.create_section(entities=[self.subsection_1], key="other") # Publish everything: content_api.publish_all_drafts(self.learning_package.id) @@ -879,7 +599,7 @@ def test_soft_delete_section(self): 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)] + assert content_api.get_entities_in_container(other_section, published=False) == [Entry(self.subsection_1_v1)] # Publish everything: content_api.publish_all_drafts(self.learning_package.id) @@ -890,8 +610,8 @@ def test_soft_delete_section(self): 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)] + assert content_api.get_entities_in_container(other_section, published=False) == [Entry(self.subsection_1_v1)] + assert content_api.get_entities_in_container(other_section, published=True) == [Entry(self.subsection_1_v1)] def test_snapshots_of_published_section(self): """ @@ -899,10 +619,10 @@ def test_snapshots_of_published_section(self): 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 + section = self.create_section(entities=[self.subsection_1]) + self.modify_container(self.subsection_1, title="Subsection 1 as of checkpoint 1") + _, before_publish = content_api.get_entities_in_container_as_of(section, 0) + assert before_publish == [] # Publish everything, creating Checkpoint 1 checkpoint_1 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 1") @@ -910,19 +630,20 @@ def test_snapshots_of_published_section(self): ######################################################################## # Now we update the title of the subsection. - self.modify_subsection(self.subsection_1, title="Subsection 1 as of checkpoint 2") + self.modify_container(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, + self.modify_container(self.subsection_1, title="Subsection 1 as of checkpoint 3") + self.modify_container(self.subsection_2, title="Subsection 2 as of checkpoint 3") + content_api.create_next_container_version( + section.pk, title="Section title in checkpoint 3", - subsections=[self.subsection_1, self.subsection_2], + entities=[self.subsection_1, self.subsection_2], created=self.now, + created_by=None, ) # Publish everything, creating Checkpoint 3 checkpoint_3 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 3") @@ -930,36 +651,37 @@ def test_snapshots_of_published_section(self): # 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, + content_api.create_next_container_version( + section.pk, title="Section title in checkpoint 4", - subsections=[self.subsection_1_v1, self.subsection_1, self.subsection_2], + entities=[self.subsection_1_v1, self.subsection_1, self.subsection_2], created=self.now, + created_by=None, ) # 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") + self.modify_container(self.subsection_1, title="Subsection 1 draft") + self.modify_container(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] == [ + _, as_of_checkpoint_1 = content_api.get_entities_in_container_as_of(section, checkpoint_1.pk) + assert [ev.entity_version.title for ev 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] == [ + _, as_of_checkpoint_2 = content_api.get_entities_in_container_as_of(section, checkpoint_2.pk) + assert [ev.entity_version.title for ev 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] == [ + _, as_of_checkpoint_3 = content_api.get_entities_in_container_as_of(section, checkpoint_3.pk) + assert [ev.entity_version.title for ev 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] == [ + _, as_of_checkpoint_4 = content_api.get_entities_in_container_as_of(section, checkpoint_4.pk) + assert [ev.entity_version.title for ev 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 @@ -970,32 +692,34 @@ 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") + subsection_1_v2 = self.modify_container(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") + section1_1pinned = self.create_section(entities=[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") + section2_1pinned_v2 = self.create_section(entities=[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") + _section3_no = self.create_section(entities=[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") + section4_unpinned = self.create_section( + entities=[ + 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") + _section5_no = self.create_section(entities=[self.subsection_2_v1, self.subsection_2], key="s5") + _section6_no = self.create_section(entities=[], 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") - ] + result = list(content_api.get_containers_with_entity(self.subsection_1.pk)) assert result == [ section1_1pinned, section2_1pinned_v2, @@ -1006,34 +730,31 @@ def test_sections_containing(self): # 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") - ] + result2 = list(content_api.get_containers_with_entity(self.subsection_1.pk, ignore_pinned=True)) assert result2 == [section4_unpinned] - def test_get_subsections_in_section_queries(self): + def test_get_entities_in_container_queries(self): """ - Test the query count of get_subsections_in_section() + Test the query count of get_entities_in_container() 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) + section = self.create_section( + entities=[ + self.subsection_1, + self.subsection_2, + self.subsection_2_v1, + ] + ) + with self.assertNumQueries(2): + result = content_api.get_entities_in_container(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) + with self.assertNumQueries(2): + result = content_api.get_entities_in_container(section, published=True) assert result == [ Entry(self.subsection_1.versioning.draft), Entry(self.subsection_2.versioning.draft), @@ -1044,26 +765,23 @@ 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, + section, section_version = self.create_section_and_version( key="section:key", title="Section", - subsections=[self.subsection_1], - created=self.now, - created_by=None, + entities=[self.subsection_1], ) - assert content_api.get_subsections_in_section(section, published=False) == [ + assert content_api.get_entities_in_container(section, published=False) == [ Entry(self.subsection_1.versioning.draft), ] - subsection_3, _ = self.create_subsection( + 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, + section_version_v2 = content_api.create_next_container_version( + section.pk, title=section_version.title, - subsections=[self.subsection_2, subsection_3], + entities=[self.subsection_2, subsection_3], created=self.now, created_by=None, entities_action=content_api.ChildrenEntitiesAction.APPEND, @@ -1072,24 +790,24 @@ def test_add_remove_container_children(self): 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) == [ + assert content_api.get_entities_in_container(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, + content_api.create_next_container_version( + section.pk, title=section_version.title, - subsections=[self.subsection_1], + entities=[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) == [ + assert content_api.get_entities_in_container(section, published=False) == [ Entry(self.subsection_2.versioning.draft), Entry(subsection_3.versioning.draft), ] @@ -1098,35 +816,35 @@ def test_get_container_children_count(self): """ Test get_container_children_count() """ - section = self.create_section_with_subsections([self.subsection_1]) - assert content_api.get_container_children_count(section.container, published=False) == 1 + section = self.create_section(entities=[self.subsection_1]) + assert content_api.get_container_children_count(section, published=False) == 1 # publish content_api.publish_all_drafts(self.learning_package.id) section_version = section.versioning.draft - content_api.create_next_section_version( - section=section, + content_api.create_next_container_version( + section.pk, title=section_version.title, - subsections=[self.subsection_2], + entities=[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 + assert content_api.get_container_children_count(section, published=False) == 2 + assert content_api.get_container_children_count(section, 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 + assert content_api.get_container_children_count(section, 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 + assert content_api.get_container_children_count(section, published=False) == 1 content_api.publish_all_drafts(self.learning_package.id) section.refresh_from_db() - assert content_api.get_container_children_count(section.container, published=True) == 1 + assert content_api.get_container_children_count(section, published=True) == 1 # Tests TODO: # Test that I can get a [PublishLog] history of a given section and all its children, including children that aren't diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py index 8d93e108..2e6fa258 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -1,137 +1,34 @@ """ 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 ..units.test_api import UnitTestCase +from ..publishing.container_test_case import ContainerTestCase, Entry -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 SubSectionTestCase(ContainerTestCase): + """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.unit_2, self.unit_2_v1 = self.create_unit( - key="Unit (2)", - title="Unit (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, - created=self.now, - created_by=None, - ) - - def create_subsection_with_units( - self, - units: list[authoring_models.Unit | authoring_models.UnitVersion], - *, - title="Unit", - key="unit:key", - ) -> authoring_models.Subsection: - """ Helper method to quickly create a subsection with some units """ - subsection, _subsection_v1 = content_api.create_subsection_and_version( - learning_package_id=self.learning_package.id, - key=key, - title=title, - units=units, - created=self.now, - created_by=None, - ) - 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 + self.unit_1, self.unit_1_v1 = self.create_unit_and_version(key="Unit (1)", title="Unit (1)") + self.unit_2, self.unit_2_v1 = self.create_unit_and_version(key="Unit (2)", title="Unit (2)") def test_get_containers(self): """ Test get_containers() """ - subsection = self.create_subsection_with_units([]) + subsection = self.create_subsection(entities=[]) 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] + assert result == [self.unit_1, self.unit_2, subsection] # Versioning data should be pre-loaded via select_related() with self.assertNumQueries(0): assert result[0].versioning.has_unpublished_changes @@ -140,24 +37,23 @@ def test_get_containers_deleted(self): """ Test that get_containers() does not return soft-deleted sections. """ - subsection = self.create_subsection_with_units([]) + subsection = self.create_subsection(entities=[]) 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] + assert result == [self.unit_1, self.unit_2, subsection] with self.assertNumQueries(1): result = list(content_api.get_containers(self.learning_package.id)) - assert result == [self.unit_1.container, self.unit_2.container] + assert result == [self.unit_1, self.unit_2] def test_get_container(self): """ Test get_container() """ - subsection = self.create_subsection_with_units([self.unit_1, self.unit_2]) + subsection = self.create_subsection(entities=[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 @@ -166,42 +62,26 @@ def test_get_container_by_key(self): """ Test get_container_by_key() """ - subsection = self.create_subsection_with_units([]) + subsection = self.create_subsection(entities=[]) 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): + with self.assertNumQueries(23): + _empty_subsection = self.create_subsection(entities=[]) + with self.assertNumQueries(33): # And try with a non-empty subsection: - self.create_subsection_with_units([self.unit_1, self.unit_2_v1], key="u2") + self.create_subsection(entities=[self.unit_1, self.unit_2_v1], key="u2") def test_create_subsection_with_invalid_children(self): """ @@ -209,33 +89,38 @@ def test_create_subsection_with_invalid_children(self): exception is raised. """ # Create two subsections: - subsection, subsection_version = content_api.create_subsection_and_version( + subsection, subsection_version = content_api.create_container_and_version( learning_package_id=self.learning_package.id, key="subsection:key", title="Subsection", created=self.now, created_by=None, + container_type=content_api.Subsection, ) assert subsection.versioning.draft == subsection_version - subsection2, _s2v1 = content_api.create_subsection_and_version( + subsection2, _s2v1 = content_api.create_container_and_version( learning_package_id=self.learning_package.id, key="subsection:key2", title="Subsection 2", created=self.now, created_by=None, + container_type=content_api.Subsection, ) # 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, + with pytest.raises( + ValidationError, match='The entity "subsection:key2" cannot be added to a "subsection" container.' + ) as exc: + content_api.create_next_container_version( + subsection.pk, title="Subsection Containing a Subsection", - units=[subsection2], + entities=[subsection2], created=self.now, created_by=None, ) + assert "Only Units can be added as children of a Subsection" in str(exc.value.__cause__) # Check that a new version was not created: subsection.refresh_from_db() - assert content_api.get_subsection(subsection.pk).versioning.draft == subsection_version + assert content_api.get_container(subsection.pk).versioning.draft == subsection_version assert subsection.versioning.draft == subsection_version def test_adding_external_units(self): @@ -244,60 +129,48 @@ def test_adding_external_units(self): subsection. """ learning_package2 = content_api.create_learning_package(key="other-package", title="Other Package") - subsection, _subsection_version = content_api.create_subsection_and_version( + subsection, _subsection_version = content_api.create_container_and_version( learning_package_id=learning_package2.pk, key="subsection:key", title="Subsection", created=self.now, created_by=None, + container_type=content_api.Subsection, ) - assert self.unit_1.container.publishable_entity.learning_package != learning_package2 + assert self.unit_1.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, + content_api.create_next_container_version( + subsection.pk, title="Subsection Containing an External Unit", - units=[self.unit_1], + entities=[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 + def test_cannot_add_deleted_container(self): """ - Test that versioned units must match their entities. + Test that non-existent units cannot be added to subsections """ - 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, - ) + self.unit_1.delete() + with pytest.raises(authoring_models.Container.DoesNotExist): + self.create_subsection(entities=[self.unit_1]) - @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): + def test_cannot_add_corrupted_unit(self): """ 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) + # Note the PublishableEntity and PublishableEntityVersion still exist, so this is a weird corrupt state: + self.unit_1_v1.publishable_entity_version.refresh_from_db() # No error + self.unit_1_v1.publishable_entity_version.entity.refresh_from_db() # No error + # Now add this corrupted unit, pinned to v1: + with pytest.raises( + ValidationError, match=r'The entity "Unit \(1\)" cannot be added to a "subsection" container.' + ) as exc: + self.create_subsection(entities=[self.unit_1_v1]) + # And the exception should be chained from a more specific exception: + assert isinstance(exc.value.__cause__, authoring_models.Container.DoesNotExist) def test_create_empty_subsection_and_version(self): """Test creating a subsection with no units. @@ -308,12 +181,13 @@ def test_create_empty_subsection_and_version(self): 3. The subsection is a draft with unpublished changes. 4. There is no published version of the subsection. """ - subsection, subsection_version = content_api.create_subsection_and_version( + subsection, subsection_version = content_api.create_container_and_version( learning_package_id=self.learning_package.id, key="subsection:key", title="Subsection", created=self.now, created_by=None, + container_type=content_api.Subsection, ) assert subsection, subsection_version assert subsection_version.version_num == 1 @@ -332,48 +206,50 @@ def test_create_next_subsection_version_with_two_unpinned_units(self): 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( + subsection, _subsection_version = content_api.create_container_and_version( learning_package_id=self.learning_package.id, key="subsection:key", title="Subsection", created=self.now, created_by=None, + container_type=content_api.Subsection, ) - subsection_version_v2 = content_api.create_next_subsection_version( - subsection=subsection, + subsection_version_v2 = content_api.create_next_container_version( + subsection.pk, title="Subsection", - units=[self.unit_1, self.unit_2], + entities=[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) == [ + assert content_api.get_entities_in_container(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) + content_api.get_entities_in_container(subsection, published=True) def test_create_next_subsection_version_forcing_version_num(self): """ Test creating a subsection version while forcing the next version number. """ - subsection, _subsection_version = content_api.create_subsection_and_version( + subsection, _subsection_version = content_api.create_container_and_version( learning_package_id=self.learning_package.id, key="subsection:key", title="Subsection", created=self.now, created_by=None, + container_type=content_api.Subsection, ) - subsection_version_v2 = content_api.create_next_subsection_version( - subsection=subsection, + subsection_version_v2 = content_api.create_next_container_version( + subsection.pk, title="Subsection", - units=[self.unit_1, self.unit_2], + entities=[self.unit_1, self.unit_2], created=self.now, created_by=None, - force_version_num=4 + force_version_num=4, ) assert subsection_version_v2.version_num == 4 @@ -381,38 +257,39 @@ 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( + subsection, _subsection_version = content_api.create_container_and_version( learning_package_id=self.learning_package.id, key="subsection:key", title="Subsection", created=self.now, created_by=None, + container_type=content_api.Subsection, ) - subsection_version_v2 = content_api.create_next_subsection_version( - subsection=subsection, + subsection_version_v2 = content_api.create_next_container_version( + subsection.pk, title="Subsection", - units=[self.unit_1, self.unit_2_v1], # Note the "v1" pinning 📌 the second one to version 1 + entities=[self.unit_1, self.unit_2_v1], # Note the "v1" pinning 📌 the second one to version 1 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) == [ + assert content_api.get_entities_in_container(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) + content_api.get_entities_in_container(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]) + subsection = self.create_subsection(entities=[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") + other_unit = 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 @@ -443,10 +320,10 @@ 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]) + subsection = self.create_subsection(entities=[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.publish_container(self.unit_1) self.unit_1.refresh_from_db() # Clear cache on '.versioning' assert self.unit_1.versioning.has_unpublished_changes is False @@ -456,19 +333,20 @@ def test_no_publish_parent(self): assert subsection.versioning.published is None with pytest.raises(authoring_models.ContainerVersion.DoesNotExist): # There is no published version of the subsection: - content_api.get_units_in_subsection(subsection, published=True) + content_api.get_entities_in_container(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( + subsection, subsection_version = content_api.create_container_and_version( learning_package_id=self.learning_package.id, key="subsection:key", title="Subsection", created=self.now, created_by=None, + container_type=content_api.Subsection, ) assert subsection.versioning.draft == subsection_version assert subsection.versioning.published is None @@ -481,10 +359,10 @@ def test_add_unit_after_publish(self): # 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, + subsection_version_v2 = content_api.create_next_container_version( + subsection.pk, title=subsection_version.title, - units=[self.unit_1], + entities=[self.unit_1], created=self.now, created_by=None, entities_action=content_api.ChildrenEntitiesAction.APPEND, @@ -505,7 +383,7 @@ def test_modify_unpinned_unit_after_publish(self): """ # Create a subsection with one unpinned draft unit: assert self.unit_1.versioning.has_unpublished_changes - subsection = self.create_subsection_with_units([self.unit_1]) + subsection = self.create_subsection(entities=[self.unit_1]) assert subsection.versioning.has_unpublished_changes # Publish the subsection and the unit: @@ -517,7 +395,7 @@ def test_modify_unpinned_unit_after_publish(self): 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") + unit_1_v2 = self.modify_container(self.unit_1, title="Modified Counting Problem with new title") # The unit now has unpublished changes; the subsection doesn't directly but does contain subsection.refresh_from_db() # Refresh to avoid stale 'versioning' cache @@ -527,19 +405,19 @@ def test_modify_unpinned_unit_after_publish(self): assert self.unit_1.versioning.has_unpublished_changes # 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) == [ + assert content_api.get_entities_in_container(subsection, published=False) == [ Entry(unit_1_v2), # new version ] - assert content_api.get_units_in_subsection(subsection, published=True) == [ + assert content_api.get_entities_in_container(subsection, published=True) == [ Entry(self.unit_1_v1), # old version ] # 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) == [ + self.publish_container(self.unit_1) + assert content_api.get_entities_in_container(subsection, published=False) == [ Entry(unit_1_v2), # new version ] - assert content_api.get_units_in_subsection(subsection, published=True) == [ + assert content_api.get_entities_in_container(subsection, published=True) == [ Entry(unit_1_v2), # new version ] assert content_api.contains_unpublished_changes(subsection.pk) is False # No more unpublished changes @@ -551,17 +429,17 @@ def test_modify_pinned_unit(self): which will continue to use the pinned version. """ # Create a subsection with one unit (pinned 📌 to v1): - subsection = self.create_subsection_with_units([self.unit_1_v1]) + subsection = self.create_subsection(entities=[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 + assert content_api.get_entities_in_container(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") + self.modify_container(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 @@ -571,12 +449,12 @@ def test_modify_pinned_unit(self): 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 + assert content_api.get_entities_in_container(subsection, published=False) == expected_subsection_contents + assert content_api.get_entities_in_container(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 + self.publish_container(self.unit_1) + assert content_api.get_entities_in_container(subsection, published=False) == expected_subsection_contents + assert content_api.get_entities_in_container(subsection, published=True) == expected_subsection_contents def test_create_two_subsections_with_same_units(self): """ @@ -584,44 +462,54 @@ def test_create_two_subsections_with_same_units(self): 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") + subsection1 = self.create_subsection(entities=[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") + subsection2 = self.create_subsection(entities=[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.entity_version.containerversion + for row in content_api.get_entities_in_container(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, + assert [ + row.entity_version.containerversion + for row in content_api.get_entities_in_container(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") + unit_1_v2 = self.modify_container(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") + unit_2_v2 = self.modify_container(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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(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 @@ -638,16 +526,16 @@ def test_publishing_shared_unit(self): """ # 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) + self.create_unit_and_version(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") + subsection1 = self.create_subsection(entities=[u1, u2, u3], title="Subsection 1", key="subsection:1") + subsection2 = self.create_subsection(entities=[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") + u2_v2 = self.modify_container(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) @@ -658,7 +546,7 @@ def test_publishing_shared_unit(self): 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") + u5_v2 = self.modify_container(u5, title="U5 version 2") # 4️⃣ The author then publishes Subsection 1, and therefore everything in it. content_api.publish_from_drafts( @@ -670,7 +558,7 @@ def test_publishing_shared_unit(self): ) # Result: Subsection 1 will show the newly published version of U2: - assert content_api.get_units_in_subsection(subsection1, published=True) == [ + assert content_api.get_entities_in_container(subsection1, published=True) == [ Entry(u1_v1), Entry(u2_v2), # new published version of U2 Entry(u3_v1), @@ -679,7 +567,7 @@ def test_publishing_shared_unit(self): # 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) == [ + assert content_api.get_entities_in_container(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) @@ -690,9 +578,9 @@ def test_publishing_shared_unit(self): 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) + self.publish_container(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) == [ + assert content_api.get_entities_in_container(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 @@ -708,19 +596,19 @@ def test_query_count_of_contains_unpublished_changes(self): unit_count = 2 units = [] for i in range(unit_count): - unit, _version = self.create_unit( + unit, _version = self.create_unit_and_version( key=f"Unit {i}", title=f"Unit {i}", ) units.append(unit) - subsection = self.create_subsection_with_units(units) + subsection = self.create_subsection(entities=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") + self.modify_container(unit, title="Modified Unit") with self.assertNumQueries(1): assert content_api.contains_unpublished_changes(subsection.pk) is True @@ -730,12 +618,12 @@ def test_metadata_change_doesnt_create_entity_list(self): 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]) + subsection = self.create_subsection(entities=[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) + content_api.create_next_container_version(subsection, title="New Title", created=self.now, created_by=None) subsection.refresh_from_db() new_version_num = subsection.versioning.draft.version_num @@ -762,31 +650,32 @@ def test_cannot_add_soft_deleted_unit(self, publish_first): 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]) + self.create_subsection(entities=[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]) + """Test removing a unit from a subsection (but not deleting it)""" + subsection = self.create_subsection(entities=[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, + content_api.create_next_container_version( + subsection.pk, title="Revised with unit 2 deleted", - units=[self.unit_2], + entities=[self.unit_2], created=self.now, + created_by=None, 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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(subsection, published=True) == [ Entry(self.unit_1_v1), Entry(self.unit_2_v1), ] @@ -799,20 +688,20 @@ def test_removing_unit(self): # 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) == [ + assert content_api.get_entities_in_container(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]) + """Test soft deleting a unit that's in a subsection (but not removing it)""" + subsection = self.create_subsection(entities=[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) == [ + assert content_api.get_entities_in_container(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 @@ -822,7 +711,7 @@ def test_soft_deleting_unit(self): 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) == [ + assert content_api.get_entities_in_container(subsection, published=True) == [ Entry(self.unit_1_v1), Entry(self.unit_2_v1), ] @@ -830,34 +719,35 @@ def test_soft_deleting_unit(self): # 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) == [ + assert content_api.get_entities_in_container(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]) + """Test soft deleting a unit that's in a subsection AND removing it""" + subsection = self.create_subsection(entities=[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, + content_api.create_next_container_version( + subsection.pk, title="Revised with unit 2 deleted", - units=[self.unit_2], + entities=[self.unit_2], created=self.now, + created_by=None, 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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(subsection, published=True) == [ Entry(self.unit_1_v1), Entry(self.unit_2_v1), ] @@ -865,27 +755,27 @@ def test_soft_deleting_and_removing_unit(self): # 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) == [ + assert content_api.get_entities_in_container(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]) + """Test soft deleting a pinned 📌 unit that's in a subsection""" + subsection = self.create_subsection(entities=[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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(subsection, published=True) == [ Entry(self.unit_1_v1, pinned=True), Entry(self.unit_2_v1, pinned=True), ] @@ -897,8 +787,8 @@ def test_soft_delete_subsection(self): 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") + subsection_to_delete = self.create_subsection(entities=[self.unit_1, self.unit_2]) + other_subsection = self.create_subsection(entities=[self.unit_1], key="other") # Publish everything: content_api.publish_all_drafts(self.learning_package.id) @@ -910,7 +800,7 @@ def test_soft_delete_subsection(self): 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)] + assert content_api.get_entities_in_container(other_subsection, published=False) == [Entry(self.unit_1_v1)] # Publish everything: content_api.publish_all_drafts(self.learning_package.id) @@ -921,8 +811,8 @@ def test_soft_delete_subsection(self): 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)] + assert content_api.get_entities_in_container(other_subsection, published=False) == [Entry(self.unit_1_v1)] + assert content_api.get_entities_in_container(other_subsection, published=True) == [Entry(self.unit_1_v1)] def test_snapshots_of_published_subsection(self): """ @@ -930,9 +820,9 @@ def test_snapshots_of_published_subsection(self): 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) + subsection = self.create_subsection(entities=[self.unit_1]) + self.modify_container(self.unit_1, title="Unit 1 as of checkpoint 1") + before_publish, _ = content_api.get_entities_in_container_as_of(subsection, 0) assert before_publish is None # Publish everything, creating Checkpoint 1 @@ -941,19 +831,20 @@ def test_snapshots_of_published_subsection(self): ######################################################################## # Now we update the title of the unit. - self.modify_unit(self.unit_1, title="Unit 1 as of checkpoint 2") + self.modify_container(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, + self.modify_container(self.unit_1, title="Unit 1 as of checkpoint 3") + self.modify_container(self.unit_2, title="Unit 2 as of checkpoint 3") + content_api.create_next_container_version( + subsection.pk, title="Subsection title in checkpoint 3", - units=[self.unit_1, self.unit_2], + entities=[self.unit_1, self.unit_2], created=self.now, + created_by=None, ) # Publish everything, creating Checkpoint 3 checkpoint_3 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 3") @@ -961,36 +852,37 @@ def test_snapshots_of_published_subsection(self): # 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, + content_api.create_next_container_version( + subsection.pk, title="Subsection title in checkpoint 4", - units=[self.unit_1_v1, self.unit_1, self.unit_2], + entities=[self.unit_1_v1, self.unit_1, self.unit_2], created=self.now, + created_by=None, ) # 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") + self.modify_container(self.unit_1, title="Unit 1 draft") + self.modify_container(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] == [ + _, as_of_checkpoint_1 = content_api.get_entities_in_container_as_of(subsection, checkpoint_1.pk) + assert [ev.entity_version.title for ev 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] == [ + _, as_of_checkpoint_2 = content_api.get_entities_in_container_as_of(subsection, checkpoint_2.pk) + assert [ev.entity_version.title for ev 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] == [ + _, as_of_checkpoint_3 = content_api.get_entities_in_container_as_of(subsection, checkpoint_3.pk) + assert [ev.entity_version.title for ev 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] == [ + _, as_of_checkpoint_4 = content_api.get_entities_in_container_as_of(subsection, checkpoint_4.pk) + assert [ev.entity_version.title for ev 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 @@ -1001,32 +893,34 @@ 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") + unit_1_v2 = self.modify_container(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") + subsection1_1pinned = self.create_subsection(entities=[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") + subsection2_1pinned_v2 = self.create_subsection(entities=[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") + _subsection3_no = self.create_subsection(entities=[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") + subsection4_unpinned = self.create_subsection(entities= + [ + 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") + _subsection5_no = self.create_subsection(entities=[self.unit_2_v1, self.unit_2], key="u5") + _subsection6_no = self.create_subsection(entities=[], 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") - ] + result = list(content_api.get_containers_with_entity(self.unit_1.pk)) assert result == [ subsection1_1pinned, subsection2_1pinned_v2, @@ -1037,34 +931,30 @@ def test_subsections_containing(self): # 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") - ] + result2 = list(content_api.get_containers_with_entity(self.unit_1.pk, ignore_pinned=True)) assert result2 == [subsection4_unpinned] - def test_get_units_in_subsection_queries(self): + def test_get_entities_in_container_queries(self): """ - Test the query count of get_units_in_subsection() - This also tests the generic method get_entities_in_container() + Test the query count of 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) + subsection = self.create_subsection(entities= + [ + self.unit_1, + self.unit_2, + self.unit_2_v1, + ] + ) + with self.assertNumQueries(2): + result = content_api.get_entities_in_container(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) + with self.assertNumQueries(2): + result = content_api.get_entities_in_container(subsection, published=True) assert result == [ Entry(self.unit_1.versioning.draft), Entry(self.unit_2.versioning.draft), @@ -1075,26 +965,24 @@ def test_add_remove_container_children(self): """ Test adding and removing children units from subsections. """ - subsection, subsection_version = content_api.create_subsection_and_version( + subsection, subsection_version = content_api.create_container_and_version( learning_package_id=self.learning_package.id, key="subsection:key", title="Subsection", - units=[self.unit_1], + entities=[self.unit_1], created=self.now, created_by=None, + container_type=content_api.Subsection, ) - assert content_api.get_units_in_subsection(subsection, published=False) == [ + assert content_api.get_entities_in_container(subsection, published=False) == [ Entry(self.unit_1.versioning.draft), ] - unit_3, _ = self.create_unit( - key="Unit (3)", - title="Unit (3)", - ) + 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, + subsection_version_v2 = content_api.create_next_container_version( + subsection.pk, title=subsection_version.title, - units=[self.unit_2, unit_3], + entities=[self.unit_2, unit_3], created=self.now, created_by=None, entities_action=content_api.ChildrenEntitiesAction.APPEND, @@ -1103,24 +991,24 @@ def test_add_remove_container_children(self): 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) == [ + assert content_api.get_entities_in_container(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, + content_api.create_next_container_version( + subsection.pk, title=subsection_version.title, - units=[self.unit_1], + entities=[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) == [ + assert content_api.get_entities_in_container(subsection, published=False) == [ Entry(self.unit_2.versioning.draft), Entry(unit_3.versioning.draft), ] @@ -1129,35 +1017,35 @@ def test_get_container_children_count(self): """ Test get_container_children_count() """ - subsection = self.create_subsection_with_units([self.unit_1]) - assert content_api.get_container_children_count(subsection.container, published=False) == 1 + subsection = self.create_subsection(entities=[self.unit_1]) + assert content_api.get_container_children_count(subsection, published=False) == 1 # publish content_api.publish_all_drafts(self.learning_package.id) subsection_version = subsection.versioning.draft - content_api.create_next_subsection_version( - subsection=subsection, + content_api.create_next_container_version( + subsection.pk, title=subsection_version.title, - units=[self.unit_2], + entities=[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 + assert content_api.get_container_children_count(subsection, published=False) == 2 + assert content_api.get_container_children_count(subsection, 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 + assert content_api.get_container_children_count(subsection, 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 + assert content_api.get_container_children_count(subsection, published=False) == 1 content_api.publish_all_drafts(self.learning_package.id) subsection.refresh_from_db() - assert content_api.get_container_children_count(subsection.container, published=True) == 1 + assert content_api.get_container_children_count(subsection, published=True) == 1 # Tests TODO: # Test that I can get a [PublishLog] history of a given subsection and all its children, including children diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 588d9d83..02815a6b 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -1,24 +1,20 @@ """ 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 ..components.test_api import ComponentTestCase - -Entry = content_api.UnitListEntry +from ..publishing.container_test_case import ContainerTestCase, Entry @ddt.ddt -class UnitTestCase(ComponentTestCase): - """ Test cases for Units (containers of components) """ +class SubSectionTestCase(ContainerTestCase): + """Test cases for Units (containers of components)""" def setUp(self) -> None: super().setUp() @@ -31,95 +27,36 @@ 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], - *, - title="Unit", - key="unit:key", - ) -> authoring_models.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, - title=title, - components=components, - created=self.now, - created_by=None, - ) - 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): + def test_get_container(self): """ - Test get_unit() + Test get_container() """ - unit = self.create_unit_with_components([self.component_1, self.component_2]) + unit = self.create_unit(entities=[self.component_1, self.component_2]) with self.assertNumQueries(1): - result = content_api.get_unit(unit.pk) + result = content_api.get_container(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): + def test_get_container_version(self): """ - Test get_unit_version() + Test get_container_version() """ - unit = self.create_unit_with_components([]) + unit = self.create_unit(entities=[]) 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) + result = content_api.get_container_version(draft.pk) assert result == draft def test_get_containers(self): """ Test get_containers() """ - unit = self.create_unit_with_components([]) + unit = self.create_unit(entities=[]) with self.assertNumQueries(1): result = list(content_api.get_containers(self.learning_package.id)) - assert result == [unit.container] + assert result == [unit] # Versioning data should be pre-loaded via select_related() with self.assertNumQueries(0): assert result[0].versioning.has_unpublished_changes @@ -128,68 +65,41 @@ def test_get_containers_deleted(self): """ Test that get_containers() does not return soft-deleted units. """ - unit = self.create_unit_with_components([]) + unit = self.create_unit(entities=[]) 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] + assert result == [unit] 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([]) + unit = self.create_unit(entities=[]) with self.assertNumQueries(1): result = content_api.get_container_by_key( self.learning_package.id, key=unit.publishable_entity.key, ) - assert result == unit.container + assert result == unit # 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): + with self.assertNumQueries(23): + _empty_unit = self.create_unit(entities=[]) + with self.assertNumQueries(31): # And try with a non-empty unit: - self.create_unit_with_components([self.component_1, self.component_2_v1], key="u2") + self.create_unit(entities=[self.component_1, self.component_2_v1], key="u2") def test_create_unit_with_invalid_children(self): """ @@ -197,33 +107,27 @@ def test_create_unit_with_invalid_children(self): exception is raised. """ # Create two units: - unit, unit_version = content_api.create_unit_and_version( - learning_package_id=self.learning_package.id, + unit, unit_version = self.create_unit_and_version( 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, + unit2 = self.create_unit( 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, + with pytest.raises(ValidationError, match='The entity "unit:key2" cannot be added to a "unit" container.'): + content_api.create_next_container_version( + container_pk=unit.pk, title="Unit Containing a Unit", - components=[unit2], + entities=[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 content_api.get_container(unit.pk).versioning.draft == unit_version assert unit.versioning.draft == unit_version def test_adding_external_components(self): @@ -232,58 +136,49 @@ def test_adding_external_components(self): unit. """ learning_package2 = content_api.create_learning_package(key="other-package", title="Other Package") - unit, _unit_version = content_api.create_unit_and_version( + unit, _version = content_api.create_container_and_version( learning_package_id=learning_package2.pk, key="unit:key", title="Unit", created=self.now, created_by=None, + container_type=content_api.Unit, ) 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, + content_api.create_next_container_version( + unit.pk, title="Unit Containing an External Component", - components=[self.component_1], + entities=[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): + def test_add_deleted_component(self): """ - Test that versioned components must match their entities. + Test adding a deleted component. + Mostly this checks that the exception thrown is reasonable. """ - 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, - ) + self.component_1.delete() + with pytest.raises(authoring_models.Component.DoesNotExist): + self.create_unit(entities=[self.component_1]) - @ddt.data(True, False) - def test_cannot_add_invalid_ids(self, pin_version): + def test_add_corrupted_component(self): """ - Test that non-existent components cannot be added to units + Test adding a corrupted component (partially deleted) + Mostly this checks that the exception thrown is reasonable. """ 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) + # Note the PublishableEntity and PublishableEntityVersion still exist, so this is a weird state: + self.component_1_v1.publishable_entity_version.refresh_from_db() # No error + self.component_1_v1.publishable_entity_version.entity.refresh_from_db() # No error + # Now add this corrupted component, pinned to v1: + with pytest.raises( + ValidationError, + match='The entity "xblock.v1:problem:Query Counting" cannot be added to a "unit" container.', + ): + self.create_unit(entities=[self.component_1_v1]) def test_create_empty_unit_and_version(self): """Test creating a unit with no components. @@ -294,12 +189,9 @@ def test_create_empty_unit_and_version(self): 3. The unit is a draft with unpublished changes. 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, + unit, unit_version = self.create_unit_and_version( key="unit:key", title="Unit", - created=self.now, - created_by=None, ) assert unit, unit_version assert unit_version.version_num == 1 @@ -318,73 +210,64 @@ 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, + unit = self.create_unit( key="unit:key", title="Unit", - created=self.now, - created_by=None, ) - unit_version_v2 = content_api.create_next_unit_version( - unit=unit, + unit_version_v2 = content_api.create_next_container_version( + unit.pk, title="Unit", - components=[self.component_1, self.component_2], + entities=[self.component_1, self.component_2], 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) == [ + assert content_api.get_entities_in_container(unit, published=False) == [ Entry(self.component_1.versioning.draft), Entry(self.component_2.versioning.draft), ] with pytest.raises(authoring_models.ContainerVersion.DoesNotExist): # There is no published version of the unit: - content_api.get_components_in_unit(unit, published=True) + content_api.get_entities_in_container(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, + unit, _unit_version = self.create_unit_and_version( key="unit:key", title="Unit", - created=self.now, - created_by=None, ) - unit_version_v2 = content_api.create_next_unit_version( - unit=unit, + unit_version_v2 = content_api.create_next_container_version( + unit.pk, title="Unit", - components=[self.component_1, self.component_2_v1], # Note the "v1" pinning 📌 the second one to version 1 + entities=[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) == [ + assert content_api.get_entities_in_container(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) + content_api.get_entities_in_container(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, + unit, _unit_version = self.create_unit_and_version( key="unit:key", title="Unit", - created=self.now, - created_by=None, ) - unit_version_v2 = content_api.create_next_unit_version( - unit=unit, + unit_version_v2 = content_api.create_next_container_version( + unit.pk, title="Unit", - components=[self.component_1, self.component_2_v1], # Note the "v1" pinning 📌 the second one to version 1 + entities=[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, @@ -396,7 +279,7 @@ 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]) + unit = self.create_unit(entities=[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") @@ -427,7 +310,7 @@ 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]) + unit = self.create_unit(entities=[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) @@ -440,19 +323,16 @@ def test_no_publish_parent(self): 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) + content_api.get_entities_in_container(unit, published=True) 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, + unit, unit_version = self.create_unit_and_version( key="unit:key", title="Unit", - created=self.now, - created_by=None, ) assert unit.versioning.draft == unit_version assert unit.versioning.published is None @@ -465,10 +345,10 @@ def test_add_component_after_publish(self): # 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, + unit_version_v2 = content_api.create_next_container_version( + unit.pk, title=unit_version.title, - components=[self.component_1], + entities=[self.component_1], created=self.now, created_by=None, entities_action=content_api.ChildrenEntitiesAction.APPEND, @@ -489,7 +369,7 @@ def test_modify_unpinned_component_after_publish(self): """ # 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]) + unit = self.create_unit(entities=[self.component_1]) assert unit.versioning.has_unpublished_changes # Publish the unit and the component: @@ -511,19 +391,19 @@ def test_modify_unpinned_component_after_publish(self): 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) == [ + assert content_api.get_entities_in_container(unit, published=False) == [ Entry(component_1_v2), # new version ] - assert content_api.get_components_in_unit(unit, published=True) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(unit, published=False) == [ Entry(component_1_v2), # new version ] - assert content_api.get_components_in_unit(unit, published=True) == [ + assert content_api.get_entities_in_container(unit, published=True) == [ Entry(component_1_v2), # new version ] assert content_api.contains_unpublished_changes(unit.pk) is False # No longer contains unpublished changes @@ -535,14 +415,14 @@ def test_modify_pinned_component(self): which will continue to use the pinned version. """ # Create a unit with one component (pinned 📌 to v1): - unit = self.create_unit_with_components([self.component_1_v1]) + unit = self.create_unit(entities=[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 + assert content_api.get_entities_in_container(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") @@ -555,12 +435,12 @@ def test_modify_pinned_component(self): 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 + assert content_api.get_entities_in_container(unit, published=False) == expected_unit_contents + assert content_api.get_entities_in_container(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 + assert content_api.get_entities_in_container(unit, published=False) == expected_unit_contents + assert content_api.get_entities_in_container(unit, published=True) == expected_unit_contents def test_create_two_units_with_same_components(self): """ @@ -568,16 +448,24 @@ def test_create_two_units_with_same_components(self): 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") + unit1 = self.create_unit(entities=[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") + unit2 = self.create_unit(entities=[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.entity_version.componentversion for row in content_api.get_entities_in_container(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, + assert [ + row.entity_version.componentversion for row in content_api.get_entities_in_container(unit2, published=False) + ] == [ + self.component_1_v1, + self.component_2_v1, + self.component_1_v1, ] # Modify component 1 @@ -588,24 +476,24 @@ def test_create_two_units_with_same_components(self): 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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(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 @@ -624,8 +512,8 @@ def test_publishing_shared_component(self): (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") + unit1 = self.create_unit(entities=[c1, c2, c3], title="Unit 1", key="unit:1") + unit2 = self.create_unit(entities=[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 @@ -654,7 +542,7 @@ def test_publishing_shared_component(self): ) # Result: Unit 1 will show the newly published version of C2: - assert content_api.get_components_in_unit(unit1, published=True) == [ + assert content_api.get_entities_in_container(unit1, published=True) == [ Entry(c1_v1), Entry(c2_v2), # new published version of C2 Entry(c3_v1), @@ -663,7 +551,7 @@ def test_publishing_shared_component(self): # 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) == [ + assert content_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) @@ -676,7 +564,7 @@ def test_publishing_shared_component(self): # 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) == [ + assert content_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 @@ -697,7 +585,7 @@ def test_query_count_of_contains_unpublished_changes(self): title=f"Querying Counting Problem {i}", ) components.append(component) - unit = self.create_unit_with_components(components) + unit = self.create_unit(entities=components) content_api.publish_all_drafts(self.learning_package.id) unit.refresh_from_db() with self.assertNumQueries(1): @@ -714,12 +602,12 @@ def test_metadata_change_doesnt_create_entity_list(self): 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]) + unit = self.create_unit(entities=[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) + content_api.create_next_container_version(unit.pk, title="New Title", created=self.now, created_by=None) unit.refresh_from_db() new_version_num = unit.versioning.draft.version_num @@ -746,31 +634,32 @@ def test_cannot_add_soft_deleted_component(self, publish_first): 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]) + self.create_unit(entities=[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]) + """Test removing a component from a unit (but not deleting it)""" + unit = self.create_unit(entities=[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, + content_api.create_next_container_version( + unit.pk, title="Revised with component 2 deleted", - components=[self.component_2], + entities=[self.component_2], created=self.now, + created_by=None, 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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(unit, published=True) == [ Entry(self.component_1_v1), Entry(self.component_2_v1), ] @@ -783,20 +672,20 @@ def test_removing_component(self): # 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) == [ + assert content_api.get_entities_in_container(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]) + """Test soft deleting a component that's in a unit (but not removing it)""" + unit = self.create_unit(entities=[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) == [ + assert content_api.get_entities_in_container(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 @@ -806,7 +695,7 @@ def test_soft_deleting_component(self): 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) == [ + assert content_api.get_entities_in_container(unit, published=True) == [ Entry(self.component_1_v1), Entry(self.component_2_v1), ] @@ -814,34 +703,35 @@ def test_soft_deleting_component(self): # 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) == [ + assert content_api.get_entities_in_container(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]) + """Test soft deleting a component that's in a unit AND removing it""" + unit = self.create_unit(entities=[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, + content_api.create_next_container_version( + unit.pk, title="Revised with component 2 deleted", - components=[self.component_2], + entities=[self.component_2], created=self.now, + created_by=None, 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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(unit, published=True) == [ Entry(self.component_1_v1), Entry(self.component_2_v1), ] @@ -849,27 +739,27 @@ def test_soft_deleting_and_removing_component(self): # 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) == [ + assert content_api.get_entities_in_container(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]) + """Test soft deleting a pinned 📌 component that's in a unit""" + unit = self.create_unit(entities=[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) == [ + assert content_api.get_entities_in_container(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) == [ + assert content_api.get_entities_in_container(unit, published=True) == [ Entry(self.component_1_v1, pinned=True), Entry(self.component_2_v1, pinned=True), ] @@ -881,8 +771,8 @@ def test_soft_delete_unit(self): 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") + unit_to_delete = self.create_unit(entities=[self.component_1, self.component_2]) + other_unit = self.create_unit(entities=[self.component_1], key="other") # Publish everything: content_api.publish_all_drafts(self.learning_package.id) @@ -894,7 +784,7 @@ def test_soft_delete_unit(self): 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)] + assert content_api.get_entities_in_container(other_unit, published=False) == [Entry(self.component_1_v1)] # Publish everything: content_api.publish_all_drafts(self.learning_package.id) @@ -905,8 +795,8 @@ def test_soft_delete_unit(self): 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)] + assert content_api.get_entities_in_container(other_unit, published=False) == [Entry(self.component_1_v1)] + assert content_api.get_entities_in_container(other_unit, published=True) == [Entry(self.component_1_v1)] def test_snapshots_of_published_unit(self): """ @@ -914,10 +804,10 @@ def test_snapshots_of_published_unit(self): units and their contents. """ # At first the unit has one component (unpinned): - unit = self.create_unit_with_components([self.component_1]) + unit = self.create_unit(entities=[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 + _, before_publish = content_api.get_entities_in_container_as_of(unit, 0) + assert before_publish == [] # Empty list # Publish everything, creating Checkpoint 1 checkpoint_1 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 1") @@ -933,11 +823,12 @@ def test_snapshots_of_published_unit(self): # 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, + content_api.create_next_container_version( + unit.pk, title="Unit title in checkpoint 3", - components=[self.component_1, self.component_2], + entities=[self.component_1, self.component_2], created=self.now, + created_by=None, ) # Publish everything, creating Checkpoint 3 checkpoint_3 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 3") @@ -945,11 +836,12 @@ def test_snapshots_of_published_unit(self): # 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, + content_api.create_next_container_version( + unit.pk, title="Unit title in checkpoint 4", - components=[self.component_1_v1, self.component_1, self.component_2], + entities=[self.component_1_v1, self.component_1, self.component_2], created=self.now, + created_by=None, ) # Publish everything, creating Checkpoint 4 checkpoint_4 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 4") @@ -960,21 +852,21 @@ def test_snapshots_of_published_unit(self): 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] == [ + _, as_of_checkpoint_1 = content_api.get_entities_in_container_as_of(unit, checkpoint_1.pk) + assert [ev.entity_version.componentversion.title for ev 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] == [ + _, as_of_checkpoint_2 = content_api.get_entities_in_container_as_of(unit, checkpoint_2.pk) + assert [ev.entity_version.componentversion.title for ev 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] == [ + _, as_of_checkpoint_3 = content_api.get_entities_in_container_as_of(unit, checkpoint_3.pk) + assert [ev.entity_version.componentversion.title for ev 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] == [ + _, as_of_checkpoint_4 = content_api.get_entities_in_container_as_of(unit, checkpoint_4.pk) + assert [ev.entity_version.componentversion.title for ev 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 @@ -991,31 +883,39 @@ def test_units_containing(self): # 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") + unit1_1pinned = self.create_unit(entities=[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") + unit2_1pinned_v2 = self.create_unit(entities=[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") + _unit3_no = self.create_unit(entities=[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") + unit4_unpinned = self.create_unit(entities= + [ + 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") + _unit5_no = self.create_unit(entities=[self.component_2_v1, self.component_2], key="u5") + _unit6_no = self.create_unit(entities=[], 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") + unit7_several = self.create_unit(entities= + [ + 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") - ] + result = list(content_api.get_containers_with_entity(self.component_1.pk)) assert result == [ unit1_1pinned, unit2_1pinned_v2, @@ -1027,32 +927,31 @@ def test_units_containing(self): # 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") - ] + result2 = list(content_api.get_containers_with_entity(self.component_1.pk, ignore_pinned=True)) assert result2 == [unit4_unpinned, unit7_several] - def test_get_components_in_unit_queries(self): + def test_get_entities_in_container_queries(self): """ - Test the query count of get_components_in_unit() + Test the query count of get_entities_in_container() 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) + unit = self.create_unit(entities= + [ + self.component_1, + self.component_2, + self.component_2_v1, + ] + ) + with self.assertNumQueries(2): + result = content_api.get_entities_in_container(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) + with self.assertNumQueries(2): + result = content_api.get_entities_in_container(unit, published=True) assert result == [ Entry(self.component_1.versioning.draft), Entry(self.component_2.versioning.draft), @@ -1063,15 +962,12 @@ 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, + unit, unit_version = self.create_unit_and_version( key="unit:key", title="Unit", - components=[self.component_1], - created=self.now, - created_by=None, + entities=[self.component_1], ) - assert content_api.get_components_in_unit(unit, published=False) == [ + assert content_api.get_entities_in_container(unit, published=False) == [ Entry(self.component_1.versioning.draft), ] component_3, _ = self.create_component( @@ -1079,10 +975,13 @@ def test_add_remove_container_children(self): title="Querying Counting Problem (3)", ) # Add component_2 and component_3 - unit_version_v2 = content_api.create_next_unit_version( - unit=unit, + unit_version_v2 = content_api.create_next_container_version( + unit.pk, title=unit_version.title, - components=[self.component_2, component_3], + entities=[ + self.component_2, + component_3, + ], created=self.now, created_by=None, entities_action=content_api.ChildrenEntitiesAction.APPEND, @@ -1091,24 +990,24 @@ def test_add_remove_container_children(self): 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) == [ + assert content_api.get_entities_in_container(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, + content_api.create_next_container_version( + unit.pk, title=unit_version.title, - components=[self.component_1], + entities=[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) == [ + assert content_api.get_entities_in_container(unit, published=False) == [ Entry(self.component_2.versioning.draft), Entry(component_3.versioning.draft), ] @@ -1117,35 +1016,35 @@ def test_get_container_children_count(self): """ Test get_container_children_count() """ - unit = self.create_unit_with_components([self.component_1]) - assert content_api.get_container_children_count(unit.container, published=False) == 1 + unit = self.create_unit(entities=[self.component_1]) + assert content_api.get_container_children_count(unit, published=False) == 1 # publish content_api.publish_all_drafts(self.learning_package.id) unit_version = unit.versioning.draft - content_api.create_next_unit_version( - unit=unit, + content_api.create_next_container_version( + unit.pk, title=unit_version.title, - components=[self.component_2], + entities=[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 + assert content_api.get_container_children_count(unit, published=False) == 2 + assert content_api.get_container_children_count(unit, 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 + assert content_api.get_container_children_count(unit, 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 + assert content_api.get_container_children_count(unit, published=False) == 1 content_api.publish_all_drafts(self.learning_package.id) unit.refresh_from_db() - assert content_api.get_container_children_count(unit.container, published=True) == 1 + assert content_api.get_container_children_count(unit, published=True) == 1 # Tests TODO: # Test that I can get a [PublishLog] history of a given unit and all its children, including children that aren't diff --git a/tests/test_django_app/__init__.py b/tests/test_django_app/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_django_app/apps.py b/tests/test_django_app/apps.py deleted file mode 100644 index e4a20f4d..00000000 --- a/tests/test_django_app/apps.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -Test Django 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" diff --git a/tests/test_django_app/models.py b/tests/test_django_app/models.py deleted file mode 100644 index 94e16dce..00000000 --- a/tests/test_django_app/models.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Models that are only for use in tests -""" - -from openedx_content.applets.publishing.models import Container - - -class TestContainer(Container): - """ - A fake subclass of Container used for test purposes. - """ - - CONTAINER_TYPE = "fake_test" - - class Meta: - app_label = "openedx_content" From 0fc800c5ea75dc4b8d68ab22d9055d69d5a7b9bb Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 10 Mar 2026 15:05:27 -0700 Subject: [PATCH 06/34] test: revert "test: add concrete TestContainer model" --- test_settings.py | 24 ------------------- .../applets/publishing/test_api.py | 11 --------- 2 files changed, 35 deletions(-) diff --git a/test_settings.py b/test_settings.py index 19635329..371903ad 100644 --- a/test_settings.py +++ b/test_settings.py @@ -97,27 +97,3 @@ 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", -] \ No newline at end of file diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index a32bf5d4..c0f11378 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -27,16 +27,6 @@ User = get_user_model() -class TestContainer(Container): - """ - A fake subclass of Container used for test purposes. - """ - CONTAINER_TYPE = "fake_test" - - class Meta: - app_label = "openedx_content" - - class LearningPackageTestCase(TestCase): """ Test creating a LearningPackage @@ -1078,7 +1068,6 @@ def test_bulk_parent_child_side_effects(self) -> None: "my_container", created=self.now, created_by=None, - container_cls=TestContainer, ) container_v1: ContainerVersion = publishing_api.create_container_version( container.pk, From cd2e1a6666dbc5a82abd1931cb77e8a091769f9f Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 10 Mar 2026 15:04:39 -0700 Subject: [PATCH 07/34] chore: update the rest to match --- .../applets/backup_restore/zipper.py | 164 ++++++++---------- src/openedx_content/applets/publishing/api.py | 23 ++- .../applets/backup_restore/test_backup.py | 3 +- .../applets/backup_restore/test_restore.py | 6 +- .../applets/collections/test_api.py | 9 +- .../applets/publishing/container_test_case.py | 53 ++++-- .../applets/publishing/test_api.py | 61 ++++--- .../applets/sections/test_api.py | 2 +- .../openedx_content/applets/units/test_api.py | 2 +- 9 files changed, 173 insertions(+), 150 deletions(-) diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 4c096386..6925fead 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -33,6 +33,9 @@ from ..components import api as components_api from ..media import api as media_api from ..publishing import api as publishing_api +from ..units.models import Unit +from ..subsections.models import Subsection +from ..sections.models import Section from .serializers import ( CollectionSerializer, ComponentSerializer, @@ -801,70 +804,69 @@ 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.""" @@ -885,47 +887,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/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 4f0341c9..fea522a0 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -16,6 +16,7 @@ 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 openedx_django_lib.fields import create_hash_digest @@ -75,6 +76,7 @@ # 🛑 UNSTABLE: All APIs related to containers are unstable until we've figured # out our approach to dynamic content (randomized, A/B tests, etc.) "ContainerTypeRecord", + "ContainerTypeImplementation", "ContainerType", "create_container", "create_container_version", @@ -1391,6 +1393,18 @@ def get_type_record(cls) -> ContainerTypeRecord: 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(cti: type[ContainerTypeImplementation]): @@ -1590,7 +1604,14 @@ def _create_container_version( create_next_container_version(). """ # validate entity_list using the type implementation: - container_type = ContainerTypeImplementation.for_code(container.container_type_record.type_code) + try: + container_type = ContainerTypeImplementation.for_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 ContainerTypeImplementation.reset_cache() because the cache contains ' + 'a reference to a row that no longer exists after the test DB has been truncated. ' + ) from exc for entity_row in entity_list.rows: try: container_type.validate_entity(entity_row.entity) diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py index 74175395..21cf7c5b 100644 --- a/tests/openedx_content/applets/backup_restore/test_backup.py +++ b/tests/openedx_content/applets/backup_restore/test_backup.py @@ -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=api.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..29a2e8e6 100644 --- a/tests/openedx_content/applets/collections/test_api.py +++ b/tests/openedx_content/applets/collections/test_api.py @@ -16,9 +16,9 @@ CollectionPublishableEntity, Component, ComponentType, + Container, LearningPackage, PublishableEntity, - Unit, ) User = get_user_model() @@ -226,7 +226,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 +246,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=api.Unit, ) # Make and publish one Component @@ -445,7 +446,7 @@ def test_get_collection_containers(self): assert list(api.get_collection_containers( self.learning_package.id, self.collection2.key, - )) == [self.draft_unit.container] + )) == [self.draft_unit] assert not list(api.get_collection_containers( self.learning_package.id, self.collection3.key, diff --git a/tests/openedx_content/applets/publishing/container_test_case.py b/tests/openedx_content/applets/publishing/container_test_case.py index 7dded9f9..7ac27058 100644 --- a/tests/openedx_content/applets/publishing/container_test_case.py +++ b/tests/openedx_content/applets/publishing/container_test_case.py @@ -3,15 +3,32 @@ """ from functools import partial +from typing import override import openedx_content.api as content_api -from openedx_content import models_api as authoring_models +from openedx_content import models_api as content_models from ..components.test_api import ComponentTestCase +class GenericContainer(content_api.ContainerTypeImplementation): + """ + A Test Container that can hold anything + """ + + type_code = "test" + + @override + @classmethod + def validate_entity(cls, entity: content_models.PublishableEntity) -> None: + """Allow any type of child""" + + +content_api.ContainerTypeImplementation.register(GenericContainer) + + def Entry( - component_version: authoring_models.PublishableEntityVersionMixin, + component_version: content_models.PublishableEntityVersionMixin, pinned: bool = False, ) -> content_api.ContainerEntityListEntry: """Helper for quickly constructing ContainerEntityListEntry entries""" @@ -34,7 +51,7 @@ def setUp(self) -> None: def create_component( self, *, title: str = "Test Component", key: str = "component:1" - ) -> tuple[authoring_models.Component, authoring_models.ComponentVersion]: + ) -> tuple[content_models.Component, content_models.ComponentVersion]: """Helper method to quickly create a component""" return content_api.create_component_and_version( self.learning_package.id, @@ -49,16 +66,16 @@ def create_container_and_version( self, *, entities: list[ - authoring_models.Component - | authoring_models.ComponentVersion - | authoring_models.Container - | authoring_models.ContainerVersion + content_models.Component + | content_models.ComponentVersion + | content_models.Container + | content_models.ContainerVersion ] | None = None, container_type: content_api.ContainerType, title: str | None = None, key: str | None = None, - ) -> authoring_models.Container: + ) -> content_models.Container: """Helper method to quickly create a container with some child entities""" container, version = content_api.create_container_and_version( learning_package_id=self.learning_package.id, @@ -75,16 +92,16 @@ def create_container( self, *, entities: list[ - authoring_models.Component - | authoring_models.ComponentVersion - | authoring_models.Container - | authoring_models.ContainerVersion + content_models.Component + | content_models.ComponentVersion + | content_models.Container + | content_models.ContainerVersion ] | None = None, container_type: content_api.ContainerType, title: str | None = None, key: str | None = None, - ) -> authoring_models.Container: + ) -> content_models.Container: """Helper method to quickly create a container with some components""" container, _version = self.create_container_and_version( entities=entities, container_type=container_type, title=title, key=key @@ -93,11 +110,11 @@ def create_container( def modify_component( self, - component: authoring_models.Component, + component: content_models.Component, *, title="Modified Component", timestamp=None, - ) -> authoring_models.ComponentVersion: + ) -> content_models.ComponentVersion: """ Helper method to modify a component for the purposes of testing units/drafts/pinning/publishing/etc. """ @@ -111,11 +128,11 @@ def modify_component( def modify_container( self, - container: authoring_models.Container, + container: content_models.Container, *, title="", timestamp=None, - ) -> authoring_models.ContainerVersion: + ) -> content_models.ContainerVersion: """ Helper method to modify a unit for the purposes of testing units/drafts/pinning/publishing/etc. """ @@ -126,7 +143,7 @@ def modify_container( created_by=None, ) - def publish_container(self, container: authoring_models.Container): + def publish_container(self, container: content_models.Container): """ Helper method to publish a single container. """ diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index c0f11378..7615ab4e 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -24,6 +24,9 @@ PublishLog, ) + +from .container_test_case import GenericContainer + User = get_user_model() @@ -954,6 +957,10 @@ def setUpTestData(cls) -> None: created=cls.now, ) + def tearDown(self): + publishing_api.ContainerTypeImplementation.reset_cache() + 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( @@ -987,14 +994,15 @@ def test_parent_child_side_effects(self) -> None: "my_container", created=self.now, created_by=None, + container_type=GenericContainer, ) 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), + entities=[ + child_1, + child_2, ], created=self.now, created_by=None, @@ -1068,15 +1076,13 @@ def test_bulk_parent_child_side_effects(self) -> None: "my_container", created=self.now, created_by=None, + container_type=GenericContainer, ) 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), - ], + entities=[child_1, child_2], created=self.now, created_by=None, ) @@ -1137,19 +1143,17 @@ def test_draft_dependency_multiple_parents(self): 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, + self.learning_package.id, "unit_1", created=self.now, created_by=None, container_type=GenericContainer, ) unit_2 = publishing_api.create_container( - self.learning_package.id, "unit_2", created=self.now, created_by=None, + self.learning_package.id, "unit_2", created=self.now, created_by=None, container_type=GenericContainer, ) 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), - ], + entities=[component], created=self.now, created_by=None, ) @@ -1183,27 +1187,25 @@ def test_multiple_layers_of_containers(self): ) unit = publishing_api.create_container( self.learning_package.id, "unit_1", created=self.now, created_by=None, + container_type=GenericContainer, ) publishing_api.create_container_version( unit.pk, 1, title="My Unit", - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=component.pk), - ], + entities=[component], created=self.now, created_by=None, ) subsection = publishing_api.create_container( self.learning_package.id, "subsection_1", created=self.now, created_by=None, + container_type=GenericContainer, ) publishing_api.create_container_version( subsection.pk, 1, title="My Subsection", - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=unit.pk), - ], + entities=[unit], created=self.now, created_by=None, ) @@ -1262,28 +1264,26 @@ def test_publish_all_layers(self): 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, + self.learning_package.id, "unit_1", created=self.now, created_by=None, container_type=GenericContainer, ) publishing_api.create_container_version( unit.pk, 1, title="My Unit", - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=component.pk), - ], + entities=[component], created=self.now, created_by=None, ) subsection = publishing_api.create_container( - self.learning_package.id, "subsection_1", created=self.now, created_by=None, + self.learning_package.id, + "subsection_1", + created=self.now, created_by=None, container_type=GenericContainer, ) publishing_api.create_container_version( subsection.pk, 1, title="My Subsection", - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=unit.pk), - ], + entities=[unit], created=self.now, created_by=None, ) @@ -1309,12 +1309,13 @@ def test_container_next_version(self): "my_container", created=self.now, created_by=None, + container_type=GenericContainer, ) assert container.versioning.latest is None v1 = publishing_api.create_next_container_version( container.pk, title="My Container v1", - entity_rows=None, + entities=None, created=self.now, created_by=None, ) @@ -1323,9 +1324,7 @@ def test_container_next_version(self): v2 = publishing_api.create_next_container_version( container.pk, title="My Container v2", - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=child_1.pk) - ], + entities=[child_1], created=self.now, created_by=None, ) @@ -1335,7 +1334,7 @@ def test_container_next_version(self): v3 = publishing_api.create_next_container_version( container.pk, title="My Container v3", - entity_rows=None, + entities=None, created=self.now, created_by=None, ) diff --git a/tests/openedx_content/applets/sections/test_api.py b/tests/openedx_content/applets/sections/test_api.py index 507eceb3..80d4e7d4 100644 --- a/tests/openedx_content/applets/sections/test_api.py +++ b/tests/openedx_content/applets/sections/test_api.py @@ -622,7 +622,7 @@ def test_snapshots_of_published_section(self): section = self.create_section(entities=[self.subsection_1]) self.modify_container(self.subsection_1, title="Subsection 1 as of checkpoint 1") _, before_publish = content_api.get_entities_in_container_as_of(section, 0) - assert before_publish == [] + assert not before_publish # empty list # Publish everything, creating Checkpoint 1 checkpoint_1 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 1") diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 02815a6b..485f721f 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -807,7 +807,7 @@ def test_snapshots_of_published_unit(self): unit = self.create_unit(entities=[self.component_1]) self.modify_component(self.component_1, title="Component 1 as of checkpoint 1") _, before_publish = content_api.get_entities_in_container_as_of(unit, 0) - assert before_publish == [] # Empty list + assert not before_publish # Empty list # Publish everything, creating Checkpoint 1 checkpoint_1 = content_api.publish_all_drafts(self.learning_package.id, message="checkpoint 1") From bc3e931214f5273e330637dd29b52b2782f0eca3 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 10 Mar 2026 17:08:36 -0700 Subject: [PATCH 08/34] refactor: Unit -> UnitType, etc. --- src/openedx_content/api.py | 6 +++--- .../applets/backup_restore/zipper.py | 18 +++++++++--------- src/openedx_content/applets/sections/models.py | 10 +++++----- .../applets/subsections/models.py | 10 +++++----- src/openedx_content/applets/units/models.py | 6 +++--- .../applets/backup_restore/test_backup.py | 2 +- .../applets/collections/test_api.py | 2 +- .../applets/publishing/container_test_case.py | 12 ++++++------ .../applets/subsections/test_api.py | 18 +++++++++--------- .../openedx_content/applets/units/test_api.py | 2 +- 10 files changed, 43 insertions(+), 43 deletions(-) diff --git a/src/openedx_content/api.py b/src/openedx_content/api.py index 7daeda40..497aa342 100644 --- a/src/openedx_content/api.py +++ b/src/openedx_content/api.py @@ -15,6 +15,6 @@ from .applets.components.api import * from .applets.media.api import * from .applets.publishing.api import * -from .applets.sections.models import Section # Note this isn't a model. Should we move it? -from .applets.subsections.models import Subsection # Note this isn't a model. Should we move it? -from .applets.units.models import Unit # Note this isn't a model. Should we move it? +from .applets.sections.models import SectionType # Note this isn't a model. Should we move it? +from .applets.subsections.models import SubsectionType # Note this isn't a model. Should we move it? +from .applets.units.models import UnitType # Note this isn't a model. Should we move it? diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 6925fead..670838e3 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 ..units.models import Unit -from ..subsections.models import Subsection -from ..sections.models import Section +from ..units.models import UnitType +from ..subsections.models import SubsectionType +from ..sections.models import SectionType from .serializers import ( CollectionSerializer, ComponentSerializer, @@ -843,7 +843,7 @@ def _save_units(self, learning_package, containers): self._save_container( learning_package, containers, - container_type=Unit, + container_type=UnitType, container_map=self.units_map_by_key, children_map=self.components_map_by_key, ) @@ -853,7 +853,7 @@ def _save_subsections(self, learning_package, containers): self._save_container( learning_package, containers, - container_type=Subsection, + container_type=SubsectionType, container_map=self.subsections_map_by_key, children_map=self.units_map_by_key, ) @@ -863,7 +863,7 @@ def _save_sections(self, learning_package, containers): self._save_container( learning_package, containers, - container_type=Section, + container_type=SectionType, container_map=self.sections_map_by_key, children_map=self.subsections_map_by_key, ) @@ -907,9 +907,9 @@ def _process_draft_containers( created_by=self.user_id, ) - _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) + _process_draft_containers(UnitType, self.units_map_by_key, children_map=self.components_map_by_key) + _process_draft_containers(SubsectionType, self.subsections_map_by_key, children_map=self.units_map_by_key) + _process_draft_containers(SectionType, self.sections_map_by_key, children_map=self.subsections_map_by_key) # -------------------------- # Utilities diff --git a/src/openedx_content/applets/sections/models.py b/src/openedx_content/applets/sections/models.py index f4ee706e..73c9204a 100644 --- a/src/openedx_content/applets/sections/models.py +++ b/src/openedx_content/applets/sections/models.py @@ -8,14 +8,14 @@ from ..publishing.api import ContainerTypeImplementation, get_container_type from ..publishing.models import PublishableEntity -from ..subsections.models import Subsection +from ..subsections.models import SubsectionType __all__ = [ - "Section", + "SectionType", ] -class Section(ContainerTypeImplementation): +class SectionType(ContainerTypeImplementation): """ A Section is type of Container that holds Subsections. @@ -31,10 +31,10 @@ 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(container) is not SubsectionType: raise ValidationError("Only Subsection can be added as children of a Section") # validate settings -ContainerTypeImplementation.register(Section) +ContainerTypeImplementation.register(SectionType) diff --git a/src/openedx_content/applets/subsections/models.py b/src/openedx_content/applets/subsections/models.py index 29279674..629f14d1 100644 --- a/src/openedx_content/applets/subsections/models.py +++ b/src/openedx_content/applets/subsections/models.py @@ -8,14 +8,14 @@ from ..publishing.api import ContainerTypeImplementation, get_container_type from ..publishing.models import PublishableEntity -from ..units.models import Unit +from ..units.models import UnitType __all__ = [ - "Subsection", + "SubsectionType", ] -class Subsection(ContainerTypeImplementation): +class SubsectionType(ContainerTypeImplementation): """ A Subsection is type of Container that holds Units. @@ -31,10 +31,10 @@ 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(container) is not UnitType: raise ValidationError("Only Units can be added as children of a Subsection") # validate settings -ContainerTypeImplementation.register(Subsection) +ContainerTypeImplementation.register(SubsectionType) diff --git a/src/openedx_content/applets/units/models.py b/src/openedx_content/applets/units/models.py index 686a5842..16a95b26 100644 --- a/src/openedx_content/applets/units/models.py +++ b/src/openedx_content/applets/units/models.py @@ -8,11 +8,11 @@ from ..publishing.models import PublishableEntity __all__ = [ - "Unit", + "UnitType", ] -class Unit(ContainerTypeImplementation): +class UnitType(ContainerTypeImplementation): """ A Unit is type of Container that holds Components. @@ -32,4 +32,4 @@ def validate_entity(cls, entity: PublishableEntity) -> None: # validate settings -ContainerTypeImplementation.register(Unit) +ContainerTypeImplementation.register(UnitType) diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py index 21cf7c5b..7e897e4d 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=api.Unit, + container_type=api.UnitType, ) 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 29a2e8e6..aa408b55 100644 --- a/tests/openedx_content/applets/collections/test_api.py +++ b/tests/openedx_content/applets/collections/test_api.py @@ -251,7 +251,7 @@ def setUpTestData(cls) -> None: key="unit-1", created=created_time, created_by=cls.user.id, - container_type=api.Unit, + container_type=api.UnitType, ) # Make and publish one Component diff --git a/tests/openedx_content/applets/publishing/container_test_case.py b/tests/openedx_content/applets/publishing/container_test_case.py index 7ac27058..444b06c7 100644 --- a/tests/openedx_content/applets/publishing/container_test_case.py +++ b/tests/openedx_content/applets/publishing/container_test_case.py @@ -40,14 +40,14 @@ class ContainerTestCase(ComponentTestCase): def setUp(self) -> None: super().setUp() - self.create_unit = partial(self.create_container, container_type=content_api.Unit) - self.create_unit_and_version = partial(self.create_container_and_version, container_type=content_api.Unit) - self.create_subsection = partial(self.create_container, container_type=content_api.Subsection) + self.create_unit = partial(self.create_container, container_type=content_api.UnitType) + self.create_unit_and_version = partial(self.create_container_and_version, container_type=content_api.UnitType) + self.create_subsection = partial(self.create_container, container_type=content_api.SubsectionType) self.create_subsection_and_version = partial( - self.create_container_and_version, container_type=content_api.Subsection + self.create_container_and_version, container_type=content_api.SubsectionType ) - self.create_section = partial(self.create_container, container_type=content_api.Section) - self.create_section_and_version = partial(self.create_container_and_version, container_type=content_api.Section) + self.create_section = partial(self.create_container, container_type=content_api.SectionType) + self.create_section_and_version = partial(self.create_container_and_version, container_type=content_api.SectionType) def create_component( self, *, title: str = "Test Component", key: str = "component:1" diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py index 2e6fa258..cefe60c2 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -95,7 +95,7 @@ def test_create_subsection_with_invalid_children(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.Subsection, + container_type=content_api.SubsectionType, ) assert subsection.versioning.draft == subsection_version subsection2, _s2v1 = content_api.create_container_and_version( @@ -104,7 +104,7 @@ def test_create_subsection_with_invalid_children(self): title="Subsection 2", created=self.now, created_by=None, - container_type=content_api.Subsection, + container_type=content_api.SubsectionType, ) # Try adding a Subsection to a Subsection with pytest.raises( @@ -135,7 +135,7 @@ def test_adding_external_units(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.Subsection, + container_type=content_api.SubsectionType, ) assert self.unit_1.publishable_entity.learning_package != learning_package2 # Try adding a a unit from LP 1 (self.learning_package) to a subsection from LP 2 @@ -187,7 +187,7 @@ def test_create_empty_subsection_and_version(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.Subsection, + container_type=content_api.SubsectionType, ) assert subsection, subsection_version assert subsection_version.version_num == 1 @@ -212,7 +212,7 @@ def test_create_next_subsection_version_with_two_unpinned_units(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.Subsection, + container_type=content_api.SubsectionType, ) subsection_version_v2 = content_api.create_next_container_version( subsection.pk, @@ -241,7 +241,7 @@ def test_create_next_subsection_version_forcing_version_num(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.Subsection, + container_type=content_api.SubsectionType, ) subsection_version_v2 = content_api.create_next_container_version( subsection.pk, @@ -263,7 +263,7 @@ def test_create_next_subsection_version_with_unpinned_and_pinned_units(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.Subsection, + container_type=content_api.SubsectionType, ) subsection_version_v2 = content_api.create_next_container_version( subsection.pk, @@ -346,7 +346,7 @@ def test_add_unit_after_publish(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.Subsection, + container_type=content_api.SubsectionType, ) assert subsection.versioning.draft == subsection_version assert subsection.versioning.published is None @@ -972,7 +972,7 @@ def test_add_remove_container_children(self): entities=[self.unit_1], created=self.now, created_by=None, - container_type=content_api.Subsection, + container_type=content_api.SubsectionType, ) assert content_api.get_entities_in_container(subsection, published=False) == [ Entry(self.unit_1.versioning.draft), diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 485f721f..2d57b8de 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -142,7 +142,7 @@ def test_adding_external_components(self): title="Unit", created=self.now, created_by=None, - container_type=content_api.Unit, + container_type=content_api.UnitType, ) 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 From 663b343eaaa0bb9c55111b281c795d2ae95a2df1 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 11 Mar 2026 09:51:59 -0700 Subject: [PATCH 09/34] chore: quality issues --- src/openedx_content/api.py | 2 +- src/openedx_content/applets/backup_restore/zipper.py | 1 + src/openedx_content/applets/publishing/api.py | 2 +- src/openedx_content/applets/publishing/models/container.py | 2 +- src/openedx_content/applets/units/models.py | 3 ++- src/openedx_content/models_api.py | 1 - .../openedx_content/applets/publishing/container_test_case.py | 4 +++- 7 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/openedx_content/api.py b/src/openedx_content/api.py index 497aa342..473ce6ed 100644 --- a/src/openedx_content/api.py +++ b/src/openedx_content/api.py @@ -9,7 +9,7 @@ """ # These wildcard imports are okay because these api modules declare __all__. -# pylint: disable=wildcard-import +# pylint: disable=wildcard-import, unused-import from .applets.backup_restore.api import * from .applets.collections.api import * from .applets.components.api import * diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index 670838e3..f8ceb92c 100644 --- a/src/openedx_content/applets/backup_restore/zipper.py +++ b/src/openedx_content/applets/backup_restore/zipper.py @@ -808,6 +808,7 @@ def _save_container( self, learning_package, containers, + *, container_type: publishing_api.ContainerType, container_map: dict, children_map: dict, diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index fea522a0..fd3d9473 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -1393,7 +1393,7 @@ def get_type_record(cls) -> ContainerTypeRecord: 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: diff --git a/src/openedx_content/applets/publishing/models/container.py b/src/openedx_content/applets/publishing/models/container.py index 4fd1a68a..d8cf52db 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/publishing/models/container.py @@ -41,7 +41,7 @@ class Meta: ), ] - def __str__(self) -> str: + def __str__(self) -> str: # pylint: disable=invalid-str-returned return self.type_code diff --git a/src/openedx_content/applets/units/models.py b/src/openedx_content/applets/units/models.py index 16a95b26..f888f1b6 100644 --- a/src/openedx_content/applets/units/models.py +++ b/src/openedx_content/applets/units/models.py @@ -27,7 +27,8 @@ class UnitType(ContainerTypeImplementation): 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: - getattr(entity, "component") # Could raise PublishableEntity.component.RelatedObjectDoesNotExist + # This could raise PublishableEntity.component.RelatedObjectDoesNotExist + getattr(entity, "component") # pylint: disable=literal-used-as-attribute # validate settings diff --git a/src/openedx_content/models_api.py b/src/openedx_content/models_api.py index c46dd2d0..7f3b0d7d 100644 --- a/src/openedx_content/models_api.py +++ b/src/openedx_content/models_api.py @@ -11,4 +11,3 @@ from .applets.components.models import * from .applets.media.models import * from .applets.publishing.models import * - diff --git a/tests/openedx_content/applets/publishing/container_test_case.py b/tests/openedx_content/applets/publishing/container_test_case.py index 444b06c7..1e68596f 100644 --- a/tests/openedx_content/applets/publishing/container_test_case.py +++ b/tests/openedx_content/applets/publishing/container_test_case.py @@ -47,7 +47,9 @@ def setUp(self) -> None: self.create_container_and_version, container_type=content_api.SubsectionType ) self.create_section = partial(self.create_container, container_type=content_api.SectionType) - self.create_section_and_version = partial(self.create_container_and_version, container_type=content_api.SectionType) + self.create_section_and_version = partial( + self.create_container_and_version, container_type=content_api.SectionType + ) def create_component( self, *, title: str = "Test Component", key: str = "component:1" From 14a56bd0da8cbb503bc69ac2a3303bcd61e6f915 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 11 Mar 2026 11:18:19 -0700 Subject: [PATCH 10/34] chore: quality issues --- src/openedx_content/applets/publishing/api.py | 7 ++++--- .../applets/publishing/container_test_case.py | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index fd3d9473..73735d99 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -1362,7 +1362,7 @@ def get_published_version_as_of(entity_id: int, publish_log_id: int) -> Publisha ######################################################################################################################## -_registered_container_types: dict[str, ContainerTypeImplementation] = {} +_registered_container_types: dict[str, type[ContainerTypeImplementation]] = {} class ContainerTypeImplementation: @@ -1371,6 +1371,7 @@ class ContainerTypeImplementation: """ type_code: str # e.g. "unit" + _type_instance: ContainerTypeRecord # Cache used by get_type_record() @classmethod def validate_entity(cls, entity: PublishableEntity) -> None: @@ -1472,7 +1473,7 @@ def parse(entities: EntityListInput) -> list[ParsedEntityReference]: if isinstance(obj, PublishableEntityMixin): try: obj = obj.publishable_entity - except obj.__class__.publishable_entity.RelatedObjectDoesNotExist as exc: + 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 @@ -1726,7 +1727,7 @@ def create_container_and_version( container_type=container_type, ) container_version = create_container_version( - container, + container.pk, 1, title=title, entities=entities or [], diff --git a/tests/openedx_content/applets/publishing/container_test_case.py b/tests/openedx_content/applets/publishing/container_test_case.py index 1e68596f..584d7081 100644 --- a/tests/openedx_content/applets/publishing/container_test_case.py +++ b/tests/openedx_content/applets/publishing/container_test_case.py @@ -77,13 +77,14 @@ def create_container_and_version( container_type: content_api.ContainerType, title: str | None = None, key: str | None = None, - ) -> content_models.Container: + ) -> tuple[content_models.Container, content_models.ContainerVersion]: """Helper method to quickly create a container with some child entities""" container, version = content_api.create_container_and_version( learning_package_id=self.learning_package.id, key=key or f"{container_type.type_code}:key", title=title or f"Test {container_type.type_code}", - entities=entities, + entities=entities, # type: ignore[arg-type] + # ^ mypy doesn't realize these list[Container] and list[PublishableEntityMixin] types are compatible? created=self.now, created_by=None, container_type=container_type, From 3bfd40ad394ae67120c5a2aaa39850c893a3fbc1 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 11 Mar 2026 11:20:29 -0700 Subject: [PATCH 11/34] chore: formatting --- src/openedx_content/applets/publishing/api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 73735d99..82b08806 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -1609,9 +1609,9 @@ def _create_container_version( container_type = ContainerTypeImplementation.for_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 ContainerTypeImplementation.reset_cache() because the cache contains ' - 'a reference to a row that no longer exists after the test DB has been truncated. ' + "Existing ContainerTypeRecord is now missing. " + "Likely your test case needs to call ContainerTypeImplementation.reset_cache() because the cache contains " + "a reference to a row that no longer exists after the test DB has been truncated. " ) from exc for entity_row in entity_list.rows: try: From bb770e68190116c081659dc55799d5efc13bf5ee Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 11 Mar 2026 11:31:52 -0700 Subject: [PATCH 12/34] chore: format --- .../openedx_content/applets/subsections/test_api.py | 8 ++++---- tests/openedx_content/applets/units/test_api.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py index cefe60c2..9ff02e05 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -905,8 +905,8 @@ def test_subsections_containing(self): # Subsection 3 doesn't contain it _subsection3_no = self.create_subsection(entities=[self.unit_2], key="u3") # Subsection 4 ✅ has unit 1, unpinned - subsection4_unpinned = self.create_subsection(entities= - [ + subsection4_unpinned = self.create_subsection( + entities=[ self.unit_1, self.unit_2, self.unit_2_v1, @@ -938,8 +938,8 @@ def test_get_entities_in_container_queries(self): """ Test the query count of get_entities_in_container() """ - subsection = self.create_subsection(entities= - [ + subsection = self.create_subsection( + entities=[ self.unit_1, self.unit_2, self.unit_2_v1, diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 2d57b8de..5a92c4e1 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -889,8 +889,8 @@ def test_units_containing(self): # Unit 3 doesn't contain it _unit3_no = self.create_unit(entities=[self.component_2], key="u3") # Unit 4 ✅ has component 1, unpinned - unit4_unpinned = self.create_unit(entities= - [ + unit4_unpinned = self.create_unit( + entities=[ self.component_1, self.component_2, self.component_2_v1, @@ -902,8 +902,8 @@ def test_units_containing(self): _unit6_no = self.create_unit(entities=[], 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(entities= - [ + unit7_several = self.create_unit( + entities=[ self.component_2, self.component_1, self.component_1_v1, @@ -935,8 +935,8 @@ def test_get_entities_in_container_queries(self): Test the query count of get_entities_in_container() This also tests the generic method get_entities_in_container() """ - unit = self.create_unit(entities= - [ + unit = self.create_unit( + entities=[ self.component_1, self.component_2, self.component_2_v1, From 161145c1079e809cc443a5f43ebcf455fe7c63f7 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 11 Mar 2026 14:04:23 -0700 Subject: [PATCH 13/34] WIP - restore the type-specific models --- src/openedx_content/api.py | 6 +- .../applets/backup_restore/zipper.py | 18 ++-- src/openedx_content/applets/publishing/api.py | 84 ++----------------- .../applets/publishing/models/container.py | 64 +++++++++++++- .../applets/sections/models.py | 47 ++++++++--- .../applets/subsections/models.py | 46 ++++++++-- src/openedx_content/applets/units/models.py | 43 ++++++++-- src/openedx_content/apps.py | 9 ++ .../0006_remove_empty_container_models.py | 30 ------- src/openedx_content/models.py | 3 + src/openedx_content/models_api.py | 3 + test_settings.py | 26 ++++++ .../applets/backup_restore/test_backup.py | 4 +- .../applets/collections/test_api.py | 3 +- .../applets/publishing/container_test_case.py | 28 ++----- .../applets/publishing/test_api.py | 2 +- .../openedx_content/applets/units/test_api.py | 22 ++--- tests/test_django_app/__init__.py | 0 tests/test_django_app/apps.py | 14 ++++ .../migrations/0001_initial.py | 26 ++++++ tests/test_django_app/migrations/__init__.py | 0 tests/test_django_app/models.py | 22 +++++ 22 files changed, 321 insertions(+), 179 deletions(-) delete mode 100644 src/openedx_content/migrations/0006_remove_empty_container_models.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/api.py b/src/openedx_content/api.py index 473ce6ed..ff1c4cc7 100644 --- a/src/openedx_content/api.py +++ b/src/openedx_content/api.py @@ -15,6 +15,6 @@ from .applets.components.api import * from .applets.media.api import * from .applets.publishing.api import * -from .applets.sections.models import SectionType # Note this isn't a model. Should we move it? -from .applets.subsections.models import SubsectionType # Note this isn't a model. Should we move it? -from .applets.units.models import UnitType # Note this isn't a model. Should we move it? +from .applets.sections.models import * +from .applets.subsections.models import * +from .applets.units.models import * diff --git a/src/openedx_content/applets/backup_restore/zipper.py b/src/openedx_content/applets/backup_restore/zipper.py index f8ceb92c..4482e25b 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 ..units.models import UnitType -from ..subsections.models import SubsectionType -from ..sections.models import SectionType +from ..units.models import Unit +from ..subsections.models import Subsection +from ..sections.models import Section from .serializers import ( CollectionSerializer, ComponentSerializer, @@ -844,7 +844,7 @@ def _save_units(self, learning_package, containers): self._save_container( learning_package, containers, - container_type=UnitType, + container_type=Unit, container_map=self.units_map_by_key, children_map=self.components_map_by_key, ) @@ -854,7 +854,7 @@ def _save_subsections(self, learning_package, containers): self._save_container( learning_package, containers, - container_type=SubsectionType, + container_type=Subsection, container_map=self.subsections_map_by_key, children_map=self.units_map_by_key, ) @@ -864,7 +864,7 @@ def _save_sections(self, learning_package, containers): self._save_container( learning_package, containers, - container_type=SectionType, + container_type=Section, container_map=self.sections_map_by_key, children_map=self.subsections_map_by_key, ) @@ -908,9 +908,9 @@ def _process_draft_containers( created_by=self.user_id, ) - _process_draft_containers(UnitType, self.units_map_by_key, children_map=self.components_map_by_key) - _process_draft_containers(SubsectionType, self.subsections_map_by_key, children_map=self.units_map_by_key) - _process_draft_containers(SectionType, self.sections_map_by_key, children_map=self.subsections_map_by_key) + _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/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 82b08806..1f1cf9b4 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -75,8 +75,6 @@ "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.) - "ContainerTypeRecord", - "ContainerTypeImplementation", "ContainerType", "create_container", "create_container_version", @@ -1361,72 +1359,6 @@ def get_published_version_as_of(entity_id: int, publish_log_id: int) -> Publisha ######################################################################################################################## - -_registered_container_types: dict[str, type[ContainerTypeImplementation]] = {} - - -class ContainerTypeImplementation: - """ - Abstract base class for container type implementations (Unit, Subsection, etc.) - """ - - type_code: str # e.g. "unit" - _type_instance: ContainerTypeRecord # Cache used by get_type_record() - - @classmethod - def validate_entity(cls, entity: PublishableEntity) -> None: - """Check if the given entity is allowed as a child of this Container type""" - - @final - def __init__(self): - raise TypeError("ContainerType and its subclasses should not be initialized") - - @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 ContainerTypeImplementation: - raise TypeError('Manipulating "naked" Containers is not allowed. Use a specific ContainerType like Unit.') - assert cls.type_code, f"ContainerTypeImplementation 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(cti: type[ContainerTypeImplementation]): - assert cti.type_code, "ContainerTypeImplementation subclasses must override type_code" - assert cti.type_code not in _registered_container_types, f"{cti.type_code} already registered" - _registered_container_types[cti.type_code] = cti - - @staticmethod - def for_code(type_code: str) -> type[ContainerTypeImplementation]: - """ - Get the subclass for the specified container type_code. - """ - try: - return _registered_container_types[type_code] - except KeyError as exc: - raise ValueError( - 'An implementation for "{type_code}" containers is not currently installed. ' - "Such containers can be read but not modified." - ) from exc - - @dataclass(frozen=True) class ContainerEntityListEntry: """ @@ -1445,7 +1377,7 @@ def entity(self): EntityListInput = list[ PublishableEntity | PublishableEntityMixin | PublishableEntityVersion | PublishableEntityVersionMixin ] -ContainerType = type[ContainerTypeImplementation] +ContainerType = type[Container] @dataclass(frozen=True, kw_only=True, slots=True) @@ -1513,7 +1445,7 @@ def create_container( Returns: The newly created container. """ - assert issubclass(container_type, ContainerTypeImplementation) + assert issubclass(container_type, Container) with atomic(): publishable_entity = create_publishable_entity( learning_package_id, @@ -1522,7 +1454,7 @@ def create_container( created_by, can_stand_alone=can_stand_alone, ) - container = Container.objects.create( + container = container_type.objects.create( publishable_entity=publishable_entity, container_type_record=container_type.get_type_record(), ) @@ -1606,13 +1538,14 @@ def _create_container_version( """ # validate entity_list using the type implementation: try: - container_type = ContainerTypeImplementation.for_code(container.container_type_record.type_code) + 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 ContainerTypeImplementation.reset_cache() because the cache contains " + "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) @@ -1632,10 +1565,11 @@ def _create_container_version( created_by=created_by, dependencies=[entity_row.entity_id for entity_row in entity_list.rows if entity_row.is_unpinned()], ) - container_version = ContainerVersion.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 @@ -1943,7 +1877,7 @@ def get_container_type(container: Container | int, /) -> ContainerType: Will raise a ValueError if the type is not currently installed. """ type_code = get_container_type_code(container) - return ContainerTypeImplementation.for_code(type_code) + return Container.subclass_for_type_code(type_code) def get_containers( diff --git a/src/openedx_content/applets/publishing/models/container.py b/src/openedx_content/applets/publishing/models/container.py index d8cf52db..02d3b181 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/publishing/models/container.py @@ -4,13 +4,18 @@ 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, tuple[type[Container], type[ContainerVersion]]] = {} class ContainerTypeRecord(models.Model): @@ -57,6 +62,9 @@ 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, @@ -65,6 +73,60 @@ class Container(PublishableEntityMixin): 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 + + @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 ValueError( + '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/models.py b/src/openedx_content/applets/sections/models.py index 73c9204a..f4939799 100644 --- a/src/openedx_content/applets/sections/models.py +++ b/src/openedx_content/applets/sections/models.py @@ -1,23 +1,24 @@ """ Models that implement sections """ - from typing import override from django.core.exceptions import ValidationError +from django.db import models -from ..publishing.api import ContainerTypeImplementation, get_container_type -from ..publishing.models import PublishableEntity -from ..subsections.models import SubsectionType +from ..publishing.models import Container, ContainerVersion, PublishableEntity +from ..publishing.api import get_container_type +from ..subsections.models import Subsection __all__ = [ - "SectionType", + "Section", + "SectionVersion", ] -class SectionType(ContainerTypeImplementation): +class Section(Container): """ - A Section is type of Container that holds Subsections. + A Section is type of Container that holds Units. Via Container and its PublishableEntityMixin, Sections are also publishable entities and can be added to other containers. @@ -25,16 +26,42 @@ class SectionType(ContainerTypeImplementation): type_code = "section" + container = models.OneToOneField( + Container, + on_delete=models.CASCADE, + parent_link=True, + 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 SubsectionType: + if get_container_type(container) is not Subsection: raise ValidationError("Only Subsection can be added as children of a Section") - # validate settings +class SectionVersion(ContainerVersion): + """ + A SectionVersion is a specific version of a Section. + + Via ContainerVersion and its EntityList, it defines the list of Units + in this version of the Section. + """ + container_version = models.OneToOneField( + ContainerVersion, + on_delete=models.CASCADE, + parent_link=True, + primary_key=True, + ) + + @property + def section(self): + """ Convenience accessor to the Section this version is associated with """ + return self.container_version.container.section # pylint: disable=no-member -ContainerTypeImplementation.register(SectionType) + # 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/models.py b/src/openedx_content/applets/subsections/models.py index 629f14d1..e98efa93 100644 --- a/src/openedx_content/applets/subsections/models.py +++ b/src/openedx_content/applets/subsections/models.py @@ -5,17 +5,19 @@ from typing import override from django.core.exceptions import ValidationError +from django.db import models -from ..publishing.api import ContainerTypeImplementation, get_container_type -from ..publishing.models import PublishableEntity -from ..units.models import UnitType +from ..publishing.models import Container, ContainerVersion, PublishableEntity +from ..publishing.api import get_container_type +from ..units.models import Unit __all__ = [ - "SubsectionType", + "Subsection", + "SubsectionVersion", ] -class SubsectionType(ContainerTypeImplementation): +class Subsection(Container): """ A Subsection is type of Container that holds Units. @@ -25,16 +27,44 @@ class SubsectionType(ContainerTypeImplementation): type_code = "subsection" + container = models.OneToOneField( + Container, + on_delete=models.CASCADE, + parent_link=True, + 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 UnitType: + if get_container_type(container) is not Unit: raise ValidationError("Only Units can be added as children of a Subsection") - # validate settings +Container.register_subclass(Subsection) + + +class SubsectionVersion(ContainerVersion): + """ + A SubsectionVersion is a specific version of a Subsection. + + 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, + parent_link=True, + primary_key=True, + ) + @property + def subsection(self): + """ Convenience accessor to the Subsection this version is associated with """ + return self.container_version.container.subsection # pylint: disable=no-member -ContainerTypeImplementation.register(SubsectionType) + # 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/models.py b/src/openedx_content/applets/units/models.py index f888f1b6..f0c0aec1 100644 --- a/src/openedx_content/applets/units/models.py +++ b/src/openedx_content/applets/units/models.py @@ -3,16 +3,17 @@ """ from typing import override +from django.db import models -from ..publishing.api import ContainerTypeImplementation -from ..publishing.models import PublishableEntity +from ..publishing.models import Container, ContainerVersion, PublishableEntity __all__ = [ - "UnitType", + "Unit", + "UnitVersion", ] -class UnitType(ContainerTypeImplementation): +class Unit(Container): """ A Unit is type of Container that holds Components. @@ -22,6 +23,13 @@ class UnitType(ContainerTypeImplementation): type_code = "unit" + container = models.OneToOneField( + Container, + on_delete=models.CASCADE, + parent_link=True, + primary_key=True, + ) + @override @classmethod def validate_entity(cls, entity: PublishableEntity) -> None: @@ -30,7 +38,30 @@ def validate_entity(cls, entity: PublishableEntity) -> None: # This could raise PublishableEntity.component.RelatedObjectDoesNotExist getattr(entity, "component") # pylint: disable=literal-used-as-attribute - # validate settings +Container.register_subclass(Unit) + + +class UnitVersion(ContainerVersion): + """ + A UnitVersion is a specific version of a Unit. + + 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, + parent_link=True, + primary_key=True, + ) + + @property + def unit(self): + """Convenience accessor to the Unit this version is associated with""" + return self.container_version.container.unit # pylint: disable=no-member -ContainerTypeImplementation.register(UnitType) + # 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/apps.py b/src/openedx_content/apps.py index 3f259277..563d5d03 100644 --- a/src/openedx_content/apps.py +++ b/src/openedx_content/apps.py @@ -30,9 +30,18 @@ def register_publishable_models(self): ComponentVersion, Container, ContainerVersion, + Section, + SectionVersion, + Subsection, + SubsectionVersion, + Unit, + UnitVersion, ) register_publishable_models(Component, ComponentVersion) register_publishable_models(Container, ContainerVersion) + register_publishable_models(Section, SectionVersion) + register_publishable_models(Subsection, SubsectionVersion) + register_publishable_models(Unit, UnitVersion) def ready(self): """ diff --git a/src/openedx_content/migrations/0006_remove_empty_container_models.py b/src/openedx_content/migrations/0006_remove_empty_container_models.py deleted file mode 100644 index 7a91535e..00000000 --- a/src/openedx_content/migrations/0006_remove_empty_container_models.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.2.11 on 2026-03-10 19:51 - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("openedx_content", "0005_containertypes"), - ] - - operations = [ - migrations.DeleteModel( - name="Section", - ), - migrations.DeleteModel( - name="SectionVersion", - ), - migrations.DeleteModel( - name="Subsection", - ), - migrations.DeleteModel( - name="SubsectionVersion", - ), - migrations.DeleteModel( - name="Unit", - ), - migrations.DeleteModel( - name="UnitVersion", - ), - ] diff --git a/src/openedx_content/models.py b/src/openedx_content/models.py index 6e009e39..91696b5f 100644 --- a/src/openedx_content/models.py +++ b/src/openedx_content/models.py @@ -12,3 +12,6 @@ from .applets.components.models import * from .applets.media.models import * from .applets.publishing.models import * +from .applets.sections.models import * +from .applets.subsections.models import * +from .applets.units.models import * diff --git a/src/openedx_content/models_api.py b/src/openedx_content/models_api.py index 7f3b0d7d..1e035b43 100644 --- a/src/openedx_content/models_api.py +++ b/src/openedx_content/models_api.py @@ -11,3 +11,6 @@ from .applets.components.models import * from .applets.media.models import * from .applets.publishing.models import * +from .applets.sections.models import * +from .applets.subsections.models import * +from .applets.units.models import * diff --git a/test_settings.py b/test_settings.py index 371903ad..cc1a0515 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", +] \ No newline at end of file diff --git a/tests/openedx_content/applets/backup_restore/test_backup.py b/tests/openedx_content/applets/backup_restore/test_backup.py index 7e897e4d..6364d6a8 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() @@ -163,7 +163,7 @@ def setUpTestData(cls): key="unit-1", created=cls.now, created_by=cls.user.id, - container_type=api.UnitType, + container_type=api.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 aa408b55..9d65b56d 100644 --- a/tests/openedx_content/applets/collections/test_api.py +++ b/tests/openedx_content/applets/collections/test_api.py @@ -19,6 +19,7 @@ Container, LearningPackage, PublishableEntity, + Unit, ) User = get_user_model() @@ -251,7 +252,7 @@ def setUpTestData(cls) -> None: key="unit-1", created=created_time, created_by=cls.user.id, - container_type=api.UnitType, + container_type=Unit, ) # Make and publish one Component diff --git a/tests/openedx_content/applets/publishing/container_test_case.py b/tests/openedx_content/applets/publishing/container_test_case.py index 584d7081..33bfca56 100644 --- a/tests/openedx_content/applets/publishing/container_test_case.py +++ b/tests/openedx_content/applets/publishing/container_test_case.py @@ -11,22 +11,6 @@ from ..components.test_api import ComponentTestCase -class GenericContainer(content_api.ContainerTypeImplementation): - """ - A Test Container that can hold anything - """ - - type_code = "test" - - @override - @classmethod - def validate_entity(cls, entity: content_models.PublishableEntity) -> None: - """Allow any type of child""" - - -content_api.ContainerTypeImplementation.register(GenericContainer) - - def Entry( component_version: content_models.PublishableEntityVersionMixin, pinned: bool = False, @@ -40,15 +24,15 @@ class ContainerTestCase(ComponentTestCase): def setUp(self) -> None: super().setUp() - self.create_unit = partial(self.create_container, container_type=content_api.UnitType) - self.create_unit_and_version = partial(self.create_container_and_version, container_type=content_api.UnitType) - self.create_subsection = partial(self.create_container, container_type=content_api.SubsectionType) + self.create_unit = partial(self.create_container, container_type=content_models.Unit) + self.create_unit_and_version = partial(self.create_container_and_version, container_type=content_models.Unit) + self.create_subsection = partial(self.create_container, container_type=content_models.Subsection) self.create_subsection_and_version = partial( - self.create_container_and_version, container_type=content_api.SubsectionType + self.create_container_and_version, container_type=content_models.Subsection ) - self.create_section = partial(self.create_container, container_type=content_api.SectionType) + self.create_section = partial(self.create_container, container_type=content_models.Section) self.create_section_and_version = partial( - self.create_container_and_version, container_type=content_api.SectionType + self.create_container_and_version, container_type=content_models.Section ) def create_component( diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index 7615ab4e..bd71062b 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -958,7 +958,7 @@ def setUpTestData(cls) -> None: ) def tearDown(self): - publishing_api.ContainerTypeImplementation.reset_cache() + Container.reset_cache() return super().tearDown() def test_parent_child_side_effects(self) -> None: diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 5a92c4e1..4400795f 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -7,13 +7,13 @@ 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 import models_api as content_models from ..publishing.container_test_case import ContainerTestCase, Entry @ddt.ddt -class SubSectionTestCase(ContainerTestCase): +class UnitsTestCase(ContainerTestCase): """Test cases for Units (containers of components)""" def setUp(self) -> None: @@ -34,7 +34,7 @@ def test_get_container(self): unit = self.create_unit(entities=[self.component_1, self.component_2]) with self.assertNumQueries(1): result = content_api.get_container(unit.pk) - assert result == unit + assert result == unit.container # Versioning data should be pre-loaded via select_related() with self.assertNumQueries(0): assert result.versioning.has_unpublished_changes @@ -85,7 +85,7 @@ def test_get_container_by_key(self): self.learning_package.id, key=unit.publishable_entity.key, ) - assert result == unit + assert result.unit == unit # Versioning data should be pre-loaded via select_related() with self.assertNumQueries(0): assert result.versioning.has_unpublished_changes @@ -95,9 +95,9 @@ 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(23): + with self.assertNumQueries(27): _empty_unit = self.create_unit(entities=[]) - with self.assertNumQueries(31): + with self.assertNumQueries(35): # And try with a non-empty unit: self.create_unit(entities=[self.component_1, self.component_2_v1], key="u2") @@ -142,7 +142,7 @@ def test_adding_external_components(self): title="Unit", created=self.now, created_by=None, - container_type=content_api.UnitType, + container_type=content_models.Unit, ) 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 @@ -161,7 +161,7 @@ def test_add_deleted_component(self): Mostly this checks that the exception thrown is reasonable. """ self.component_1.delete() - with pytest.raises(authoring_models.Component.DoesNotExist): + with pytest.raises(content_models.Component.DoesNotExist): self.create_unit(entities=[self.component_1]) def test_add_corrupted_component(self): @@ -227,7 +227,7 @@ def test_create_next_unit_version_with_two_unpinned_components(self): Entry(self.component_1.versioning.draft), Entry(self.component_2.versioning.draft), ] - with pytest.raises(authoring_models.ContainerVersion.DoesNotExist): + with pytest.raises(content_models.ContainerVersion.DoesNotExist): # There is no published version of the unit: content_api.get_entities_in_container(unit, published=True) @@ -252,7 +252,7 @@ def test_create_next_unit_version_with_unpinned_and_pinned_components(self): Entry(self.component_1_v1), Entry(self.component_2_v1, pinned=True), # Pinned 📌 to v1 ] - with pytest.raises(authoring_models.ContainerVersion.DoesNotExist): + with pytest.raises(content_models.ContainerVersion.DoesNotExist): # There is no published version of the unit: content_api.get_entities_in_container(unit, published=True) @@ -321,7 +321,7 @@ def test_no_publish_parent(self): 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): + with pytest.raises(content_models.ContainerVersion.DoesNotExist): # There is no published version of the unit: content_api.get_entities_in_container(unit, published=True) 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..e4a20f4d --- /dev/null +++ b/tests/test_django_app/apps.py @@ -0,0 +1,14 @@ +""" +Test Django 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" 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..f0a98a0d --- /dev/null +++ b/tests/test_django_app/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.11 on 2026-03-11 21:08 + +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='GenericContainer', + fields=[ + ('container_ptr', models.OneToOneField(auto_created=True, 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',), + ), + ] 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..6c274c28 --- /dev/null +++ b/tests/test_django_app/models.py @@ -0,0 +1,22 @@ +""" +Models that are only for use in tests +""" +from typing import override + +from openedx_content.models_api import Container, PublishableEntity + + +class GenericContainer(Container): + """ + A Test Container that can hold anything + """ + + type_code = "test" + + @override + @classmethod + def validate_entity(cls, entity: PublishableEntity) -> None: + """Allow any type of child""" + + +Container.register_subclass(GenericContainer) From d80761efc43a5b57784355afdae8c83b9e780725 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 12 Mar 2026 10:04:38 -0700 Subject: [PATCH 14/34] WIP --- src/openedx_content/applets/publishing/api.py | 13 +++++++---- .../applets/publishing/models/container.py | 1 + src/openedx_content/applets/units/models.py | 4 +--- .../applets/publishing/container_test_case.py | 1 - .../openedx_content/applets/units/test_api.py | 23 ++++++++++++------- 5 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 1f1cf9b4..35ba5813 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum -from typing import final, ContextManager, Optional +from typing import ContextManager, Optional, TypeVar from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models import F, Prefetch, Q, QuerySet @@ -44,6 +44,11 @@ ) 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) + # 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 @@ -1427,9 +1432,9 @@ def create_container( created: datetime, created_by: int | None, *, + container_type: type[ContainerModel], can_stand_alone: bool = True, - container_type: ContainerType, -) -> Container: +) -> ContainerModel: """ [ 🛑 UNSTABLE ] Create a new container. @@ -1439,8 +1444,8 @@ def create_container( key: The key of the container. created: The date and time the container was created. created_by: The ID of the user who created the container - can_stand_alone: Set to False when created as part of containers 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. diff --git a/src/openedx_content/applets/publishing/models/container.py b/src/openedx_content/applets/publishing/models/container.py index 02d3b181..122aef7c 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/publishing/models/container.py @@ -113,6 +113,7 @@ def register_subclass(container_type: type[Container]): 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]: diff --git a/src/openedx_content/applets/units/models.py b/src/openedx_content/applets/units/models.py index f0c0aec1..dbdcdda5 100644 --- a/src/openedx_content/applets/units/models.py +++ b/src/openedx_content/applets/units/models.py @@ -13,6 +13,7 @@ ] +@Container.register_subclass class Unit(Container): """ A Unit is type of Container that holds Components. @@ -39,9 +40,6 @@ def validate_entity(cls, entity: PublishableEntity) -> None: getattr(entity, "component") # pylint: disable=literal-used-as-attribute -Container.register_subclass(Unit) - - class UnitVersion(ContainerVersion): """ A UnitVersion is a specific version of a Unit. diff --git a/tests/openedx_content/applets/publishing/container_test_case.py b/tests/openedx_content/applets/publishing/container_test_case.py index 33bfca56..fb14df2e 100644 --- a/tests/openedx_content/applets/publishing/container_test_case.py +++ b/tests/openedx_content/applets/publishing/container_test_case.py @@ -3,7 +3,6 @@ """ from functools import partial -from typing import override import openedx_content.api as content_api from openedx_content import models_api as content_models diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 4400795f..67e734d4 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -44,10 +44,10 @@ def test_get_container_version(self): Test get_container_version() """ unit = self.create_unit(entities=[]) - draft = unit.versioning.draft + unit_draft = unit.versioning.draft with self.assertNumQueries(1): - result = content_api.get_container_version(draft.pk) - assert result == draft + result = content_api.get_container_version(unit_draft.pk) + assert result == unit_draft.container_version def test_get_containers(self): """ @@ -56,7 +56,7 @@ def test_get_containers(self): unit = self.create_unit(entities=[]) with self.assertNumQueries(1): result = list(content_api.get_containers(self.learning_package.id)) - assert result == [unit] + assert result == [unit.container] # Versioning data should be pre-loaded via select_related() with self.assertNumQueries(0): assert result[0].versioning.has_unpublished_changes @@ -68,7 +68,7 @@ def test_get_containers_deleted(self): unit = self.create_unit(entities=[]) content_api.soft_delete_draft(unit.pk) with self.assertNumQueries(1): - result = list(content_api.get_containers(self.learning_package.id, include_deleted=True)) + result = [c.unit for c in content_api.get_containers(self.learning_package.id, include_deleted=True).select_related("unit")] assert result == [unit] with self.assertNumQueries(1): @@ -127,7 +127,7 @@ def test_create_unit_with_invalid_children(self): ) # Check that a new version was not created: unit.refresh_from_db() - assert content_api.get_container(unit.pk).versioning.draft == unit_version + assert content_api.get_container(unit.pk).versioning.draft == unit_version.container_version assert unit.versioning.draft == unit_version def test_adding_external_components(self): @@ -915,7 +915,9 @@ def test_units_containing(self): # No need to publish anything as the get_containers_with_entity() API only considers drafts (for now). with self.assertNumQueries(1): - result = list(content_api.get_containers_with_entity(self.component_1.pk)) + 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, @@ -927,7 +929,12 @@ def test_units_containing(self): # about pinned uses anyways (they would be unaffected by a delete). with self.assertNumQueries(1): - result2 = list(content_api.get_containers_with_entity(self.component_1.pk, ignore_pinned=True)) + 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_entities_in_container_queries(self): From 6e8a63c86e1afbe2aca0325f2e8660c7dc2ee8b3 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 12 Mar 2026 10:17:58 -0700 Subject: [PATCH 15/34] WIP --- .../applets/subsections/test_api.py | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py index 9ff02e05..a76542a8 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -7,7 +7,7 @@ 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 import models_api as content_models from ..publishing.container_test_case import ContainerTestCase, Entry @@ -28,7 +28,7 @@ def test_get_containers(self): subsection = self.create_subsection(entities=[]) with self.assertNumQueries(1): result = list(content_api.get_containers(self.learning_package.id)) - assert result == [self.unit_1, self.unit_2, subsection] + 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 @@ -41,11 +41,11 @@ def test_get_containers_deleted(self): 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, self.unit_2, subsection] + 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, self.unit_2] + assert result == [self.unit_1.container, self.unit_2.container] def test_get_container(self): """ @@ -77,9 +77,9 @@ 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(23): + with self.assertNumQueries(27): _empty_subsection = self.create_subsection(entities=[]) - with self.assertNumQueries(33): + with self.assertNumQueries(37): # And try with a non-empty subsection: self.create_subsection(entities=[self.unit_1, self.unit_2_v1], key="u2") @@ -95,7 +95,7 @@ def test_create_subsection_with_invalid_children(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.SubsectionType, + container_type=content_models.Subsection, ) assert subsection.versioning.draft == subsection_version subsection2, _s2v1 = content_api.create_container_and_version( @@ -104,7 +104,7 @@ def test_create_subsection_with_invalid_children(self): title="Subsection 2", created=self.now, created_by=None, - container_type=content_api.SubsectionType, + container_type=content_models.Subsection, ) # Try adding a Subsection to a Subsection with pytest.raises( @@ -120,7 +120,7 @@ def test_create_subsection_with_invalid_children(self): assert "Only Units can be added as children of a Subsection" in str(exc.value.__cause__) # Check that a new version was not created: subsection.refresh_from_db() - assert content_api.get_container(subsection.pk).versioning.draft == subsection_version + assert content_api.get_container(subsection.pk).versioning.draft == subsection_version.container_version assert subsection.versioning.draft == subsection_version def test_adding_external_units(self): @@ -135,7 +135,7 @@ def test_adding_external_units(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.SubsectionType, + container_type=content_models.Subsection, ) assert self.unit_1.publishable_entity.learning_package != learning_package2 # Try adding a a unit from LP 1 (self.learning_package) to a subsection from LP 2 @@ -153,8 +153,10 @@ def test_cannot_add_deleted_container(self): Test that non-existent units cannot be added to subsections """ self.unit_1.delete() - with pytest.raises(authoring_models.Container.DoesNotExist): + with pytest.raises(ValidationError) as exc: self.create_subsection(entities=[self.unit_1]) + # With the cause being more specific + assert "PublishableEntity has no container" in str(exc.value.__cause__) def test_cannot_add_corrupted_unit(self): """ @@ -170,7 +172,7 @@ def test_cannot_add_corrupted_unit(self): ) as exc: self.create_subsection(entities=[self.unit_1_v1]) # And the exception should be chained from a more specific exception: - assert isinstance(exc.value.__cause__, authoring_models.Container.DoesNotExist) + assert isinstance(exc.value.__cause__, content_models.Container.DoesNotExist) def test_create_empty_subsection_and_version(self): """Test creating a subsection with no units. @@ -187,7 +189,7 @@ def test_create_empty_subsection_and_version(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.SubsectionType, + container_type=content_models.Subsection, ) assert subsection, subsection_version assert subsection_version.version_num == 1 @@ -212,7 +214,7 @@ def test_create_next_subsection_version_with_two_unpinned_units(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.SubsectionType, + container_type=content_models.Subsection, ) subsection_version_v2 = content_api.create_next_container_version( subsection.pk, @@ -227,7 +229,7 @@ def test_create_next_subsection_version_with_two_unpinned_units(self): Entry(self.unit_1.versioning.draft), Entry(self.unit_2.versioning.draft), ] - with pytest.raises(authoring_models.ContainerVersion.DoesNotExist): + with pytest.raises(content_models.ContainerVersion.DoesNotExist): # There is no published version of the subsection: content_api.get_entities_in_container(subsection, published=True) @@ -241,7 +243,7 @@ def test_create_next_subsection_version_forcing_version_num(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.SubsectionType, + container_type=content_models.Subsection, ) subsection_version_v2 = content_api.create_next_container_version( subsection.pk, @@ -263,7 +265,7 @@ def test_create_next_subsection_version_with_unpinned_and_pinned_units(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.SubsectionType, + container_type=content_models.Subsection, ) subsection_version_v2 = content_api.create_next_container_version( subsection.pk, @@ -278,7 +280,7 @@ def test_create_next_subsection_version_with_unpinned_and_pinned_units(self): Entry(self.unit_1_v1), Entry(self.unit_2_v1, pinned=True), # Pinned 📌 to v1 ] - with pytest.raises(authoring_models.ContainerVersion.DoesNotExist): + with pytest.raises(content_models.ContainerVersion.DoesNotExist): # There is no published version of the subsection: content_api.get_entities_in_container(subsection, published=True) @@ -331,7 +333,7 @@ def test_no_publish_parent(self): 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(content_models.ContainerVersion.DoesNotExist): # There is no published version of the subsection: content_api.get_entities_in_container(subsection, published=True) @@ -346,7 +348,7 @@ def test_add_unit_after_publish(self): title="Subsection", created=self.now, created_by=None, - container_type=content_api.SubsectionType, + container_type=content_models.Subsection, ) assert subsection.versioning.draft == subsection_version assert subsection.versioning.published is None @@ -468,7 +470,7 @@ def test_create_two_subsections_with_same_units(self): # Check that the contents are as expected: assert [ - row.entity_version.containerversion + row.entity_version.containerversion.unitversion for row in content_api.get_entities_in_container(subsection1, published=False) ] == [ self.unit_2_v1, @@ -476,7 +478,7 @@ def test_create_two_subsections_with_same_units(self): self.unit_1_v1, ] assert [ - row.entity_version.containerversion + row.entity_version.containerversion.unitversion for row in content_api.get_entities_in_container(subsection2, published=False) ] == [ self.unit_1_v1, @@ -920,7 +922,7 @@ def test_subsections_containing(self): # No need to publish anything as the get_containers_with_entity() API only considers drafts (for now). with self.assertNumQueries(1): - result = list(content_api.get_containers_with_entity(self.unit_1.pk)) + 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, @@ -931,7 +933,7 @@ def test_subsections_containing(self): # about pinned uses anyways (they would be unaffected by a delete). with self.assertNumQueries(1): - result2 = list(content_api.get_containers_with_entity(self.unit_1.pk, ignore_pinned=True)) + 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_entities_in_container_queries(self): @@ -972,7 +974,7 @@ def test_add_remove_container_children(self): entities=[self.unit_1], created=self.now, created_by=None, - container_type=content_api.SubsectionType, + container_type=content_models.Subsection, ) assert content_api.get_entities_in_container(subsection, published=False) == [ Entry(self.unit_1.versioning.draft), From 3f81e4dcee4219c2a818dd1e597d013693599f7d Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 12 Mar 2026 10:23:27 -0700 Subject: [PATCH 16/34] WIP --- src/openedx_content/api.py | 6 ++-- .../applets/subsections/test_api.py | 29 +++++++++++++++++-- .../openedx_content/applets/units/test_api.py | 24 ++++++++++++++- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/openedx_content/api.py b/src/openedx_content/api.py index ff1c4cc7..59964e5f 100644 --- a/src/openedx_content/api.py +++ b/src/openedx_content/api.py @@ -15,6 +15,6 @@ from .applets.components.api import * from .applets.media.api import * from .applets.publishing.api import * -from .applets.sections.models import * -from .applets.subsections.models import * -from .applets.units.models import * +# from .applets.sections.api import * +# from .applets.subsections.api import * +# from .applets.units.api import * diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py index a76542a8..9ed8751e 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -72,6 +72,23 @@ def test_get_container_by_key(self): 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(entities=[self.unit_1, self.unit_2]) + assert isinstance(subsection, content_models.Subsection) + container = subsection.container + assert container.__class__ is content_models.Container + container_version = container.versioning.draft + assert isinstance(container_version, content_models.ContainerVersion) + subsection_version = subsection.versioning.draft + assert isinstance(subsection_version, content_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 @@ -922,7 +939,10 @@ def test_subsections_containing(self): # 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")] + 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, @@ -933,7 +953,12 @@ def test_subsections_containing(self): # 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")] + 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_entities_in_container_queries(self): diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 67e734d4..ac24417b 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -68,7 +68,12 @@ def test_get_containers_deleted(self): unit = self.create_unit(entities=[]) content_api.soft_delete_draft(unit.pk) with self.assertNumQueries(1): - result = [c.unit for c in content_api.get_containers(self.learning_package.id, include_deleted=True).select_related("unit")] + result = [ + c.unit + for c in content_api.get_containers(self.learning_package.id, include_deleted=True).select_related( + "unit" + ) + ] assert result == [unit] with self.assertNumQueries(1): @@ -90,6 +95,23 @@ def test_get_container_by_key(self): 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(entities=[self.component_1, self.component_2]) + assert isinstance(unit, content_models.Unit) + container = unit.container + assert container.__class__ is content_models.Container + container_version = container.versioning.draft + assert isinstance(container_version, content_models.ContainerVersion) + unit_version = unit.versioning.draft + assert isinstance(unit_version, content_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 From 9c335f61cf2fac8b5618c340436432eb18b1c9a0 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 12 Mar 2026 14:22:57 -0700 Subject: [PATCH 17/34] WIP - refactoring test cases --- .../applets/publishing/container_test_case.py | 139 ---- .../applets/publishing/test_api.py | 409 ------------ .../applets/publishing/test_containers.py | 601 ++++++++++++++++++ .../applets/sections/test_api.py | 2 +- .../applets/subsections/test_api.py | 2 +- .../openedx_content/applets/units/test_api.py | 4 +- tests/test_django_app/apps.py | 29 + .../migrations/0001_initial.py | 81 ++- tests/test_django_app/models.py | 59 +- 9 files changed, 763 insertions(+), 563 deletions(-) delete mode 100644 tests/openedx_content/applets/publishing/container_test_case.py create mode 100644 tests/openedx_content/applets/publishing/test_containers.py diff --git a/tests/openedx_content/applets/publishing/container_test_case.py b/tests/openedx_content/applets/publishing/container_test_case.py deleted file mode 100644 index fb14df2e..00000000 --- a/tests/openedx_content/applets/publishing/container_test_case.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -Basic tests for the units API. -""" - -from functools import partial - -import openedx_content.api as content_api -from openedx_content import models_api as content_models - -from ..components.test_api import ComponentTestCase - - -def Entry( - component_version: content_models.PublishableEntityVersionMixin, - pinned: bool = False, -) -> content_api.ContainerEntityListEntry: - """Helper for quickly constructing ContainerEntityListEntry entries""" - return content_api.ContainerEntityListEntry(component_version.publishable_entity_version, pinned=pinned) - - -class ContainerTestCase(ComponentTestCase): - """Base class with useful functions for testing containers. Has no tests on its own.""" - - def setUp(self) -> None: - super().setUp() - self.create_unit = partial(self.create_container, container_type=content_models.Unit) - self.create_unit_and_version = partial(self.create_container_and_version, container_type=content_models.Unit) - self.create_subsection = partial(self.create_container, container_type=content_models.Subsection) - self.create_subsection_and_version = partial( - self.create_container_and_version, container_type=content_models.Subsection - ) - self.create_section = partial(self.create_container, container_type=content_models.Section) - self.create_section_and_version = partial( - self.create_container_and_version, container_type=content_models.Section - ) - - def create_component( - self, *, title: str = "Test Component", key: str = "component:1" - ) -> tuple[content_models.Component, content_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_container_and_version( - self, - *, - entities: list[ - content_models.Component - | content_models.ComponentVersion - | content_models.Container - | content_models.ContainerVersion - ] - | None = None, - container_type: content_api.ContainerType, - title: str | None = None, - key: str | None = None, - ) -> tuple[content_models.Container, content_models.ContainerVersion]: - """Helper method to quickly create a container with some child entities""" - container, version = content_api.create_container_and_version( - learning_package_id=self.learning_package.id, - key=key or f"{container_type.type_code}:key", - title=title or f"Test {container_type.type_code}", - entities=entities, # type: ignore[arg-type] - # ^ mypy doesn't realize these list[Container] and list[PublishableEntityMixin] types are compatible? - created=self.now, - created_by=None, - container_type=container_type, - ) - return container, version - - def create_container( - self, - *, - entities: list[ - content_models.Component - | content_models.ComponentVersion - | content_models.Container - | content_models.ContainerVersion - ] - | None = None, - container_type: content_api.ContainerType, - title: str | None = None, - key: str | None = None, - ) -> content_models.Container: - """Helper method to quickly create a container with some components""" - container, _version = self.create_container_and_version( - entities=entities, container_type=container_type, title=title, key=key - ) - return container - - def modify_component( - self, - component: content_models.Component, - *, - title="Modified Component", - timestamp=None, - ) -> content_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 modify_container( - self, - container: content_models.Container, - *, - title="", - timestamp=None, - ) -> content_models.ContainerVersion: - """ - Helper method to modify a unit for the purposes of testing units/drafts/pinning/publishing/etc. - """ - return content_api.create_next_container_version( - container.pk, - title=title or f"Modified {content_api.get_container_type_code(container)}", - created=timestamp or self.now, - created_by=None, - ) - - def publish_container(self, container: content_models.Container): - """ - Helper method to publish a single container. - """ - content_api.publish_from_drafts( - self.learning_package.pk, - draft_qset=content_api.get_all_drafts(self.learning_package.pk).filter(entity=container.publishable_entity), - ) diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index bd71062b..bb7a1387 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -13,20 +13,14 @@ 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, ) - -from .container_test_case import GenericContainer - User = get_user_model() @@ -941,409 +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 tearDown(self): - Container.reset_cache() - 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: Container = publishing_api.create_container( - self.learning_package.id, - "my_container", - created=self.now, - created_by=None, - container_type=GenericContainer, - ) - container_v1: ContainerVersion = publishing_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: Container = publishing_api.create_container( - self.learning_package.id, - "my_container", - created=self.now, - created_by=None, - container_type=GenericContainer, - ) - container_v1: ContainerVersion = publishing_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): - """ - 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, container_type=GenericContainer, - ) - unit_2 = publishing_api.create_container( - self.learning_package.id, "unit_2", created=self.now, created_by=None, container_type=GenericContainer, - ) - for unit in [unit_1, unit_2]: - publishing_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): - """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, - container_type=GenericContainer, - ) - publishing_api.create_container_version( - unit.pk, - 1, - title="My Unit", - entities=[component], - created=self.now, - created_by=None, - ) - subsection = publishing_api.create_container( - self.learning_package.id, "subsection_1", created=self.now, created_by=None, - container_type=GenericContainer, - ) - publishing_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 - 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, container_type=GenericContainer, - ) - publishing_api.create_container_version( - unit.pk, - 1, - title="My Unit", - entities=[component], - created=self.now, - created_by=None, - ) - subsection = publishing_api.create_container( - self.learning_package.id, - "subsection_1", - created=self.now, created_by=None, container_type=GenericContainer, - ) - publishing_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): - """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, - container_type=GenericContainer, - ) - assert container.versioning.latest is None - v1 = publishing_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 = publishing_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 = publishing_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 - - 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..bf4cf50e --- /dev/null +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -0,0 +1,601 @@ +""" +Basic tests for the publishing containers API. +""" + +from datetime import datetime, timezone + +from django.test import TestCase + +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, + PublishableEntityVersionMixin, + PublishLog, +) +# 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 +# "GenericContainer" and "ContainerContainer" from test_django_app, which are specifically for testing the publishing +# API. + +from tests.test_django_app.models import GenericContainer, ContainerContainer + + +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) + + +class BaseContainerTestCase(TestCase): + """Base class with useful functions for testing containers. Has no tests on its own.""" + + learning_package: LearningPackage + now: datetime + + @classmethod + def setUpTestData(cls) -> None: + cls.learning_package = publishing_api.create_learning_package( + key="BaseContainerTestCase-test-key", + title="Learning Package for Testing Containers", + ) + cls.now = datetime(2026, 5, 8, tzinfo=timezone.utc) + + def tearDown(self) -> None: + Container.reset_cache() + super().tearDown() + + # def create_container_and_version( + # self, + # *, + # entities: list[ + # Component + # | ComponentVersion + # | Container + # | ContainerVersion + # ] + # | None = None, + # container_type: publishing_api.ContainerType, + # title: str | None = None, + # key: str | None = None, + # ) -> tuple[Container, ContainerVersion]: + # """Helper method to quickly create a container with some child entities""" + # container, version = publishing_api.create_container_and_version( + # learning_package_id=self.learning_package.id, + # key=key or f"{container_type.type_code}:key", + # title=title or f"Test {container_type.type_code}", + # entities=entities, # type: ignore[arg-type] + # # ^ mypy doesn't realize these list[Container] and list[PublishableEntityMixin] types are compatible? + # created=self.now, + # created_by=None, + # container_type=container_type, + # ) + # return container, version + + # def create_container( + # self, + # *, + # entities: list[ + # Component + # | ComponentVersion + # | Container + # | ContainerVersion + # ] + # | None = None, + # container_type: publishing_api.ContainerType, + # title: str | None = None, + # key: str | None = None, + # ) -> Container: + # """Helper method to quickly create a container with some components""" + # container, _version = self.create_container_and_version( + # entities=entities, container_type=container_type, title=title, key=key + # ) + # return container + + def modify_container( + self, + container: Container, + *, + title="", + timestamp=None, + ) -> ContainerVersion: + """ + Helper method to modify a unit for the purposes of testing units/drafts/pinning/publishing/etc. + """ + return publishing_api.create_next_container_version( + container.pk, + title=title or f"Modified {publishing_api.get_container_type_code(container)}", + created=timestamp or self.now, + created_by=None, + ) + + def publish_container(self, container: Container): + """ + Helper method to publish a single container. + """ + publishing_api.publish_from_drafts( + self.learning_package.pk, + draft_qset=publishing_api.get_all_drafts(self.learning_package.pk).filter( + entity=container.publishable_entity + ), + ) + + +class ContainersApiTestCase(BaseContainerTestCase): + """ + Tests related to the Containers API + """ + + # `create_container`, and `create_container_version` are not tested directly in this test suite, as they are used + # indirectly by `create_container_and_version`. They are also used explicitly in `ContainerSideEffectsTestCase`, + # below. + + # Basic tests: `create_container_and_version`, `get_container`, `get_container_version` + + # def test_create_and_get_container(self): + + + + + # 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 + # 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 + + +class ContainerSideEffectsTestCase(BaseContainerTestCase): + """ + Tests related to Container side effects and dependencies + """ + + 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_type=GenericContainer, + ) + container_v1: ContainerVersion = publishing_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: Container = publishing_api.create_container( + self.learning_package.id, + "my_container", + created=self.now, + created_by=None, + container_type=GenericContainer, + ) + container_v1: ContainerVersion = publishing_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): + """ + 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, + container_type=GenericContainer, + ) + unit_2 = publishing_api.create_container( + self.learning_package.id, + "unit_2", + created=self.now, + created_by=None, + container_type=GenericContainer, + ) + for unit in [unit_1, unit_2]: + publishing_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): + """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, + container_type=GenericContainer, + ) + publishing_api.create_container_version( + unit.pk, + 1, + title="My Unit", + entities=[component], + created=self.now, + created_by=None, + ) + subsection = publishing_api.create_container( + self.learning_package.id, + "subsection_1", + created=self.now, + created_by=None, + container_type=GenericContainer, + ) + publishing_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 + 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, + container_type=GenericContainer, + ) + publishing_api.create_container_version( + unit.pk, + 1, + title="My Unit", + entities=[component], + created=self.now, + created_by=None, + ) + subsection = publishing_api.create_container( + self.learning_package.id, + "subsection_1", + created=self.now, + created_by=None, + container_type=GenericContainer, + ) + publishing_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): + """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, + container_type=GenericContainer, + ) + assert container.versioning.latest is None + v1 = publishing_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 = publishing_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 = publishing_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 diff --git a/tests/openedx_content/applets/sections/test_api.py b/tests/openedx_content/applets/sections/test_api.py index 80d4e7d4..027012ad 100644 --- a/tests/openedx_content/applets/sections/test_api.py +++ b/tests/openedx_content/applets/sections/test_api.py @@ -9,7 +9,7 @@ import openedx_content.api as content_api from openedx_content import models_api as authoring_models -from ..publishing.container_test_case import ContainerTestCase, Entry +from ..publishing.test_containers import ContainerTestCase, Entry @ddt.ddt diff --git a/tests/openedx_content/applets/subsections/test_api.py b/tests/openedx_content/applets/subsections/test_api.py index 9ed8751e..9c399391 100644 --- a/tests/openedx_content/applets/subsections/test_api.py +++ b/tests/openedx_content/applets/subsections/test_api.py @@ -9,7 +9,7 @@ import openedx_content.api as content_api from openedx_content import models_api as content_models -from ..publishing.container_test_case import ContainerTestCase, Entry +from ..publishing.test_containers import ContainerTestCase, Entry @ddt.ddt diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index ac24417b..89f4b759 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -9,11 +9,11 @@ import openedx_content.api as content_api from openedx_content import models_api as content_models -from ..publishing.container_test_case import ContainerTestCase, Entry +from ..publishing.test_containers import BaseContainerTestCase, Entry @ddt.ddt -class UnitsTestCase(ContainerTestCase): +class UnitsTestCase(BaseContainerTestCase): """Test cases for Units (containers of components)""" def setUp(self) -> None: diff --git a/tests/test_django_app/apps.py b/tests/test_django_app/apps.py index e4a20f4d..4294f81d 100644 --- a/tests/test_django_app/apps.py +++ b/tests/test_django_app/apps.py @@ -2,6 +2,12 @@ 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 @@ -12,3 +18,26 @@ class TestAppConfig(AppConfig): 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 ( + GenericContainer, + GenericContainerVersion, + ContainerContainer, + ContainerContainerVersion, + ) + + register_publishable_models(GenericContainer, GenericContainerVersion) + 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 index f0a98a0d..7e5eb38a 100644 --- a/tests/test_django_app/migrations/0001_initial.py +++ b/tests/test_django_app/migrations/0001_initial.py @@ -1,26 +1,93 @@ -# Generated by Django 5.2.11 on 2026-03-11 21:08 +# Generated by Django 5.2.11 on 2026-03-12 21:03 import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): - initial = True dependencies = [ - ('openedx_content', '0005_containertypes'), + ("openedx_content", "0005_containertypes"), ] operations = [ migrations.CreateModel( - name='GenericContainer', + name="ContainerContainer", + fields=[ + ( + "container_ptr", + models.OneToOneField( + auto_created=True, + 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="GenericContainer", + fields=[ + ( + "container_ptr", + models.OneToOneField( + auto_created=True, + 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="GenericContainerVersion", fields=[ - ('container_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='openedx_content.container')), + ( + "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, + "abstract": False, }, - bases=('openedx_content.container',), + bases=("openedx_content.containerversion",), ), ] diff --git a/tests/test_django_app/models.py b/tests/test_django_app/models.py index 6c274c28..46a2c856 100644 --- a/tests/test_django_app/models.py +++ b/tests/test_django_app/models.py @@ -1,17 +1,24 @@ """ -Models that are only for use in tests +Models that are only for use in tests. + +These models are specifically for testing the `publishing` API. """ + from typing import override -from openedx_content.models_api import Container, PublishableEntity +from django.core.exceptions import ValidationError +from django.db import models + +from openedx_content.models_api import Container, ContainerVersion, PublishableEntity +@Container.register_subclass class GenericContainer(Container): """ A Test Container that can hold anything """ - type_code = "test" + type_code = "test_generic" @override @classmethod @@ -19,4 +26,48 @@ def validate_entity(cls, entity: PublishableEntity) -> None: """Allow any type of child""" -Container.register_subclass(GenericContainer) +class GenericContainerVersion(ContainerVersion): + """ + A GenericContainerVersion is a specific version of a GenericContainer. + """ + + container_version = models.OneToOneField( + ContainerVersion, + on_delete=models.CASCADE, + parent_link=True, + primary_key=True, + ) + + # @property + # def generic_container(self): + # """Convenience accessor to the GenericContainer this version is associated with""" + # return self.container_version.container.genericcontainer # pylint: disable=no-member + + +@Container.register_subclass +class ContainerContainer(Container): + """ + A Test Container that can hold any container + """ + + type_code = "test_container_container" + + @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 86774569e544f874b6379013b6fed4190c4fbcd9 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 12 Mar 2026 16:52:08 -0700 Subject: [PATCH 18/34] WIP - refactoring test cases --- src/openedx_content/applets/publishing/api.py | 6 +- .../applets/publishing/test_containers.py | 475 +++++++++++------- tests/test_django_app/apps.py | 3 + .../migrations/0001_initial.py | 42 +- tests/test_django_app/models.py | 26 +- 5 files changed, 359 insertions(+), 193 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 35ba5813..ad3a088e 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -1827,6 +1827,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. @@ -1870,8 +1872,9 @@ def get_container_by_key(learning_package_id: int, /, key: str) -> Container: def get_container_type_code(container: Container | int, /) -> str: """Get the type of a container, as a string - e.g. "unit".""" - if not isinstance(container, Container): + if isinstance(container, int): container = get_container(container) + assert isinstance(container, Container) return container.container_type_record.type_code @@ -1895,7 +1898,6 @@ def get_containers( 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: diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index bf4cf50e..85b769ba 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -3,6 +3,7 @@ """ from datetime import datetime, timezone +import pytest from django.test import TestCase @@ -24,7 +25,139 @@ # "GenericContainer" and "ContainerContainer" from test_django_app, which are specifically for testing the publishing # API. -from tests.test_django_app.models import GenericContainer, ContainerContainer +from tests.test_django_app.models import ( + GenericEntity, + GenericEntityVersion, + GenericContainer, + GenericContainerVersion, + ContainerContainer, + ContainerContainerVersion, +) + +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: + +@pytest.fixture(name="lp") +def _lp() -> LearningPackage: + """ + Get a Learning Package. + """ + return publishing_api.create_learning_package( + key="BaseContainerTestCase-test-key", + title="Learning Package for Testing Containers", + ) + + +def create_generic_entity(learning_package: LearningPackage, key: str, title: str) -> GenericEntity: + """Create a GenericEntity with a draft version""" + pe = publishing_api.create_publishable_entity(learning_package.id, key, created=now, created_by=None) + new_entity = GenericEntity.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, + ) + GenericEntityVersion.objects.create(publishable_entity_version=pev) + return new_entity + + +@pytest.fixture(name="child_entity1") +def _child_entity1(lp: LearningPackage) -> PublishableEntity: + """An example entity, such as a component""" + return create_generic_entity(lp, key="child_entity1", title="Child 1 🌴") + + +@pytest.fixture(name="child_entity2") +def _child_entity2(lp: LearningPackage) -> PublishableEntity: + """An example entity, such as a component""" + return create_generic_entity(lp, key="child_entity2", title="Child 2 🌈") + + +@pytest.fixture(name="child_entity3") +def _child_entity3(lp: LearningPackage) -> PublishableEntity: + """An example entity, such as a component""" + return create_generic_entity(lp, key="child_entity3", title="Child 3 ⛵️") + + +@pytest.fixture(name="parent_of_two") +def _parent_of_two( + lp: LearningPackage, child_entity1: PublishableEntity, child_entity2: PublishableEntity +) -> GenericContainer: + """An GenericContainer with two children""" + parent_of_two, _version = publishing_api.create_container_and_version( + lp.id, + key="parent_of_two", + title="Generic Container with Two Unpinned Children", + entities=[child_entity1, child_entity2], + container_type=GenericContainer, + created=now, + created_by=None, + ) + return parent_of_two + + +@pytest.fixture(name="parent_of_three") +def _parent_of_three( + lp: LearningPackage, + child_entity1: PublishableEntity, + child_entity2: PublishableEntity, + child_entity3: PublishableEntity, +) -> GenericContainer: + """An GenericContainer with three children, two of which are pinned""" + parent_of_three, _version = publishing_api.create_container_and_version( + lp.id, + 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], + container_type=GenericContainer, + created=now, + created_by=None, + ) + return parent_of_three + + +@pytest.fixture(name="grandparent") +def _grandparent( + lp: LearningPackage, + parent_of_two: GenericContainer, + parent_of_three: GenericContainer, +) -> 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 GenericContainer children", + entities=[parent_of_two, parent_of_three], + container_type=ContainerContainer, + created=now, + created_by=None, + ) + return grandparent + + +def publish_container(container: Container): + """Helper method to publish a single container.""" + lp = container.publishable_entity.learning_package_id + publishing_api.publish_from_drafts( + lp.pk, + draft_qset=publishing_api.get_all_drafts(lp.pk).filter(entity=container.publishable_entity), + ) def Entry( @@ -34,173 +167,145 @@ def Entry( """Helper for quickly constructing ContainerEntityListEntry entries""" return publishing_api.ContainerEntityListEntry(component_version.publishable_entity_version, pinned=pinned) +######################################################################################################################## -class BaseContainerTestCase(TestCase): - """Base class with useful functions for testing containers. Has no tests on its own.""" - - learning_package: LearningPackage - now: datetime - - @classmethod - def setUpTestData(cls) -> None: - cls.learning_package = publishing_api.create_learning_package( - key="BaseContainerTestCase-test-key", - title="Learning Package for Testing Containers", - ) - cls.now = datetime(2026, 5, 8, tzinfo=timezone.utc) - - def tearDown(self) -> None: - Container.reset_cache() - super().tearDown() - - # def create_container_and_version( - # self, - # *, - # entities: list[ - # Component - # | ComponentVersion - # | Container - # | ContainerVersion - # ] - # | None = None, - # container_type: publishing_api.ContainerType, - # title: str | None = None, - # key: str | None = None, - # ) -> tuple[Container, ContainerVersion]: - # """Helper method to quickly create a container with some child entities""" - # container, version = publishing_api.create_container_and_version( - # learning_package_id=self.learning_package.id, - # key=key or f"{container_type.type_code}:key", - # title=title or f"Test {container_type.type_code}", - # entities=entities, # type: ignore[arg-type] - # # ^ mypy doesn't realize these list[Container] and list[PublishableEntityMixin] types are compatible? - # created=self.now, - # created_by=None, - # container_type=container_type, - # ) - # return container, version - - # def create_container( - # self, - # *, - # entities: list[ - # Component - # | ComponentVersion - # | Container - # | ContainerVersion - # ] - # | None = None, - # container_type: publishing_api.ContainerType, - # title: str | None = None, - # key: str | None = None, - # ) -> Container: - # """Helper method to quickly create a container with some components""" - # container, _version = self.create_container_and_version( - # entities=entities, container_type=container_type, title=title, key=key - # ) - # return container - - def modify_container( - self, - container: Container, - *, - title="", - timestamp=None, - ) -> ContainerVersion: - """ - Helper method to modify a unit for the purposes of testing units/drafts/pinning/publishing/etc. - """ - return publishing_api.create_next_container_version( - container.pk, - title=title or f"Modified {publishing_api.get_container_type_code(container)}", - created=timestamp or self.now, - created_by=None, - ) +# `create_container`, and `create_container_version` are not tested directly in this test suite, as they are used +# indirectly by `create_container_and_version`. They are also used explicitly in `ContainerSideEffectsTestCase`, +# below. - def publish_container(self, container: Container): - """ - Helper method to publish a single container. - """ - publishing_api.publish_from_drafts( - self.learning_package.pk, - draft_qset=publishing_api.get_all_drafts(self.learning_package.pk).filter( - entity=container.publishable_entity - ), - ) +# Basic tests of `create_container_and_version` -class ContainersApiTestCase(BaseContainerTestCase): +def test_create_generic_empty_container(lp): """ - Tests related to the Containers API + Creating an empty GenericContainer. 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=GenericContainer, + created=now, + can_stand_alone=False, + ) + + assert isinstance(container, GenericContainer) + assert isinstance(container_v1, GenericContainerVersion) + assert container.versioning.draft == container_v1 + assert container.versioning.published is None - # `create_container`, and `create_container_version` are not tested directly in this test suite, as they are used - # indirectly by `create_container_and_version`. They are also used explicitly in `ContainerSideEffectsTestCase`, - # below. + 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) - # Basic tests: `create_container_and_version`, `get_container`, `get_container_version` + # Test soft deletion + # test publishing - # def test_create_and_get_container(self): - +# create_next_container_version +# get_container +def test_get_container(parent_of_two: GenericContainer, 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) - # 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 - # 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 +# get_container_version +# get_container_by_key +# get_container_type_code and get_container_type -class ContainerSideEffectsTestCase(BaseContainerTestCase): +def test_get_container_type( + grandparent: ContainerContainer, parent_of_two: GenericContainer, child_entity1: PublishableEntity +): + """ + 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 "GenericContainer": + assert isinstance(parent_of_two, GenericContainer) + assert publishing_api.get_container_type_code(parent_of_two) == "test_generic" + assert publishing_api.get_container_type(parent_of_two) is GenericContainer + 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 GenericContainer + + # Passing in a non-container will trigger an assert failure: + with pytest.raises(AssertionError): + publishing_api.get_container_type(child_entity1) + + +# get_containers +# get_collection_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 + + +class TestContainerSideEffects: """ Tests related to Container side effects and dependencies """ - def test_parent_child_side_effects(self) -> None: + 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( - self.learning_package.id, + lp.id, "child_1", - created=self.now, + created=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=now, created_by=None, ) child_2 = publishing_api.create_publishable_entity( - self.learning_package.id, + lp.id, "child_2", - created=self.now, + created=now, created_by=None, ) publishing_api.create_publishable_entity_version( child_2.id, version_num=1, title="Child 2 🌴", - created=self.now, + created=now, created_by=None, ) container: Container = publishing_api.create_container( - self.learning_package.id, + lp.id, "my_container", - created=self.now, + created=now, created_by=None, container_type=GenericContainer, ) @@ -212,7 +317,7 @@ def test_parent_child_side_effects(self) -> None: child_1, child_2, ], - created=self.now, + created=now, created_by=None, ) @@ -223,7 +328,7 @@ def test_parent_child_side_effects(self) -> None: child_1.id, version_num=2, title="Child 1 v2", - created=self.now, + created=now, created_by=None, ) last_change_log = DraftChangeLog.objects.order_by("-id").first() @@ -248,39 +353,39 @@ def test_parent_child_side_effects(self) -> None: assert side_effect.cause == child_1_change assert side_effect.effect == container_change - def test_bulk_parent_child_side_effects(self) -> None: + 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(self.learning_package.id): + with publishing_api.bulk_draft_changes_for(lp.id): child_1 = publishing_api.create_publishable_entity( - self.learning_package.id, + lp.id, "child_1", - created=self.now, + created=now, created_by=None, ) publishing_api.create_publishable_entity_version( child_1.id, version_num=1, title="Child 1 🌴", - created=self.now, + created=now, created_by=None, ) child_2 = publishing_api.create_publishable_entity( - self.learning_package.id, + lp.id, "child_2", - created=self.now, + created=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=now, created_by=None, ) container: Container = publishing_api.create_container( - self.learning_package.id, + lp.id, "my_container", - created=self.now, + created=now, created_by=None, container_type=GenericContainer, ) @@ -289,7 +394,7 @@ def test_bulk_parent_child_side_effects(self) -> None: 1, title="My Container", entities=[child_1, child_2], - created=self.now, + created=now, created_by=None, ) @@ -300,7 +405,7 @@ def test_bulk_parent_child_side_effects(self) -> None: child_1.id, version_num=2, title="Child 1 v2", - created=self.now, + created=now, created_by=None, ) @@ -333,7 +438,7 @@ def test_bulk_parent_child_side_effects(self) -> None: assert caused_by_child_1.effect == container_change assert caused_by_child_2.effect == container_change - def test_draft_dependency_multiple_parents(self): + def test_draft_dependency_multiple_parents(self, lp: LearningPackage) -> None: """ Test that a change in a draft component affects multiple parents. @@ -341,29 +446,29 @@ def test_draft_dependency_multiple_parents(self): """ # Set up a Component that lives in two Units component = publishing_api.create_publishable_entity( - self.learning_package.id, + lp.id, "component_1", - created=self.now, + created=now, created_by=None, ) publishing_api.create_publishable_entity_version( component.id, version_num=1, title="Component 1 🌴", - created=self.now, + created=now, created_by=None, ) unit_1 = publishing_api.create_container( - self.learning_package.id, + lp.id, "unit_1", - created=self.now, + created=now, created_by=None, container_type=GenericContainer, ) unit_2 = publishing_api.create_container( - self.learning_package.id, + lp.id, "unit_2", - created=self.now, + created=now, created_by=None, container_type=GenericContainer, ) @@ -373,7 +478,7 @@ def test_draft_dependency_multiple_parents(self): 1, title="My Unit", entities=[component], - created=self.now, + created=now, created_by=None, ) @@ -387,7 +492,7 @@ def test_draft_dependency_multiple_parents(self): component.id, version_num=2, title="Component 1.2 🌴", - created=self.now, + created=now, created_by=None, ) side_effects = DraftSideEffect.objects.all() @@ -396,29 +501,29 @@ def test_draft_dependency_multiple_parents(self): assert side_effects.filter(effect__entity=unit_1.publishable_entity).count() == 1 assert side_effects.filter(effect__entity=unit_2.publishable_entity).count() == 1 - def test_multiple_layers_of_containers(self): + def test_multiple_layers_of_containers(self, 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( - self.learning_package.id, + lp.id, "component_1", - created=self.now, + created=now, created_by=None, ) publishing_api.create_publishable_entity_version( component.id, version_num=1, title="Component 1 🌴", - created=self.now, + created=now, created_by=None, ) unit = publishing_api.create_container( - self.learning_package.id, + lp.id, "unit_1", - created=self.now, + created=now, created_by=None, container_type=GenericContainer, ) @@ -427,13 +532,13 @@ def test_multiple_layers_of_containers(self): 1, title="My Unit", entities=[component], - created=self.now, + created=now, created_by=None, ) subsection = publishing_api.create_container( - self.learning_package.id, + lp.id, "subsection_1", - created=self.now, + created=now, created_by=None, container_type=GenericContainer, ) @@ -442,7 +547,7 @@ def test_multiple_layers_of_containers(self): 1, title="My Subsection", entities=[unit], - created=self.now, + created=now, created_by=None, ) @@ -450,12 +555,12 @@ def test_multiple_layers_of_containers(self): # 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: + 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=self.now, + created=now, created_by=None, ) @@ -470,18 +575,18 @@ def test_multiple_layers_of_containers(self): 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) + 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=self.now, + created=now, created_by=None, ) publish_log = publishing_api.publish_from_drafts( - self.learning_package.id, + lp.id, Draft.objects.filter(entity_id=component.pk), ) assert publish_log.records.count() == 3 @@ -495,29 +600,29 @@ def test_multiple_layers_of_containers(self): assert subsection_publish.affected_by.count() == 1 assert subsection_publish.affected_by.first().cause == unit_publish - def test_publish_all_layers(self): + 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( - self.learning_package.id, + lp.id, "component_1", - created=self.now, + created=now, created_by=None, ) publishing_api.create_publishable_entity_version( component.id, version_num=1, title="Component 1 🌴", - created=self.now, + created=now, created_by=None, ) unit = publishing_api.create_container( - self.learning_package.id, + lp.id, "unit_1", - created=self.now, + created=now, created_by=None, container_type=GenericContainer, ) @@ -526,13 +631,13 @@ def test_publish_all_layers(self): 1, title="My Unit", entities=[component], - created=self.now, + created=now, created_by=None, ) subsection = publishing_api.create_container( - self.learning_package.id, + lp.id, "subsection_1", - created=self.now, + created=now, created_by=None, container_type=GenericContainer, ) @@ -541,11 +646,11 @@ def test_publish_all_layers(self): 1, title="My Subsection", entities=[unit], - created=self.now, + created=now, created_by=None, ) publish_log = publishing_api.publish_from_drafts( - self.learning_package.id, + lp.id, Draft.objects.filter(pk=subsection.pk), ) @@ -553,18 +658,18 @@ def test_publish_all_layers(self): # the publish log records. assert publish_log.records.count() == 3 - def test_container_next_version(self): + def test_container_next_version(self, lp: LearningPackage) -> None: """Test that next_version works for containers.""" child_1 = publishing_api.create_publishable_entity( - self.learning_package.id, + lp.id, "child_1", - created=self.now, + created=now, created_by=None, ) container = publishing_api.create_container( - self.learning_package.id, + lp.id, "my_container", - created=self.now, + created=now, created_by=None, container_type=GenericContainer, ) @@ -573,7 +678,7 @@ def test_container_next_version(self): container.pk, title="My Container v1", entities=None, - created=self.now, + created=now, created_by=None, ) assert v1.version_num == 1 @@ -582,7 +687,7 @@ def test_container_next_version(self): container.pk, title="My Container v2", entities=[child_1], - created=self.now, + created=now, created_by=None, ) assert v2.version_num == 2 @@ -592,7 +697,7 @@ def test_container_next_version(self): container.pk, title="My Container v3", entities=None, - created=self.now, + created=now, created_by=None, ) assert v3.version_num == 3 diff --git a/tests/test_django_app/apps.py b/tests/test_django_app/apps.py index 4294f81d..4b8908f7 100644 --- a/tests/test_django_app/apps.py +++ b/tests/test_django_app/apps.py @@ -25,12 +25,15 @@ def register_publishable_models(self): """ from openedx_content.api import register_publishable_models from .models import ( + GenericEntity, + GenericEntityVersion, GenericContainer, GenericContainerVersion, ContainerContainer, ContainerContainerVersion, ) + register_publishable_models(GenericEntity, GenericEntityVersion) register_publishable_models(GenericContainer, GenericContainerVersion) register_publishable_models(ContainerContainer, ContainerContainerVersion) diff --git a/tests/test_django_app/migrations/0001_initial.py b/tests/test_django_app/migrations/0001_initial.py index 7e5eb38a..545df58b 100644 --- a/tests/test_django_app/migrations/0001_initial.py +++ b/tests/test_django_app/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.11 on 2026-03-12 21:03 +# Generated by Django 5.2.11 on 2026-03-12 22:26 import django.db.models.deletion from django.db import migrations, models @@ -16,9 +16,8 @@ class Migration(migrations.Migration): name="ContainerContainer", fields=[ ( - "container_ptr", + "base_container", models.OneToOneField( - auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, @@ -55,9 +54,8 @@ class Migration(migrations.Migration): name="GenericContainer", fields=[ ( - "container_ptr", + "container", models.OneToOneField( - auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, @@ -90,4 +88,38 @@ class Migration(migrations.Migration): }, bases=("openedx_content.containerversion",), ), + migrations.CreateModel( + name="GenericEntity", + 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="GenericEntityVersion", + 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/models.py b/tests/test_django_app/models.py index 46a2c856..67940b40 100644 --- a/tests/test_django_app/models.py +++ b/tests/test_django_app/models.py @@ -9,7 +9,26 @@ from django.core.exceptions import ValidationError from django.db import models -from openedx_content.models_api import Container, ContainerVersion, PublishableEntity +from openedx_content.models_api import ( + Container, + ContainerVersion, + PublishableEntity, + PublishableEntityMixin, + PublishableEntityVersionMixin, +) + + +class GenericEntity(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. + """ + + +class GenericEntityVersion(PublishableEntityVersionMixin): + """ + A particular version of a GenericEntity. + """ @Container.register_subclass @@ -20,6 +39,8 @@ class GenericContainer(Container): 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: @@ -52,6 +73,9 @@ class ContainerContainer(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: From d3cdf75e7e80c8881fe6acc437582ec86e23a5bc Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 12 Mar 2026 22:50:13 -0700 Subject: [PATCH 19/34] WIP - refactoring test cases --- src/openedx_content/applets/publishing/api.py | 7 +- .../applets/publishing/models/__init__.py | 2 +- .../applets/publishing/models/container.py | 2 +- .../applets/publishing/test_containers.py | 74 +++++++++++++++++-- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index ad3a088e..f8d6bdc7 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -23,7 +23,6 @@ from .contextmanagers import DraftChangeLogContext from .models import ( Container, - ContainerTypeRecord, ContainerVersion, Draft, DraftChangeLog, @@ -42,6 +41,7 @@ PublishLogRecord, PublishSideEffect, ) +from .models.container import ContainerTypeRecord from .models.publish_log import Published # A few of the APIs in this file are generic and can be used for Containers in @@ -1882,7 +1882,10 @@ def get_container_type(container: Container | int, /) -> ContainerType: """ Get the type of a container. - Will raise a ValueError if the type is not currently installed. + 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 `ValueError` if the type is not currently installed. """ type_code = get_container_type_code(container) return Container.subclass_for_type_code(type_code) diff --git a/src/openedx_content/applets/publishing/models/__init__.py b/src/openedx_content/applets/publishing/models/__init__.py index 6abb16dd..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, ContainerTypeRecord, 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 122aef7c..cd828645 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/publishing/models/container.py @@ -124,7 +124,7 @@ def subclass_for_type_code(type_code: str) -> type[Container]: return _registered_container_types[type_code] except KeyError as exc: raise ValueError( - 'An implementation for "{type_code}" containers is not currently installed. ' + f'An implementation for "{type_code}" containers is not currently installed. ' "Such containers can be read but not modified." ) from exc diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index 85b769ba..8bf8b525 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -5,8 +5,6 @@ from datetime import datetime, timezone import pytest -from django.test import TestCase - from openedx_content.applets.publishing import api as publishing_api from openedx_content.applets.publishing.models import ( Container, @@ -20,6 +18,10 @@ PublishableEntityVersionMixin, PublishLog, ) + +# This one is imported separately for internal test purposes, since it's not part of the public API: +from openedx_content.applets.publishing.models.container import ContainerTypeRecord + # 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 # "GenericContainer" and "ContainerContainer" from test_django_app, which are specifically for testing the publishing @@ -48,9 +50,11 @@ def container_tear_down(): yield None # run the test Container.reset_cache() + ######################################################################################################################## # Fixtures: + @pytest.fixture(name="lp") def _lp() -> LearningPackage: """ @@ -151,6 +155,28 @@ def _grandparent( return grandparent +@pytest.fixture(name="container_of_uninstalled_type") +def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: PublishableEntity) -> Container: + """ + A container whose ContainerType implementation is no longer available, + e.g. leftover data from an uninstalled plugin. + """ + # First create a GenericContainer, 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=GenericContainer, + 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) + container.refresh_from_db() + return container + + def publish_container(container: Container): """Helper method to publish a single container.""" lp = container.publishable_entity.learning_package_id @@ -167,6 +193,7 @@ def Entry( """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 in this test suite, as they are used @@ -176,7 +203,7 @@ def Entry( # Basic tests of `create_container_and_version` -def test_create_generic_empty_container(lp): +def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None: """ Creating an empty GenericContainer. It will have only a draft version. """ @@ -186,6 +213,7 @@ def test_create_generic_empty_container(lp): title="Test Container 1", container_type=GenericContainer, created=now, + created_by=admin_user.pk, can_stand_alone=False, ) @@ -193,18 +221,26 @@ def test_create_generic_empty_container(lp): assert isinstance(container_v1, GenericContainerVersion) 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 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) - # Test soft deletion - # test publishing +# Test soft deletion +# test publishing + +# TODO: test "can_stand_alone" # create_next_container_version # get_container + def test_get_container(parent_of_two: GenericContainer, django_assert_num_queries) -> None: """ Test `get_container()` @@ -216,6 +252,7 @@ def test_get_container(parent_of_two: GenericContainer, django_assert_num_querie 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. @@ -223,11 +260,26 @@ def test_get_container_nonexistent() -> None: with pytest.raises(Container.DoesNotExist): publishing_api.get_container(-5000) + +def test_get_container_soft_deleted(parent_of_two: GenericContainer) -> 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. + + # get_container_version # get_container_by_key # get_container_type_code and get_container_type + def test_get_container_type( grandparent: ContainerContainer, parent_of_two: GenericContainer, child_entity1: PublishableEntity ): @@ -256,6 +308,18 @@ def test_get_container_type( publishing_api.get_container_type(child_entity1) +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(ValueError, 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 # get_collection_containers # ChildrenEntitiesAction From e44661b9f9f512125ea7453db5ff1034119c3167 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 12 Mar 2026 23:43:43 -0700 Subject: [PATCH 20/34] WIP - refactoring test cases --- src/openedx_content/applets/publishing/api.py | 8 +- .../applets/publishing/models/container.py | 6 +- .../applets/publishing/test_containers.py | 108 ++++++++++++++++-- 3 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index f8d6bdc7..52b72a5b 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -41,7 +41,7 @@ PublishLogRecord, PublishSideEffect, ) -from .models.container import ContainerTypeRecord +from .models.container import ContainerTypeRecord, ContainerImplementationMissingError from .models.publish_log import Published # A few of the APIs in this file are generic and can be used for Containers in @@ -81,6 +81,7 @@ # 🛑 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", @@ -1748,7 +1749,7 @@ def create_next_entity_list( def create_next_container_version( container_pk: int, *, - title: str | None, + title: str | None = None, entities: EntityListInput | None = None, created: datetime, created_by: int | None, @@ -1776,7 +1777,6 @@ def create_next_container_version( 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. @@ -1885,7 +1885,7 @@ def get_container_type(container: Container | int, /) -> ContainerType: 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 `ValueError` if the type is not currently installed. + 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) diff --git a/src/openedx_content/applets/publishing/models/container.py b/src/openedx_content/applets/publishing/models/container.py index cd828645..03125a6f 100644 --- a/src/openedx_content/applets/publishing/models/container.py +++ b/src/openedx_content/applets/publishing/models/container.py @@ -18,6 +18,10 @@ _registered_container_types: dict[str, tuple[type[Container], type[ContainerVersion]]] = {} +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. @@ -123,7 +127,7 @@ def subclass_for_type_code(type_code: str) -> type[Container]: try: return _registered_container_types[type_code] except KeyError as exc: - raise ValueError( + raise ContainerImplementationMissingError( f'An implementation for "{type_code}" containers is not currently installed. ' "Such containers can be read but not modified." ) from exc diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index 8bf8b525..dab7de7e 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -55,6 +55,27 @@ def container_tear_down(): # Fixtures: +# The fixtures available below and their hierarchy are: +# +# lp (LearningPackage) +# └─ grandparent (ContainerContainer) +# ├─ parent_of_two (GenericContainer) +# │ ├─ child_entity1 (PublishableEntity) +# │ └─ child_entity2 (PublishableEntity) +# └─ parent_of_three (GenericContainer) +# ├─ child_entity3 (📌 pinned to v1, PublishableEntity) +# ├─ child_entity2 (📌 pinned to v1, PublishableEntity) +# └─ child_entity1 (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: """ @@ -173,8 +194,7 @@ def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: Publishab # 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) - container.refresh_from_db() - return container + return Container.objects.get(pk=container.pk) # Reload and just use the base Container type def publish_container(container: Container): @@ -225,6 +245,8 @@ def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None 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 @@ -232,12 +254,72 @@ def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None publishing_api.get_container_children_count(container, published=True) -# Test soft deletion -# test publishing +# create_next_container_version + + +def test_create_next_container_version_no_changes(parent_of_two: GenericContainer, 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) + publishing_api.create_next_container_version( + parent_of_two.pk, + created=v2_date, + created_by=other_user.pk, + # Specify no changes at all + ) + + # Now it should have an incremented version number but be unchanged: + parent_of_two.refresh_from_db() + version_2 = parent_of_two.versioning.draft + assert version_2.version_num == 2 + assert version_2.title == original_version.title + 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: GenericContainer, child_entity1: PublishableEntity, child_entity2: PublishableEntity +): + """ + 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.pk, + 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: + parent_of_two.refresh_from_db() + 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"] -# TODO: test "can_stand_alone" -# create_next_container_version # get_container @@ -274,6 +356,15 @@ def test_get_container_soft_deleted(parent_of_two: GenericContainer) -> None: 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 # get_container_by_key @@ -313,7 +404,10 @@ 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(ValueError, match='An implementation for "misc" containers is not currently installed.'): + 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: From 84d565ba996852f6735772aa6b597a844d785efc Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 09:18:48 -0700 Subject: [PATCH 21/34] WIP - refactoring test cases --- .../applets/publishing/test_containers.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index dab7de7e..ef0befe1 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -5,6 +5,8 @@ from datetime import datetime, timezone import pytest +from django.db.utils import IntegrityError + from openedx_content.applets.publishing import api as publishing_api from openedx_content.applets.publishing.models import ( Container, @@ -320,6 +322,76 @@ def test_create_next_container_version_with_changes( 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: GenericContainer, + child_entity1: PublishableEntity, + child_entity2: PublishableEntity, + child_entity3: PublishableEntity, +): + """ + 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.pk, + entities=[child_entity3, child_entity1_v1], + created=now, + created_by=None, + entities_action=publishing_api.ChildrenEntitiesAction.APPEND, + ) + + parent_of_two.refresh_from_db() + 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_conflicting_version(parent_of_two: GenericContainer): + """ + 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, + ) + + # get_container From 2a39de0ba053dd03caa6656425334c01d3fb7be9 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 09:31:37 -0700 Subject: [PATCH 22/34] fix: learning package ID seems always to be required in this fn --- src/openedx_content/applets/publishing/api.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 52b72a5b..d8ffea6d 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -1709,17 +1709,13 @@ def create_next_entity_list( """ parsed_entities = ParsedEntityReference.parse(entities) # 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.") + 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 From 8cbe7b08c26f601225798a690209ec39e9e33f7a Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 10:44:30 -0700 Subject: [PATCH 23/34] WIP - refactoring test cases --- src/openedx_content/applets/publishing/api.py | 14 +- .../applets/publishing/test_containers.py | 208 +++++++++++++++++- 2 files changed, 208 insertions(+), 14 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index d8ffea6d..6d628241 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -1728,15 +1728,17 @@ def create_next_entity_list( for entity in last_entities ] + 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 parsed_entities]) - .order_by("order_num") + # 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.all() + for entity in last_entities_qs.all() ] return create_entity_list_with_rows(parsed_entities, learning_package_id=learning_package_id) diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index ef0befe1..99700bee 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -60,14 +60,25 @@ def container_tear_down(): # The fixtures available below and their hierarchy are: # # lp (LearningPackage) -# └─ grandparent (ContainerContainer) -# ├─ parent_of_two (GenericContainer) -# │ ├─ child_entity1 (PublishableEntity) -# │ └─ child_entity2 (PublishableEntity) -# └─ parent_of_three (GenericContainer) -# ├─ child_entity3 (📌 pinned to v1, PublishableEntity) -# ├─ child_entity2 (📌 pinned to v1, PublishableEntity) -# └─ child_entity1 (PublishableEntity) +# ├─ grandparent (ContainerContainer) +# │ ├─ parent_of_two (GenericContainer) +# │ │ ├─ child_entity1 (PublishableEntity) +# │ │ └─ child_entity2 (PublishableEntity) +# │ └─ parent_of_three (GenericContainer) +# │ ├─ child_entity3 (📌 pinned to v1, PublishableEntity) +# │ ├─ child_entity2 (📌 pinned to v1, PublishableEntity) +# │ └─ child_entity1 (PublishableEntity) +# │ +# ├─ parent_of_six_duplicates (GenericContainer) +# │ ├─ 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) # # Note that the "child" entities are referenced in multiple containers # Everything is initially in a draft state only, with no published version. @@ -159,6 +170,34 @@ def _parent_of_three( return parent_of_three +@pytest.fixture(name="parent_of_six") +def _parent_of_six( + lp: LearningPackage, + child_entity1: PublishableEntity, + child_entity2: PublishableEntity, + child_entity3: PublishableEntity, +) -> GenericContainer: + """An GenericContainer with six children, two of each entity, with different pinned combinations""" + parent_of_six, _version = publishing_api.create_container_and_version( + lp.id, + 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, + ], + container_type=GenericContainer, + created=now, + created_by=None, + ) + return parent_of_six + + @pytest.fixture(name="grandparent") def _grandparent( lp: LearningPackage, @@ -354,6 +393,159 @@ def test_create_next_container_version_with_append( Entry(child_entity1_v1, pinned=True), # 🆕 entity 1, appended, 📌 pinned ] +def test_create_next_container_version_with_remove_1( + parent_of_six: GenericContainer, + child_entity1: PublishableEntity, + child_entity2: PublishableEntity, + child_entity3: PublishableEntity, +): + """ + 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: GenericContainer, + child_entity1: PublishableEntity, + child_entity2: PublishableEntity, + child_entity3: PublishableEntity, +): + """ + 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: GenericContainer, + child_entity1: PublishableEntity, + child_entity2: PublishableEntity, + child_entity3: PublishableEntity, +): + """ + 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: GenericContainer, + child_entity1: PublishableEntity, + child_entity2: PublishableEntity, + child_entity3: PublishableEntity, +): + """ + 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: GenericContainer): """ From 1a1288e18d1a5ddee9b668533f2bb7e7b685c89d Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 14:36:54 -0700 Subject: [PATCH 24/34] WIP - refactoring test cases --- .../applets/collections/api.py | 15 +- src/openedx_content/applets/publishing/api.py | 32 ++-- .../applets/publishing/test_containers.py | 148 ++++++++++++++++-- 3 files changed, 162 insertions(+), 33 deletions(-) diff --git a/src/openedx_content/applets/collections/api.py b/src/openedx_content/applets/collections/api.py index f23b6fbc..72b24765 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_containers", "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_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") + + 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 6d628241..ad9843ee 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -92,7 +92,6 @@ "get_container_type_code", "get_container_type", "get_containers", - "get_collection_containers", "ChildrenEntitiesAction", "ContainerEntityListEntry", "get_entities_in_container", @@ -1862,10 +1861,18 @@ 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: @@ -1911,21 +1918,6 @@ def get_containers( 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") - - def get_entities_in_container( container: Container, *, diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index 99700bee..8db1a817 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -78,7 +78,11 @@ def container_tear_down(): # │ └─ child_entity3 (PublishableEntity) # │ # └─ container_of_uninstalled_type ("misc" Container - it's specific type plugin no longer available) -# │ └─ child_entity1 (PublishableEntity) +# └─ child_entity1 (PublishableEntity) +# +# lp2 (LearningPackage) +# └─ other_lp_parent (GenericContainer) +# └─ 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. @@ -91,13 +95,14 @@ def _other_user(django_user_model): @pytest.fixture(name="lp") def _lp() -> LearningPackage: - """ - Get a Learning Package. - """ - return publishing_api.create_learning_package( - key="BaseContainerTestCase-test-key", - title="Learning Package for Testing Containers", - ) + """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_generic_entity(learning_package: LearningPackage, key: str, title: str) -> GenericEntity: @@ -133,6 +138,12 @@ def _child_entity3(lp: LearningPackage) -> PublishableEntity: return create_generic_entity(lp, key="child_entity3", title="Child 3 ⛵️") +@pytest.fixture(name="other_lp_child") +def _other_lp_child(lp2: LearningPackage) -> PublishableEntity: + """An example entity, such as a component""" + return create_generic_entity(lp2, key="other_lp_child", title="Child in other Learning Package 📦") + + @pytest.fixture(name="parent_of_two") def _parent_of_two( lp: LearningPackage, child_entity1: PublishableEntity, child_entity2: PublishableEntity @@ -238,6 +249,21 @@ def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: Publishab 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) -> GenericContainer: + """An GenericContainer 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=GenericContainer, + created=now, + created_by=None, + ) + return other_lp_parent + + def publish_container(container: Container): """Helper method to publish a single container.""" lp = container.publishable_entity.learning_package_id @@ -257,9 +283,8 @@ def Entry( ######################################################################################################################## -# `create_container`, and `create_container_version` are not tested directly in this test suite, as they are used -# indirectly by `create_container_and_version`. They are also used explicitly in `ContainerSideEffectsTestCase`, -# below. +# `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` @@ -393,6 +418,7 @@ def test_create_next_container_version_with_append( Entry(child_entity1_v1, pinned=True), # 🆕 entity 1, appended, 📌 pinned ] + def test_create_next_container_version_with_remove_1( parent_of_six: GenericContainer, child_entity1: PublishableEntity, @@ -435,6 +461,7 @@ def test_create_next_container_version_with_remove_1( Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned ] + def test_create_next_container_version_with_remove_2( parent_of_six: GenericContainer, child_entity1: PublishableEntity, @@ -472,6 +499,7 @@ def test_create_next_container_version_with_remove_2( Entry(child_entity3.versioning.draft, pinned=False), # entity 3, unpinned ] + def test_create_next_container_version_with_remove_3( parent_of_six: GenericContainer, child_entity1: PublishableEntity, @@ -509,6 +537,7 @@ def test_create_next_container_version_with_remove_3( 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: GenericContainer, child_entity1: PublishableEntity, @@ -630,8 +659,49 @@ def test_get_container_uninstalled_type(container_of_uninstalled_type: Container # get_container_version + + +def test_get_container_version(parent_of_two: GenericContainer) -> 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: GenericContainer) -> None: + """ + Test getting a specific container by key + """ + result = publishing_api.get_container_by_key(lp, parent_of_two.key) + assert result == parent_of_two.container + # The API always returns "Container", not specific subclasses like GenericContainer: + 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 @@ -679,7 +749,61 @@ def test_get_container_type_deleted(container_of_uninstalled_type: Container): # get_containers -# get_collection_containers + + +def test_get_containers( + lp: LearningPackage, + grandparent: ContainerContainer, + parent_of_two: GenericContainer, + parent_of_three: GenericContainer, + lp2: LearningPackage, + other_lp_parent: GenericContainer, +): + """ + Test that we can get all containers in a Learning Package + """ + result = list(publishing_api.get_containers(lp)) + # 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)) == [ + other_lp_parent.container, + ] + + +def test_get_containers_published( + lp: LearningPackage, + grandparent: ContainerContainer, + parent_of_two: GenericContainer, + parent_of_three: GenericContainer, +): + """ + 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)) == [ + # 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, include_deleted=True)) == [ + parent_of_two.container, + parent_of_three.container, + grandparent.base_container, + ] + + # ChildrenEntitiesAction # ContainerEntityListEntry # get_entities_in_container From 2d2b07861b3537206d8bb7d26ff8ba4e8b3374b2 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 15:52:34 -0700 Subject: [PATCH 25/34] WIP - refactoring test cases --- src/openedx_content/applets/publishing/api.py | 7 +- .../applets/publishing/test_containers.py | 306 +++++++++++++++--- .../openedx_content/applets/units/test_api.py | 259 --------------- tests/test_django_app/models.py | 6 - 4 files changed, 272 insertions(+), 306 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index ad9843ee..998499e6 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -2016,7 +2016,7 @@ def get_entities_in_container_as_of( return container_version, entity_list -def contains_unpublished_changes(container_id: int) -> bool: +def contains_unpublished_changes(container_or_pk: Container | int, /) -> bool: """ [ 🛑 UNSTABLE ] Check recursively if a container has any unpublished changes. @@ -2034,6 +2034,11 @@ 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") diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index 8db1a817..bc95e74f 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone import pytest +from django.core.exceptions import ValidationError from django.db.utils import IntegrityError from openedx_content.applets.publishing import api as publishing_api @@ -17,6 +18,7 @@ DraftSideEffect, LearningPackage, PublishableEntity, + PublishableEntityMixin, PublishableEntityVersionMixin, PublishLog, ) @@ -121,33 +123,31 @@ def create_generic_entity(learning_package: LearningPackage, key: str, title: st @pytest.fixture(name="child_entity1") -def _child_entity1(lp: LearningPackage) -> PublishableEntity: +def _child_entity1(lp: LearningPackage) -> GenericEntity: """An example entity, such as a component""" return create_generic_entity(lp, key="child_entity1", title="Child 1 🌴") @pytest.fixture(name="child_entity2") -def _child_entity2(lp: LearningPackage) -> PublishableEntity: +def _child_entity2(lp: LearningPackage) -> GenericEntity: """An example entity, such as a component""" return create_generic_entity(lp, key="child_entity2", title="Child 2 🌈") @pytest.fixture(name="child_entity3") -def _child_entity3(lp: LearningPackage) -> PublishableEntity: +def _child_entity3(lp: LearningPackage) -> GenericEntity: """An example entity, such as a component""" return create_generic_entity(lp, key="child_entity3", title="Child 3 ⛵️") @pytest.fixture(name="other_lp_child") -def _other_lp_child(lp2: LearningPackage) -> PublishableEntity: +def _other_lp_child(lp2: LearningPackage) -> GenericEntity: """An example entity, such as a component""" return create_generic_entity(lp2, key="other_lp_child", title="Child in other Learning Package 📦") @pytest.fixture(name="parent_of_two") -def _parent_of_two( - lp: LearningPackage, child_entity1: PublishableEntity, child_entity2: PublishableEntity -) -> GenericContainer: +def _parent_of_two(lp: LearningPackage, child_entity1: GenericEntity, child_entity2: GenericEntity) -> GenericContainer: """An GenericContainer with two children""" parent_of_two, _version = publishing_api.create_container_and_version( lp.id, @@ -164,9 +164,9 @@ def _parent_of_two( @pytest.fixture(name="parent_of_three") def _parent_of_three( lp: LearningPackage, - child_entity1: PublishableEntity, - child_entity2: PublishableEntity, - child_entity3: PublishableEntity, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, ) -> GenericContainer: """An GenericContainer with three children, two of which are pinned""" parent_of_three, _version = publishing_api.create_container_and_version( @@ -184,9 +184,9 @@ def _parent_of_three( @pytest.fixture(name="parent_of_six") def _parent_of_six( lp: LearningPackage, - child_entity1: PublishableEntity, - child_entity2: PublishableEntity, - child_entity3: PublishableEntity, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, ) -> GenericContainer: """An GenericContainer with six children, two of each entity, with different pinned combinations""" parent_of_six, _version = publishing_api.create_container_and_version( @@ -229,7 +229,7 @@ def _grandparent( @pytest.fixture(name="container_of_uninstalled_type") -def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: PublishableEntity) -> Container: +def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: GenericEntity) -> Container: """ A container whose ContainerType implementation is no longer available, e.g. leftover data from an uninstalled plugin. @@ -264,12 +264,12 @@ def _other_lp_parent(lp2: LearningPackage, other_lp_child: PublishableEntity) -> return other_lp_parent -def publish_container(container: Container): - """Helper method to publish a single container.""" - lp = container.publishable_entity.learning_package_id +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.pk, - draft_qset=publishing_api.get_all_drafts(lp.pk).filter(entity=container.publishable_entity), + lp_id, + draft_qset=publishing_api.get_all_drafts(lp_id).filter(entity=obj.publishable_entity), ) @@ -320,6 +320,37 @@ def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None publishing_api.get_container_children_count(container, published=True) +def test_create_container_queries(lp: LearningPackage, child_entity1: GenericEntity, django_assert_num_queries) -> None: + """Test how many database queries are required to create a container.""" + base_args = {"title": "Test Container", "created": now, "created_by": None, "container_type": GenericContainer} + # 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: GenericContainer): + """ + Test that the .versioning helper of a subclass like `GenericContainer` returns a `GenericContainerVersion`, and + same for the base class `Container` equivalent. + """ + assert isinstance(parent_of_two, GenericContainer) + 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, GenericContainerVersion) + assert subclass_version.container_version == container_version + assert subclass_version.container_version.container == base_container + assert subclass_version.container_version.container.genericcontainer == parent_of_two + + # create_next_container_version @@ -354,7 +385,7 @@ def test_create_next_container_version_no_changes(parent_of_two: GenericContaine def test_create_next_container_version_with_changes( - parent_of_two: GenericContainer, child_entity1: PublishableEntity, child_entity2: PublishableEntity + parent_of_two: GenericContainer, child_entity1: GenericEntity, child_entity2: GenericEntity ): """ Test creating a new version of the "parent of two" container, changing the @@ -388,9 +419,9 @@ def test_create_next_container_version_with_changes( def test_create_next_container_version_with_append( parent_of_two: GenericContainer, - child_entity1: PublishableEntity, - child_entity2: PublishableEntity, - child_entity3: PublishableEntity, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, ): """ Test creating a new version of the "parent of two" container, using the APPEND action to append new children. @@ -421,9 +452,9 @@ def test_create_next_container_version_with_append( def test_create_next_container_version_with_remove_1( parent_of_six: GenericContainer, - child_entity1: PublishableEntity, - child_entity2: PublishableEntity, - child_entity3: PublishableEntity, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, ): """ Test creating a new version of the "parent of two" container, using the REMOVE action to remove children. @@ -464,9 +495,9 @@ def test_create_next_container_version_with_remove_1( def test_create_next_container_version_with_remove_2( parent_of_six: GenericContainer, - child_entity1: PublishableEntity, - child_entity2: PublishableEntity, - child_entity3: PublishableEntity, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, ): """ Test creating a new version of the "parent of two" container, using the REMOVE action to remove children. @@ -502,9 +533,9 @@ def test_create_next_container_version_with_remove_2( def test_create_next_container_version_with_remove_3( parent_of_six: GenericContainer, - child_entity1: PublishableEntity, - child_entity2: PublishableEntity, - child_entity3: PublishableEntity, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, ): """ Test creating a new version of the "parent of two" container, using the REMOVE action to remove children. @@ -540,9 +571,9 @@ def test_create_next_container_version_with_remove_3( def test_create_next_container_version_with_remove_4( parent_of_six: GenericContainer, - child_entity1: PublishableEntity, - child_entity2: PublishableEntity, - child_entity3: PublishableEntity, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, ): """ Test creating a new version of the "parent of two" container, using the REMOVE action to remove children. @@ -613,6 +644,20 @@ def test_create_next_container_version_uninstalled_plugin(container_of_uninstall ) +def test_create_next_container_version_other_lp(parent_of_two: GenericContainer, 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 @@ -706,7 +751,7 @@ def test_get_container_by_key_nonexistent(lp: LearningPackage) -> None: def test_get_container_type( - grandparent: ContainerContainer, parent_of_two: GenericContainer, child_entity1: PublishableEntity + grandparent: ContainerContainer, parent_of_two: GenericContainer, child_entity1: GenericEntity ): """ Test get_container_type_code() and get_container_type() @@ -778,7 +823,7 @@ def test_get_containers( ] -def test_get_containers_published( +def test_get_containers_soft_deleted( lp: LearningPackage, grandparent: ContainerContainer, parent_of_two: GenericContainer, @@ -804,11 +849,192 @@ def test_get_containers_published( ] -# ChildrenEntitiesAction -# ContainerEntityListEntry +# General publishing tests. + + +def test_contains_unpublished_changes_queries( + grandparent: ContainerContainer, child_entity1: GenericEntity, 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: GenericContainer, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, +): + """ + 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: GenericContainer, child_entity1: GenericEntity): + """ + 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: GenericContainer, child_entity3: GenericEntity): + """ + 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: GenericContainer, child_entity1: GenericEntity, child_entity2: GenericEntity +): + """ + 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 by changing its title (it remains a draft): + new_raw_version = publishing_api.create_publishable_entity_version( + child_entity1.pk, version_num=2, title="New", created=now, created_by=None + ) + child_entity1_v2 = GenericEntityVersion.objects.create(pk=new_raw_version.pk) + + # 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 + # 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 diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 89f4b759..d87a2532 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -39,90 +39,6 @@ def test_get_container(self): with self.assertNumQueries(0): assert result.versioning.has_unpublished_changes - def test_get_container_version(self): - """ - Test get_container_version() - """ - unit = self.create_unit(entities=[]) - unit_draft = unit.versioning.draft - with self.assertNumQueries(1): - result = content_api.get_container_version(unit_draft.pk) - assert result == unit_draft.container_version - - def test_get_containers(self): - """ - Test get_containers() - """ - unit = self.create_unit(entities=[]) - 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(entities=[]) - content_api.soft_delete_draft(unit.pk) - with self.assertNumQueries(1): - result = [ - c.unit - for c in content_api.get_containers(self.learning_package.id, include_deleted=True).select_related( - "unit" - ) - ] - assert result == [unit] - - with self.assertNumQueries(1): - result = list(content_api.get_containers(self.learning_package.id)) - assert not result - - def test_get_container_by_key(self): - """ - Test get_container_by_key() - """ - unit = self.create_unit(entities=[]) - with self.assertNumQueries(1): - result = content_api.get_container_by_key( - self.learning_package.id, - key=unit.publishable_entity.key, - ) - assert result.unit == unit - # 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(entities=[self.component_1, self.component_2]) - assert isinstance(unit, content_models.Unit) - container = unit.container - assert container.__class__ is content_models.Container - container_version = container.versioning.draft - assert isinstance(container_version, content_models.ContainerVersion) - unit_version = unit.versioning.draft - assert isinstance(unit_version, content_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(27): - _empty_unit = self.create_unit(entities=[]) - with self.assertNumQueries(35): - # And try with a non-empty unit: - self.create_unit(entities=[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 @@ -152,31 +68,6 @@ def test_create_unit_with_invalid_children(self): assert content_api.get_container(unit.pk).versioning.draft == unit_version.container_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, _version = content_api.create_container_and_version( - learning_package_id=learning_package2.pk, - key="unit:key", - title="Unit", - created=self.now, - created_by=None, - container_type=content_models.Unit, - ) - 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_container_version( - unit.pk, - title="Unit Containing an External Component", - entities=[self.component_1], - created=self.now, - created_by=None, - ) - def test_add_deleted_component(self): """ Test adding a deleted component. @@ -186,22 +77,6 @@ def test_add_deleted_component(self): with pytest.raises(content_models.Component.DoesNotExist): self.create_unit(entities=[self.component_1]) - def test_add_corrupted_component(self): - """ - Test adding a corrupted component (partially deleted) - Mostly this checks that the exception thrown is reasonable. - """ - self.component_1.delete() - # Note the PublishableEntity and PublishableEntityVersion still exist, so this is a weird state: - self.component_1_v1.publishable_entity_version.refresh_from_db() # No error - self.component_1_v1.publishable_entity_version.entity.refresh_from_db() # No error - # Now add this corrupted component, pinned to v1: - with pytest.raises( - ValidationError, - match='The entity "xblock.v1:problem:Query Counting" cannot be added to a "unit" container.', - ): - self.create_unit(entities=[self.component_1_v1]) - def test_create_empty_unit_and_version(self): """Test creating a unit with no components. @@ -296,140 +171,6 @@ def test_create_next_unit_version_forcing_version_num(self): ) 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(entities=[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(entities=[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 - - # 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(content_models.ContainerVersion.DoesNotExist): - # There is no published version of the unit: - content_api.get_entities_in_container(unit, published=True) - - 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 = self.create_unit_and_version( - key="unit:key", - title="Unit", - ) - 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 - - # Add a published component (unpinned): - assert self.component_1.versioning.has_unpublished_changes is False - unit_version_v2 = content_api.create_next_container_version( - unit.pk, - title=unit_version.title, - entities=[self.component_1], - created=self.now, - created_by=None, - entities_action=content_api.ChildrenEntitiesAction.APPEND, - ) - # 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(entities=[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_entities_in_container(unit, published=False) == [ - Entry(component_1_v2), # new version - ] - assert content_api.get_entities_in_container(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_entities_in_container(unit, published=False) == [ - Entry(component_1_v2), # new version - ] - assert content_api.get_entities_in_container(unit, published=True) == [ - Entry(component_1_v2), # new version - ] - assert content_api.contains_unpublished_changes(unit.pk) is False # No longer contains unpublished changes - def test_modify_pinned_component(self): """ When a pinned 📌 component in unit is modified and/or published, it will diff --git a/tests/test_django_app/models.py b/tests/test_django_app/models.py index 67940b40..be0d80db 100644 --- a/tests/test_django_app/models.py +++ b/tests/test_django_app/models.py @@ -59,12 +59,6 @@ class GenericContainerVersion(ContainerVersion): primary_key=True, ) - # @property - # def generic_container(self): - # """Convenience accessor to the GenericContainer this version is associated with""" - # return self.container_version.container.genericcontainer # pylint: disable=no-member - - @Container.register_subclass class ContainerContainer(Container): """ From b830f39603b64ebe6dc28acb0bfcd0b386a41e3f Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 16:21:41 -0700 Subject: [PATCH 26/34] WIP - refactoring test cases --- .../applets/publishing/test_containers.py | 144 ++++++++++- .../openedx_content/applets/units/test_api.py | 241 ------------------ 2 files changed, 139 insertions(+), 246 deletions(-) diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index bc95e74f..6187fd84 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -273,6 +273,15 @@ def publish_entity(obj: PublishableEntityMixin): ) +def modify_entity(obj: GenericEntity): + """Modify a GenericEntity, creating a new version with a new title""" + assert isinstance(obj, GenericEntity) + new_raw_version = publishing_api.create_publishable_entity_version( + obj.pk, version_num=2, title="New", created=now, created_by=None + ) + return GenericEntityVersion.objects.create(pk=new_raw_version.pk) + + def Entry( component_version: PublishableEntityVersionMixin, pinned: bool = False, @@ -376,6 +385,7 @@ def test_create_next_container_version_no_changes(parent_of_two: GenericContaine 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 @@ -996,11 +1006,8 @@ def test_modify_unpinned_entity_after_publish( 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 by changing its title (it remains a draft): - new_raw_version = publishing_api.create_publishable_entity_version( - child_entity1.pk, version_num=2, title="New", created=now, created_by=None - ) - child_entity1_v2 = GenericEntityVersion.objects.create(pk=new_raw_version.pk) + # 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 @@ -1033,6 +1040,133 @@ def test_modify_unpinned_entity_after_publish( ] 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: GenericContainer, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, +): + """ + 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 `GenericContainer` and `GenericEntity` 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_generic_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=GenericContainer, + ) + 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=GenericContainer, + ) + 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 # get_entities_in_container_as_of # get_containers_with_entity diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index d87a2532..b32da4d6 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -153,251 +153,10 @@ def test_create_next_unit_version_with_unpinned_and_pinned_components(self): # There is no published version of the unit: content_api.get_entities_in_container(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 = self.create_unit_and_version( - key="unit:key", - title="Unit", - ) - unit_version_v2 = content_api.create_next_container_version( - unit.pk, - title="Unit", - entities=[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_modify_pinned_component(self): - """ - 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. - """ - # Create a unit with one component (pinned 📌 to v1): - unit = self.create_unit(entities=[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_entities_in_container(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_entities_in_container(unit, published=False) == expected_unit_contents - assert content_api.get_entities_in_container(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_entities_in_container(unit, published=False) == expected_unit_contents - assert content_api.get_entities_in_container(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(entities=[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(entities=[self.component_1_v1, self.component_2, self.component_1], key="u2") - - # Check that the contents are as expected: - assert [ - row.entity_version.componentversion for row in content_api.get_entities_in_container(unit1, published=False) - ] == [ - self.component_2_v1, - self.component_2_v1, - self.component_1_v1, - ] - assert [ - row.entity_version.componentversion for row in content_api.get_entities_in_container(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_entities_in_container(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_entities_in_container(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_entities_in_container(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_entities_in_container(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(entities=[c1, c2, c3], title="Unit 1", key="unit:1") - unit2 = self.create_unit(entities=[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_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 content_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 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_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 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}", - ) - components.append(component) - unit = self.create_unit(entities=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(entities=[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_container_version(unit.pk, title="New Title", created=self.now, created_by=None) - - 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(entities=[component]) def test_removing_component(self): """Test removing a component from a unit (but not deleting it)""" From 0dc42582e09c00b813fc65d26bc6ac1fe9d78d5a Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 16:57:36 -0700 Subject: [PATCH 27/34] WIP - refactoring test cases --- .../applets/publishing/test_containers.py | 94 +++++++++++++++++++ .../openedx_content/applets/units/test_api.py | 40 -------- 2 files changed, 94 insertions(+), 40 deletions(-) diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index 6187fd84..55adba25 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -1167,13 +1167,107 @@ def test_publishing_shared_component(lp: LearningPackage): 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: GenericContainer, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, +) -> 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: GenericContainer, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, +) -> 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: GenericContainer, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, +) -> 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 # get_containers_with_entity # get_container_children_count # bulk_draft_changes_for # get_container_children_entities_keys +# Container side effects and dependencies + class TestContainerSideEffects: """ diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index b32da4d6..ce7d1971 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -158,46 +158,6 @@ def test_create_next_unit_version_with_unpinned_and_pinned_components(self): - def test_removing_component(self): - """Test removing a component from a unit (but not deleting it)""" - unit = self.create_unit(entities=[self.component_1, self.component_2]) - content_api.publish_all_drafts(self.learning_package.id) - - # Now remove component 2 - content_api.create_next_container_version( - unit.pk, - title="Revised with component 2 deleted", - entities=[self.component_2], - created=self.now, - created_by=None, - entities_action=content_api.ChildrenEntitiesAction.REMOVE, - ) - - # Now it should not be listed in the unit: - assert content_api.get_entities_in_container(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_entities_in_container(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_entities_in_container(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(entities=[self.component_1, self.component_2]) From 028907c27aa322926bcf8d27575f824681272b32 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 17:23:39 -0700 Subject: [PATCH 28/34] WIP - refactoring test cases --- .../applets/publishing/test_containers.py | 127 +++++++++++++++++- .../openedx_content/applets/units/test_api.py | 124 ----------------- 2 files changed, 125 insertions(+), 126 deletions(-) diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index 55adba25..238bc886 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -71,7 +71,7 @@ def container_tear_down(): # │ ├─ child_entity2 (📌 pinned to v1, PublishableEntity) # │ └─ child_entity1 (PublishableEntity) # │ -# ├─ parent_of_six_duplicates (GenericContainer) +# ├─ parent_of_six (GenericContainer, has duplicate children) # │ ├─ child_entity3 (📌 pinned to v1, PublishableEntity) # │ ├─ child_entity2 (📌 pinned to v1, PublishableEntity) # │ ├─ child_entity1 (PublishableEntity) @@ -1263,9 +1263,132 @@ def test_get_entities_in_container_soft_deletion_pinned( # get_entities_in_container_as_of # get_containers_with_entity # get_container_children_count -# bulk_draft_changes_for + + +def test_get_container_children_count( + lp: LearningPackage, + parent_of_two: GenericContainer, + parent_of_three: GenericContainer, + parent_of_six: GenericContainer, + 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.pk, + entities=[parent_of_two, parent_of_three, parent_of_six], + created=now, + created_by=None, + ) + grandparent.refresh_from_db() # Warning: this is required + 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: GenericContainer, + parent_of_six: GenericContainer, + child_entity2: GenericEntity, +): + """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: GenericContainer, + parent_of_six: GenericContainer, + 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: GenericContainer) -> 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: GenericContainer, child_entity1: GenericEntity): + """ + 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 is 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 diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index ce7d1971..cd831e45 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -156,130 +156,6 @@ def test_create_next_unit_version_with_unpinned_and_pinned_components(self): - - - def test_soft_deleting_component(self): - """Test soft deleting a component that's in a unit (but not removing it)""" - unit = self.create_unit(entities=[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_entities_in_container(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_entities_in_container(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_entities_in_container(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(entities=[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_container_version( - unit.pk, - title="Revised with component 2 deleted", - entities=[self.component_2], - created=self.now, - created_by=None, - entities_action=content_api.ChildrenEntitiesAction.REMOVE, - ) - - # Now it should not be listed in the unit: - assert content_api.get_entities_in_container(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_entities_in_container(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_entities_in_container(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(entities=[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_entities_in_container(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_entities_in_container(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(entities=[self.component_1, self.component_2]) - other_unit = self.create_unit(entities=[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_entities_in_container(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_entities_in_container(other_unit, published=False) == [Entry(self.component_1_v1)] - assert content_api.get_entities_in_container(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 From 3398ae6011e5a7b5b83dfe6e8fae28665ddcf599 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 18:03:17 -0700 Subject: [PATCH 29/34] WIP - refactoring test cases --- .../applets/publishing/test_containers.py | 187 +++++++++++-- .../openedx_content/applets/units/test_api.py | 264 ------------------ 2 files changed, 167 insertions(+), 284 deletions(-) diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index 238bc886..474c8bf2 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -146,19 +146,31 @@ def _other_lp_child(lp2: LearningPackage) -> GenericEntity: return create_generic_entity(lp2, key="other_lp_child", title="Child in other Learning Package 📦") +def create_generic_container( + learning_package: LearningPackage, key: str, entities: publishing_api.EntityListInput, title: str = "" +) -> GenericEntity: + """Create a GenericEntity 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=GenericContainer, + created=now, + created_by=None, + ) + return container + + @pytest.fixture(name="parent_of_two") def _parent_of_two(lp: LearningPackage, child_entity1: GenericEntity, child_entity2: GenericEntity) -> GenericContainer: """An GenericContainer with two children""" - parent_of_two, _version = publishing_api.create_container_and_version( - lp.id, + return create_generic_container( + lp, key="parent_of_two", title="Generic Container with Two Unpinned Children", entities=[child_entity1, child_entity2], - container_type=GenericContainer, - created=now, - created_by=None, ) - return parent_of_two @pytest.fixture(name="parent_of_three") @@ -169,16 +181,12 @@ def _parent_of_three( child_entity3: GenericEntity, ) -> GenericContainer: """An GenericContainer with three children, two of which are pinned""" - parent_of_three, _version = publishing_api.create_container_and_version( - lp.id, + return create_generic_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], - container_type=GenericContainer, - created=now, - created_by=None, ) - return parent_of_three @pytest.fixture(name="parent_of_six") @@ -189,8 +197,8 @@ def _parent_of_six( child_entity3: GenericEntity, ) -> GenericContainer: """An GenericContainer with six children, two of each entity, with different pinned combinations""" - parent_of_six, _version = publishing_api.create_container_and_version( - lp.id, + return create_generic_container( + lp, key="parent_of_six", title="Generic Container with Two 📌 Pinned Children and One Unpinned", entities=[ @@ -202,11 +210,7 @@ def _parent_of_six( child_entity2.versioning.draft, child_entity3, ], - container_type=GenericContainer, - created=now, - created_by=None, ) - return parent_of_six @pytest.fixture(name="grandparent") @@ -273,11 +277,11 @@ def publish_entity(obj: PublishableEntityMixin): ) -def modify_entity(obj: GenericEntity): +def modify_entity(obj: GenericEntity, title="Newly modified entity"): """Modify a GenericEntity, creating a new version with a new title""" assert isinstance(obj, GenericEntity) new_raw_version = publishing_api.create_publishable_entity_version( - obj.pk, version_num=2, title="New", created=now, created_by=None + obj.pk, version_num=obj.versioning.latest.version_num + 1, title=title, created=now, created_by=None ) return GenericEntityVersion.objects.create(pk=new_raw_version.pk) @@ -1261,7 +1265,142 @@ def test_get_entities_in_container_soft_deletion_pinned( # get_entities_in_container_as_of + + +def test_snapshots_of_published_unit(lp: LearningPackage, child_entity1: GenericEntity, child_entity2: GenericEntity): + """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_generic_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: GenericContainer, + parent_of_three: GenericContainer, + parent_of_six: GenericContainer, + child_entity1: GenericEntity, + child_entity2: GenericEntity, + child_entity3: GenericEntity, + lp2: LearningPackage, + other_lp_parent: GenericContainer, + other_lp_child: GenericEntity, + 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 list(publishing_api.get_containers_with_entity(other_lp_parent.publishable_entity.pk)) == [] + + # get_container_children_count @@ -1827,3 +1966,11 @@ def test_container_next_version(self, lp: LearningPackage) -> None: 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/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index cd831e45..d4469174 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -153,267 +153,3 @@ def test_create_next_unit_version_with_unpinned_and_pinned_components(self): # There is no published version of the unit: content_api.get_entities_in_container(unit, published=True) - - - - 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(entities=[self.component_1]) - self.modify_component(self.component_1, title="Component 1 as of checkpoint 1") - _, before_publish = content_api.get_entities_in_container_as_of(unit, 0) - assert not before_publish # Empty list - - # 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_container_version( - unit.pk, - title="Unit title in checkpoint 3", - entities=[self.component_1, self.component_2], - created=self.now, - created_by=None, - ) - # 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_container_version( - unit.pk, - title="Unit title in checkpoint 4", - entities=[self.component_1_v1, self.component_1, self.component_2], - created=self.now, - created_by=None, - ) - # 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_entities_in_container_as_of(unit, checkpoint_1.pk) - assert [ev.entity_version.componentversion.title for ev in as_of_checkpoint_1] == [ - "Component 1 as of checkpoint 1", - ] - _, as_of_checkpoint_2 = content_api.get_entities_in_container_as_of(unit, checkpoint_2.pk) - assert [ev.entity_version.componentversion.title for ev in as_of_checkpoint_2] == [ - "Component 1 as of checkpoint 2", - ] - _, as_of_checkpoint_3 = content_api.get_entities_in_container_as_of(unit, checkpoint_3.pk) - assert [ev.entity_version.componentversion.title for ev in as_of_checkpoint_3] == [ - "Component 1 as of checkpoint 3", - "Component 2 as of checkpoint 3", - ] - _, as_of_checkpoint_4 = content_api.get_entities_in_container_as_of(unit, checkpoint_4.pk) - assert [ev.entity_version.componentversion.title for ev 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(entities=[self.component_1_v1, self.component_2], key="u1") - # Unit 2 ✅ has component 1, pinned 📌 to V2 - unit2_1pinned_v2 = self.create_unit(entities=[component_1_v2, self.component_2_v1], key="u2") - # Unit 3 doesn't contain it - _unit3_no = self.create_unit(entities=[self.component_2], key="u3") - # Unit 4 ✅ has component 1, unpinned - unit4_unpinned = self.create_unit( - entities=[ - self.component_1, - self.component_2, - self.component_2_v1, - ], - key="u4", - ) - # Units 5/6 don't contain it - _unit5_no = self.create_unit(entities=[self.component_2_v1, self.component_2], key="u5") - _unit6_no = self.create_unit(entities=[], 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( - entities=[ - 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_entities_in_container_queries(self): - """ - Test the query count of get_entities_in_container() - This also tests the generic method get_entities_in_container() - """ - unit = self.create_unit( - entities=[ - self.component_1, - self.component_2, - self.component_2_v1, - ] - ) - with self.assertNumQueries(2): - result = content_api.get_entities_in_container(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(2): - result = content_api.get_entities_in_container(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 = self.create_unit_and_version( - key="unit:key", - title="Unit", - entities=[self.component_1], - ) - assert content_api.get_entities_in_container(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_container_version( - unit.pk, - title=unit_version.title, - entities=[ - 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_entities_in_container(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_container_version( - unit.pk, - title=unit_version.title, - entities=[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_entities_in_container(unit, published=False) == [ - Entry(self.component_2.versioning.draft), - Entry(component_3.versioning.draft), - ] - - def test_get_container_children_count(self): - """ - Test get_container_children_count() - """ - unit = self.create_unit(entities=[self.component_1]) - assert content_api.get_container_children_count(unit, published=False) == 1 - # publish - content_api.publish_all_drafts(self.learning_package.id) - unit_version = unit.versioning.draft - content_api.create_next_container_version( - unit.pk, - title=unit_version.title, - entities=[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, published=False) == 2 - assert content_api.get_container_children_count(unit, 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, 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, published=False) == 1 - content_api.publish_all_drafts(self.learning_package.id) - unit.refresh_from_db() - assert content_api.get_container_children_count(unit, published=True) == 1 - - # 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. 🫣 From 7baa35617353caef6201b8bde317591decf10ddd Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 18:32:24 -0700 Subject: [PATCH 30/34] rename GenericContainer -> TestContainer --- .../applets/publishing/test_containers.py | 320 +++++++++--------- tests/test_django_app/apps.py | 12 +- .../migrations/0001_initial.py | 8 +- tests/test_django_app/models.py | 16 +- 4 files changed, 180 insertions(+), 176 deletions(-) diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index 474c8bf2..0f09f75d 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -28,14 +28,14 @@ # 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 -# "GenericContainer" and "ContainerContainer" from test_django_app, which are specifically for testing the publishing +# "TestContainer" and "ContainerContainer" from test_django_app, which are specifically for testing the publishing # API. from tests.test_django_app.models import ( - GenericEntity, - GenericEntityVersion, - GenericContainer, - GenericContainerVersion, + TestEntity, + TestEntityVersion, + TestContainer, + TestContainerVersion, ContainerContainer, ContainerContainerVersion, ) @@ -63,15 +63,15 @@ def container_tear_down(): # # lp (LearningPackage) # ├─ grandparent (ContainerContainer) -# │ ├─ parent_of_two (GenericContainer) +# │ ├─ parent_of_two (TestContainer) # │ │ ├─ child_entity1 (PublishableEntity) # │ │ └─ child_entity2 (PublishableEntity) -# │ └─ parent_of_three (GenericContainer) +# │ └─ parent_of_three (TestContainer) # │ ├─ child_entity3 (📌 pinned to v1, PublishableEntity) # │ ├─ child_entity2 (📌 pinned to v1, PublishableEntity) # │ └─ child_entity1 (PublishableEntity) # │ -# ├─ parent_of_six (GenericContainer, has duplicate children) +# ├─ parent_of_six (TestContainer, has duplicate children) # │ ├─ child_entity3 (📌 pinned to v1, PublishableEntity) # │ ├─ child_entity2 (📌 pinned to v1, PublishableEntity) # │ ├─ child_entity1 (PublishableEntity) @@ -83,7 +83,7 @@ def container_tear_down(): # └─ child_entity1 (PublishableEntity) # # lp2 (LearningPackage) -# └─ other_lp_parent (GenericContainer) +# └─ other_lp_parent (TestContainer) # └─ other_lp_child (PublishableEntity) # # Note that the "child" entities are referenced in multiple containers @@ -107,10 +107,10 @@ def _lp2() -> LearningPackage: return publishing_api.create_learning_package(key="containers-test-lp2", title="Testing Containers (📦 2)") -def create_generic_entity(learning_package: LearningPackage, key: str, title: str) -> GenericEntity: - """Create a GenericEntity with a draft version""" +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 = GenericEntity.objects.create(publishable_entity=pe) + new_entity = TestEntity.objects.create(publishable_entity=pe) pev = publishing_api.create_publishable_entity_version( new_entity.pk, version_num=1, @@ -118,44 +118,44 @@ def create_generic_entity(learning_package: LearningPackage, key: str, title: st created=now, created_by=None, ) - GenericEntityVersion.objects.create(publishable_entity_version=pev) + TestEntityVersion.objects.create(publishable_entity_version=pev) return new_entity @pytest.fixture(name="child_entity1") -def _child_entity1(lp: LearningPackage) -> GenericEntity: +def _child_entity1(lp: LearningPackage) -> TestEntity: """An example entity, such as a component""" - return create_generic_entity(lp, key="child_entity1", title="Child 1 🌴") + return create_test_entity(lp, key="child_entity1", title="Child 1 🌴") @pytest.fixture(name="child_entity2") -def _child_entity2(lp: LearningPackage) -> GenericEntity: +def _child_entity2(lp: LearningPackage) -> TestEntity: """An example entity, such as a component""" - return create_generic_entity(lp, key="child_entity2", title="Child 2 🌈") + return create_test_entity(lp, key="child_entity2", title="Child 2 🌈") @pytest.fixture(name="child_entity3") -def _child_entity3(lp: LearningPackage) -> GenericEntity: +def _child_entity3(lp: LearningPackage) -> TestEntity: """An example entity, such as a component""" - return create_generic_entity(lp, key="child_entity3", title="Child 3 ⛵️") + return create_test_entity(lp, key="child_entity3", title="Child 3 ⛵️") @pytest.fixture(name="other_lp_child") -def _other_lp_child(lp2: LearningPackage) -> GenericEntity: +def _other_lp_child(lp2: LearningPackage) -> TestEntity: """An example entity, such as a component""" - return create_generic_entity(lp2, key="other_lp_child", title="Child in other Learning Package 📦") + return create_test_entity(lp2, key="other_lp_child", title="Child in other Learning Package 📦") -def create_generic_container( +def create_test_container( learning_package: LearningPackage, key: str, entities: publishing_api.EntityListInput, title: str = "" -) -> GenericEntity: - """Create a GenericEntity with a draft version""" +) -> TestEntity: + """Create a TestEntity 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=GenericContainer, + container_type=TestContainer, created=now, created_by=None, ) @@ -163,9 +163,9 @@ def create_generic_container( @pytest.fixture(name="parent_of_two") -def _parent_of_two(lp: LearningPackage, child_entity1: GenericEntity, child_entity2: GenericEntity) -> GenericContainer: - """An GenericContainer with two children""" - return create_generic_container( +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", @@ -176,12 +176,12 @@ def _parent_of_two(lp: LearningPackage, child_entity1: GenericEntity, child_enti @pytest.fixture(name="parent_of_three") def _parent_of_three( lp: LearningPackage, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, -) -> GenericContainer: - """An GenericContainer with three children, two of which are pinned""" - return create_generic_container( + 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", @@ -192,12 +192,12 @@ def _parent_of_three( @pytest.fixture(name="parent_of_six") def _parent_of_six( lp: LearningPackage, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, -) -> GenericContainer: - """An GenericContainer with six children, two of each entity, with different pinned combinations""" - return create_generic_container( + 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", @@ -216,14 +216,14 @@ def _parent_of_six( @pytest.fixture(name="grandparent") def _grandparent( lp: LearningPackage, - parent_of_two: GenericContainer, - parent_of_three: GenericContainer, + 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 GenericContainer children", + title="Generic Container with Two Unpinned TestContainer children", entities=[parent_of_two, parent_of_three], container_type=ContainerContainer, created=now, @@ -233,18 +233,18 @@ def _grandparent( @pytest.fixture(name="container_of_uninstalled_type") -def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: GenericEntity) -> Container: +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 GenericContainer, then we'll modify it to simulate it being 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=GenericContainer, + container_type=TestContainer, created=now, ) # Now create the plugin type (no public API for this; only do this in a test) @@ -254,14 +254,14 @@ def _container_of_uninstalled_type(lp: LearningPackage, child_entity1: GenericEn @pytest.fixture(name="other_lp_parent") -def _other_lp_parent(lp2: LearningPackage, other_lp_child: PublishableEntity) -> GenericContainer: - """An GenericContainer with one child""" +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=GenericContainer, + container_type=TestContainer, created=now, created_by=None, ) @@ -277,13 +277,13 @@ def publish_entity(obj: PublishableEntityMixin): ) -def modify_entity(obj: GenericEntity, title="Newly modified entity"): - """Modify a GenericEntity, creating a new version with a new title""" - assert isinstance(obj, GenericEntity) +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 GenericEntityVersion.objects.create(pk=new_raw_version.pk) + return TestEntityVersion.objects.create(pk=new_raw_version.pk) def Entry( @@ -304,20 +304,20 @@ def Entry( def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None: """ - Creating an empty GenericContainer. It will have only a draft version. + 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=GenericContainer, + container_type=TestContainer, created=now, created_by=admin_user.pk, can_stand_alone=False, ) - assert isinstance(container, GenericContainer) - assert isinstance(container_v1, GenericContainerVersion) + 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" @@ -333,9 +333,9 @@ def test_create_generic_empty_container(lp: LearningPackage, admin_user) -> None publishing_api.get_container_children_count(container, published=True) -def test_create_container_queries(lp: LearningPackage, child_entity1: GenericEntity, django_assert_num_queries) -> None: +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 = {"title": "Test Container", "created": now, "created_by": None, "container_type": GenericContainer} + base_args = {"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) @@ -347,27 +347,27 @@ def test_create_container_queries(lp: LearningPackage, child_entity1: GenericEnt # versioning helpers -def test_container_versioning_helpers(parent_of_two: GenericContainer): +def test_container_versioning_helpers(parent_of_two: TestContainer): """ - Test that the .versioning helper of a subclass like `GenericContainer` returns a `GenericContainerVersion`, and + 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, GenericContainer) + 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, GenericContainerVersion) + 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.genericcontainer == parent_of_two + 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: GenericContainer, other_user): +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. @@ -399,7 +399,7 @@ def test_create_next_container_version_no_changes(parent_of_two: GenericContaine def test_create_next_container_version_with_changes( - parent_of_two: GenericContainer, child_entity1: GenericEntity, child_entity2: GenericEntity + parent_of_two: TestContainer, child_entity1: TestEntity, child_entity2: TestEntity ): """ Test creating a new version of the "parent of two" container, changing the @@ -432,10 +432,10 @@ def test_create_next_container_version_with_changes( def test_create_next_container_version_with_append( - parent_of_two: GenericContainer, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, + 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. @@ -465,10 +465,10 @@ def test_create_next_container_version_with_append( def test_create_next_container_version_with_remove_1( - parent_of_six: GenericContainer, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, + 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. @@ -508,10 +508,10 @@ def test_create_next_container_version_with_remove_1( def test_create_next_container_version_with_remove_2( - parent_of_six: GenericContainer, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, + 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. @@ -546,10 +546,10 @@ def test_create_next_container_version_with_remove_2( def test_create_next_container_version_with_remove_3( - parent_of_six: GenericContainer, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, + 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. @@ -584,10 +584,10 @@ def test_create_next_container_version_with_remove_3( def test_create_next_container_version_with_remove_4( - parent_of_six: GenericContainer, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, + 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. @@ -621,7 +621,7 @@ def test_create_next_container_version_with_remove_4( ] -def test_create_next_container_version_with_conflicting_version(parent_of_two: GenericContainer): +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. @@ -658,7 +658,7 @@ def test_create_next_container_version_uninstalled_plugin(container_of_uninstall ) -def test_create_next_container_version_other_lp(parent_of_two: GenericContainer, other_lp_child: PublishableEntity): +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. """ @@ -675,7 +675,7 @@ def test_create_next_container_version_other_lp(parent_of_two: GenericContainer, # get_container -def test_get_container(parent_of_two: GenericContainer, django_assert_num_queries) -> None: +def test_get_container(parent_of_two: TestContainer, django_assert_num_queries) -> None: """ Test `get_container()` """ @@ -695,7 +695,7 @@ def test_get_container_nonexistent() -> None: publishing_api.get_container(-5000) -def test_get_container_soft_deleted(parent_of_two: GenericContainer) -> None: +def test_get_container_soft_deleted(parent_of_two: TestContainer) -> None: """ Test `get_container()` with a soft deleted container """ @@ -720,7 +720,7 @@ def test_get_container_uninstalled_type(container_of_uninstalled_type: Container # get_container_version -def test_get_container_version(parent_of_two: GenericContainer) -> None: +def test_get_container_version(parent_of_two: TestContainer) -> None: """ Test getting a specific container version """ @@ -740,13 +740,13 @@ def test_get_container_version_nonexistent() -> None: # get_container_by_key -def test_get_container_by_key(lp: LearningPackage, parent_of_two: GenericContainer) -> None: +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, parent_of_two.key) assert result == parent_of_two.container - # The API always returns "Container", not specific subclasses like GenericContainer: + # The API always returns "Container", not specific subclasses like TestContainer: assert result.__class__ is Container @@ -765,7 +765,7 @@ def test_get_container_by_key_nonexistent(lp: LearningPackage) -> None: def test_get_container_type( - grandparent: ContainerContainer, parent_of_two: GenericContainer, child_entity1: GenericEntity + grandparent: ContainerContainer, parent_of_two: TestContainer, child_entity1: TestEntity ): """ Test get_container_type_code() and get_container_type() @@ -779,13 +779,13 @@ def test_get_container_type( 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 "GenericContainer": - assert isinstance(parent_of_two, GenericContainer) + # "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 GenericContainer + 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 GenericContainer + 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): @@ -813,10 +813,10 @@ def test_get_container_type_deleted(container_of_uninstalled_type: Container): def test_get_containers( lp: LearningPackage, grandparent: ContainerContainer, - parent_of_two: GenericContainer, - parent_of_three: GenericContainer, + parent_of_two: TestContainer, + parent_of_three: TestContainer, lp2: LearningPackage, - other_lp_parent: GenericContainer, + other_lp_parent: TestContainer, ): """ Test that we can get all containers in a Learning Package @@ -840,8 +840,8 @@ def test_get_containers( def test_get_containers_soft_deleted( lp: LearningPackage, grandparent: ContainerContainer, - parent_of_two: GenericContainer, - parent_of_three: GenericContainer, + parent_of_two: TestContainer, + parent_of_three: TestContainer, ): """ Test that soft deleted containers are excluded from `get_containers()` by @@ -867,7 +867,7 @@ def test_get_containers_soft_deleted( def test_contains_unpublished_changes_queries( - grandparent: ContainerContainer, child_entity1: GenericEntity, django_assert_num_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. @@ -904,10 +904,10 @@ def test_contains_unpublished_changes_queries( def test_auto_publish_children( - parent_of_two: GenericContainer, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, + parent_of_two: TestContainer, + child_entity1: TestEntity, + child_entity2: TestEntity, + child_entity3: TestEntity, ): """ Test that publishing a container publishes its child components automatically. @@ -937,7 +937,7 @@ def test_auto_publish_children( assert child_entity3.versioning.published is None -def test_no_publish_parent(parent_of_two: GenericContainer, child_entity1: GenericEntity): +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 """ @@ -961,7 +961,7 @@ def test_no_publish_parent(parent_of_two: GenericContainer, child_entity1: Gener publishing_api.get_entities_in_container(parent_of_two, published=True) -def test_add_entity_after_publish(lp: LearningPackage, parent_of_two: GenericContainer, child_entity3: GenericEntity): +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. @@ -992,7 +992,7 @@ def test_add_entity_after_publish(lp: LearningPackage, parent_of_two: GenericCon def test_modify_unpinned_entity_after_publish( - parent_of_two: GenericContainer, child_entity1: GenericEntity, child_entity2: GenericEntity + 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 @@ -1047,10 +1047,10 @@ def test_modify_unpinned_entity_after_publish( def test_modify_pinned_entity( lp: LearningPackage, - parent_of_three: GenericContainer, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, + 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 @@ -1090,7 +1090,7 @@ 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 `GenericContainer` and `GenericEntity` standing + Note these are not actual "Unit"s nor "Components" but instead `TestContainer` and `TestEntity` standing in for them. Unit 1: components C1, C2, C3 @@ -1098,7 +1098,7 @@ def test_publishing_shared_component(lp: LearningPackage): Everything is "unpinned". """ # 1️⃣ Create the units and publish them: - c1, c2, c3, c4, c5 = [create_generic_entity(lp, key=f"C{i}", title=f"Component {i}") for i in range(1, 6)] + 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 @@ -1110,7 +1110,7 @@ def test_publishing_shared_component(lp: LearningPackage): key="unit:1", created=now, created_by=None, - container_type=GenericContainer, + container_type=TestContainer, ) unit2, _ = publishing_api.create_container_and_version( lp.pk, @@ -1119,7 +1119,7 @@ def test_publishing_shared_component(lp: LearningPackage): key="unit:2", created=now, created_by=None, - container_type=GenericContainer, + container_type=TestContainer, ) publishing_api.publish_all_drafts(lp.pk) assert publishing_api.contains_unpublished_changes(unit1.pk) is False @@ -1177,10 +1177,10 @@ def test_publishing_shared_component(lp: LearningPackage): def test_get_entities_in_container( - parent_of_three: GenericContainer, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, + parent_of_three: TestContainer, + child_entity1: TestEntity, + child_entity2: TestEntity, + child_entity3: TestEntity, ) -> None: """ Test `get_entities_in_container()` @@ -1203,10 +1203,10 @@ def test_get_entities_in_container( def test_get_entities_in_container_soft_deletion_unpinned( - parent_of_three: GenericContainer, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, + 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: @@ -1236,10 +1236,10 @@ def test_get_entities_in_container_soft_deletion_unpinned( def test_get_entities_in_container_soft_deletion_pinned( - parent_of_three: GenericContainer, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, + 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: @@ -1267,12 +1267,12 @@ def test_get_entities_in_container_soft_deletion_pinned( # get_entities_in_container_as_of -def test_snapshots_of_published_unit(lp: LearningPackage, child_entity1: GenericEntity, child_entity2: GenericEntity): +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_generic_container(lp, key="c", entities=[child_entity1]) + 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 @@ -1347,15 +1347,15 @@ def test_snapshots_of_published_unit(lp: LearningPackage, child_entity1: Generic def test_get_containers_with_entity_draft( lp: LearningPackage, grandparent: ContainerContainer, - parent_of_two: GenericContainer, - parent_of_three: GenericContainer, - parent_of_six: GenericContainer, - child_entity1: GenericEntity, - child_entity2: GenericEntity, - child_entity3: GenericEntity, + 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: GenericContainer, - other_lp_child: GenericEntity, + 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.""" @@ -1406,9 +1406,9 @@ def test_get_containers_with_entity_draft( def test_get_container_children_count( lp: LearningPackage, - parent_of_two: GenericContainer, - parent_of_three: GenericContainer, - parent_of_six: GenericContainer, + parent_of_two: TestContainer, + parent_of_three: TestContainer, + parent_of_six: TestContainer, grandparent: ContainerContainer, ): """Test `get_container_children_count()`""" @@ -1439,9 +1439,9 @@ def test_get_container_children_count( def test_get_container_children_count_soft_deletion( lp: LearningPackage, - parent_of_two: GenericContainer, - parent_of_six: GenericContainer, - child_entity2: GenericEntity, + 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) @@ -1457,8 +1457,8 @@ def test_get_container_children_count_soft_deletion( def test_get_container_children_count_queries( lp: LearningPackage, - parent_of_two: GenericContainer, - parent_of_six: GenericContainer, + parent_of_two: TestContainer, + parent_of_six: TestContainer, django_assert_num_queries, ): """Test how many database queries `get_container_children_count()` needs""" @@ -1476,7 +1476,7 @@ def test_get_container_children_count_queries( # get_container_children_entities_keys -def test_get_container_children_entities_keys(grandparent: ContainerContainer, parent_of_six: GenericContainer) -> None: +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. @@ -1500,7 +1500,7 @@ def test_get_container_children_entities_keys(grandparent: ContainerContainer, p # Container deletion -def test_soft_delete_container(lp: LearningPackage, parent_of_two: GenericContainer, child_entity1: GenericEntity): +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. @@ -1569,7 +1569,7 @@ def test_parent_child_side_effects(self, lp: LearningPackage) -> None: "my_container", created=now, created_by=None, - container_type=GenericContainer, + container_type=TestContainer, ) container_v1: ContainerVersion = publishing_api.create_container_version( container.pk, @@ -1649,7 +1649,7 @@ def test_bulk_parent_child_side_effects(self, lp: LearningPackage) -> None: "my_container", created=now, created_by=None, - container_type=GenericContainer, + container_type=TestContainer, ) container_v1: ContainerVersion = publishing_api.create_container_version( container.pk, @@ -1725,14 +1725,14 @@ def test_draft_dependency_multiple_parents(self, lp: LearningPackage) -> None: "unit_1", created=now, created_by=None, - container_type=GenericContainer, + container_type=TestContainer, ) unit_2 = publishing_api.create_container( lp.id, "unit_2", created=now, created_by=None, - container_type=GenericContainer, + container_type=TestContainer, ) for unit in [unit_1, unit_2]: publishing_api.create_container_version( @@ -1787,7 +1787,7 @@ def test_multiple_layers_of_containers(self, lp: LearningPackage) -> None: "unit_1", created=now, created_by=None, - container_type=GenericContainer, + container_type=TestContainer, ) publishing_api.create_container_version( unit.pk, @@ -1802,7 +1802,7 @@ def test_multiple_layers_of_containers(self, lp: LearningPackage) -> None: "subsection_1", created=now, created_by=None, - container_type=GenericContainer, + container_type=TestContainer, ) publishing_api.create_container_version( subsection.pk, @@ -1886,7 +1886,7 @@ def test_publish_all_layers(self, lp: LearningPackage) -> None: "unit_1", created=now, created_by=None, - container_type=GenericContainer, + container_type=TestContainer, ) publishing_api.create_container_version( unit.pk, @@ -1901,7 +1901,7 @@ def test_publish_all_layers(self, lp: LearningPackage) -> None: "subsection_1", created=now, created_by=None, - container_type=GenericContainer, + container_type=TestContainer, ) publishing_api.create_container_version( subsection.pk, @@ -1933,7 +1933,7 @@ def test_container_next_version(self, lp: LearningPackage) -> None: "my_container", created=now, created_by=None, - container_type=GenericContainer, + container_type=TestContainer, ) assert container.versioning.latest is None v1 = publishing_api.create_next_container_version( diff --git a/tests/test_django_app/apps.py b/tests/test_django_app/apps.py index 4b8908f7..e9a64468 100644 --- a/tests/test_django_app/apps.py +++ b/tests/test_django_app/apps.py @@ -25,16 +25,16 @@ def register_publishable_models(self): """ from openedx_content.api import register_publishable_models from .models import ( - GenericEntity, - GenericEntityVersion, - GenericContainer, - GenericContainerVersion, + TestEntity, + TestEntityVersion, + TestContainer, + TestContainerVersion, ContainerContainer, ContainerContainerVersion, ) - register_publishable_models(GenericEntity, GenericEntityVersion) - register_publishable_models(GenericContainer, GenericContainerVersion) + register_publishable_models(TestEntity, TestEntityVersion) + register_publishable_models(TestContainer, TestContainerVersion) register_publishable_models(ContainerContainer, ContainerContainerVersion) def ready(self): diff --git a/tests/test_django_app/migrations/0001_initial.py b/tests/test_django_app/migrations/0001_initial.py index 545df58b..02df4410 100644 --- a/tests/test_django_app/migrations/0001_initial.py +++ b/tests/test_django_app/migrations/0001_initial.py @@ -51,7 +51,7 @@ class Migration(migrations.Migration): bases=("openedx_content.containerversion",), ), migrations.CreateModel( - name="GenericContainer", + name="TestContainer", fields=[ ( "container", @@ -70,7 +70,7 @@ class Migration(migrations.Migration): bases=("openedx_content.container",), ), migrations.CreateModel( - name="GenericContainerVersion", + name="TestContainerVersion", fields=[ ( "container_version", @@ -89,7 +89,7 @@ class Migration(migrations.Migration): bases=("openedx_content.containerversion",), ), migrations.CreateModel( - name="GenericEntity", + name="TestEntity", fields=[ ( "publishable_entity", @@ -106,7 +106,7 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name="GenericEntityVersion", + name="TestEntityVersion", fields=[ ( "publishable_entity_version", diff --git a/tests/test_django_app/models.py b/tests/test_django_app/models.py index be0d80db..cdd94799 100644 --- a/tests/test_django_app/models.py +++ b/tests/test_django_app/models.py @@ -18,24 +18,27 @@ ) -class GenericEntity(PublishableEntityMixin): +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 GenericEntityVersion(PublishableEntityVersionMixin): +class TestEntityVersion(PublishableEntityVersionMixin): """ - A particular version of a GenericEntity. + A particular version of a TestEntity. """ + __test__ = False @Container.register_subclass -class GenericContainer(Container): +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" @@ -47,10 +50,11 @@ def validate_entity(cls, entity: PublishableEntity) -> None: """Allow any type of child""" -class GenericContainerVersion(ContainerVersion): +class TestContainerVersion(ContainerVersion): """ - A GenericContainerVersion is a specific version of a GenericContainer. + A TestContainerVersion is a specific version of a TestContainer. """ + __test__ = False container_version = models.OneToOneField( ContainerVersion, From e0d0f17c3d6d27619d6e54de8d64b182839e48ff Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 19:00:51 -0700 Subject: [PATCH 31/34] WIP --- src/openedx_content/applets/publishing/api.py | 4 ++-- tests/openedx_content/applets/publishing/test_containers.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 998499e6..f5d27b98 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -1633,12 +1633,12 @@ def create_container_and_version( key: str, *, title: str, - container_type: ContainerType, + container_type: type[ContainerModel], entities: EntityListInput | None = None, created: datetime, created_by: int | None = None, can_stand_alone: bool = True, -) -> tuple[Container, ContainerVersion]: +) -> tuple[ContainerModel, ContainerVersion]: """ [ 🛑 UNSTABLE ] Create a new unit and its version. diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index 0f09f75d..f3b48fae 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -148,8 +148,8 @@ def _other_lp_child(lp2: LearningPackage) -> TestEntity: def create_test_container( learning_package: LearningPackage, key: str, entities: publishing_api.EntityListInput, title: str = "" -) -> TestEntity: - """Create a TestEntity with a draft version""" +) -> TestContainer: + """Create a TestContainer with a draft version""" container, _version = publishing_api.create_container_and_version( learning_package.id, key=key, From 6ff1178f35b92cdb149113555acf42cf9d12d043 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 19:24:12 -0700 Subject: [PATCH 32/34] feat: automatically clear .versioning.draft when modifying a container --- src/openedx_content/applets/publishing/api.py | 11 +++++++++-- .../applets/publishing/test_containers.py | 14 ++++++-------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index f5d27b98..2226cc75 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -1744,7 +1744,8 @@ def create_next_entity_list( def create_next_container_version( - container_pk: int, + container: Container | int, + /, *, title: str | None = None, entities: EntityListInput | None = None, @@ -1788,7 +1789,9 @@ def create_next_container_version( use force_version_num to override the default behavior. """ 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: @@ -1816,6 +1819,10 @@ def create_next_container_version( 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): + PublishableEntity.draft.related.delete_cached_value(container.publishable_entity) return next_container_version diff --git a/tests/openedx_content/applets/publishing/test_containers.py b/tests/openedx_content/applets/publishing/test_containers.py index f3b48fae..7966ef92 100644 --- a/tests/openedx_content/applets/publishing/test_containers.py +++ b/tests/openedx_content/applets/publishing/test_containers.py @@ -378,14 +378,13 @@ 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) publishing_api.create_next_container_version( - parent_of_two.pk, + parent_of_two, created=v2_date, created_by=other_user.pk, # Specify no changes at all ) # Now it should have an incremented version number but be unchanged: - parent_of_two.refresh_from_db() version_2 = parent_of_two.versioning.draft assert version_2.version_num == 2 assert version_2.title == original_version.title @@ -411,7 +410,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( - parent_of_two.pk, + 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, @@ -420,7 +419,6 @@ def test_create_next_container_version_with_changes( ) # Now retrieve the new version: - parent_of_two.refresh_from_db() 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 @@ -447,14 +445,13 @@ def test_create_next_container_version_with_append( # Create a new version, APPENDing entity 3 and 📌 pinned entity1 (v1) version_2 = publishing_api.create_next_container_version( - parent_of_two.pk, + parent_of_two, entities=[child_entity3, child_entity1_v1], created=now, created_by=None, entities_action=publishing_api.ChildrenEntitiesAction.APPEND, ) - parent_of_two.refresh_from_db() 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 @@ -1427,12 +1424,13 @@ def test_get_container_children_count( # Add another container to "grandparent": publishing_api.create_next_container_version( - grandparent.pk, + grandparent, entities=[parent_of_two, parent_of_three, parent_of_six], created=now, created_by=None, ) - grandparent.refresh_from_db() # Warning: this is required + # 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 From 10836508c28bac44f50dcc8424569325e67027df Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 13 Mar 2026 19:43:16 -0700 Subject: [PATCH 33/34] WIP - refactoring test cases --- src/openedx_content/api.py | 2 +- src/openedx_content/applets/units/api.py | 104 +++++++++++++++ .../applets/components/test_api.py | 15 ++- .../openedx_content/applets/units/test_api.py | 124 ++++++------------ 4 files changed, 157 insertions(+), 88 deletions(-) create mode 100644 src/openedx_content/applets/units/api.py diff --git a/src/openedx_content/api.py b/src/openedx_content/api.py index 59964e5f..7c3e90b6 100644 --- a/src/openedx_content/api.py +++ b/src/openedx_content/api.py @@ -17,4 +17,4 @@ from .applets.publishing.api import * # from .applets.sections.api import * # from .applets.subsections.api import * -# from .applets.units.api import * +from .applets.units.api import * diff --git a/src/openedx_content/applets/units/api.py b/src/openedx_content/applets/units/api.py new file mode 100644 index 00000000..7795f8ca --- /dev/null +++ b/src/openedx_content/applets/units/api.py @@ -0,0 +1,104 @@ +"""Units API. + +This module provides functions to manage units. +""" + +from dataclasses import dataclass +from datetime import datetime +from functools import partial + +from ..publishing import api as publishing_api +from ..components.models import Component, ComponentVersion + +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__ = [ + "get_unit", + "create_unit_and_version", + "create_next_unit_version", + "UnitListEntry", + "get_components_in_unit", +] + + +def get_unit(unit_id: int, /): + """Get a unit""" + return Unit.objects.select_related("container").get(pk=unit_id) + + +create_unit_and_version = partial(publishing_api.create_container_and_version, container_type=Unit) + + +def create_next_unit_version( + unit: Unit | int, + *, + title: str | None = None, + components: list[Component | ComponentVersion] | None = None, + created: datetime, + created_by: int | None, +) -> UnitVersion: + """ + See documentation of content_api.create_next_container_version() + + 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. + """ + if isinstance(unit, int): + unit = get_unit(unit) + assert isinstance(unit, Unit) + return publishing_api.create_next_container_version( + unit, + title=title, + entities=components, + created=created, + created_by=created_by, + # For now, `entities_action` and `force_version_num` are unsupported but we could add them in the future. + ) + + +@dataclass(frozen=True) +class UnitListEntry: + """ + [ 🛑 UNSTABLE ] + Data about a single entity in a container, e.g. a component in a unit. + """ + + component_version: ComponentVersion + pinned: bool = False + + @property + def component(self): + return self.component_version.component + + +def get_components_in_unit( + unit: Unit, + *, + published: bool, +) -> list[UnitListEntry]: + """ + [ 🛑 UNSTABLE ] + Get the list of entities and their versions in the draft or published + version of the given Unit. + + Args: + unit: The Unit, e.g. returned by `get_unit()` + published: `True` if we want the published version of the unit, or + `False` for the draft version. + """ + assert isinstance(unit, Unit) + components = [] + entries = publishing_api.get_entities_in_container( + unit, + published=published, + select_related_version="componentversion", + ) + 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 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/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index d4469174..2993a0e3 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -1,19 +1,23 @@ """ Basic tests for the units API. """ +from datetime import datetime -import ddt # type: ignore[import] import pytest from django.core.exceptions import ValidationError import openedx_content.api as content_api from openedx_content import models_api as content_models -from ..publishing.test_containers import BaseContainerTestCase, Entry +import openedx_content.api as content_api +from openedx_content import models_api as content_models + +from ..components.test_api import ComponentTestCase +Entry = content_api.UnitListEntry -@ddt.ddt -class UnitsTestCase(BaseContainerTestCase): + +class UnitsTestCase(ComponentTestCase): """Test cases for Units (containers of components)""" def setUp(self) -> None: @@ -27,55 +31,23 @@ def setUp(self) -> None: title="Querying Counting Problem (2)", ) - def test_get_container(self): - """ - Test get_container() - """ - unit = self.create_unit(entities=[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_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 = self.create_unit_and_version( - key="unit:key", - title="Unit", - ) - assert unit.versioning.draft == unit_version - unit2 = self.create_unit( - key="unit:key2", - title="Unit 2", + def create_unit_with_components( + self, + components: list[content_models.Component | content_models.ComponentVersion], + *, + title="Unit", + key="unit:key", + ) -> content_models.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, + title=title, + entities=components, + created=self.now, + created_by=None, ) - # 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_container_version( - container_pk=unit.pk, - title="Unit Containing a Unit", - entities=[unit2], - created=self.now, - created_by=None, - ) - # Check that a new version was not created: - unit.refresh_from_db() - assert content_api.get_container(unit.pk).versioning.draft == unit_version.container_version - assert unit.versioning.draft == unit_version - - def test_add_deleted_component(self): - """ - Test adding a deleted component. - Mostly this checks that the exception thrown is reasonable. - """ - self.component_1.delete() - with pytest.raises(content_models.Component.DoesNotExist): - self.create_unit(entities=[self.component_1]) + return unit def test_create_empty_unit_and_version(self): """Test creating a unit with no components. @@ -86,10 +58,15 @@ def test_create_empty_unit_and_version(self): 3. The unit is a draft with unpublished changes. 4. There is no published version of the unit. """ - unit, unit_version = self.create_unit_and_version( + unit, unit_version = content_api.create_unit_and_version( + learning_package_id=self.learning_package.pk, key="unit:key", title="Unit", + created=self.now, + created_by=None, ) + assert isinstance(unit, content_models.Unit) + assert isinstance(unit_version, content_models.UnitVersion) assert unit, unit_version assert unit_version.version_num == 1 assert unit_version in unit.versioning.versions.all() @@ -107,49 +84,24 @@ 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 = self.create_unit( - key="unit:key", - title="Unit", - ) - unit_version_v2 = content_api.create_next_container_version( - unit.pk, + unit = self.create_unit_with_components([]) + unit_version_v2 = content_api.create_next_unit_version( + unit, title="Unit", - entities=[self.component_1, self.component_2], + components=[self.component_1, self.component_2], 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_entities_in_container(unit, published=False) == [ + 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(content_models.ContainerVersion.DoesNotExist): # There is no published version of the unit: - content_api.get_entities_in_container(unit, published=True) + 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 = self.create_unit_and_version( - key="unit:key", - title="Unit", - ) - unit_version_v2 = content_api.create_next_container_version( - unit.pk, - title="Unit", - entities=[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_entities_in_container(unit, published=False) == [ - Entry(self.component_1_v1), - Entry(self.component_2_v1, pinned=True), # Pinned 📌 to v1 - ] - with pytest.raises(content_models.ContainerVersion.DoesNotExist): - # There is no published version of the unit: - content_api.get_entities_in_container(unit, published=True) + # TODO: verify that the same thing works if we call the generic create_next_container_version API too. + # TODO: verify that only Components can be added to the unit, whether through create() or create_next_version() APIs \ No newline at end of file From 027f7af3b0be58ebebee5f9655ad3b73dd7addae Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Sat, 14 Mar 2026 16:35:47 -0700 Subject: [PATCH 34/34] WIP - refactoring units test cases --- src/openedx_content/applets/units/api.py | 14 ++-- .../openedx_content/applets/units/test_api.py | 64 +++++++++++++++---- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/openedx_content/applets/units/api.py b/src/openedx_content/applets/units/api.py index 7795f8ca..70f6ed49 100644 --- a/src/openedx_content/applets/units/api.py +++ b/src/openedx_content/applets/units/api.py @@ -8,6 +8,7 @@ from functools import partial from ..publishing import api as publishing_api +from ..publishing.models import ContainerVersion from ..components.models import Component, ComponentVersion from .models import Unit, UnitVersion @@ -91,11 +92,14 @@ 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 diff --git a/tests/openedx_content/applets/units/test_api.py b/tests/openedx_content/applets/units/test_api.py index 2993a0e3..3fbf9200 100644 --- a/tests/openedx_content/applets/units/test_api.py +++ b/tests/openedx_content/applets/units/test_api.py @@ -1,18 +1,15 @@ """ Basic tests for the units API. """ -from datetime import datetime import pytest from django.core.exceptions import ValidationError import openedx_content.api as content_api -from openedx_content import models_api as content_models - -import openedx_content.api as content_api -from openedx_content import models_api as content_models +from openedx_content.models_api import Component, ComponentVersion, Unit, UnitVersion from ..components.test_api import ComponentTestCase +from tests.test_django_app.models import TestContainer Entry = content_api.UnitListEntry @@ -33,11 +30,11 @@ def setUp(self) -> None: def create_unit_with_components( self, - components: list[content_models.Component | content_models.ComponentVersion], + components: list[Component | ComponentVersion], *, title="Unit", key="unit:key", - ) -> content_models.Unit: + ) -> 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, @@ -65,8 +62,8 @@ def test_create_empty_unit_and_version(self): created=self.now, created_by=None, ) - assert isinstance(unit, content_models.Unit) - assert isinstance(unit_version, content_models.UnitVersion) + 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() @@ -92,16 +89,59 @@ def test_create_next_unit_version_with_two_unpinned_components(self): 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(content_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) - # TODO: verify that the same thing works if we call the generic create_next_container_version API too. + def test_get_unit(self) -> None: + """Test `get_unit()`""" + unit = self.create_unit_with_components([self.component_1]) + + unit_retrieved = content_api.get_unit(unit.pk) + assert isinstance(unit_retrieved, Unit) + assert unit_retrieved == unit + + 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) - # TODO: verify that only Components can be added to the unit, whether through create() or create_next_version() APIs \ No newline at end of file + 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, + container_type=TestContainer, + ) + with pytest.raises(Unit.DoesNotExist): + content_api.get_unit(other_container.pk) + + + # TODO: verify that only Components can be added to the unit, whether through create() or create_next_version() APIs + + def test_unit_queries(self) -> None: + """ + Test the number of queries needed for each part of the units API + """ + 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), + ) + with self.assertNumQueries(3): + result = content_api.get_components_in_unit(unit, published=True) + assert result == [ + Entry(self.component_1_v1), + Entry(self.component_2_v1, pinned=True), + ]