From f305d061fce319784c896f487775507640078154 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Fri, 25 Oct 2024 16:43:37 +0200 Subject: [PATCH 01/27] feat: implement sketch of models and API for containers --- openedx_learning/api/authoring.py | 2 + .../apps/authoring/containers/__init__.py | 0 .../apps/authoring/containers/api.py | 473 ++++++++++++++++++ .../apps/authoring/containers/apps.py | 26 + .../containers/migrations/0001_initial.py | 55 ++ .../containers/migrations/__init__.py | 0 .../apps/authoring/containers/models.py | 154 ++++++ .../apps/authoring/containers/models_mixin.py | 106 ++++ .../apps/authoring/units/__init__.py | 0 openedx_learning/apps/authoring/units/api.py | 218 ++++++++ openedx_learning/apps/authoring/units/apps.py | 25 + .../units/migrations/0001_initial.py | 38 ++ .../authoring/units/migrations/__init__.py | 0 .../apps/authoring/units/models.py | 23 + projects/dev.py | 2 + test_settings.py | 2 + .../apps/authoring/units/__init__.py | 0 .../apps/authoring/units/test_api.py | 198 ++++++++ 18 files changed, 1322 insertions(+) create mode 100644 openedx_learning/apps/authoring/containers/__init__.py create mode 100644 openedx_learning/apps/authoring/containers/api.py create mode 100644 openedx_learning/apps/authoring/containers/apps.py create mode 100644 openedx_learning/apps/authoring/containers/migrations/0001_initial.py create mode 100644 openedx_learning/apps/authoring/containers/migrations/__init__.py create mode 100644 openedx_learning/apps/authoring/containers/models.py create mode 100644 openedx_learning/apps/authoring/containers/models_mixin.py create mode 100644 openedx_learning/apps/authoring/units/__init__.py create mode 100644 openedx_learning/apps/authoring/units/api.py create mode 100644 openedx_learning/apps/authoring/units/apps.py create mode 100644 openedx_learning/apps/authoring/units/migrations/0001_initial.py create mode 100644 openedx_learning/apps/authoring/units/migrations/__init__.py create mode 100644 openedx_learning/apps/authoring/units/models.py create mode 100644 tests/openedx_learning/apps/authoring/units/__init__.py create mode 100644 tests/openedx_learning/apps/authoring/units/test_api.py diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index 1da667af..2fe11e93 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -13,6 +13,8 @@ from ..apps.authoring.components.api import * from ..apps.authoring.contents.api import * from ..apps.authoring.publishing.api import * +from ..apps.authoring.containers.api import * +from ..apps.authoring.units.api import * # This was renamed after the authoring API refactoring pushed this and other # app APIs into the openedx_learning.api.authoring module. Here I'm aliasing to diff --git a/openedx_learning/apps/authoring/containers/__init__.py b/openedx_learning/apps/authoring/containers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py new file mode 100644 index 00000000..4feb3a5f --- /dev/null +++ b/openedx_learning/apps/authoring/containers/api.py @@ -0,0 +1,473 @@ +""" +Containers API. + +This module provides a set of functions to interact with the containers +models in the Open edX Learning platform. +""" + +from django.db.transaction import atomic +from django.db.models import QuerySet + +from datetime import datetime +from ..containers.models import ( + ContainerEntity, + ContainerEntityVersion, + EntityList, + EntityListRow, +) +from ..publishing.models import PublishableEntity +from ..publishing import api as publishing_api + + +__all__ = [ + "create_container", + "create_container_version", + "create_next_container_version", + "create_container_and_version", + "get_container", + "get_defined_list_rows_for_container_version", + "get_initial_list_rows_for_container_version", + "get_frozen_list_rows_for_container_version", +] + + +def create_container( + learning_package_id: int, + key: str, + created: datetime, + created_by: int | None, +) -> ContainerEntity: + """ + Create a new container. + + Args: + learning_package_id: The ID of the learning package that contains the container. + key: The key of the container. + created: The date and time the container was created. + created_by: The ID of the user who created the container + + Returns: + The newly created container. + """ + with atomic(): + publishable_entity = publishing_api.create_publishable_entity( + learning_package_id, key, created, created_by + ) + container = ContainerEntity.objects.create( + publishable_entity=publishable_entity, + ) + return container + + +def create_entity_list() -> EntityList: + """ + Create a new entity list. This is an structure that holds a list of entities + that will be referenced by the container. + + Returns: + The newly created entity list. + """ + return EntityList.objects.create() + + +def create_next_defined_list( + previous_entity_list: EntityList | None, + new_entity_list: EntityList, + entity_pks: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], +) -> EntityListRow: + """ + Create new entity list rows for an entity list. + + Args: + previous_entity_list: The previous entity list that the new entity list is based on. + new_entity_list: The entity list to create the rows for. + entity_pks: The IDs of the publishable entities that the entity list rows reference. + draft_version_pks: The IDs of the draft versions of the entities (PublishableEntityVersion) that the entity list rows reference. + published_version_pks: The IDs of the published versions of the entities (PublishableEntityVersion) that the entity list rows reference. + + Returns: + The newly created entity list rows. + """ + order_nums = range(len(entity_pks)) + with atomic(): + # Case 1: create first container version (no previous rows created for container) + # 1. Create new rows for the entity list + # Case 2: create next container version (previous rows created for container) + # 1. Get all the rows in the previous version entity list + # 2. Only associate existent rows to the new entity list iff: the order is the same, the PublishableEntity is in entity_pks and versions are not pinned + # 3. If the order is different for a row with the PublishableEntity, create new row with the same PublishableEntity for the new order + # and associate the new row to the new entity list + current_rows = previous_entity_list.entitylistrow_set.all() + publishable_entities_in_rows = {row.entity.pk: row for row in current_rows} + new_rows = [] + for order_num, entity_pk, draft_version_pk, published_version_pk in zip( + order_nums, entity_pks, draft_version_pks, published_version_pks + ): + row = publishable_entities_in_rows.get(entity_pk) + if row and row.order_num == order_num: + new_entity_list.entitylistrow_set.add(row) + continue + new_rows.append( + EntityListRow( + entity_list=new_entity_list, + entity_id=entity_pk, + order_num=order_num, + draft_version_id=draft_version_pk, + published_version_id=published_version_pk, + ) + ) + EntityListRow.objects.bulk_create(new_rows) + return new_entity_list + +def create_defined_list_with_rows( + entity_pks: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], +) -> EntityList: + """ + Create new entity list rows for an entity list. + + Args: + entity_list: The entity list to create the rows for. + entity_pks: The IDs of the publishable entities that the entity list rows reference. + draft_version_pks: The IDs of the draft versions of the entities (PublishableEntityVersion) that the entity list rows reference. + published_version_pks: The IDs of the published versions of the entities (PublishableEntityVersion) that the entity list rows reference. + + Returns: + The newly created entity list. + """ + order_nums = range(len(entity_pks)) + with atomic(): + entity_list = create_entity_list() + EntityListRow.objects.bulk_create( + [ + EntityListRow( + entity_list=entity_list, + entity_id=entity_pk, + order_num=order_num, + draft_version_id=draft_version_pk, + published_version_id=published_version_pk, + ) + for order_num, entity_pk, draft_version_pk, published_version_pk in zip( + order_nums, entity_pks, draft_version_pks, published_version_pks + ) + ] + ) + return entity_list + + +def get_entity_list_with_pinned_versions( + rows: QuerySet[EntityListRow], +) -> EntityList: + """ + Copy rows from an existing entity list to a new entity list. + + Args: + entity_list: The entity list to copy the rows to. + rows: The rows to copy to the new entity list. + + Returns: + The newly created entity list. + """ + entity_list = create_entity_list() + with atomic(): + _ = EntityListRow.objects.bulk_create( + [ + EntityListRow( + entity_list=entity_list, + entity_id=row.entity.id, + order_num=row.order_num, + draft_version_id=None, + published_version_id=None, # For simplicity, we are not copying the pinned versions + ) + for row in rows + ] + ) + + return entity_list + + +def check_unpinned_versions_in_defined_list( + defined_list: EntityList, +) -> bool: + """ + Check if there are any unpinned versions in the defined list. + + Args: + defined_list: The defined list to check for unpinned versions. + + Returns: + True if there are unpinned versions in the defined list, False otherwise. + """ + # Is there a way to short-circuit this? + return any( + row.draft_version is None or row.published_version is None + for row in defined_list.entitylistrow_set.all() + ) + + +def check_new_changes_in_defined_list( + entity_list: EntityList, + publishable_entities_pk: list[int], +) -> bool: + """ + Check if there are any new changes in the defined list. + + Args: + entity_list: The entity list to check for new changes. + publishable_entities: The publishable entities to check for new changes. + + Returns: + True if there are new changes in the defined list, False otherwise. + """ + # Is there a way to short-circuit this? Using queryset operations + # For simplicity, return True + return True + + +def create_container_version( + container_pk: int, + version_num: int, + title: str, + publishable_entities_pk: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], + entity: PublishableEntity, + created: datetime, + created_by: int | None, +) -> ContainerEntityVersion: + """ + Create a new container version. + + Args: + container_pk: The ID of the container that the version belongs to. + version_num: The version number of the container. + title: The title of the container. + publishable_entities_pk: The IDs of the members of the container. + entity: The entity that the container belongs to. + created: The date and time the container version was created. + created_by: The ID of the user who created the container version. + + Returns: + The newly created container version. + """ + with atomic(): + publishable_entity_version = publishing_api.create_publishable_entity_version( + entity.pk, + version_num=version_num, + title=title, + created=created, + created_by=created_by, + ) + defined_list = create_defined_list_with_rows( + entity_pks=publishable_entities_pk, + draft_version_pks=draft_version_pks, + published_version_pks=published_version_pks, + ) + container_version = ContainerEntityVersion.objects.create( + publishable_entity_version=publishable_entity_version, + container_id=container_pk, + defined_list=defined_list, + initial_list=defined_list, + # TODO: Check for unpinned versions in defined_list to know whether to point this to the defined_list + # point to None. + # If this is the first version ever created for this ContainerEntity, then start as None. + frozen_list=None, + ) + return container_version + + +def create_next_container_version( + container_pk: int, + title: str, + publishable_entities_pk: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], + entity: PublishableEntity, + created: datetime, + created_by: int | None, +) -> ContainerEntityVersion: + """ + Create the next version of a container. A new version of the container is created + only when its metadata changes: + + * Something was added to the Container. + * We re-ordered the rows in the container. + * Something was removed to the container. + * The Container's metadata changed, e.g. the title. + * We pin to different versions of the Container. + + Args: + container_pk: The ID of the container to create the next version of. + title: The title of the container. + publishable_entities_pk: The IDs of the members current members of the container. + entity: The entity that the container belongs to. + created: The date and time the container version was created. + created_by: The ID of the user who created the container version. + + Returns: + The newly created container version. + """ + with atomic(): + container = ContainerEntity.objects.get(pk=container_pk) + last_version = container.versioning.latest + next_version_num = last_version.version_num + 1 + publishable_entity_version = publishing_api.create_publishable_entity_version( + entity.pk, + version_num=next_version_num, + title=title, + created=created, + created_by=created_by, + ) + # 1. Check if there are any changes in the container's members + # 2. Pin versions in previous frozen list for last container version + # 3. Create new defined list for author changes + # 4. Pin versions in defined list to create initial list + # 5. Check for unpinned references in defined_list to determine if frozen_list should be None + # 6. Point frozen_list to None or defined_list + if check_new_changes_in_defined_list( + entity_list=last_version.defined_list, + publishable_entities_pk=publishable_entities_pk, + ): + # Only change if there are unpin versions in defined list, meaning last frozen list is None + # When does this has to happen? Before? + if not last_version.frozen_list: + last_version.frozen_list = get_entity_list_with_pinned_versions( + rows=last_version.defined_list.entitylistrow_set.all() + ) + last_version.save() + next_defined_list = create_next_defined_list( + previous_entity_list=last_version.defined_list, + new_entity_list=create_entity_list(), + entity_pks=publishable_entities_pk, + draft_version_pks=draft_version_pks, + published_version_pks=published_version_pks, + ) + next_initial_list = get_entity_list_with_pinned_versions( + rows=next_defined_list.entitylistrow_set.all() + ) + if check_unpinned_versions_in_defined_list(next_defined_list): + next_frozen_list = None + else: + next_frozen_list = next_initial_list + else: + # Do I need to create new EntityList and copy rows? + # I do think so because frozen can change when creating a new version + # Does it need to change though? + # What would happen if I only change the title? + next_defined_list = last_version.defined_list + next_initial_list = last_version.initial_list + next_frozen_list = last_version.frozen_list + next_container_version = ContainerEntityVersion.objects.create( + publishable_entity_version=publishable_entity_version, + container_id=container_pk, + defined_list=next_defined_list, + initial_list=next_initial_list, + frozen_list=next_frozen_list, + ) + + return next_container_version + + +def create_container_and_version( + learning_package_id: int, + key: str, + created: datetime, + created_by: int | None, + title: str, + publishable_entities_pk: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], +) -> ContainerEntityVersion: + """ + Create a new container and its first version. + + Args: + learning_package_id: The ID of the learning package that contains the container. + key: The key of the container. + created: The date and time the container was created. + created_by: The ID of the user who created the container. + version_num: The version number of the container. + title: The title of the container. + members_pk: The IDs of the members of the container. + + Returns: + The newly created container version. + """ + with atomic(): + container = create_container(learning_package_id, key, created, created_by) + container_version = create_container_version( + container_pk=container.publishable_entity.pk, + version_num=1, + title=title, + publishable_entities_pk=publishable_entities_pk, + draft_version_pks=draft_version_pks, + published_version_pks=published_version_pks, + entity=container.publishable_entity, + created=created, + created_by=created_by, + ) + return (container, container_version) + + +def get_container(pk: int) -> ContainerEntity: + """ + Get a container by its primary key. + + Args: + pk: The primary key of the container. + + Returns: + The container with the given primary key. + """ + # TODO: should this use with_publishing_relations as in components? + return ContainerEntity.objects.get(pk=pk) + + +def get_defined_list_rows_for_container_version( + container_version: ContainerEntityVersion, +) -> QuerySet[EntityListRow]: + """ + Get the user-defined members of a container version. + + Args: + container_version: The container version to get the members of. + + Returns: + The members of the container version. + """ + return container_version.defined_list.entitylistrow_set.all() + + +def get_initial_list_rows_for_container_version( + container_version: ContainerEntityVersion, +) -> QuerySet[EntityListRow]: + """ + Get the initial members of a container version. + + Args: + container_version: The container version to get the initial members of. + + Returns: + The initial members of the container version. + """ + return container_version.initial_list.entitylistrow_set.all() + + +def get_frozen_list_rows_for_container_version( + container_version: ContainerEntityVersion, +) -> QuerySet[EntityListRow]: + """ + Get the frozen members of a container version. + + Args: + container_version: The container version to get the frozen members of. + + Returns: + The frozen members of the container version. + """ + if container_version.frozen_list is None: + return QuerySet[EntityListRow]() + return container_version.frozen_list.entitylistrow_set.all() diff --git a/openedx_learning/apps/authoring/containers/apps.py b/openedx_learning/apps/authoring/containers/apps.py new file mode 100644 index 00000000..e8b2e36a --- /dev/null +++ b/openedx_learning/apps/authoring/containers/apps.py @@ -0,0 +1,26 @@ +""" +Containers Django application initialization. +""" + +from django.apps import AppConfig + + +class ContainersConfig(AppConfig): + """ + Configuration for the containers Django application. + """ + + name = "openedx_learning.apps.authoring.containers" + verbose_name = "Learning Core > Authoring > Containers" + default_auto_field = "django.db.models.BigAutoField" + label = "oel_containers" + + + def ready(self): + """ + Register ContainerEntity and ContainerEntityVersion. + """ + from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel + from .models import ContainerEntity, ContainerEntityVersion # pylint: disable=import-outside-toplevel + + register_content_models(ContainerEntity, ContainerEntityVersion) diff --git a/openedx_learning/apps/authoring/containers/migrations/0001_initial.py b/openedx_learning/apps/authoring/containers/migrations/0001_initial.py new file mode 100644 index 00000000..47c4a4ab --- /dev/null +++ b/openedx_learning/apps/authoring/containers/migrations/0001_initial.py @@ -0,0 +1,55 @@ +# Generated by Django 4.2.16 on 2024-10-29 12:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('oel_publishing', '0002_alter_learningpackage_key_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ContainerEntity', + fields=[ + ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='EntityList', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), + migrations.CreateModel( + name='EntityListRow', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order_num', models.PositiveIntegerField()), + ('draft_version', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='draft_version', to='oel_publishing.publishableentityversion')), + ('entity', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='oel_publishing.publishableentity')), + ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.entitylist')), + ('published_version', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='published_version', to='oel_publishing.publishableentityversion')), + ], + ), + migrations.CreateModel( + name='ContainerEntityVersion', + fields=[ + ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')), + ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_containers.containerentity')), + ('defined_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='defined_list', to='oel_containers.entitylist')), + ('frozen_list', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='frozen_list', to='oel_containers.entitylist')), + ('initial_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='initial_list', to='oel_containers.entitylist')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/openedx_learning/apps/authoring/containers/migrations/__init__.py b/openedx_learning/apps/authoring/containers/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/containers/models.py b/openedx_learning/apps/authoring/containers/models.py new file mode 100644 index 00000000..66588bf3 --- /dev/null +++ b/openedx_learning/apps/authoring/containers/models.py @@ -0,0 +1,154 @@ +from django.db import models + +from openedx_learning.apps.authoring.publishing.models import ( + PublishableEntity, + PublishableEntityVersion, +) +from ..publishing.model_mixins import ( + PublishableEntityMixin, + PublishableEntityVersionMixin, +) + + +class EntityList(models.Model): + """ + EntityLists are a common structure to hold parent-child relations. + + EntityLists are not PublishableEntities in and of themselves. That's because + sometimes we'll want the same kind of data structure for things that we + dynamically generate for individual students (e.g. Variants). EntityLists are + anonymous in a senseโ€“they're pointed to by ContainerEntityVersions and + other models, rather than being looked up by their own identifers. + """ + + pass + + +class EntityListRow(models.Model): + """ + Each EntityListRow points to a PublishableEntity, optionally at a specific + version. + + There is a row in this table for each member of an EntityList. The order_num + field is used to determine the order of the members in the list. + """ + + entity_list = models.ForeignKey(EntityList, on_delete=models.CASCADE) + + # This ordering should be treated as immutableโ€“if the ordering needs to + # change, we create a new EntityList and copy things over. + order_num = models.PositiveIntegerField() + + # Simple case would use these fields with our convention that null versions + # means "get the latest draft or published as appropriate". These entities + # could be Selectors, in which case we'd need to do more work to find the right + # variant. The publishing app itself doesn't know anything about Selectors + # however, and just treats it as another PublishableEntity. + entity = models.ForeignKey(PublishableEntity, on_delete=models.RESTRICT) + + # The version references point to the specific PublishableEntityVersion that + # this EntityList has for this PublishableEntity for both the draft and + # published states. However, we don't want to have to create new EntityList + # every time that a member is updated, because that would waste a lot of + # space and make it difficult to figure out when the metadata of something + # like a Unit *actually* changed, vs. when its child members were being + # updated. Doing so could also potentially lead to race conditions when + # updating multiple layers of containers. + # + # So our approach to this is to use a value of None (null) to represent an + # unpinned reference to a PublishableEntity. It's shorthand for "just use + # the latest draft or published version of this, as appropriate". + draft_version = models.ForeignKey( + PublishableEntityVersion, + on_delete=models.RESTRICT, + null=True, + related_name="draft_version", + ) + published_version = models.ForeignKey( + PublishableEntityVersion, + on_delete=models.RESTRICT, + null=True, + related_name="published_version", + ) + + +class ContainerEntity(PublishableEntityMixin): + """ + 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. + """ + + pass + + +class ContainerEntityVersion(PublishableEntityVersionMixin): + """ + A version of a ContainerEntity. + + By convention, we would only want to create new versions when the Container + itself changes, and not when the Container's child elements change. For + example: + + * Something was added to the Container. + * We re-ordered the rows in the container. + * Something was removed to the container. + * The Container's metadata changed, e.g. the title. + * We pin to different versions of the Container. + + The last looks a bit odd, but it's because *how we've defined the Unit* has + changed if we decide to explicitly pin a set of versions for the children, + and then later change our minds and move to a different set. It also just + makes things easier to reason about if we say that defined_list never + changes for a given ContainerEntityVersion. + """ + + container = models.ForeignKey( + ContainerEntity, + on_delete=models.CASCADE, + related_name="versions", + ) + + # This is the EntityList that the author defines. This should never change, + # even if the things it references get soft-deleted (because we'll need to + # maintain it for reverts). + defined_list = models.ForeignKey( + EntityList, + on_delete=models.RESTRICT, + null=False, + related_name="defined_list", + ) + + # inital_list is an EntityList where all the versions are pinned, to show + # what the exact versions of the children were at the time that the + # Container was created. We could technically derive this, but it would be + # awkward to query. + # + # If the Container was defined so that all references were pinned, then this + # can point to the exact same EntityList as defined_list. + initial_list = models.ForeignKey( + EntityList, + on_delete=models.RESTRICT, + null=False, + related_name="initial_list", + ) + + # This is the EntityList that's created when the next ContainerEntityVersion + # is created. All references in this list should be pinned, and it serves as + # "the last state the children were in for this version of the Container". + # If defined_list has only pinned references, this should point to the same + # EntityList as defined_list and initial_list. + # + # This value is mutable if and only if there are unpinned references in + # defined_list. In that case, frozen_list should start as None, and be + # updated to pin references when another version of this Container becomes + # the Draft version. But if this version ever becomes the Draft *again* + # (e.g. the user hits "discard changes" or some kind of revert happens), + # then we need to clear this back to None. + frozen_list = models.ForeignKey( + EntityList, + on_delete=models.RESTRICT, + null=True, + default=None, + related_name="frozen_list", + ) diff --git a/openedx_learning/apps/authoring/containers/models_mixin.py b/openedx_learning/apps/authoring/containers/models_mixin.py new file mode 100644 index 00000000..4accd119 --- /dev/null +++ b/openedx_learning/apps/authoring/containers/models_mixin.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from django.db import models + +from openedx_learning.apps.authoring.containers.models import ( + ContainerEntity, + ContainerEntityVersion, +) + +from django.db.models.query import QuerySet + +from openedx_learning.apps.authoring.publishing.model_mixins import ( + PublishableEntityMixin, + PublishableEntityVersionMixin, +) + +__all__ = [ + "ContainerEntityMixin", + "ContainerEntityVersionMixin", +] + + +class ContainerEntityMixin(PublishableEntityMixin): + """ + Convenience mixin to link your models against ContainerEntity. + + Please see docstring for ContainerEntity for more details. + + If you use this class, you *MUST* also use ContainerEntityVersionMixin + """ + + class ContainerEntityMixinManager(models.Manager): + def get_queryset(self) -> QuerySet: + return ( + super() + .get_queryset() + .select_related( + "container_entity", + ) + ) + + objects: models.Manager[ContainerEntityMixin] = ContainerEntityMixinManager() + + container_entity = models.OneToOneField( + ContainerEntity, + on_delete=models.CASCADE, + ) + + @property + def uuid(self): + return self.container_entity.uuid + + @property + def created(self): + return self.container_entity.created + + class Meta: + abstract = True + + +class ContainerEntityVersionMixin(PublishableEntityVersionMixin): + """ + Convenience mixin to link your models against ContainerEntityVersion. + + Please see docstring for ContainerEntityVersion for more details. + + If you use this class, you *MUST* also use ContainerEntityMixin + """ + + class ContainerEntityVersionMixinManager(models.Manager): + def get_queryset(self) -> QuerySet: + return ( + super() + .get_queryset() + .select_related( + "container_entity_version", + ) + ) + + objects: models.Manager[ContainerEntityVersionMixin] = ( + ContainerEntityVersionMixinManager() + ) + + container_entity_version = models.OneToOneField( + ContainerEntityVersion, + on_delete=models.CASCADE, + ) + + @property + def uuid(self): + return self.container_entity_version.uuid + + @property + def title(self): + return self.container_entity_version.title + + @property + def created(self): + return self.container_entity_version.created + + @property + def version_num(self): + return self.container_entity_version.version_num + + class Meta: + abstract = True diff --git a/openedx_learning/apps/authoring/units/__init__.py b/openedx_learning/apps/authoring/units/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py new file mode 100644 index 00000000..c0a950b2 --- /dev/null +++ b/openedx_learning/apps/authoring/units/api.py @@ -0,0 +1,218 @@ +"""Units API. + +This module provides functions to manage units. +""" + +from django.db.transaction import atomic + +from openedx_learning.apps.authoring.containers.models import EntityListRow +from ..publishing import api as publishing_api +from ..containers import api as container_api +from .models import Unit, UnitVersion +from django.db.models import QuerySet + + +from datetime import datetime + +__all__ = [ + "create_unit", + "create_unit_version", + "create_next_unit_version", + "create_unit_and_version", + "get_unit", + "get_unit_version", + "get_latest_unit_version", + "get_user_defined_list_in_unit_version", + "get_initial_list_in_unit_version", + "get_frozen_list_in_unit_version", +] + + +def create_unit( + learning_package_id: int, key: str, created: datetime, created_by: int | None +) -> Unit: + """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. + """ + with atomic(): + container = container_api.create_container( + learning_package_id, key, created, created_by + ) + unit = Unit.objects.create( + container_entity=container, + publishable_entity=container.publishable_entity, + ) + return unit + + +def create_unit_version( + unit: Unit, + version_num: int, + title: str, + publishable_entities_pks: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], + created: datetime, + created_by: int | None = None, +) -> Unit: + """Create a new unit version. + + Args: + unit_pk: The unit ID. + version_num: The version number. + title: The title. + publishable_entities_pk: The publishable entities. + entity: The entity. + created: The creation date. + created_by: The user who created the unit. + """ + with atomic(): + container_entity_version = container_api.create_container_version( + unit.container_entity.pk, + version_num, + title, + publishable_entities_pks, + draft_version_pks, + published_version_pks, + unit.container_entity.publishable_entity, + created, + created_by, + ) + unit_version = UnitVersion.objects.create( + unit=unit, + container_entity_version=container_entity_version, + publishable_entity_version=container_entity_version.publishable_entity_version, + ) + return unit_version + + +def create_next_unit_version( + unit: Unit, + title: str, + publishable_entities_pks: list[int], + draft_version_pks: list[int | None], + published_version_pks: list[int | None], + created: datetime, + created_by: int | None = None, +) -> Unit: + """Create the next unit version. + + Args: + unit_pk: The unit ID. + title: The title. + publishable_entities_pk: The components. + entity: The entity. + created: The creation date. + created_by: The user who created the unit. + """ + with atomic(): + # TODO: how can we enforce that publishable entities must be components? + # This currently allows for any publishable entity to be added to a unit. + container_entity_version = container_api.create_next_container_version( + unit.container_entity.pk, + title, + publishable_entities_pks, + draft_version_pks, + published_version_pks, + unit.container_entity.publishable_entity, + created, + created_by, + ) + unit_version = UnitVersion.objects.create( + unit=unit, + container_entity_version=container_entity_version, + publishable_entity_version=container_entity_version.publishable_entity_version, + ) + return unit_version + + +def create_unit_and_version( + learning_package_id: int, + key: str, + title: str, + created: datetime, + created_by: int | None = None, +) -> tuple[Unit, UnitVersion]: + """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. + """ + with atomic(): + unit = create_unit(learning_package_id, key, created, created_by) + unit_version = create_unit_version( + unit, + 1, + title, + [], + [], + [], + created, + created_by, + ) + return unit, unit_version + + +def get_unit(unit_pk: int) -> Unit: + """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: + """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: + """Get the latest unit version. + + Args: + unit_pk: The unit ID. + """ + return Unit.objects.get(pk=unit_pk).versioning.latest + + +def get_user_defined_list_in_unit_version(unit_version_pk: int) -> QuerySet[EntityListRow]: + """Get the list in a unit version. + + Args: + unit_version_pk: The unit version ID. + """ + unit_version = UnitVersion.objects.get(pk=unit_version_pk) + return container_api.get_defined_list_rows_for_container_version(unit_version.container_entity_version) + + +def get_initial_list_in_unit_version(unit_version_pk: int) -> list[int]: + """Get the initial list in a unit version. + + Args: + unit_version_pk: The unit version ID. + """ + unit_version = UnitVersion.objects.get(pk=unit_version_pk) + return container_api.get_initial_list_rows_for_container_version(unit_version.container_entity_version) + + +def get_frozen_list_in_unit_version(unit_version_pk: int) -> list[int]: + """Get the frozen list in a unit version. + + Args: + unit_version_pk: The unit version ID. + """ + unit_version = UnitVersion.objects.get(pk=unit_version_pk) + return container_api.get_frozen_list_rows_for_container_version(unit_version.container_entity_version) diff --git a/openedx_learning/apps/authoring/units/apps.py b/openedx_learning/apps/authoring/units/apps.py new file mode 100644 index 00000000..f0beecf3 --- /dev/null +++ b/openedx_learning/apps/authoring/units/apps.py @@ -0,0 +1,25 @@ +""" +Unit Django application initialization. +""" + +from django.apps import AppConfig + + +class UnitsConfig(AppConfig): + """ + Configuration for the units Django application. + """ + + name = "openedx_learning.apps.authoring.units" + verbose_name = "Learning Core > Authoring > Units" + default_auto_field = "django.db.models.BigAutoField" + label = "oel_units" + + def ready(self): + """ + Register Unit and UnitVersion. + """ + from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel + from .models import Unit, UnitVersion # pylint: disable=import-outside-toplevel + + register_content_models(Unit, UnitVersion) diff --git a/openedx_learning/apps/authoring/units/migrations/0001_initial.py b/openedx_learning/apps/authoring/units/migrations/0001_initial.py new file mode 100644 index 00000000..3e3171c8 --- /dev/null +++ b/openedx_learning/apps/authoring/units/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.16 on 2024-10-30 11:36 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('oel_containers', '0001_initial'), + ('oel_publishing', '0002_alter_learningpackage_key_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Unit', + fields=[ + ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')), + ('container_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.containerentity')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='UnitVersion', + fields=[ + ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')), + ('container_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.containerentityversion')), + ('unit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_units.unit')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/openedx_learning/apps/authoring/units/migrations/__init__.py b/openedx_learning/apps/authoring/units/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/openedx_learning/apps/authoring/units/models.py b/openedx_learning/apps/authoring/units/models.py new file mode 100644 index 00000000..a9d167a2 --- /dev/null +++ b/openedx_learning/apps/authoring/units/models.py @@ -0,0 +1,23 @@ +from django.db import models + +from ..containers.models_mixin import ContainerEntityMixin, ContainerEntityVersionMixin + + +class Unit(ContainerEntityMixin): + """ + A Unit is Container, which is a PublishableEntity. + """ + + +class UnitVersion(ContainerEntityVersionMixin): + """ + A UnitVersion is a ContainerVersion, which is a PublishableEntityVersion. + """ + + # Not sure what other metadata goes here, but we want to try to separate things + # like scheduling information and such into different models. + unit = models.ForeignKey( + Unit, + on_delete=models.CASCADE, + related_name="versions", + ) diff --git a/projects/dev.py b/projects/dev.py index ada76e5e..094494ab 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -35,6 +35,8 @@ "openedx_learning.apps.authoring.components.apps.ComponentsConfig", "openedx_learning.apps.authoring.contents.apps.ContentsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", + "openedx_learning.apps.authoring.containers.apps.ContainersConfig", + "openedx_learning.apps.authoring.units.apps.UnitsConfig", # Learning Contrib Apps "openedx_learning.contrib.media_server.apps.MediaServerConfig", # Apps that don't belong in this repo in the long term, but are here to make diff --git a/test_settings.py b/test_settings.py index f5b154f5..9b58f909 100644 --- a/test_settings.py +++ b/test_settings.py @@ -45,6 +45,8 @@ def root(*args): "openedx_learning.apps.authoring.contents.apps.ContentsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", "openedx_tagging.core.tagging.apps.TaggingConfig", + "openedx_learning.apps.authoring.containers.apps.ContainersConfig", + "openedx_learning.apps.authoring.units.apps.UnitsConfig", ] AUTHENTICATION_BACKENDS = [ diff --git a/tests/openedx_learning/apps/authoring/units/__init__.py b/tests/openedx_learning/apps/authoring/units/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py new file mode 100644 index 00000000..e09253c6 --- /dev/null +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -0,0 +1,198 @@ +""" +Basic tests for the units API. +""" +from ..components.test_api import ComponentTestCase +from openedx_learning.api import authoring as authoring_api + + +class UnitTestCase(ComponentTestCase): + + def setUp(self) -> None: + self.component_1, self.component_version_1 = authoring_api.create_component_and_version( + self.learning_package.id, + component_type=self.problem_type, + local_key="Query Counting", + title="Querying Counting Problem", + created=self.now, + created_by=None, + ) + self.component_2, self.component_version_2 = authoring_api.create_component_and_version( + self.learning_package.id, + component_type=self.problem_type, + local_key="Query Counting (2)", + title="Querying Counting Problem (2)", + created=self.now, + created_by=None, + ) + + def test_create_unit_with_content_instead_of_components(self): + """Test creating a unit with content instead of components. + + Expected results: + 1. An error is raised indicating the content restriction for units. + 2. The unit is not created. + """ + + def test_create_empty_first_unit_and_version(self): + """Test creating a unit with no components. + + Expected results: + 1. A unit and unit version are created. + 2. The unit version number is 1. + 3. The unit version is in the unit's versions. + """ + unit, unit_version = authoring_api.create_unit_and_version( + learning_package_id=self.learning_package.id, + key=f"unit:key", + title="Unit", + created=self.now, + created_by=None, + ) + assert unit, unit_version + assert unit_version.version_num == 1 + assert unit_version in unit.versioning.versions.all() + + def test_create_next_unit_version_with_two_components(self): + """Test creating a unit version with two components. + + Expected results: + 1. A new unit version is created. + 2. The unit version number is 2. + 3. The unit version is in the unit's versions. + 4. The components are in the unit version's user defined list. + 5. Initial list contains the pinned versions of the defined list. + 6. Frozen list is empty. + """ + unit, unit_version = authoring_api.create_unit_and_version( + learning_package_id=self.learning_package.id, + key=f"unit:key", + title="Unit", + created=self.now, + created_by=None, + ) + unit_version_v2 = authoring_api.create_next_unit_version( + unit=unit, + title="Unit", + publishable_entities_pks=[ + self.component_1.publishable_entity.id, + self.component_2.publishable_entity.id, + ], + draft_version_pks=[None, None], + published_version_pks=[None, None], + created=self.now, + created_by=None, + ) + assert unit_version_v2.version_num == 2 + assert unit_version_v2 in unit.versioning.versions.all() + publishable_entities_in_list = [ + row.entity for row in authoring_api.get_user_defined_list_in_unit_version(unit_version_v2.pk) + ] + assert self.component_1.publishable_entity in publishable_entities_in_list + assert self.component_2.publishable_entity in publishable_entities_in_list + + + def test_next_version_with_different_different_title(self): + """Test creating a unit version with a different title. + + Expected results: + 1. A new unit version is created. + 2. The unit version number is 2. + 3. The unit version is in the unit's versions. + 4. The unit version's title is different from the previous version. + 5. The user defined is the same as the previous version. + 6. The frozen list is empty. + """ + + def test_create_two_units_with_same_components(self): + """Test creating two units with the same components. + + Expected results: + 1. Two different units are created. + 2. The units have the same components. + """ + + def test_check_author_defined_list_matches_components(self): + """Test checking the author defined list matches the components. + + Expected results: + 1. The author defined list matches the components used to create the unit version. + """ + + def test_check_initial_list_matches_components(self): + """Test checking the initial list matches the components. + + Expected results: + 1. The initial list matches the components (pinned) used to create the unit version. + """ + + def test_check_frozen_list_is_none_floating_versions(self): + """Test checking the frozen list is None when floating versions are used in the author defined list. + + Expected results: + 1. The frozen list is None. + """ + + def test_check_frozen_list_when_next_version_is_created(self): + """Test checking the frozen list when a new version is created. + + Expected results: + 1. The frozen list has pinned versions of the user defined list from the previous version. + """ + + def test_check_lists_equal_when_pinned_versions(self): + """Test checking the lists are equal when pinned versions are used. + + Expected results: + 1. The author defined list == initial list == frozen list. + """ + + def test_publish_unit_version(self): + """Test publish unpublished unit version. + + Expected results: + 1. The newly created unit version has unpublished changes. + 2. The published version matches the unit version. + 3. The draft version matches the unit version. + """ + + def test_publish_unit_with_unpublished_component(self): + """Test publishing a unit with an unpublished component. + + Expected results: + 1. The unit version is published. + 2. The component is published. + """ + + def test_next_version_with_different_order(self): + """Test creating a unit version with different order of components. + + Expected results: + 1. A new unit version is created. + 2. The unit version number is 2. + 3. The unit version is in the unit's versions. + 4. The user defined list is different from the previous version. + 5. The initial list contains the pinned versions of the defined list. + 6. The frozen list is empty. + """ + + def test_soft_delete_component_from_units(self): + """Soft-delete a component from a unit. + + Expected result: + After soft-deleting the component (draft), a new unit version (draft) is created for the unit. + """ + + def test_soft_delete_component_from_units_and_publish(self): + """Soft-delete a component from a unit and publish the unit. + + Expected result: + After soft-deleting the component (draft), a new unit version (draft) is created for the unit. + Then, if the unit is published all units referencing the component are published as well. + """ + + def test_unit_version_becomes_draft_again(self): + """Test a unit version becomes a draft again. + + Expected results: + 1. The frozen list is None after the unit version becomes a draft again. + """ From fd601c6975f4805ec188c19d1484204d6567f3bb Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 13 Feb 2025 09:48:46 -0800 Subject: [PATCH 02/27] feat: Implement higher-level APIs for getting components from units --- .../apps/authoring/containers/api.py | 69 ++++++++- openedx_learning/apps/authoring/units/api.py | 52 +++++++ .../apps/authoring/components/test_api.py | 11 ++ .../apps/authoring/units/test_api.py | 134 ++++++++++++++++-- 4 files changed, 256 insertions(+), 10 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index 4feb3a5f..adaa01f6 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -4,18 +4,21 @@ This module provides a set of functions to interact with the containers models in the Open edX Learning platform. """ +from dataclasses import dataclass from django.db.transaction import atomic from django.db.models import QuerySet from datetime import datetime + +from openedx_learning.apps.authoring.containers.models_mixin import ContainerEntityMixin, ContainerEntityVersionMixin from ..containers.models import ( ContainerEntity, ContainerEntityVersion, EntityList, EntityListRow, ) -from ..publishing.models import PublishableEntity +from ..publishing.models import PublishableEntity, PublishableEntityVersion from ..publishing import api as publishing_api @@ -28,6 +31,9 @@ "get_defined_list_rows_for_container_version", "get_initial_list_rows_for_container_version", "get_frozen_list_rows_for_container_version", + "ContainerEntityListEntry", + "get_entities_in_draft_container", + "get_entities_in_published_container", ] @@ -471,3 +477,64 @@ def get_frozen_list_rows_for_container_version( if container_version.frozen_list is None: return QuerySet[EntityListRow]() return container_version.frozen_list.entitylistrow_set.all() + + +@dataclass(frozen=True) +class ContainerEntityListEntry: + """ + 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 + + +def get_entities_in_draft_container( + container: ContainerEntity | ContainerEntityMixin, +) -> list[ContainerEntityListEntry]: + """ + Get the list of entities and their versions in the draft version of the + given container. + """ + if isinstance(container, ContainerEntityMixin): + container = container.container_entity + assert isinstance(container, ContainerEntity) + entity_list = [] + defined_list = container.versioning.draft.defined_list + for row in defined_list.entitylistrow_set.order_by("order_num"): + entity_list.append(ContainerEntityListEntry( + entity_version=row.draft_version or row.entity.draft.version, + pinned=row.draft_version is not None, + )) + return entity_list + + +def get_entities_in_published_container( + container: ContainerEntity | ContainerEntityMixin, +) -> list[ContainerEntityListEntry] | None: + """ + Get the list of entities and their versions in the draft version of the + given container. + """ + if isinstance(container, ContainerEntityMixin): + cev = container.container_entity.versioning.published + elif isinstance(container, ContainerEntity): + cev = container.versioning.published + if cev == None: + return None # There is no published version of this container. Should this be an exception? + assert isinstance(cev, ContainerEntityVersion) + # TODO: do we ever need frozen_list? e.g. when accessing a historical version? + # Doesn't make a lot of sense when the versions of the container don't capture many of the changes to the contents, + # e.g. container version 1 had component version 1 through 50, and container version 2 had component versions 51 + # through 100, what is the point of tracking whether container version 1 "should" show v1 or v50 when they're wildly + # different? + entity_list = [] + for row in cev.defined_list.entitylistrow_set.order_by("order_num"): + entity_list.append(ContainerEntityListEntry( + entity_version=row.published_version or row.entity.published.version, + pinned=row.published_version is not None, + )) + return entity_list diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index c0a950b2..a42324fc 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -2,9 +2,11 @@ This module provides functions to manage units. """ +from dataclasses import dataclass from django.db.transaction import atomic +from openedx_learning.apps.authoring.components.models import ComponentVersion from openedx_learning.apps.authoring.containers.models import EntityListRow from ..publishing import api as publishing_api from ..containers import api as container_api @@ -25,6 +27,9 @@ "get_user_defined_list_in_unit_version", "get_initial_list_in_unit_version", "get_frozen_list_in_unit_version", + "UnitListEntry", + "get_components_in_draft_unit", + "get_components_in_published_unit", ] @@ -216,3 +221,50 @@ def get_frozen_list_in_unit_version(unit_version_pk: int) -> list[int]: """ unit_version = UnitVersion.objects.get(pk=unit_version_pk) return container_api.get_frozen_list_rows_for_container_version(unit_version.container_entity_version) + + +@dataclass(frozen=True) +class UnitListEntry: + """ + Data about a single entity in a container, e.g. a component in a unit. + """ + component_version: ComponentVersion + pinned: bool + + @property + def component(self): + return self.component_version.component + + +def get_components_in_draft_unit( + unit: Unit, +) -> list[UnitListEntry]: + """ + Get the list of entities and their versions in the draft version of the + given container. + """ + assert isinstance(unit, Unit) + entity_list = [] + for entry in container_api.get_entities_in_draft_container(unit): + # Convert from generic PublishableEntityVersion to ComponentVersion: + component_version = entry.entity_version.componentversion + assert isinstance(component_version, ComponentVersion) + entity_list.append(UnitListEntry(component_version=component_version, pinned=entry.pinned)) + return entity_list + + +def get_components_in_published_unit( + unit: Unit | UnitVersion, +) -> list[UnitListEntry]: + """ + Get the list of entities and their versions in the draft version of the + given container. + """ + assert isinstance(unit, (Unit, UnitVersion)) + entity_list = [] + for entry in container_api.get_entities_in_published_container(unit): + # Convert from generic PublishableEntityVersion to ComponentVersion: + component_version = entry.entity_version.componentversion + assert isinstance(component_version, ComponentVersion) + entity_list.append(UnitListEntry(component_version=component_version, pinned=entry.pinned)) + return entity_list diff --git a/tests/openedx_learning/apps/authoring/components/test_api.py b/tests/openedx_learning/apps/authoring/components/test_api.py index 279a0335..70e5b582 100644 --- a/tests/openedx_learning/apps/authoring/components/test_api.py +++ b/tests/openedx_learning/apps/authoring/components/test_api.py @@ -43,6 +43,17 @@ def setUpTestData(cls) -> None: cls.problem_type = components_api.get_or_create_component_type("xblock.v1", "problem") cls.video_type = components_api.get_or_create_component_type("xblock.v1", "video") + def publish_component(self, component: Component): + """ + Helper method to publish a single component. + """ + publishing_api.publish_from_drafts( + self.learning_package.pk, + draft_qset=publishing_api.get_all_drafts(self.learning_package.pk).filter( + entity=component.publishable_entity, + ), + ) + class PerformanceTestCase(ComponentTestCase): """ diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index e09253c6..d8b1a0c5 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -8,7 +8,7 @@ class UnitTestCase(ComponentTestCase): def setUp(self) -> None: - self.component_1, self.component_version_1 = authoring_api.create_component_and_version( + self.component_1, self.component_1_v1 = authoring_api.create_component_and_version( self.learning_package.id, component_type=self.problem_type, local_key="Query Counting", @@ -16,7 +16,7 @@ def setUp(self) -> None: created=self.now, created_by=None, ) - self.component_2, self.component_version_2 = authoring_api.create_component_and_version( + self.component_2, self.component_2_v2 = authoring_api.create_component_and_version( self.learning_package.id, component_type=self.problem_type, local_key="Query Counting (2)", @@ -33,13 +33,14 @@ def test_create_unit_with_content_instead_of_components(self): 2. The unit is not created. """ - def test_create_empty_first_unit_and_version(self): + def test_create_empty_unit_and_version(self): """Test creating a unit with no components. Expected results: 1. A unit and unit version are created. 2. The unit version number is 1. - 3. The unit version is in the unit's versions. + 3. The unit is a draft with unpublished changes. + 4. There is no published version of the unit. """ unit, unit_version = authoring_api.create_unit_and_version( learning_package_id=self.learning_package.id, @@ -51,6 +52,9 @@ def test_create_empty_first_unit_and_version(self): assert unit, unit_version assert unit_version.version_num == 1 assert unit_version in unit.versioning.versions.all() + assert unit.versioning.has_unpublished_changes == True + assert unit.versioning.draft == unit_version + assert unit.versioning.published is None def test_create_next_unit_version_with_two_components(self): """Test creating a unit version with two components. @@ -78,17 +82,129 @@ def test_create_next_unit_version_with_two_components(self): self.component_2.publishable_entity.id, ], draft_version_pks=[None, None], - published_version_pks=[None, None], + published_version_pks=[None, None], # FIXME: why do we specify this? created=self.now, created_by=None, ) assert unit_version_v2.version_num == 2 assert unit_version_v2 in unit.versioning.versions.all() - publishable_entities_in_list = [ - row.entity for row in authoring_api.get_user_defined_list_in_unit_version(unit_version_v2.pk) + assert authoring_api.get_components_in_draft_unit(unit) == [ + authoring_api.UnitListEntry(component_version=self.component_1.versioning.draft, pinned=False), + authoring_api.UnitListEntry(component_version=self.component_2.versioning.draft, pinned=False), ] - assert self.component_1.publishable_entity in publishable_entities_in_list - assert self.component_2.publishable_entity in publishable_entities_in_list + + 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 = authoring_api.create_unit_and_version( + learning_package_id=self.learning_package.id, + key=f"unit:key", + title="Unit", + created=self.now, + created_by=None, + ) + assert unit.versioning.draft == unit_version + assert unit.versioning.published is None + assert unit.versioning.has_unpublished_changes == True + # Publish the empty unit: + authoring_api.publish_all_drafts(self.learning_package.id) + unit.refresh_from_db() # Reloading the unit is necessary + assert unit.versioning.has_unpublished_changes == False + + # Add a published component (unpinned): + assert self.component_1.versioning.has_unpublished_changes == False + unit_version_v2 = authoring_api.create_next_unit_version( + unit=unit, + title=unit_version.title, + publishable_entities_pks=[ + self.component_1.publishable_entity.id, + ], + draft_version_pks=[None], + published_version_pks=[None], # FIXME: why do we specify this? + created=self.now, + created_by=None, + ) + # Now the unit should have unpublished changes: + unit.refresh_from_db() # Reloading the unit is necessary + assert unit.versioning.has_unpublished_changes == True + assert unit.versioning.draft == unit_version_v2 + assert unit.versioning.published == unit_version + + def test_modify_component_after_publish(self): + """ + Modifying a component in a published unit will NOT create a new version + nor show that the unit has unpublished changes. The modifications will + appear in the published version of the unit only after the component is + published. + """ + # Create a unit: + unit, unit_version = authoring_api.create_unit_and_version( + learning_package_id=self.learning_package.id, + key=f"unit:key", + title="Unit", + created=self.now, + created_by=None, + ) + # Add a draft component (unpinned): + assert self.component_1.versioning.has_unpublished_changes == True + unit_version_v2 = authoring_api.create_next_unit_version( + unit=unit, + title=unit_version.title, + publishable_entities_pks=[ + self.component_1.publishable_entity.id, + ], + draft_version_pks=[None], + published_version_pks=[None], # FIXME: why do we specify this? + created=self.now, + created_by=None, + ) + # Publish the unit and the component: + authoring_api.publish_all_drafts(self.learning_package.id) + unit.refresh_from_db() # Reloading the unit is necessary + self.component_1.refresh_from_db() + assert unit.versioning.has_unpublished_changes == False + assert self.component_1.versioning.has_unpublished_changes == False + + # Now modify the component by changing its title (it remains a draft): + component_1_v2 = authoring_api.create_next_component_version( + self.component_1.pk, + content_to_replace={}, + title="Modified Counting Problem with new title", + created=self.now, + created_by=None, + ) + + # The component now has unpublished changes, but the unit doesn't (โญ๏ธ Is this what we want? โญ๏ธ) + unit.refresh_from_db() # Reloading the unit is necessary + self.component_1.refresh_from_db() + assert unit.versioning.has_unpublished_changes == False + assert self.component_1.versioning.has_unpublished_changes == True + + # Since the component changes haven't been published, they should only appear in the draft unit + assert authoring_api.get_components_in_draft_unit(unit) == [ + authoring_api.UnitListEntry(component_version=component_1_v2, pinned=False), # new version + ] + assert authoring_api.get_components_in_published_unit(unit) == [ + authoring_api.UnitListEntry(component_version=self.component_1_v1, pinned=False), # 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 authoring_api.get_components_in_draft_unit(unit) == [ + authoring_api.UnitListEntry(component_version=component_1_v2, pinned=False), # new version + ] + assert authoring_api.get_components_in_published_unit(unit) == [ + authoring_api.UnitListEntry(component_version=component_1_v2, pinned=False), # new version + ] + + + # Test that only components can be added to units + # Test that components must be in the same learning package + # Test that _version_pks=[] arguments must be related to publishable_entities_pks + # Test that publishing a unit publishes its components + # Test viewing old snapshots of units and components by passing in a timestamp to some get_historic_unit() API def test_next_version_with_different_different_title(self): From 15bad535ec559e37b8c8f10b448bad79951ec734 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 14 Feb 2025 10:48:18 -0800 Subject: [PATCH 03/27] feat: Expand tests cases, add more high-level APIs Added APIs: contains_unpublished_changes(), API to get a historic published version of a unit removed defined_list/initial_list/frozen_list from the API for now refactor: Simplify EntityList: merge draft_version+published_version -> entity_version refactor: Simplify create_next_unit_version() API docs: mark all containers APIs as unstable refactor: re-enable the too-many-positional-arguments pylint check --- openedx_learning/api/authoring.py | 2 +- openedx_learning/api/authoring_models.py | 2 + .../apps/authoring/components/api.py | 4 +- .../apps/authoring/components/models.py | 16 +- .../apps/authoring/containers/api.py | 272 +++---- .../apps/authoring/containers/apps.py | 1 - .../containers/migrations/0001_initial.py | 7 +- .../apps/authoring/containers/models.py | 33 +- .../apps/authoring/containers/models_mixin.py | 42 +- .../apps/authoring/publishing/api.py | 15 + .../apps/authoring/publishing/model_mixins.py | 36 +- openedx_learning/apps/authoring/units/api.py | 190 ++--- .../units/migrations/0001_initial.py | 2 +- .../apps/authoring/units/models.py | 8 + openedx_learning/lib/managers.py | 8 +- openedx_tagging/core/tagging/api.py | 4 +- openedx_tagging/core/tagging/models/base.py | 2 +- pylintrc | 1 - pylintrc_tweaks | 1 - .../apps/authoring/units/test_api.py | 672 +++++++++++++++--- .../core/tagging/test_views.py | 27 +- 21 files changed, 937 insertions(+), 408 deletions(-) diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index 2fe11e93..de756616 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -11,9 +11,9 @@ # pylint: disable=wildcard-import from ..apps.authoring.collections.api import * from ..apps.authoring.components.api import * +from ..apps.authoring.containers.api import * from ..apps.authoring.contents.api import * from ..apps.authoring.publishing.api import * -from ..apps.authoring.containers.api import * from ..apps.authoring.units.api import * # This was renamed after the authoring API refactoring pushed this and other diff --git a/openedx_learning/api/authoring_models.py b/openedx_learning/api/authoring_models.py index fb0ab669..8c7752b2 100644 --- a/openedx_learning/api/authoring_models.py +++ b/openedx_learning/api/authoring_models.py @@ -9,6 +9,8 @@ # pylint: disable=wildcard-import from ..apps.authoring.collections.models import * from ..apps.authoring.components.models import * +from ..apps.authoring.containers.models import * from ..apps.authoring.contents.models import * from ..apps.authoring.publishing.model_mixins import * from ..apps.authoring.publishing.models import * +from ..apps.authoring.units.models import * diff --git a/openedx_learning/apps/authoring/components/api.py b/openedx_learning/apps/authoring/components/api.py index 48a45af4..7f5bb33e 100644 --- a/openedx_learning/apps/authoring/components/api.py +++ b/openedx_learning/apps/authoring/components/api.py @@ -231,7 +231,7 @@ def create_next_component_version( return component_version -def create_component_and_version( +def create_component_and_version( # pylint: disable=too-many-positional-arguments learning_package_id: int, /, component_type: ComponentType, @@ -326,7 +326,7 @@ def component_exists_by_key( return False -def get_components( +def get_components( # pylint: disable=too-many-positional-arguments learning_package_id: int, /, draft: bool | None = None, diff --git a/openedx_learning/apps/authoring/components/models.py b/openedx_learning/apps/authoring/components/models.py index b0225fc9..f3ef8b03 100644 --- a/openedx_learning/apps/authoring/components/models.py +++ b/openedx_learning/apps/authoring/components/models.py @@ -17,6 +17,8 @@ """ from __future__ import annotations +from typing import ClassVar + from django.db import models from ....lib.fields import case_sensitive_char_field, immutable_uuid_field, key_field @@ -76,7 +78,7 @@ def __str__(self): return f"{self.namespace}:{self.name}" -class Component(PublishableEntityMixin): # type: ignore[django-manager-missing] +class Component(PublishableEntityMixin): """ This represents any Component that has ever existed in a LearningPackage. @@ -120,14 +122,12 @@ class Component(PublishableEntityMixin): # type: ignore[django-manager-missing] Make a foreign key to the Component model when you need a stable reference that will exist for as long as the LearningPackage itself exists. """ - # Tell mypy what type our objects manager has. - # It's actually PublishableEntityMixinManager, but that has the exact same - # interface as the base manager class. - objects: models.Manager[Component] = WithRelationsManager( + # Set up our custom manager. It has the same API as the default one, but selects related objects by default. + objects: ClassVar[WithRelationsManager[Component]] = WithRelationsManager( # type: ignore[assignment] 'component_type' ) - with_publishing_relations: models.Manager[Component] = WithRelationsManager( + with_publishing_relations = WithRelationsManager( 'component_type', 'publishable_entity', 'publishable_entity__draft__version', @@ -201,10 +201,6 @@ class ComponentVersion(PublishableEntityVersionMixin): This holds the content using a M:M relationship with Content via ComponentVersionContent. """ - # Tell mypy what type our objects manager has. - # It's actually PublishableEntityVersionMixinManager, but that has the exact - # same interface as the base manager class. - objects: models.Manager[ComponentVersion] # This is technically redundant, since we can get this through # publishable_entity_version.publishable.component, but this is more diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index adaa01f6..7df5fe0e 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -5,35 +5,30 @@ models in the Open edX Learning platform. """ from dataclasses import dataclass +from datetime import datetime -from django.db.transaction import atomic +from django.core.exceptions import ValidationError from django.db.models import QuerySet +from django.db.transaction import atomic -from datetime import datetime +from openedx_learning.apps.authoring.containers.models_mixin import ContainerEntityMixin -from openedx_learning.apps.authoring.containers.models_mixin import ContainerEntityMixin, ContainerEntityVersionMixin -from ..containers.models import ( - ContainerEntity, - ContainerEntityVersion, - EntityList, - EntityListRow, -) -from ..publishing.models import PublishableEntity, PublishableEntityVersion +from ..containers.models import ContainerEntity, ContainerEntityVersion, EntityList, EntityListRow from ..publishing import api as publishing_api +from ..publishing.models import PublishableEntity, PublishableEntityVersion - +# ๐Ÿ›‘ 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_container", "create_container_version", "create_next_container_version", "create_container_and_version", "get_container", - "get_defined_list_rows_for_container_version", - "get_initial_list_rows_for_container_version", - "get_frozen_list_rows_for_container_version", "ContainerEntityListEntry", "get_entities_in_draft_container", "get_entities_in_published_container", + "contains_unpublished_changes", ] @@ -44,6 +39,7 @@ def create_container( created_by: int | None, ) -> ContainerEntity: """ + [ ๐Ÿ›‘ UNSTABLE ] Create a new container. Args: @@ -67,6 +63,7 @@ def create_container( def create_entity_list() -> EntityList: """ + [ ๐Ÿ›‘ UNSTABLE ] Create a new entity list. This is an structure that holds a list of entities that will be referenced by the container. @@ -77,21 +74,22 @@ def create_entity_list() -> EntityList: def create_next_defined_list( - previous_entity_list: EntityList | None, + previous_entity_list: EntityList | None, # pylint: disable=unused-argument new_entity_list: EntityList, entity_pks: list[int], - draft_version_pks: list[int | None], - published_version_pks: list[int | None], -) -> EntityListRow: + entity_version_pks: list[int | None], +) -> EntityList: """ + [ ๐Ÿ›‘ UNSTABLE ] Create new entity list rows for an entity list. Args: previous_entity_list: The previous entity list that the new entity list is based on. new_entity_list: The entity list to create the rows for. entity_pks: The IDs of the publishable entities that the entity list rows reference. - draft_version_pks: The IDs of the draft versions of the entities (PublishableEntityVersion) that the entity list rows reference. - published_version_pks: The IDs of the published versions of the entities (PublishableEntityVersion) that the entity list rows reference. + entity_version_pks: The IDs of the draft versions of the entities + (PublishableEntityVersion) that the entity list rows reference, or + Nones for "unpinned" (default). Returns: The newly created entity list rows. @@ -102,44 +100,40 @@ def create_next_defined_list( # 1. Create new rows for the entity list # Case 2: create next container version (previous rows created for container) # 1. Get all the rows in the previous version entity list - # 2. Only associate existent rows to the new entity list iff: the order is the same, the PublishableEntity is in entity_pks and versions are not pinned - # 3. If the order is different for a row with the PublishableEntity, create new row with the same PublishableEntity for the new order - # and associate the new row to the new entity list - current_rows = previous_entity_list.entitylistrow_set.all() - publishable_entities_in_rows = {row.entity.pk: row for row in current_rows} + # 2. Only associate existent rows to the new entity list iff: the order is the same, the PublishableEntity is in + # entity_pks and versions are not pinned + # 3. If the order is different for a row with the PublishableEntity, create new row with the same + # PublishableEntity for the new order and associate the new row to the new entity list new_rows = [] - for order_num, entity_pk, draft_version_pk, published_version_pk in zip( - order_nums, entity_pks, draft_version_pks, published_version_pks + for order_num, entity_pk, entity_version_pk in zip( + order_nums, entity_pks, entity_version_pks ): - row = publishable_entities_in_rows.get(entity_pk) - if row and row.order_num == order_num: - new_entity_list.entitylistrow_set.add(row) - continue new_rows.append( EntityListRow( entity_list=new_entity_list, entity_id=entity_pk, order_num=order_num, - draft_version_id=draft_version_pk, - published_version_id=published_version_pk, + entity_version_id=entity_version_pk, ) ) EntityListRow.objects.bulk_create(new_rows) return new_entity_list + def create_defined_list_with_rows( entity_pks: list[int], - draft_version_pks: list[int | None], - published_version_pks: list[int | None], + entity_version_pks: list[int | None], ) -> EntityList: """ + [ ๐Ÿ›‘ UNSTABLE ] Create new entity list rows for an entity list. Args: entity_list: The entity list to create the rows for. entity_pks: The IDs of the publishable entities that the entity list rows reference. - draft_version_pks: The IDs of the draft versions of the entities (PublishableEntityVersion) that the entity list rows reference. - published_version_pks: The IDs of the published versions of the entities (PublishableEntityVersion) that the entity list rows reference. + entity_version_pks: The IDs of the versions of the entities + (PublishableEntityVersion) that the entity list rows reference, or + Nones for "unpinned" (default). Returns: The newly created entity list. @@ -153,11 +147,10 @@ def create_defined_list_with_rows( entity_list=entity_list, entity_id=entity_pk, order_num=order_num, - draft_version_id=draft_version_pk, - published_version_id=published_version_pk, + entity_version_id=entity_version_pk, ) - for order_num, entity_pk, draft_version_pk, published_version_pk in zip( - order_nums, entity_pks, draft_version_pks, published_version_pks + for order_num, entity_pk, entity_version_pk in zip( + order_nums, entity_pks, entity_version_pks ) ] ) @@ -168,6 +161,7 @@ def get_entity_list_with_pinned_versions( rows: QuerySet[EntityListRow], ) -> EntityList: """ + [ ๐Ÿ›‘ UNSTABLE ] Copy rows from an existing entity list to a new entity list. Args: @@ -185,8 +179,7 @@ def get_entity_list_with_pinned_versions( entity_list=entity_list, entity_id=row.entity.id, order_num=row.order_num, - draft_version_id=None, - published_version_id=None, # For simplicity, we are not copying the pinned versions + entity_version_id=None, # For simplicity, we are not copying the pinned versions ) for row in rows ] @@ -199,6 +192,7 @@ def check_unpinned_versions_in_defined_list( defined_list: EntityList, ) -> bool: """ + [ ๐Ÿ›‘ UNSTABLE ] Check if there are any unpinned versions in the defined list. Args: @@ -209,16 +203,17 @@ def check_unpinned_versions_in_defined_list( """ # Is there a way to short-circuit this? return any( - row.draft_version is None or row.published_version is None + row.entity_version is None for row in defined_list.entitylistrow_set.all() ) def check_new_changes_in_defined_list( - entity_list: EntityList, - publishable_entities_pk: list[int], + entity_list: EntityList, # pylint: disable=unused-argument + publishable_entities_pks: list[int], # pylint: disable=unused-argument ) -> bool: """ + [ ๐Ÿ›‘ UNSTABLE ] Check if there are any new changes in the defined list. Args: @@ -236,22 +231,23 @@ def check_new_changes_in_defined_list( def create_container_version( container_pk: int, version_num: int, + *, title: str, - publishable_entities_pk: list[int], - draft_version_pks: list[int | None], - published_version_pks: list[int | None], + publishable_entities_pks: list[int], + entity_version_pks: list[int | None], entity: PublishableEntity, created: datetime, created_by: int | None, ) -> ContainerEntityVersion: """ + [ ๐Ÿ›‘ UNSTABLE ] Create a new container version. Args: container_pk: The ID of the container that the version belongs to. version_num: The version number of the container. title: The title of the container. - publishable_entities_pk: The IDs of the members of the container. + publishable_entities_pks: The IDs of the members of the container. entity: The entity that the container belongs to. created: The date and time the container version was created. created_by: The ID of the user who created the container version. @@ -268,9 +264,8 @@ def create_container_version( created_by=created_by, ) defined_list = create_defined_list_with_rows( - entity_pks=publishable_entities_pk, - draft_version_pks=draft_version_pks, - published_version_pks=published_version_pks, + entity_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, ) container_version = ContainerEntityVersion.objects.create( publishable_entity_version=publishable_entity_version, @@ -287,28 +282,29 @@ def create_container_version( def create_next_container_version( container_pk: int, + *, title: str, - publishable_entities_pk: list[int], - draft_version_pks: list[int | None], - published_version_pks: list[int | None], + publishable_entities_pks: list[int], + entity_version_pks: list[int | None], entity: PublishableEntity, created: datetime, created_by: int | None, ) -> ContainerEntityVersion: """ + [ ๐Ÿ›‘ UNSTABLE ] Create the next version of a container. A new version of the container is created only when its metadata changes: * Something was added to the Container. * We re-ordered the rows in the container. - * Something was removed to the container. + * Something was removed from the container. * The Container's metadata changed, e.g. the title. * We pin to different versions of the Container. Args: container_pk: The ID of the container to create the next version of. title: The title of the container. - publishable_entities_pk: The IDs of the members current members of the container. + publishable_entities_pks: The IDs of the members current members of the container. entity: The entity that the container belongs to. created: The date and time the container version was created. created_by: The ID of the user who created the container version. @@ -316,6 +312,13 @@ def create_next_container_version( Returns: The newly created container version. """ + # Do a quick check that the given entities are in the right learning package: + if PublishableEntity.objects.filter( + pk__in=publishable_entities_pks, + ).exclude( + learning_package_id=entity.learning_package_id, + ).exists(): + raise ValidationError("Container entities must be from the same learning package.") with atomic(): container = ContainerEntity.objects.get(pk=container_pk) last_version = container.versioning.latest @@ -335,7 +338,7 @@ def create_next_container_version( # 6. Point frozen_list to None or defined_list if check_new_changes_in_defined_list( entity_list=last_version.defined_list, - publishable_entities_pk=publishable_entities_pk, + publishable_entities_pks=publishable_entities_pks, ): # Only change if there are unpin versions in defined list, meaning last frozen list is None # When does this has to happen? Before? @@ -347,9 +350,8 @@ def create_next_container_version( next_defined_list = create_next_defined_list( previous_entity_list=last_version.defined_list, new_entity_list=create_entity_list(), - entity_pks=publishable_entities_pk, - draft_version_pks=draft_version_pks, - published_version_pks=published_version_pks, + entity_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, ) next_initial_list = get_entity_list_with_pinned_versions( rows=next_defined_list.entitylistrow_set.all() @@ -380,14 +382,15 @@ def create_next_container_version( def create_container_and_version( learning_package_id: int, key: str, + *, created: datetime, created_by: int | None, title: str, - publishable_entities_pk: list[int], - draft_version_pks: list[int | None], - published_version_pks: list[int | None], -) -> ContainerEntityVersion: + publishable_entities_pks: list[int], + entity_version_pks: list[int | None], +) -> tuple[ContainerEntity, ContainerEntityVersion]: """ + [ ๐Ÿ›‘ UNSTABLE ] Create a new container and its first version. Args: @@ -408,9 +411,8 @@ def create_container_and_version( container_pk=container.publishable_entity.pk, version_num=1, title=title, - publishable_entities_pk=publishable_entities_pk, - draft_version_pks=draft_version_pks, - published_version_pks=published_version_pks, + publishable_entities_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, entity=container.publishable_entity, created=created, created_by=created_by, @@ -420,6 +422,7 @@ def create_container_and_version( def get_container(pk: int) -> ContainerEntity: """ + [ ๐Ÿ›‘ UNSTABLE ] Get a container by its primary key. Args: @@ -432,56 +435,10 @@ def get_container(pk: int) -> ContainerEntity: return ContainerEntity.objects.get(pk=pk) -def get_defined_list_rows_for_container_version( - container_version: ContainerEntityVersion, -) -> QuerySet[EntityListRow]: - """ - Get the user-defined members of a container version. - - Args: - container_version: The container version to get the members of. - - Returns: - The members of the container version. - """ - return container_version.defined_list.entitylistrow_set.all() - - -def get_initial_list_rows_for_container_version( - container_version: ContainerEntityVersion, -) -> QuerySet[EntityListRow]: - """ - Get the initial members of a container version. - - Args: - container_version: The container version to get the initial members of. - - Returns: - The initial members of the container version. - """ - return container_version.initial_list.entitylistrow_set.all() - - -def get_frozen_list_rows_for_container_version( - container_version: ContainerEntityVersion, -) -> QuerySet[EntityListRow]: - """ - Get the frozen members of a container version. - - Args: - container_version: The container version to get the frozen members of. - - Returns: - The frozen members of the container version. - """ - if container_version.frozen_list is None: - return QuerySet[EntityListRow]() - return container_version.frozen_list.entitylistrow_set.all() - - @dataclass(frozen=True) class ContainerEntityListEntry: """ + [ ๐Ÿ›‘ UNSTABLE ] Data about a single entity in a container, e.g. a component in a unit. """ entity_version: PublishableEntityVersion @@ -496,6 +453,7 @@ def get_entities_in_draft_container( container: ContainerEntity | ContainerEntityMixin, ) -> list[ContainerEntityListEntry]: """ + [ ๐Ÿ›‘ UNSTABLE ] Get the list of entities and their versions in the draft version of the given container. """ @@ -505,10 +463,13 @@ def get_entities_in_draft_container( entity_list = [] defined_list = container.versioning.draft.defined_list for row in defined_list.entitylistrow_set.order_by("order_num"): - entity_list.append(ContainerEntityListEntry( - entity_version=row.draft_version or row.entity.draft.version, - pinned=row.draft_version is not None, - )) + entity_version = row.entity_version or 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=row.entity_version or row.entity.draft.version, + pinned=row.entity_version is not None, + )) + # else should we indicate somehow a deleted item was here? return entity_list @@ -516,6 +477,7 @@ def get_entities_in_published_container( container: ContainerEntity | ContainerEntityMixin, ) -> list[ContainerEntityListEntry] | None: """ + [ ๐Ÿ›‘ UNSTABLE ] Get the list of entities and their versions in the draft version of the given container. """ @@ -523,7 +485,9 @@ def get_entities_in_published_container( cev = container.container_entity.versioning.published elif isinstance(container, ContainerEntity): cev = container.versioning.published - if cev == None: + else: + raise TypeError(f"Expected ContainerEntity or ContainerEntityMixin; got {type(container)}") + if cev is None: return None # There is no published version of this container. Should this be an exception? assert isinstance(cev, ContainerEntityVersion) # TODO: do we ever need frozen_list? e.g. when accessing a historical version? @@ -533,8 +497,66 @@ def get_entities_in_published_container( # different? entity_list = [] for row in cev.defined_list.entitylistrow_set.order_by("order_num"): - entity_list.append(ContainerEntityListEntry( - entity_version=row.published_version or row.entity.published.version, - pinned=row.published_version is not None, - )) + entity_version = row.entity_version or row.entity.published.version + if entity_version is not None: # As long as this hasn't been soft-deleted: + entity_list.append(ContainerEntityListEntry( + entity_version=entity_version, + pinned=row.entity_version is not None, + )) + # else should we indicate somehow a deleted item was here? return entity_list + + +def contains_unpublished_changes( + container: ContainerEntity | ContainerEntityMixin, +) -> bool: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Check recursively if a container has any unpublished changes. + + Note: container.versioning.has_unpublished_changes only checks if the container + itself has unpublished changes, not if its contents do. + """ + if isinstance(container, ContainerEntityMixin): + # The query below pre-loads the data we need but is otherwise the same thing as: + # container = container.container_entity + container = ContainerEntity.objects.select_related( + "publishable_entity", + "publishable_entity__draft", + "publishable_entity__draft__version", + "publishable_entity__draft__version__containerentityversion__defined_list", + ).get(pk=container.container_entity_id) + else: + pass # TODO: select_related if we're given a raw ContainerEntity rather than a ContainerEntityMixin like Unit? + assert isinstance(container, ContainerEntity) + + if container.versioning.has_unpublished_changes: + return True + + # We only care about children that are un-pinned, since published changes to pinned children don't matter + defined_list = container.versioning.draft.defined_list + + # TODO: This is a naive inefficient implementation but hopefully correct. + # Once we know it's correct and have a good test suite, then we can optimize. + # We will likely change to a tracking-based approach rather than a "scan for changes" based approach. + for row in defined_list.entitylistrow_set.filter(entity_version=None).select_related( + "entity__containerentity", + "entity__draft__version", + "entity__published__version", + ): + try: + child_container = row.entity.containerentity + except ContainerEntity.DoesNotExist: + child_container = None + if child_container: + child_container = row.entity.containerentity + # This is itself a container - check recursively: + if child_container.versioning.has_unpublished_changes or contains_unpublished_changes(child_container): + return True + else: + # This is not a container: + draft_pk = row.entity.draft.version_id if row.entity.draft else None + published_pk = row.entity.published.version_id if row.entity.published else None + if draft_pk != published_pk: + return True + return False diff --git a/openedx_learning/apps/authoring/containers/apps.py b/openedx_learning/apps/authoring/containers/apps.py index e8b2e36a..95850cac 100644 --- a/openedx_learning/apps/authoring/containers/apps.py +++ b/openedx_learning/apps/authoring/containers/apps.py @@ -15,7 +15,6 @@ class ContainersConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" label = "oel_containers" - def ready(self): """ Register ContainerEntity and ContainerEntityVersion. diff --git a/openedx_learning/apps/authoring/containers/migrations/0001_initial.py b/openedx_learning/apps/authoring/containers/migrations/0001_initial.py index 47c4a4ab..be274d59 100644 --- a/openedx_learning/apps/authoring/containers/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/containers/migrations/0001_initial.py @@ -1,7 +1,7 @@ -# Generated by Django 4.2.16 on 2024-10-29 12:57 +# Generated by Django 4.2.19 on 2025-02-14 23:04 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): @@ -33,10 +33,9 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('order_num', models.PositiveIntegerField()), - ('draft_version', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='draft_version', to='oel_publishing.publishableentityversion')), ('entity', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='oel_publishing.publishableentity')), ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.entitylist')), - ('published_version', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='published_version', to='oel_publishing.publishableentityversion')), + ('entity_version', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='oel_publishing.publishableentityversion')), ], ), migrations.CreateModel( diff --git a/openedx_learning/apps/authoring/containers/models.py b/openedx_learning/apps/authoring/containers/models.py index 66588bf3..6de71a21 100644 --- a/openedx_learning/apps/authoring/containers/models.py +++ b/openedx_learning/apps/authoring/containers/models.py @@ -1,13 +1,16 @@ +""" +Models that implement containers +""" from django.db import models -from openedx_learning.apps.authoring.publishing.models import ( - PublishableEntity, - PublishableEntityVersion, -) -from ..publishing.model_mixins import ( - PublishableEntityMixin, - PublishableEntityVersionMixin, -) +from openedx_learning.apps.authoring.publishing.models import PublishableEntity, PublishableEntityVersion + +from ..publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin + +__all__ = [ + "ContainerEntity", + "ContainerEntityVersion", +] class EntityList(models.Model): @@ -21,8 +24,6 @@ class EntityList(models.Model): other models, rather than being looked up by their own identifers. """ - pass - class EntityListRow(models.Model): """ @@ -58,17 +59,11 @@ class EntityListRow(models.Model): # So our approach to this is to use a value of None (null) to represent an # unpinned reference to a PublishableEntity. It's shorthand for "just use # the latest draft or published version of this, as appropriate". - draft_version = models.ForeignKey( - PublishableEntityVersion, - on_delete=models.RESTRICT, - null=True, - related_name="draft_version", - ) - published_version = models.ForeignKey( + entity_version = models.ForeignKey( PublishableEntityVersion, on_delete=models.RESTRICT, null=True, - related_name="published_version", + related_name="+", # Do we need the reverse relation? ) @@ -79,8 +74,6 @@ class ContainerEntity(PublishableEntityMixin): child elements were published. """ - pass - class ContainerEntityVersion(PublishableEntityVersionMixin): """ diff --git a/openedx_learning/apps/authoring/containers/models_mixin.py b/openedx_learning/apps/authoring/containers/models_mixin.py index 4accd119..99ea8cc2 100644 --- a/openedx_learning/apps/authoring/containers/models_mixin.py +++ b/openedx_learning/apps/authoring/containers/models_mixin.py @@ -1,18 +1,18 @@ +""" +Mixins for models that implement containers +""" from __future__ import annotations -from django.db import models - -from openedx_learning.apps.authoring.containers.models import ( - ContainerEntity, - ContainerEntityVersion, -) +from typing import ClassVar, Self -from django.db.models.query import QuerySet +from django.db import models +from openedx_learning.apps.authoring.containers.models import ContainerEntity, ContainerEntityVersion from openedx_learning.apps.authoring.publishing.model_mixins import ( PublishableEntityMixin, PublishableEntityVersionMixin, ) +from openedx_learning.lib.managers import WithRelationsManager __all__ = [ "ContainerEntityMixin", @@ -29,17 +29,8 @@ class ContainerEntityMixin(PublishableEntityMixin): If you use this class, you *MUST* also use ContainerEntityVersionMixin """ - class ContainerEntityMixinManager(models.Manager): - def get_queryset(self) -> QuerySet: - return ( - super() - .get_queryset() - .select_related( - "container_entity", - ) - ) - - objects: models.Manager[ContainerEntityMixin] = ContainerEntityMixinManager() + # select these related entities by default for all queries + objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager("container_entity") # type: ignore[assignment] container_entity = models.OneToOneField( ContainerEntity, @@ -67,18 +58,9 @@ class ContainerEntityVersionMixin(PublishableEntityVersionMixin): If you use this class, you *MUST* also use ContainerEntityMixin """ - class ContainerEntityVersionMixinManager(models.Manager): - def get_queryset(self) -> QuerySet: - return ( - super() - .get_queryset() - .select_related( - "container_entity_version", - ) - ) - - objects: models.Manager[ContainerEntityVersionMixin] = ( - ContainerEntityVersionMixinManager() + # select these related entities by default for all queries + objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager( # type: ignore[assignment] + "container_entity_version", ) container_entity_version = models.OneToOneField( diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index 3facc891..1a61ed70 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -513,3 +513,18 @@ def filter_publishable_entities( entities = entities.filter(published__version__isnull=not has_published) return entities + + +def get_published_version_as_of(entity_id: int, publish_log_id: int) -> PublishableEntityVersion | None: + """ + Get the published version of the given entity, at a specific snapshot in the + history of this Learning Package, given by the PublishLog ID. + + This is a semi-private function, only available to other apps in the + authoring package. + """ + record = PublishLogRecord.objects.filter( + entity_id=entity_id, + publish_log_id__lte=publish_log_id, + ).order_by('-publish_log_id').first() + return record.new_version if record else None diff --git a/openedx_learning/apps/authoring/publishing/model_mixins.py b/openedx_learning/apps/authoring/publishing/model_mixins.py index 85ef6c96..8743b333 100644 --- a/openedx_learning/apps/authoring/publishing/model_mixins.py +++ b/openedx_learning/apps/authoring/publishing/model_mixins.py @@ -4,10 +4,12 @@ from __future__ import annotations from functools import cached_property +from typing import ClassVar, Self from django.core.exceptions import ImproperlyConfigured from django.db import models -from django.db.models.query import QuerySet + +from openedx_learning.lib.managers import WithRelationsManager from .models import PublishableEntity, PublishableEntityVersion @@ -28,17 +30,12 @@ class PublishableEntityMixin(models.Model): the publishing app's api.register_content_models (see its docstring for details). """ - - class PublishableEntityMixinManager(models.Manager): - def get_queryset(self) -> QuerySet: - return super().get_queryset() \ - .select_related( - "publishable_entity", - "publishable_entity__published", - "publishable_entity__draft", - ) - - objects: models.Manager[PublishableEntityMixin] = PublishableEntityMixinManager() + # select these related entities by default for all queries + objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager( + "publishable_entity", + "publishable_entity__published", + "publishable_entity__draft", + ) publishable_entity = models.OneToOneField( PublishableEntity, on_delete=models.CASCADE, primary_key=True @@ -294,17 +291,10 @@ class PublishableEntityVersionMixin(models.Model): details). """ - class PublishableEntityVersionMixinManager(models.Manager): - def get_queryset(self) -> QuerySet: - return ( - super() - .get_queryset() - .select_related( - "publishable_entity_version", - ) - ) - - objects: models.Manager[PublishableEntityVersionMixin] = PublishableEntityVersionMixinManager() + # select these related entities by default for all queries + objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager( + "publishable_entity_version", + ) publishable_entity_version = models.OneToOneField( PublishableEntityVersion, on_delete=models.CASCADE, primary_key=True diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index a42324fc..8b3f0448 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -3,19 +3,18 @@ This module provides functions to manage units. """ from dataclasses import dataclass +from datetime import datetime from django.db.transaction import atomic -from openedx_learning.apps.authoring.components.models import ComponentVersion -from openedx_learning.apps.authoring.containers.models import EntityListRow -from ..publishing import api as publishing_api +from openedx_learning.apps.authoring.components.models import Component, ComponentVersion + from ..containers import api as container_api +from ..publishing.api import get_published_version_as_of from .models import Unit, UnitVersion -from django.db.models import QuerySet - - -from datetime import datetime +# ๐Ÿ›‘ 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", @@ -24,19 +23,18 @@ "get_unit", "get_unit_version", "get_latest_unit_version", - "get_user_defined_list_in_unit_version", - "get_initial_list_in_unit_version", - "get_frozen_list_in_unit_version", "UnitListEntry", "get_components_in_draft_unit", "get_components_in_published_unit", + "get_components_in_published_unit_as_of", ] def create_unit( learning_package_id: int, key: str, created: datetime, created_by: int | None ) -> Unit: - """Create a new unit. + """ + [ ๐Ÿ›‘ UNSTABLE ] Create a new unit. Args: learning_package_id: The learning package ID. @@ -58,14 +56,15 @@ def create_unit( def create_unit_version( unit: Unit, version_num: int, + *, title: str, publishable_entities_pks: list[int], - draft_version_pks: list[int | None], - published_version_pks: list[int | None], + entity_version_pks: list[int | None], created: datetime, created_by: int | None = None, -) -> Unit: - """Create a new unit version. +) -> UnitVersion: + """ + [ ๐Ÿ›‘ UNSTABLE ] Create a new unit version. Args: unit_pk: The unit ID. @@ -80,13 +79,12 @@ def create_unit_version( container_entity_version = container_api.create_container_version( unit.container_entity.pk, version_num, - title, - publishable_entities_pks, - draft_version_pks, - published_version_pks, - unit.container_entity.publishable_entity, - created, - created_by, + title=title, + publishable_entities_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, + entity=unit.container_entity.publishable_entity, + created=created, + created_by=created_by, ) unit_version = UnitVersion.objects.create( unit=unit, @@ -99,34 +97,43 @@ def create_unit_version( def create_next_unit_version( unit: Unit, title: str, - publishable_entities_pks: list[int], - draft_version_pks: list[int | None], - published_version_pks: list[int | None], + components: list[Component | ComponentVersion], created: datetime, created_by: int | None = None, -) -> Unit: - """Create the next unit version. +) -> UnitVersion: + """ + [ ๐Ÿ›‘ UNSTABLE ] Create the next unit version. Args: unit_pk: The unit ID. title: The title. - publishable_entities_pk: The components. + components: The components, as a list of Components (unpinned) and/or ComponentVersions (pinned) entity: The entity. created: The creation date. created_by: The user who created the unit. """ + for c in components: + if not isinstance(c, (Component, ComponentVersion)): + raise TypeError("Unit components must be either Component or ComponentVersion.") + publishable_entities_pks = [ + (c.publishable_entity_id if isinstance(c, Component) else c.component.publishable_entity_id) + for c in components + ] + entity_version_pks = [ + (cv.pk if isinstance(cv, ComponentVersion) else None) + for cv in components + ] with atomic(): # TODO: how can we enforce that publishable entities must be components? # This currently allows for any publishable entity to be added to a unit. container_entity_version = container_api.create_next_container_version( unit.container_entity.pk, - title, - publishable_entities_pks, - draft_version_pks, - published_version_pks, - unit.container_entity.publishable_entity, - created, - created_by, + title=title, + publishable_entities_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, + entity=unit.container_entity.publishable_entity, + created=created, + created_by=created_by, ) unit_version = UnitVersion.objects.create( unit=unit, @@ -143,7 +150,8 @@ def create_unit_and_version( created: datetime, created_by: int | None = None, ) -> tuple[Unit, UnitVersion]: - """Create a new unit and its version. + """ + [ ๐Ÿ›‘ UNSTABLE ] Create a new unit and its version. Args: learning_package_id: The learning package ID. @@ -156,18 +164,18 @@ def create_unit_and_version( unit_version = create_unit_version( unit, 1, - title, - [], - [], - [], - created, - created_by, + title=title, + publishable_entities_pks=[], + entity_version_pks=[], + created=created, + created_by=created_by, ) return unit, unit_version def get_unit(unit_pk: int) -> Unit: - """Get a unit. + """ + [ ๐Ÿ›‘ UNSTABLE ] Get a unit. Args: unit_pk: The unit ID. @@ -176,7 +184,8 @@ def get_unit(unit_pk: int) -> Unit: def get_unit_version(unit_version_pk: int) -> UnitVersion: - """Get a unit version. + """ + [ ๐Ÿ›‘ UNSTABLE ] Get a unit version. Args: unit_version_pk: The unit version ID. @@ -185,7 +194,8 @@ def get_unit_version(unit_version_pk: int) -> UnitVersion: def get_latest_unit_version(unit_pk: int) -> UnitVersion: - """Get the latest unit version. + """ + [ ๐Ÿ›‘ UNSTABLE ] Get the latest unit version. Args: unit_pk: The unit ID. @@ -193,43 +203,14 @@ def get_latest_unit_version(unit_pk: int) -> UnitVersion: return Unit.objects.get(pk=unit_pk).versioning.latest -def get_user_defined_list_in_unit_version(unit_version_pk: int) -> QuerySet[EntityListRow]: - """Get the list in a unit version. - - Args: - unit_version_pk: The unit version ID. - """ - unit_version = UnitVersion.objects.get(pk=unit_version_pk) - return container_api.get_defined_list_rows_for_container_version(unit_version.container_entity_version) - - -def get_initial_list_in_unit_version(unit_version_pk: int) -> list[int]: - """Get the initial list in a unit version. - - Args: - unit_version_pk: The unit version ID. - """ - unit_version = UnitVersion.objects.get(pk=unit_version_pk) - return container_api.get_initial_list_rows_for_container_version(unit_version.container_entity_version) - - -def get_frozen_list_in_unit_version(unit_version_pk: int) -> list[int]: - """Get the frozen list in a unit version. - - Args: - unit_version_pk: The unit version ID. - """ - unit_version = UnitVersion.objects.get(pk=unit_version_pk) - return container_api.get_frozen_list_rows_for_container_version(unit_version.container_entity_version) - - @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 + pinned: bool = False @property def component(self): @@ -240,6 +221,7 @@ def get_components_in_draft_unit( unit: Unit, ) -> list[UnitListEntry]: """ + [ ๐Ÿ›‘ UNSTABLE ] Get the list of entities and their versions in the draft version of the given container. """ @@ -254,17 +236,63 @@ def get_components_in_draft_unit( def get_components_in_published_unit( - unit: Unit | UnitVersion, -) -> list[UnitListEntry]: + unit: Unit, +) -> list[UnitListEntry] | None: """ - Get the list of entities and their versions in the draft version of the + [ ๐Ÿ›‘ UNSTABLE ] + Get the list of entities and their versions in the published version of the given container. + + Returns None if the unit was never published (TODO: should it throw instead?). """ - assert isinstance(unit, (Unit, UnitVersion)) + assert isinstance(unit, Unit) + published_entities = container_api.get_entities_in_published_container(unit) + if published_entities is None: + return None # There is no published version of this unit. Should this be an exception? entity_list = [] - for entry in container_api.get_entities_in_published_container(unit): + for entry in published_entities: # Convert from generic PublishableEntityVersion to ComponentVersion: component_version = entry.entity_version.componentversion assert isinstance(component_version, ComponentVersion) entity_list.append(UnitListEntry(component_version=component_version, pinned=entry.pinned)) return entity_list + + +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 = 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. + unit_version = unit_pub_entity_version.unitversion # type: ignore[attr-defined] + + entity_list = [] + rows = unit_version.container_entity_version.defined_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 = 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/openedx_learning/apps/authoring/units/migrations/0001_initial.py b/openedx_learning/apps/authoring/units/migrations/0001_initial.py index 3e3171c8..537264ee 100644 --- a/openedx_learning/apps/authoring/units/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/units/migrations/0001_initial.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.16 on 2024-10-30 11:36 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/openedx_learning/apps/authoring/units/models.py b/openedx_learning/apps/authoring/units/models.py index a9d167a2..67919a63 100644 --- a/openedx_learning/apps/authoring/units/models.py +++ b/openedx_learning/apps/authoring/units/models.py @@ -1,7 +1,15 @@ +""" +Models that implement units +""" from django.db import models from ..containers.models_mixin import ContainerEntityMixin, ContainerEntityVersionMixin +__all__ = [ + "Unit", + "UnitVersion", +] + class Unit(ContainerEntityMixin): """ diff --git a/openedx_learning/lib/managers.py b/openedx_learning/lib/managers.py index b21f0a6b..0298c87b 100644 --- a/openedx_learning/lib/managers.py +++ b/openedx_learning/lib/managers.py @@ -1,11 +1,17 @@ """ Custom Django ORM Managers. """ +from __future__ import annotations + +from typing import TypeVar + from django.db import models from django.db.models.query import QuerySet +M = TypeVar('M', bound=models.Model) + -class WithRelationsManager(models.Manager): +class WithRelationsManager(models.Manager[M]): """ Custom Manager that adds select_related to the default queryset. diff --git a/openedx_tagging/core/tagging/api.py b/openedx_tagging/core/tagging/api.py index 8c03c740..e4283005 100644 --- a/openedx_tagging/core/tagging/api.py +++ b/openedx_tagging/core/tagging/api.py @@ -28,7 +28,7 @@ TagDoesNotExist = Tag.DoesNotExist -def create_taxonomy( +def create_taxonomy( # pylint: disable=too-many-positional-arguments name: str, description: str | None = None, enabled=True, @@ -321,7 +321,7 @@ def _get_current_tags( return current_tags -def tag_object( +def tag_object( # pylint: disable=too-many-positional-arguments object_id: str, taxonomy: Taxonomy | None, tags: list[str], diff --git a/openedx_tagging/core/tagging/models/base.py b/openedx_tagging/core/tagging/models/base.py index 6b2cccf9..aa2ef407 100644 --- a/openedx_tagging/core/tagging/models/base.py +++ b/openedx_tagging/core/tagging/models/base.py @@ -382,7 +382,7 @@ def copy(self, taxonomy: Taxonomy) -> Taxonomy: return self - def get_filtered_tags( + def get_filtered_tags( # pylint: disable=too-many-positional-arguments self, depth: int | None = TAXONOMY_MAX_DEPTH, parent_tag_value: str | None = None, diff --git a/pylintrc b/pylintrc index b56bdeec..42cd7f9d 100644 --- a/pylintrc +++ b/pylintrc @@ -290,7 +290,6 @@ disable = invalid-name, django-not-configured, consider-using-with, - too-many-positional-arguments, [REPORTS] output-format = text diff --git a/pylintrc_tweaks b/pylintrc_tweaks index 8eb879bd..f999b169 100644 --- a/pylintrc_tweaks +++ b/pylintrc_tweaks @@ -8,4 +8,3 @@ disable+= invalid-name, django-not-configured, consider-using-with, - too-many-positional-arguments, diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index d8b1a0c5..d4f8af58 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -1,37 +1,144 @@ """ Basic tests for the units API. """ -from ..components.test_api import ComponentTestCase +import ddt # type: ignore[import] +import pytest +from django.core.exceptions import ValidationError + from openedx_learning.api import authoring as authoring_api +from openedx_learning.api import authoring_models + +from ..components.test_api import ComponentTestCase +Entry = authoring_api.UnitListEntry + +@ddt.ddt class UnitTestCase(ComponentTestCase): + """ Test cases for Units (containers of components) """ def setUp(self) -> None: - self.component_1, self.component_1_v1 = authoring_api.create_component_and_version( + super().setUp() + self.component_1, self.component_1_v1 = self.create_component( + key="Query Counting", + title="Querying Counting Problem", + ) + self.component_2, self.component_2_v1 = self.create_component( + key="Query Counting (2)", + 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 authoring_api.create_component_and_version( self.learning_package.id, component_type=self.problem_type, - local_key="Query Counting", - title="Querying Counting Problem", + local_key=key, + title=title, created=self.now, created_by=None, ) - self.component_2, self.component_2_v2 = authoring_api.create_component_and_version( - self.learning_package.id, - component_type=self.problem_type, - local_key="Query Counting (2)", - title="Querying Counting Problem (2)", + + 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 = authoring_api.create_unit_and_version( + learning_package_id=self.learning_package.id, + key=key, + title=title, + created=self.now, + created_by=None, + ) + _unit_v2 = authoring_api.create_next_unit_version( + unit=unit, + title=title, + components=components, created=self.now, created_by=None, ) + unit.refresh_from_db() + 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 authoring_api.create_next_component_version( + component.pk, + content_to_replace={}, + title=title, + created=timestamp or self.now, + created_by=None, + ) - def test_create_unit_with_content_instead_of_components(self): - """Test creating a unit with content instead of components. + 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 = authoring_api.create_unit_and_version( + learning_package_id=self.learning_package.id, + key="unit:key", + title="Unit", + created=self.now, + created_by=None, + ) + unit2, _u2v1 = authoring_api.create_unit_and_version( + learning_package_id=self.learning_package.id, + key="unit:key2", + title="Unit 2", + created=self.now, + created_by=None, + ) + # Try adding a Unit to a Unit + with pytest.raises(TypeError, match="Unit components must be either Component or ComponentVersion."): + authoring_api.create_next_unit_version( + unit=unit, + title="Unit Containing a Unit", + components=[unit2], + created=self.now, + created_by=None, + ) + # Check that a new version was not created: + assert unit.versioning.draft == unit_version - Expected results: - 1. An error is raised indicating the content restriction for units. - 2. The unit is not created. + def test_adding_external_components(self): + """ + Test that components from another learning package cannot be added to a + unit. """ + learning_package2 = authoring_api.create_learning_package(key="other-package", title="Other Package") + unit, _unit_version = authoring_api.create_unit_and_version( + learning_package_id=learning_package2.pk, + key="unit:key", + title="Unit", + created=self.now, + created_by=None, + ) + assert self.component_1.learning_package != learning_package2 + # Try adding a a component from LP 1 (self.learning_package) to a unit from LP 2 + with pytest.raises(ValidationError, match="Container entities must be from the same learning package."): + authoring_api.create_next_unit_version( + unit=unit, + title="Unit Containing an External Component", + components=[self.component_1], + created=self.now, + created_by=None, + ) def test_create_empty_unit_and_version(self): """Test creating a unit with no components. @@ -44,7 +151,7 @@ def test_create_empty_unit_and_version(self): """ unit, unit_version = authoring_api.create_unit_and_version( learning_package_id=self.learning_package.id, - key=f"unit:key", + key="unit:key", title="Unit", created=self.now, created_by=None, @@ -52,24 +159,48 @@ def test_create_empty_unit_and_version(self): assert unit, unit_version assert unit_version.version_num == 1 assert unit_version in unit.versioning.versions.all() - assert unit.versioning.has_unpublished_changes == True + assert unit.versioning.has_unpublished_changes assert unit.versioning.draft == unit_version assert unit.versioning.published is None - def test_create_next_unit_version_with_two_components(self): - """Test creating a unit version with two components. + def test_create_next_unit_version_with_two_unpinned_components(self): + """Test creating a unit version with two unpinned components. Expected results: 1. A new unit version is created. 2. The unit version number is 2. 3. The unit version is in the unit's versions. - 4. The components are in the unit version's user defined list. - 5. Initial list contains the pinned versions of the defined list. - 6. Frozen list is empty. + 4. The components are in the draft unit version's component list and are unpinned. """ - unit, unit_version = authoring_api.create_unit_and_version( + unit, _unit_version = authoring_api.create_unit_and_version( + learning_package_id=self.learning_package.id, + key="unit:key", + title="Unit", + created=self.now, + created_by=None, + ) + unit_version_v2 = authoring_api.create_next_unit_version( + unit=unit, + title="Unit", + 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 authoring_api.get_components_in_draft_unit(unit) == [ + Entry(self.component_1.versioning.draft), + Entry(self.component_2.versioning.draft), + ] + assert authoring_api.get_components_in_published_unit(unit) is None + + 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 = authoring_api.create_unit_and_version( learning_package_id=self.learning_package.id, - key=f"unit:key", + key="unit:key", title="Unit", created=self.now, created_by=None, @@ -77,21 +208,17 @@ def test_create_next_unit_version_with_two_components(self): unit_version_v2 = authoring_api.create_next_unit_version( unit=unit, title="Unit", - publishable_entities_pks=[ - self.component_1.publishable_entity.id, - self.component_2.publishable_entity.id, - ], - draft_version_pks=[None, None], - published_version_pks=[None, None], # FIXME: why do we specify this? + components=[self.component_1, self.component_2_v1], # Note the "v1" pinning ๐Ÿ“Œ the second one to version 1 created=self.now, created_by=None, ) assert unit_version_v2.version_num == 2 assert unit_version_v2 in unit.versioning.versions.all() assert authoring_api.get_components_in_draft_unit(unit) == [ - authoring_api.UnitListEntry(component_version=self.component_1.versioning.draft, pinned=False), - authoring_api.UnitListEntry(component_version=self.component_2.versioning.draft, pinned=False), + Entry(self.component_1_v1), + Entry(self.component_2_v1, pinned=True), # Pinned ๐Ÿ“Œ to v1 ] + assert authoring_api.get_components_in_published_unit(unit) is None def test_add_component_after_publish(self): """ @@ -100,112 +227,475 @@ def test_add_component_after_publish(self): """ unit, unit_version = authoring_api.create_unit_and_version( learning_package_id=self.learning_package.id, - key=f"unit:key", + key="unit:key", title="Unit", created=self.now, created_by=None, ) assert unit.versioning.draft == unit_version assert unit.versioning.published is None - assert unit.versioning.has_unpublished_changes == True + assert unit.versioning.has_unpublished_changes # Publish the empty unit: authoring_api.publish_all_drafts(self.learning_package.id) unit.refresh_from_db() # Reloading the unit is necessary - assert unit.versioning.has_unpublished_changes == False + assert unit.versioning.has_unpublished_changes is False # Shallow check for just the unit itself, not children + assert authoring_api.contains_unpublished_changes(unit) is False # Deeper check # Add a published component (unpinned): - assert self.component_1.versioning.has_unpublished_changes == False + assert self.component_1.versioning.has_unpublished_changes is False unit_version_v2 = authoring_api.create_next_unit_version( unit=unit, title=unit_version.title, - publishable_entities_pks=[ - self.component_1.publishable_entity.id, - ], - draft_version_pks=[None], - published_version_pks=[None], # FIXME: why do we specify this? + components=[self.component_1], created=self.now, created_by=None, ) # Now the unit should have unpublished changes: unit.refresh_from_db() # Reloading the unit is necessary - assert unit.versioning.has_unpublished_changes == True + assert unit.versioning.has_unpublished_changes # Shallow check - adding a child is a change to the unit + assert authoring_api.contains_unpublished_changes(unit) # Deeper check assert unit.versioning.draft == unit_version_v2 assert unit.versioning.published == unit_version - def test_modify_component_after_publish(self): + def test_modify_unpinned_component_after_publish(self): """ - Modifying a component in a published unit will NOT create a new version - nor show that the unit has unpublished changes. The modifications will - appear in the published version of the unit only after the component is - published. + 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: - unit, unit_version = authoring_api.create_unit_and_version( - learning_package_id=self.learning_package.id, - key=f"unit:key", - title="Unit", - created=self.now, - created_by=None, - ) - # Add a draft component (unpinned): - assert self.component_1.versioning.has_unpublished_changes == True - unit_version_v2 = authoring_api.create_next_unit_version( - unit=unit, - title=unit_version.title, - publishable_entities_pks=[ - self.component_1.publishable_entity.id, - ], - draft_version_pks=[None], - published_version_pks=[None], # FIXME: why do we specify this? - created=self.now, - created_by=None, - ) + # Create a unit with one unpinned draft component: + assert self.component_1.versioning.has_unpublished_changes + unit = self.create_unit_with_components([self.component_1]) + assert unit.versioning.has_unpublished_changes + # Publish the unit and the component: authoring_api.publish_all_drafts(self.learning_package.id) - unit.refresh_from_db() # Reloading the unit is necessary + 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 == False - assert self.component_1.versioning.has_unpublished_changes == False + assert unit.versioning.has_unpublished_changes is False # Shallow check + assert authoring_api.contains_unpublished_changes(unit) 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 = authoring_api.create_next_component_version( - self.component_1.pk, - content_to_replace={}, - title="Modified Counting Problem with new title", - created=self.now, - created_by=None, - ) + component_1_v2 = self.modify_component(self.component_1, title="Modified Counting Problem with new title") - # The component now has unpublished changes, but the unit doesn't (โญ๏ธ Is this what we want? โญ๏ธ) - unit.refresh_from_db() # Reloading the unit is necessary + # 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 == False - assert self.component_1.versioning.has_unpublished_changes == True + assert unit.versioning.has_unpublished_changes is False # Shallow check should be false - unit is unchanged + assert authoring_api.contains_unpublished_changes(unit) # 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 authoring_api.get_components_in_draft_unit(unit) == [ - authoring_api.UnitListEntry(component_version=component_1_v2, pinned=False), # new version + Entry(component_1_v2), # new version ] assert authoring_api.get_components_in_published_unit(unit) == [ - authoring_api.UnitListEntry(component_version=self.component_1_v1, pinned=False), # old version + 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 authoring_api.get_components_in_draft_unit(unit) == [ - authoring_api.UnitListEntry(component_version=component_1_v2, pinned=False), # new version + Entry(component_1_v2), # new version ] assert authoring_api.get_components_in_published_unit(unit) == [ - authoring_api.UnitListEntry(component_version=component_1_v2, pinned=False), # new version + Entry(component_1_v2), # new version + ] + assert authoring_api.contains_unpublished_changes(unit) 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 + 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_with_components([self.component_1_v1]) + + # Publish the unit and the component: + authoring_api.publish_all_drafts(self.learning_package.id) + expected_unit_contents = [ + Entry(self.component_1_v1, pinned=True), # pinned ๐Ÿ“Œ to v1 ] + assert authoring_api.get_components_in_published_unit(unit) == 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 authoring_api.contains_unpublished_changes(unit) 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 authoring_api.get_components_in_draft_unit(unit) == expected_unit_contents + assert authoring_api.get_components_in_published_unit(unit) == expected_unit_contents + # Even if we publish the component, the unit stays pinned to the specified version: + self.publish_component(self.component_1) + assert authoring_api.get_components_in_draft_unit(unit) == expected_unit_contents + assert authoring_api.get_components_in_published_unit(unit) == expected_unit_contents + def test_create_two_units_with_same_components(self): + """Test creating two units with the same components. + Expected results: + 1. Two different units are created. + 2. The units have the same components. + """ + # Create a unit with component 2 unpinned, component 2 pinned ๐Ÿ“Œ, and component 1: + unit1 = self.create_unit_with_components([self.component_2, self.component_2_v1, self.component_1], key="u1") + # Create a second unit with component 1 pinned ๐Ÿ“Œ, component 2, and component 1 unpinned: + unit2 = self.create_unit_with_components([self.component_1_v1, self.component_2, self.component_1], key="u2") + + # Check that the contents are as expected: + assert [row.component_version for row in authoring_api.get_components_in_draft_unit(unit1)] == [ + self.component_2_v1, self.component_2_v1, self.component_1_v1, + ] + assert [row.component_version for row in authoring_api.get_components_in_draft_unit(unit2)] == [ + 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 + authoring_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 authoring_api.get_components_in_draft_unit(unit1) == [ + 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 authoring_api.get_components_in_draft_unit(unit2) == [ + 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 authoring_api.get_components_in_published_unit(unit1) == [ + 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 authoring_api.get_components_in_published_unit(unit2) == [ + Entry(self.component_1_v1, pinned=True), # pinned ๐Ÿ“Œ to v1 + Entry(self.component_2_v1), # v1 in the published version + Entry(component_1_v2), # v2 + ] + + def test_publishing_shared_component(self): + """ + A complex test case involving two units with a shared component and + other non-shared components. + + Unit 1: components C1, C2, C3 + Unit 2: components C2, C4, C5 + Everything is "unpinned". + """ + # 1๏ธโƒฃ Create the units and publish them: + (c1, c1_v1), (c2, _c2_v1), (c3, c3_v1), (c4, c4_v1), (c5, c5_v1) = [ + self.create_component(key=f"C{i}", title=f"Component {i}") for i in range(1, 6) + ] + unit1 = self.create_unit_with_components([c1, c2, c3], title="Unit 1", key="unit:1") + unit2 = self.create_unit_with_components([c2, c4, c5], title="Unit 2", key="unit:2") + authoring_api.publish_all_drafts(self.learning_package.id) + assert authoring_api.contains_unpublished_changes(unit1) is False + assert authoring_api.contains_unpublished_changes(unit2) 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 authoring_api.contains_unpublished_changes(unit1) + assert authoring_api.contains_unpublished_changes(unit2) + # (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. + # FIXME: this should only require publishing the unit itself, but we don't yet do auto-publishing children + authoring_api.publish_from_drafts( + self.learning_package.pk, + draft_qset=authoring_api.get_all_drafts(self.learning_package.pk).filter( + entity_id__in=[ + unit1.publishable_entity.id, + c1.publishable_entity.id, + c2.publishable_entity.id, + c3.publishable_entity.id, + ], + ), + ) + + # Result: Unit 1 will show the newly published version of C2: + assert authoring_api.get_components_in_published_unit(unit1) == [ + 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 authoring_api.get_components_in_published_unit(unit2) == [ + 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 authoring_api.contains_unpublished_changes(unit1) is False + assert authoring_api.contains_unpublished_changes(unit2) + + # 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 authoring_api.get_components_in_published_unit(unit2) == [ + 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 authoring_api.contains_unpublished_changes(unit2) 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_with_components(components) + authoring_api.publish_all_drafts(self.learning_package.id) + unit.refresh_from_db() + with self.assertNumQueries(2): + assert authoring_api.contains_unpublished_changes(unit) is False + + # Modify the most recently created component: + self.modify_component(component, title="Modified Component") + with self.assertNumQueries(2): + assert authoring_api.contains_unpublished_changes(unit) is True + + @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: + authoring_api.publish_all_drafts(self.learning_package.id) + # Now delete it. The draft version is now deleted: + authoring_api.soft_delete_draft(component.pk) + # Now try adding that component to a unit: + with pytest.raises(ValidationError, match="component is deleted"): + self.create_unit_with_components([component]) + + def test_removing_component(self): + """ Test removing a component from a unit (but not deleting it) """ + unit = self.create_unit_with_components([self.component_1, self.component_2]) + authoring_api.publish_all_drafts(self.learning_package.id) + + # Now remove component 2 + authoring_api.create_next_unit_version( + unit=unit, + title="Revised with component 2 deleted", + components=[self.component_1], # component 2 is gone + created=self.now, + ) + + # Now it should not be listed in the unit: + assert authoring_api.get_components_in_draft_unit(unit) == [ + 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 authoring_api.contains_unpublished_changes(unit) + # The published version of the unit is not yet affected: + assert authoring_api.get_components_in_published_unit(unit) == [ + 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: + authoring_api.publish_all_drafts(self.learning_package.id) + # FIXME: Refreshing the unit is necessary here because get_entities_in_published_container() accesses + # container_entity.versioning.published, and .versioning is cached with the old version. But this seems like + # a footgun? + unit.refresh_from_db() + assert authoring_api.contains_unpublished_changes(unit) is False + assert authoring_api.get_components_in_published_unit(unit) == [ + 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]) + authoring_api.publish_all_drafts(self.learning_package.id) + + # Now soft delete component 2 + authoring_api.soft_delete_draft(self.component_2.pk) + + # Now it should not be listed in the unit: + assert authoring_api.get_components_in_draft_unit(unit) == [ + 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 authoring_api.contains_unpublished_changes(unit) # But it CONTAINS an unpublished change (a deletion) + # The published version of the unit is not yet affected: + assert authoring_api.get_components_in_published_unit(unit) == [ + Entry(self.component_1_v1), + Entry(self.component_2_v1), + ] + + # But when we publish the deletion, the published version is affected: + authoring_api.publish_all_drafts(self.learning_package.id) + assert authoring_api.contains_unpublished_changes(unit) is False + assert authoring_api.get_components_in_published_unit(unit) == [ + 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]) + authoring_api.publish_all_drafts(self.learning_package.id) + + # Now soft delete component 2 + authoring_api.soft_delete_draft(self.component_2.pk) + # And remove it from the unit: + authoring_api.create_next_unit_version( + unit=unit, + title="Revised with component 2 deleted", + components=[self.component_1], + created=self.now, + ) + + # Now it should not be listed in the unit: + assert authoring_api.get_components_in_draft_unit(unit) == [ + Entry(self.component_1_v1), + ] + assert unit.versioning.has_unpublished_changes is True + assert authoring_api.contains_unpublished_changes(unit) + # The published version of the unit is not yet affected: + assert authoring_api.get_components_in_published_unit(unit) == [ + Entry(self.component_1_v1), + Entry(self.component_2_v1), + ] + + # But when we publish the deletion, the published version is affected: + authoring_api.publish_all_drafts(self.learning_package.id) + assert authoring_api.contains_unpublished_changes(unit) is False + assert authoring_api.get_components_in_published_unit(unit) == [ + Entry(self.component_1_v1), + ] + + # Test the query counts of various operations # Test that only components can be added to units # Test that components must be in the same learning package + # Test that invalid component PKs cannot be added to a unit # Test that _version_pks=[] arguments must be related to publishable_entities_pks - # Test that publishing a unit publishes its components - # Test viewing old snapshots of units and components by passing in a timestamp to some get_historic_unit() API + # Test that publishing a unit publishes its child components automatically + # Test that publishing a component does NOT publish changes to its parent unit + # Test that I can get a 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 history of a given unit and its children, that includes changes made to the child components + # while they were part of the unit but excludes changes made to those children while they were not part of + # the unit. ๐Ÿซฃ + + def test_snapshots_of_published_unit(self): + """ + Test that we can access snapshots of the historic published version of + units and their contents. + """ + # At first the unit has one component (unpinned): + unit = self.create_unit_with_components([self.component_1]) + self.modify_component(self.component_1, title="Component 1 as of checkpoint 1") + + # Publish everything, creating Checkpoint 1 + checkpoint_1 = authoring_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 = authoring_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") + authoring_api.create_next_unit_version( + unit=unit, + title="Unit title in checkpoint 3", + components=[self.component_1, self.component_2], + created=self.now, + ) + # Publish everything, creating Checkpoint 3 + checkpoint_3 = authoring_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. + authoring_api.create_next_unit_version( + unit=unit, + title="Unit title in checkpoint 4", + components=[self.component_1_v1, self.component_1, self.component_2], + created=self.now, + ) + # Publish everything, creating Checkpoint 4 + checkpoint_4 = authoring_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 = authoring_api.get_components_in_published_unit_as_of(unit, checkpoint_1.pk) + assert [cv.component_version.title for cv in as_of_checkpoint_1] == [ + "Component 1 as of checkpoint 1", + ] + as_of_checkpoint_2 = authoring_api.get_components_in_published_unit_as_of(unit, checkpoint_2.pk) + assert [cv.component_version.title for cv in as_of_checkpoint_2] == [ + "Component 1 as of checkpoint 2", + ] + as_of_checkpoint_3 = authoring_api.get_components_in_published_unit_as_of(unit, checkpoint_3.pk) + assert [cv.component_version.title for cv in as_of_checkpoint_3] == [ + "Component 1 as of checkpoint 3", + "Component 2 as of checkpoint 3", + ] + as_of_checkpoint_4 = authoring_api.get_components_in_published_unit_as_of(unit, checkpoint_4.pk) + assert [cv.component_version.title for cv in as_of_checkpoint_4] == [ + "Querying Counting Problem", # Pinned. This title is self.component_1_v1.title (original v1 title) + "Component 1 as of checkpoint 3", # we didn't modify these components so they're same as in snapshot 3 + "Component 2 as of checkpoint 3", # we didn't modify these components so they're same as in snapshot 3 + ] def test_next_version_with_different_different_title(self): """Test creating a unit version with a different title. @@ -219,14 +709,6 @@ def test_next_version_with_different_different_title(self): 6. The frozen list is empty. """ - def test_create_two_units_with_same_components(self): - """Test creating two units with the same components. - - Expected results: - 1. Two different units are created. - 2. The units have the same components. - """ - def test_check_author_defined_list_matches_components(self): """Test checking the author defined list matches the components. diff --git a/tests/openedx_tagging/core/tagging/test_views.py b/tests/openedx_tagging/core/tagging/test_views.py index 936ba0a3..181a5509 100644 --- a/tests/openedx_tagging/core/tagging/test_views.py +++ b/tests/openedx_tagging/core/tagging/test_views.py @@ -45,8 +45,9 @@ def check_taxonomy( data, - taxonomy_id, - name, + *, + taxonomy_id: int, + name: str, description="", enabled=True, allow_multiple=True, @@ -333,7 +334,7 @@ def test_detail_taxonomy( expected_data["can_change_taxonomy"] = is_admin expected_data["can_delete_taxonomy"] = is_admin expected_data["can_tag_object"] = False - check_taxonomy(response.data, taxonomy.pk, **expected_data) + check_taxonomy(response.data, taxonomy_id=taxonomy.pk, **expected_data) # type: ignore[arg-type] def test_detail_system_taxonomy(self): url = TAXONOMY_DETAIL_URL.format(pk=LANGUAGE_TAXONOMY_ID) @@ -385,11 +386,11 @@ def test_create_taxonomy(self, user_attr: str | None, expected_status: int): create_data["can_change_taxonomy"] = True create_data["can_delete_taxonomy"] = True create_data["can_tag_object"] = False - check_taxonomy(response.data, response.data["id"], **create_data) + check_taxonomy(response.data, taxonomy_id=response.data["id"], **create_data) # type: ignore[arg-type] url = TAXONOMY_DETAIL_URL.format(pk=response.data["id"]) response = self.client.get(url) - check_taxonomy(response.data, response.data["id"], **create_data) + check_taxonomy(response.data, taxonomy_id=response.data["id"], **create_data) # type: ignore[arg-type] def test_create_without_export_id(self): url = TAXONOMY_LIST_URL @@ -409,7 +410,7 @@ def test_create_without_export_id(self): create_data["can_tag_object"] = False check_taxonomy( response.data, - response.data["id"], + taxonomy_id=response.data["id"], export_id="2-taxonomy-data-3", **create_data, ) @@ -465,7 +466,7 @@ def test_update_taxonomy(self, user_attr, expected_status): response = self.client.get(url) check_taxonomy( response.data, - response.data["id"], + taxonomy_id=response.data["id"], **{ "name": "new name", "description": "taxonomy description", @@ -526,7 +527,7 @@ def test_patch_taxonomy(self, user_attr, expected_status): response = self.client.get(url) check_taxonomy( response.data, - response.data["id"], + taxonomy_id=response.data["id"], **{ "name": "new name", "enabled": True, @@ -1041,7 +1042,15 @@ def test_object_tags_remaining_http_methods( ("staff", "taxonomy", {}, ["Invalid"], status.HTTP_400_BAD_REQUEST, "abc.xyz"), ) @ddt.unpack - def test_tag_object(self, user_attr, taxonomy_attr, taxonomy_flags, tag_values, expected_status, object_id): + def test_tag_object( # pylint: disable=too-many-positional-arguments + self, + user_attr, + taxonomy_attr, + taxonomy_flags, + tag_values, + expected_status, + object_id, + ): if user_attr: user = getattr(self, user_attr) self.client.force_authenticate(user=user) From 6586bd41cd520d3597734c419c64b3500db21339 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 20 Feb 2025 16:46:15 -0800 Subject: [PATCH 04/27] refactor: consolidate defined_list/initial_list/frozen_list into entity_list --- .../apps/authoring/containers/api.py | 161 ++---------------- .../containers/migrations/0001_initial.py | 4 +- .../apps/authoring/containers/models.py | 46 +---- openedx_learning/apps/authoring/units/api.py | 2 +- .../apps/authoring/units/test_api.py | 64 +------ 5 files changed, 22 insertions(+), 255 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index 7df5fe0e..0565dd04 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -73,9 +73,7 @@ def create_entity_list() -> EntityList: return EntityList.objects.create() -def create_next_defined_list( - previous_entity_list: EntityList | None, # pylint: disable=unused-argument - new_entity_list: EntityList, +def create_entity_list_with_rows( entity_pks: list[int], entity_version_pks: list[int | None], ) -> EntityList: @@ -84,52 +82,6 @@ def create_next_defined_list( Create new entity list rows for an entity list. Args: - previous_entity_list: The previous entity list that the new entity list is based on. - new_entity_list: The entity list to create the rows for. - entity_pks: The IDs of the publishable entities that the entity list rows reference. - entity_version_pks: The IDs of the draft versions of the entities - (PublishableEntityVersion) that the entity list rows reference, or - Nones for "unpinned" (default). - - Returns: - The newly created entity list rows. - """ - order_nums = range(len(entity_pks)) - with atomic(): - # Case 1: create first container version (no previous rows created for container) - # 1. Create new rows for the entity list - # Case 2: create next container version (previous rows created for container) - # 1. Get all the rows in the previous version entity list - # 2. Only associate existent rows to the new entity list iff: the order is the same, the PublishableEntity is in - # entity_pks and versions are not pinned - # 3. If the order is different for a row with the PublishableEntity, create new row with the same - # PublishableEntity for the new order and associate the new row to the new entity list - new_rows = [] - for order_num, entity_pk, entity_version_pk in zip( - order_nums, entity_pks, entity_version_pks - ): - new_rows.append( - EntityListRow( - entity_list=new_entity_list, - entity_id=entity_pk, - order_num=order_num, - entity_version_id=entity_version_pk, - ) - ) - EntityListRow.objects.bulk_create(new_rows) - return new_entity_list - - -def create_defined_list_with_rows( - entity_pks: list[int], - entity_version_pks: list[int | None], -) -> EntityList: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Create new entity list rows for an entity list. - - Args: - entity_list: The entity list to create the rows for. entity_pks: The IDs of the publishable entities that the entity list rows reference. entity_version_pks: The IDs of the versions of the entities (PublishableEntityVersion) that the entity list rows reference, or @@ -188,46 +140,6 @@ def get_entity_list_with_pinned_versions( return entity_list -def check_unpinned_versions_in_defined_list( - defined_list: EntityList, -) -> bool: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Check if there are any unpinned versions in the defined list. - - Args: - defined_list: The defined list to check for unpinned versions. - - Returns: - True if there are unpinned versions in the defined list, False otherwise. - """ - # Is there a way to short-circuit this? - return any( - row.entity_version is None - for row in defined_list.entitylistrow_set.all() - ) - - -def check_new_changes_in_defined_list( - entity_list: EntityList, # pylint: disable=unused-argument - publishable_entities_pks: list[int], # pylint: disable=unused-argument -) -> bool: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Check if there are any new changes in the defined list. - - Args: - entity_list: The entity list to check for new changes. - publishable_entities: The publishable entities to check for new changes. - - Returns: - True if there are new changes in the defined list, False otherwise. - """ - # Is there a way to short-circuit this? Using queryset operations - # For simplicity, return True - return True - - def create_container_version( container_pk: int, version_num: int, @@ -263,19 +175,14 @@ def create_container_version( created=created, created_by=created_by, ) - defined_list = create_defined_list_with_rows( + entity_list = create_entity_list_with_rows( entity_pks=publishable_entities_pks, entity_version_pks=entity_version_pks, ) container_version = ContainerEntityVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, - defined_list=defined_list, - initial_list=defined_list, - # TODO: Check for unpinned versions in defined_list to know whether to point this to the defined_list - # point to None. - # If this is the first version ever created for this ContainerEntity, then start as None. - frozen_list=None, + entity_list=entity_list, ) return container_version @@ -330,50 +237,14 @@ def create_next_container_version( created=created, created_by=created_by, ) - # 1. Check if there are any changes in the container's members - # 2. Pin versions in previous frozen list for last container version - # 3. Create new defined list for author changes - # 4. Pin versions in defined list to create initial list - # 5. Check for unpinned references in defined_list to determine if frozen_list should be None - # 6. Point frozen_list to None or defined_list - if check_new_changes_in_defined_list( - entity_list=last_version.defined_list, - publishable_entities_pks=publishable_entities_pks, - ): - # Only change if there are unpin versions in defined list, meaning last frozen list is None - # When does this has to happen? Before? - if not last_version.frozen_list: - last_version.frozen_list = get_entity_list_with_pinned_versions( - rows=last_version.defined_list.entitylistrow_set.all() - ) - last_version.save() - next_defined_list = create_next_defined_list( - previous_entity_list=last_version.defined_list, - new_entity_list=create_entity_list(), - entity_pks=publishable_entities_pks, - entity_version_pks=entity_version_pks, - ) - next_initial_list = get_entity_list_with_pinned_versions( - rows=next_defined_list.entitylistrow_set.all() - ) - if check_unpinned_versions_in_defined_list(next_defined_list): - next_frozen_list = None - else: - next_frozen_list = next_initial_list - else: - # Do I need to create new EntityList and copy rows? - # I do think so because frozen can change when creating a new version - # Does it need to change though? - # What would happen if I only change the title? - next_defined_list = last_version.defined_list - next_initial_list = last_version.initial_list - next_frozen_list = last_version.frozen_list + next_entity_list = create_entity_list_with_rows( + entity_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, + ) next_container_version = ContainerEntityVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, - defined_list=next_defined_list, - initial_list=next_initial_list, - frozen_list=next_frozen_list, + entity_list=next_entity_list, ) return next_container_version @@ -461,8 +332,7 @@ def get_entities_in_draft_container( container = container.container_entity assert isinstance(container, ContainerEntity) entity_list = [] - defined_list = container.versioning.draft.defined_list - for row in defined_list.entitylistrow_set.order_by("order_num"): + for row in container.versioning.draft.entity_list.entitylistrow_set.order_by("order_num"): entity_version = row.entity_version or row.entity.draft.version if entity_version is not None: # As long as this hasn't been soft-deleted: entity_list.append(ContainerEntityListEntry( @@ -490,13 +360,8 @@ def get_entities_in_published_container( if cev is None: return None # There is no published version of this container. Should this be an exception? assert isinstance(cev, ContainerEntityVersion) - # TODO: do we ever need frozen_list? e.g. when accessing a historical version? - # Doesn't make a lot of sense when the versions of the container don't capture many of the changes to the contents, - # e.g. container version 1 had component version 1 through 50, and container version 2 had component versions 51 - # through 100, what is the point of tracking whether container version 1 "should" show v1 or v50 when they're wildly - # different? entity_list = [] - for row in cev.defined_list.entitylistrow_set.order_by("order_num"): + for row in cev.entity_list.entitylistrow_set.order_by("order_num"): entity_version = row.entity_version or row.entity.published.version if entity_version is not None: # As long as this hasn't been soft-deleted: entity_list.append(ContainerEntityListEntry( @@ -524,7 +389,7 @@ def contains_unpublished_changes( "publishable_entity", "publishable_entity__draft", "publishable_entity__draft__version", - "publishable_entity__draft__version__containerentityversion__defined_list", + "publishable_entity__draft__version__containerentityversion__entity_list", ).get(pk=container.container_entity_id) else: pass # TODO: select_related if we're given a raw ContainerEntity rather than a ContainerEntityMixin like Unit? @@ -534,12 +399,12 @@ def contains_unpublished_changes( return True # We only care about children that are un-pinned, since published changes to pinned children don't matter - defined_list = container.versioning.draft.defined_list + entity_list = container.versioning.draft.entity_list # TODO: This is a naive inefficient implementation but hopefully correct. # Once we know it's correct and have a good test suite, then we can optimize. # We will likely change to a tracking-based approach rather than a "scan for changes" based approach. - for row in defined_list.entitylistrow_set.filter(entity_version=None).select_related( + for row in entity_list.entitylistrow_set.filter(entity_version=None).select_related( "entity__containerentity", "entity__draft__version", "entity__published__version", diff --git a/openedx_learning/apps/authoring/containers/migrations/0001_initial.py b/openedx_learning/apps/authoring/containers/migrations/0001_initial.py index be274d59..ab1068e2 100644 --- a/openedx_learning/apps/authoring/containers/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/containers/migrations/0001_initial.py @@ -43,9 +43,7 @@ class Migration(migrations.Migration): fields=[ ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')), ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_containers.containerentity')), - ('defined_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='defined_list', to='oel_containers.entitylist')), - ('frozen_list', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='frozen_list', to='oel_containers.entitylist')), - ('initial_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='initial_list', to='oel_containers.entitylist')), + ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='entity_list', to='oel_containers.entitylist')), ], options={ 'abstract': False, diff --git a/openedx_learning/apps/authoring/containers/models.py b/openedx_learning/apps/authoring/containers/models.py index 6de71a21..9d4ec990 100644 --- a/openedx_learning/apps/authoring/containers/models.py +++ b/openedx_learning/apps/authoring/containers/models.py @@ -21,7 +21,7 @@ class EntityList(models.Model): sometimes we'll want the same kind of data structure for things that we dynamically generate for individual students (e.g. Variants). EntityLists are anonymous in a senseโ€“they're pointed to by ContainerEntityVersions and - other models, rather than being looked up by their own identifers. + other models, rather than being looked up by their own identifiers. """ @@ -92,7 +92,7 @@ class ContainerEntityVersion(PublishableEntityVersionMixin): The last looks a bit odd, but it's because *how we've defined the Unit* has changed if we decide to explicitly pin a set of versions for the children, and then later change our minds and move to a different set. It also just - makes things easier to reason about if we say that defined_list never + makes things easier to reason about if we say that entity_list never changes for a given ContainerEntityVersion. """ @@ -102,46 +102,10 @@ class ContainerEntityVersion(PublishableEntityVersionMixin): related_name="versions", ) - # This is the EntityList that the author defines. This should never change, - # even if the things it references get soft-deleted (because we'll need to - # maintain it for reverts). - defined_list = models.ForeignKey( + # The list of entities (frozen and/or unfrozen) in this container + entity_list = models.ForeignKey( EntityList, on_delete=models.RESTRICT, null=False, - related_name="defined_list", - ) - - # inital_list is an EntityList where all the versions are pinned, to show - # what the exact versions of the children were at the time that the - # Container was created. We could technically derive this, but it would be - # awkward to query. - # - # If the Container was defined so that all references were pinned, then this - # can point to the exact same EntityList as defined_list. - initial_list = models.ForeignKey( - EntityList, - on_delete=models.RESTRICT, - null=False, - related_name="initial_list", - ) - - # This is the EntityList that's created when the next ContainerEntityVersion - # is created. All references in this list should be pinned, and it serves as - # "the last state the children were in for this version of the Container". - # If defined_list has only pinned references, this should point to the same - # EntityList as defined_list and initial_list. - # - # This value is mutable if and only if there are unpinned references in - # defined_list. In that case, frozen_list should start as None, and be - # updated to pin references when another version of this Container becomes - # the Draft version. But if this version ever becomes the Draft *again* - # (e.g. the user hits "discard changes" or some kind of revert happens), - # then we need to clear this back to None. - frozen_list = models.ForeignKey( - EntityList, - on_delete=models.RESTRICT, - null=True, - default=None, - related_name="frozen_list", + related_name="entity_list", ) diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index 8b3f0448..e6db8316 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -283,7 +283,7 @@ def get_components_in_published_unit_as_of( unit_version = unit_pub_entity_version.unitversion # type: ignore[attr-defined] entity_list = [] - rows = unit_version.container_entity_version.defined_list.entitylistrow_set.order_by("order_num") + rows = unit_version.container_entity_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 diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index d4f8af58..345f8b36 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -705,43 +705,7 @@ def test_next_version_with_different_different_title(self): 2. The unit version number is 2. 3. The unit version is in the unit's versions. 4. The unit version's title is different from the previous version. - 5. The user defined is the same as the previous version. - 6. The frozen list is empty. - """ - - def test_check_author_defined_list_matches_components(self): - """Test checking the author defined list matches the components. - - Expected results: - 1. The author defined list matches the components used to create the unit version. - """ - - def test_check_initial_list_matches_components(self): - """Test checking the initial list matches the components. - - Expected results: - 1. The initial list matches the components (pinned) used to create the unit version. - """ - - def test_check_frozen_list_is_none_floating_versions(self): - """Test checking the frozen list is None when floating versions are used in the author defined list. - - Expected results: - 1. The frozen list is None. - """ - - def test_check_frozen_list_when_next_version_is_created(self): - """Test checking the frozen list when a new version is created. - - Expected results: - 1. The frozen list has pinned versions of the user defined list from the previous version. - """ - - def test_check_lists_equal_when_pinned_versions(self): - """Test checking the lists are equal when pinned versions are used. - - Expected results: - 1. The author defined list == initial list == frozen list. + 5. The entity list is the same as the previous version. """ def test_publish_unit_version(self): @@ -768,29 +732,5 @@ def test_next_version_with_different_order(self): 1. A new unit version is created. 2. The unit version number is 2. 3. The unit version is in the unit's versions. - 4. The user defined list is different from the previous version. - 5. The initial list contains the pinned versions of the defined list. - 6. The frozen list is empty. - """ - - def test_soft_delete_component_from_units(self): - """Soft-delete a component from a unit. - - Expected result: - After soft-deleting the component (draft), a new unit version (draft) is created for the unit. - """ - - def test_soft_delete_component_from_units_and_publish(self): - """Soft-delete a component from a unit and publish the unit. - - Expected result: - After soft-deleting the component (draft), a new unit version (draft) is created for the unit. - Then, if the unit is published all units referencing the component are published as well. - """ - - def test_unit_version_becomes_draft_again(self): - """Test a unit version becomes a draft again. - - Expected results: - 1. The frozen list is None after the unit version becomes a draft again. + 4. The entity list is different from the previous version. """ From 5f202e97165f9a32de51139313d519c7b292e1d9 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 21 Feb 2025 15:53:04 -0800 Subject: [PATCH 05/27] test: expand test cases test: add tests that publishes cascade down but not up test: update docs about further tests we should write test: add test for invalid component IDs --- .../apps/authoring/containers/api.py | 2 +- .../apps/authoring/units/test_api.py | 121 ++++++++++-------- 2 files changed, 71 insertions(+), 52 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index 0565dd04..1c31e5c2 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -421,7 +421,7 @@ def contains_unpublished_changes( else: # This is not a container: draft_pk = row.entity.draft.version_id if row.entity.draft else None - published_pk = row.entity.published.version_id if row.entity.published else None + published_pk = row.entity.published.version_id if hasattr(row.entity, "published") else None if draft_pk != published_pk: return True return False diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index 345f8b36..efe35290 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -4,6 +4,7 @@ import ddt # type: ignore[import] import pytest from django.core.exceptions import ValidationError +from django.db import IntegrityError from openedx_learning.api import authoring as authoring_api from openedx_learning.api import authoring_models @@ -140,6 +141,19 @@ def test_adding_external_components(self): created_by=None, ) + @ddt.data(True, False) + def test_cannot_add_invalid_ids(self, pin_version): + """ + Test that non-existent components cannot be added to units + """ + self.component_1.delete() + if pin_version: + components = [self.component_1_v1] + else: + components = [self.component_1] + with pytest.raises((IntegrityError, authoring_models.Component.DoesNotExist)): + self.create_unit_with_components(components) + def test_create_empty_unit_and_version(self): """Test creating a unit with no components. @@ -220,6 +234,56 @@ def test_create_next_unit_version_with_unpinned_and_pinned_components(self): ] assert authoring_api.get_components_in_published_unit(unit) is None + @pytest.mark.skip(reason="FIXME: auto-publishing children is not implemented yet") + def test_auto_publish_children(self): + """ + Test that publishing a unit publishes its child components automatically. + """ + # Create a draft unit with two draft components + unit = self.create_unit_with_components([self.component_1, self.component_2]) + # Also create another component that's not in the unit at all: + other_component, _oc_v1 = self.create_component(title="A draft component not in the unit", key="component:3") + + assert authoring_api.contains_unpublished_changes(unit) + 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 + authoring_api.publish_from_drafts( + self.learning_package.pk, + draft_qset=authoring_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 authoring_api.contains_unpublished_changes(unit) is False # Deep check + assert self.component_1.versioning.published == self.component_1_v1 # v1 is now the published version. + + # But our other component that's outside the unit is not affected: + other_component.refresh_from_db() + assert other_component.versioning.has_unpublished_changes + assert other_component.versioning.published is None + + def test_no_publish_parent(self): + """ + Test that publishing a component does NOT publish changes to its parent unit + """ + # Create a draft unit with two draft components + unit = self.create_unit_with_components([self.component_1, self.component_2]) + assert unit.versioning.has_unpublished_changes + # Publish ONLY one of its child components + self.publish_component(self.component_1) + self.component_1.refresh_from_db() # Clear cache on '.versioning' + assert self.component_1.versioning.has_unpublished_changes is False + + # 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 + assert authoring_api.get_components_in_published_unit(unit) is None + def test_add_component_after_publish(self): """ Adding a component to a published unit will create a new version and @@ -340,11 +404,9 @@ def test_modify_pinned_component(self): assert authoring_api.get_components_in_published_unit(unit) == expected_unit_contents def test_create_two_units_with_same_components(self): - """Test creating two units with the same components. - - Expected results: - 1. Two different units are created. - 2. The units have the same components. + """ + Test creating two units with different combinations of the same two + components in each unit. """ # Create a unit with component 2 unpinned, component 2 pinned ๐Ÿ“Œ, and component 1: unit1 = self.create_unit_with_components([self.component_2, self.component_2_v1, self.component_1], key="u1") @@ -542,7 +604,8 @@ def test_removing_component(self): authoring_api.publish_all_drafts(self.learning_package.id) # FIXME: Refreshing the unit is necessary here because get_entities_in_published_container() accesses # container_entity.versioning.published, and .versioning is cached with the old version. But this seems like - # a footgun? + # 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 authoring_api.contains_unpublished_changes(unit) is False assert authoring_api.get_components_in_published_unit(unit) == [ @@ -614,13 +677,7 @@ def test_soft_deleting_and_removing_component(self): Entry(self.component_1_v1), ] - # Test the query counts of various operations - # Test that only components can be added to units - # Test that components must be in the same learning package - # Test that invalid component PKs cannot be added to a unit - # Test that _version_pks=[] arguments must be related to publishable_entities_pks - # Test that publishing a unit publishes its child components automatically - # Test that publishing a component does NOT publish changes to its parent unit + # TODO: test that I can find all the units that contain the given component. # Test that I can get a 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 history of a given unit and its children, that includes changes made to the child components @@ -696,41 +753,3 @@ def test_snapshots_of_published_unit(self): "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_next_version_with_different_different_title(self): - """Test creating a unit version with a different title. - - Expected results: - 1. A new unit version is created. - 2. The unit version number is 2. - 3. The unit version is in the unit's versions. - 4. The unit version's title is different from the previous version. - 5. The entity list is the same as the previous version. - """ - - def test_publish_unit_version(self): - """Test publish unpublished unit version. - - Expected results: - 1. The newly created unit version has unpublished changes. - 2. The published version matches the unit version. - 3. The draft version matches the unit version. - """ - - def test_publish_unit_with_unpublished_component(self): - """Test publishing a unit with an unpublished component. - - Expected results: - 1. The unit version is published. - 2. The component is published. - """ - - def test_next_version_with_different_order(self): - """Test creating a unit version with different order of components. - - Expected results: - 1. A new unit version is created. - 2. The unit version number is 2. - 3. The unit version is in the unit's versions. - 4. The entity list is different from the previous version. - """ From d3f21743faf6de6ef2f91c5a9b17f85439b9f652 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 25 Feb 2025 14:54:40 -0800 Subject: [PATCH 06/27] feat: get_containers_with_entity() API to find containers using a component --- .../apps/authoring/containers/api.py | 35 +++++++++++ .../apps/authoring/units/test_api.py | 59 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index 1c31e5c2..2467d4e1 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -29,6 +29,7 @@ "get_entities_in_draft_container", "get_entities_in_published_container", "contains_unpublished_changes", + "get_containers_with_entity", ] @@ -425,3 +426,37 @@ def contains_unpublished_changes( if draft_pk != published_pk: return True return False + + +def get_containers_with_entity( + publishable_entity_pk: int, + *, + ignore_pinned=False, +) -> QuerySet[ContainerEntity]: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Find all draft containers that directly contain the given entity. + + They will always be from the same learning package; cross-package containers + are not allowed. + + Args: + publishable_entity_pk: The ID of the PublishableEntity to search for. + ignore_pinned: if true, ignore any pinned references to the entity. + """ + if ignore_pinned: + qs = ContainerEntity.objects.filter( + publishable_entity__draft__version__containerentityversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501 + publishable_entity__draft__version__containerentityversion__entity_list__entitylistrow__entity_version_id=None, # pylint: disable=line-too-long # noqa: E501 + ).order_by("pk") # Ordering is mostly for consistent test cases. + else: + qs = ContainerEntity.objects.filter( + publishable_entity__draft__version__containerentityversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501 + ).order_by("pk") # Ordering is mostly for consistent test cases. + # Could alternately do this query in two steps. Not sure which is more efficient; depends on how the DB plans it. + # # Find all the EntityLists that contain the given entity: + # lists = EntityList.objects.filter(entitylistrow__entity_id=publishable_entity_pk).values_list('pk', flat=True) + # qs = ContainerEntity.objects.filter( + # publishable_entity__draft__version__containerentityversion__entity_list__in=lists + # ) + return qs diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index efe35290..3efe77e9 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -677,6 +677,27 @@ def test_soft_deleting_and_removing_component(self): 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]) + authoring_api.publish_all_drafts(self.learning_package.id) + + # Now soft delete component 2 + authoring_api.soft_delete_draft(self.component_2.pk) + + # Now it should still be listed in the unit: + assert authoring_api.get_components_in_draft_unit(unit) == [ + 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 authoring_api.contains_unpublished_changes(unit) is False # nor does it contain changes + # The published version of the unit is also not affected: + assert authoring_api.get_components_in_published_unit(unit) == [ + Entry(self.component_1_v1, pinned=True), + Entry(self.component_2_v1, pinned=True), + ] + # TODO: test that I can find all the units that contain the given component. # Test that I can get a 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. @@ -753,3 +774,41 @@ def test_snapshots_of_published_unit(self): "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: + unit1_1pinned = self.create_unit_with_components([self.component_1_v1], key="u1") # โœ… has it 1 pinned ๐Ÿ“Œ to V1 + unit2_1pinned_v2 = self.create_unit_with_components([component_1_v2], key="u2") # โœ… has it pinned ๐Ÿ“Œ to V2 + _unit3_no = self.create_unit_with_components([self.component_2], key="u3") + unit4_unpinned = self.create_unit_with_components([self.component_1], key="u4") # โœ… has component 1, unpinned + _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") + + # 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 + authoring_api.get_containers_with_entity(self.component_1.pk).select_related("unit") + ] + assert result == [ + unit1_1pinned, + unit2_1pinned_v2, + unit4_unpinned, + ] + + # 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 + authoring_api.get_containers_with_entity(self.component_1.pk, ignore_pinned=True).select_related("unit") + ] + assert result2 == [unit4_unpinned] From 4fda3cbb1c943468d16a52e822d0f72c99c5f107 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 25 Feb 2025 16:16:05 -0800 Subject: [PATCH 07/27] refactor: remove redundant 'entity' arg --- openedx_learning/apps/authoring/containers/api.py | 14 ++++++-------- openedx_learning/apps/authoring/units/api.py | 3 --- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index 2467d4e1..5762c968 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -148,7 +148,6 @@ def create_container_version( title: str, publishable_entities_pks: list[int], entity_version_pks: list[int | None], - entity: PublishableEntity, created: datetime, created_by: int | None, ) -> ContainerEntityVersion: @@ -161,7 +160,6 @@ def create_container_version( version_num: The version number of the container. title: The title of the container. publishable_entities_pks: The IDs of the members of the container. - entity: The entity that the container belongs to. created: The date and time the container version was created. created_by: The ID of the user who created the container version. @@ -169,6 +167,8 @@ def create_container_version( The newly created container version. """ with atomic(): + container = ContainerEntity.objects.select_related("publishable_entity").get(pk=container_pk) + entity = container.publishable_entity publishable_entity_version = publishing_api.create_publishable_entity_version( entity.pk, version_num=version_num, @@ -194,7 +194,6 @@ def create_next_container_version( title: str, publishable_entities_pks: list[int], entity_version_pks: list[int | None], - entity: PublishableEntity, created: datetime, created_by: int | None, ) -> ContainerEntityVersion: @@ -213,7 +212,6 @@ def create_next_container_version( container_pk: The ID of the container to create the next version of. title: The title of the container. publishable_entities_pks: The IDs of the members current members of the container. - entity: The entity that the container belongs to. created: The date and time the container version was created. created_by: The ID of the user who created the container version. @@ -228,7 +226,8 @@ def create_next_container_version( ).exists(): raise ValidationError("Container entities must be from the same learning package.") with atomic(): - container = ContainerEntity.objects.get(pk=container_pk) + container = ContainerEntity.objects.select_related("publishable_entity").get(pk=container_pk) + entity = container.publishable_entity last_version = container.versioning.latest next_version_num = last_version.version_num + 1 publishable_entity_version = publishing_api.create_publishable_entity_version( @@ -280,12 +279,11 @@ def create_container_and_version( with atomic(): container = create_container(learning_package_id, key, created, created_by) container_version = create_container_version( - container_pk=container.publishable_entity.pk, - version_num=1, + container.publishable_entity.pk, + 1, title=title, publishable_entities_pks=publishable_entities_pks, entity_version_pks=entity_version_pks, - entity=container.publishable_entity, created=created, created_by=created_by, ) diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index e6db8316..6ff8b1ee 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -82,7 +82,6 @@ def create_unit_version( title=title, publishable_entities_pks=publishable_entities_pks, entity_version_pks=entity_version_pks, - entity=unit.container_entity.publishable_entity, created=created, created_by=created_by, ) @@ -108,7 +107,6 @@ def create_next_unit_version( unit_pk: The unit ID. title: The title. components: The components, as a list of Components (unpinned) and/or ComponentVersions (pinned) - entity: The entity. created: The creation date. created_by: The user who created the unit. """ @@ -131,7 +129,6 @@ def create_next_unit_version( title=title, publishable_entities_pks=publishable_entities_pks, entity_version_pks=entity_version_pks, - entity=unit.container_entity.publishable_entity, created=created, created_by=created_by, ) From dbbcedbcb664877b49c3bdd291043f14035e7dfa Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 25 Feb 2025 16:16:54 -0800 Subject: [PATCH 08/27] feat: allow changing container title/metadata without changing its list --- .../apps/authoring/containers/api.py | 97 +++++++++---------- openedx_learning/apps/authoring/units/api.py | 39 ++++---- .../apps/authoring/units/test_api.py | 20 ++++ 3 files changed, 86 insertions(+), 70 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index 5762c968..41b7408e 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -110,44 +110,13 @@ def create_entity_list_with_rows( return entity_list -def get_entity_list_with_pinned_versions( - rows: QuerySet[EntityListRow], -) -> EntityList: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Copy rows from an existing entity list to a new entity list. - - Args: - entity_list: The entity list to copy the rows to. - rows: The rows to copy to the new entity list. - - Returns: - The newly created entity list. - """ - entity_list = create_entity_list() - with atomic(): - _ = EntityListRow.objects.bulk_create( - [ - EntityListRow( - entity_list=entity_list, - entity_id=row.entity.id, - order_num=row.order_num, - entity_version_id=None, # For simplicity, we are not copying the pinned versions - ) - for row in rows - ] - ) - - return entity_list - - def create_container_version( container_pk: int, version_num: int, *, title: str, publishable_entities_pks: list[int], - entity_version_pks: list[int | None], + entity_version_pks: list[int | None] | None, created: datetime, created_by: int | None, ) -> ContainerEntityVersion: @@ -169,6 +138,23 @@ def create_container_version( with atomic(): container = ContainerEntity.objects.select_related("publishable_entity").get(pk=container_pk) entity = container.publishable_entity + + # Do a quick check that the given entities are in the right learning package: + if PublishableEntity.objects.filter( + pk__in=publishable_entities_pks, + ).exclude( + learning_package_id=entity.learning_package_id, + ).exists(): + raise ValidationError("Container entities must be from the same learning package.") + + assert title is not None + assert publishable_entities_pks is not None + if entity_version_pks is None: + entity_version_pks = [None] * len(publishable_entities_pks) + entity_list = create_entity_list_with_rows( + entity_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, + ) publishable_entity_version = publishing_api.create_publishable_entity_version( entity.pk, version_num=version_num, @@ -176,24 +162,21 @@ def create_container_version( created=created, created_by=created_by, ) - entity_list = create_entity_list_with_rows( - entity_pks=publishable_entities_pks, - entity_version_pks=entity_version_pks, - ) container_version = ContainerEntityVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, entity_list=entity_list, ) + return container_version def create_next_container_version( container_pk: int, *, - title: str, - publishable_entities_pks: list[int], - entity_version_pks: list[int | None], + title: str | None, + publishable_entities_pks: list[int] | None, + entity_version_pks: list[int | None] | None, created: datetime, created_by: int | None, ) -> ContainerEntityVersion: @@ -210,37 +193,45 @@ 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. - publishable_entities_pks: The IDs of the members current members of the container. + title: The title of the container. None to keep the current title. + publishable_entities_pks: The IDs of the members current members of the container. Or None for no change. + entity_version_pks: The IDs of the versions to pin to, if pinning is desired. created: The date and time the container version was created. created_by: The ID of the user who created the container version. Returns: The newly created container version. """ - # Do a quick check that the given entities are in the right learning package: - if PublishableEntity.objects.filter( - pk__in=publishable_entities_pks, - ).exclude( - learning_package_id=entity.learning_package_id, - ).exists(): - raise ValidationError("Container entities must be from the same learning package.") with atomic(): container = ContainerEntity.objects.select_related("publishable_entity").get(pk=container_pk) entity = container.publishable_entity last_version = container.versioning.latest + assert last_version is not None next_version_num = last_version.version_num + 1 + if publishable_entities_pks is None: + # We're only changing metadata. Keep the same entity list. + next_entity_list = last_version.entity_list + else: + # Do a quick check that the given entities are in the right learning package: + if PublishableEntity.objects.filter( + pk__in=publishable_entities_pks, + ).exclude( + learning_package_id=entity.learning_package_id, + ).exists(): + raise ValidationError("Container entities must be from the same learning package.") + if entity_version_pks is None: + entity_version_pks = [None] * len(publishable_entities_pks) + next_entity_list = create_entity_list_with_rows( + entity_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, + ) publishable_entity_version = publishing_api.create_publishable_entity_version( entity.pk, version_num=next_version_num, - title=title, + title=title if title is not None else last_version.title, created=created, created_by=created_by, ) - next_entity_list = create_entity_list_with_rows( - entity_pks=publishable_entities_pks, - entity_version_pks=entity_version_pks, - ) next_container_version = ContainerEntityVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index 6ff8b1ee..398b684b 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -95,8 +95,9 @@ def create_unit_version( def create_next_unit_version( unit: Unit, - title: str, - components: list[Component | ComponentVersion], + *, + title: str | None = None, + components: list[Component | ComponentVersion] | None = None, created: datetime, created_by: int | None = None, ) -> UnitVersion: @@ -105,25 +106,29 @@ def create_next_unit_version( Args: unit_pk: The unit ID. - title: The title. - components: The components, as a list of Components (unpinned) and/or ComponentVersions (pinned) + 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. """ - for c in components: - if not isinstance(c, (Component, ComponentVersion)): - raise TypeError("Unit components must be either Component or ComponentVersion.") - publishable_entities_pks = [ - (c.publishable_entity_id if isinstance(c, Component) else c.component.publishable_entity_id) - for c in components - ] - entity_version_pks = [ - (cv.pk if isinstance(cv, ComponentVersion) else None) - for cv in components - ] + if components is not None: + for c in components: + if not isinstance(c, (Component, ComponentVersion)): + raise TypeError("Unit components must be either Component or ComponentVersion.") + publishable_entities_pks = [ + (c.publishable_entity_id if isinstance(c, Component) else c.component.publishable_entity_id) + for c in components + ] + entity_version_pks = [ + (cv.pk if isinstance(cv, ComponentVersion) else None) + for cv in components + ] + else: + # When these are None, that means don't change the entities in the list. + publishable_entities_pks = None + entity_version_pks = None with atomic(): - # TODO: how can we enforce that publishable entities must be components? - # This currently allows for any publishable entity to be added to a unit. container_entity_version = container_api.create_next_container_version( unit.container_entity.pk, title=title, diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index 3efe77e9..02ee4063 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -554,6 +554,26 @@ def test_query_count_of_contains_unpublished_changes(self): with self.assertNumQueries(2): assert authoring_api.contains_unpublished_changes(unit) is True + def test_metadata_change_doesnt_create_entity_list(self): + """ + Test that changing a container's metadata like title will create a new + version, but can re-use the same EntityList. API consumers generally + shouldn't depend on this behavior; it's an optimization. + """ + unit = self.create_unit_with_components([self.component_1, self.component_2_v1]) + + orig_version_num = unit.container_entity.versioning.draft.version_num + orig_entity_list_id = unit.container_entity.versioning.draft.entity_list.pk + + authoring_api.create_next_unit_version(unit, title="New Title", created=self.now) + + unit.refresh_from_db() + new_version_num = unit.container_entity.versioning.draft.version_num + new_entity_list_id = unit.container_entity.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): From 8719a7058aa407aed5ca7561a471168e28e89831 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 26 Feb 2025 10:04:37 -0800 Subject: [PATCH 09/27] test: add test for deleting unit without affecting components within --- .../apps/authoring/units/test_api.py | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index 02ee4063..6a486f0a 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -718,12 +718,39 @@ def test_soft_deleting_pinned_component(self): Entry(self.component_2_v1, pinned=True), ] - # TODO: test that I can find all the units that contain the given component. - # Test that I can get a 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 history of a given unit and its children, that includes changes made to the child components - # while they were part of the unit but excludes changes made to those children while they were not part of - # the unit. ๐Ÿซฃ + def test_soft_delete_unit(self): + """ + I can delete a unit without deleting the components it contains. + + See https://github.com/openedx/frontend-app-authoring/issues/1693 + """ + # Create two units, one of which we will soon delete: + unit_to_delete = self.create_unit_with_components([self.component_1, self.component_2]) + other_unit = self.create_unit_with_components([self.component_1], key="other") + + # Publish everything: + authoring_api.publish_all_drafts(self.learning_package.id) + # Delete the unit: + authoring_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 authoring_api.get_components_in_draft_unit(other_unit) == [Entry(self.component_1_v1)] + + # Publish everything: + authoring_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 authoring_api.get_components_in_draft_unit(other_unit) == [Entry(self.component_1_v1)] + assert authoring_api.get_components_in_published_unit(other_unit) == [Entry(self.component_1_v1)] def test_snapshots_of_published_unit(self): """ @@ -832,3 +859,10 @@ def test_units_containing(self): authoring_api.get_containers_with_entity(self.component_1.pk, ignore_pinned=True).select_related("unit") ] assert result2 == [unit4_unpinned] + + # 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 ee3f845a2f8f29e7ba8b272d465d46a52aceeb3b Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 28 Feb 2025 16:58:43 -0800 Subject: [PATCH 10/27] refactor: ContainerEntity -> Container, ContainerEntityVersion -> ContainerVersion --- .../apps/authoring/containers/api.py | 92 +++++++++---------- .../apps/authoring/containers/apps.py | 6 +- .../containers/migrations/0001_initial.py | 6 +- .../apps/authoring/containers/models.py | 16 ++-- .../apps/authoring/containers/models_mixin.py | 46 +++++----- openedx_learning/apps/authoring/units/api.py | 20 ++-- .../units/migrations/0001_initial.py | 4 +- .../apps/authoring/units/models.py | 6 +- .../apps/authoring/units/test_api.py | 10 +- 9 files changed, 103 insertions(+), 103 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index 41b7408e..88396290 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -11,9 +11,9 @@ from django.db.models import QuerySet from django.db.transaction import atomic -from openedx_learning.apps.authoring.containers.models_mixin import ContainerEntityMixin +from openedx_learning.apps.authoring.containers.models_mixin import ContainerMixin -from ..containers.models import ContainerEntity, ContainerEntityVersion, EntityList, EntityListRow +from ..containers.models import Container, ContainerVersion, EntityList, EntityListRow from ..publishing import api as publishing_api from ..publishing.models import PublishableEntity, PublishableEntityVersion @@ -38,7 +38,7 @@ def create_container( key: str, created: datetime, created_by: int | None, -) -> ContainerEntity: +) -> Container: """ [ ๐Ÿ›‘ UNSTABLE ] Create a new container. @@ -56,7 +56,7 @@ def create_container( publishable_entity = publishing_api.create_publishable_entity( learning_package_id, key, created, created_by ) - container = ContainerEntity.objects.create( + container = Container.objects.create( publishable_entity=publishable_entity, ) return container @@ -119,7 +119,7 @@ def create_container_version( entity_version_pks: list[int | None] | None, created: datetime, created_by: int | None, -) -> ContainerEntityVersion: +) -> ContainerVersion: """ [ ๐Ÿ›‘ UNSTABLE ] Create a new container version. @@ -136,7 +136,7 @@ def create_container_version( The newly created container version. """ with atomic(): - container = ContainerEntity.objects.select_related("publishable_entity").get(pk=container_pk) + container = Container.objects.select_related("publishable_entity").get(pk=container_pk) entity = container.publishable_entity # Do a quick check that the given entities are in the right learning package: @@ -162,7 +162,7 @@ def create_container_version( created=created, created_by=created_by, ) - container_version = ContainerEntityVersion.objects.create( + container_version = ContainerVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, entity_list=entity_list, @@ -179,7 +179,7 @@ def create_next_container_version( entity_version_pks: list[int | None] | None, created: datetime, created_by: int | None, -) -> ContainerEntityVersion: +) -> ContainerVersion: """ [ ๐Ÿ›‘ UNSTABLE ] Create the next version of a container. A new version of the container is created @@ -203,7 +203,7 @@ def create_next_container_version( The newly created container version. """ with atomic(): - container = ContainerEntity.objects.select_related("publishable_entity").get(pk=container_pk) + container = Container.objects.select_related("publishable_entity").get(pk=container_pk) entity = container.publishable_entity last_version = container.versioning.latest assert last_version is not None @@ -232,7 +232,7 @@ def create_next_container_version( created=created, created_by=created_by, ) - next_container_version = ContainerEntityVersion.objects.create( + next_container_version = ContainerVersion.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, entity_list=next_entity_list, @@ -250,7 +250,7 @@ def create_container_and_version( title: str, publishable_entities_pks: list[int], entity_version_pks: list[int | None], -) -> tuple[ContainerEntity, ContainerEntityVersion]: +) -> tuple[Container, ContainerVersion]: """ [ ๐Ÿ›‘ UNSTABLE ] Create a new container and its first version. @@ -281,7 +281,7 @@ def create_container_and_version( return (container, container_version) -def get_container(pk: int) -> ContainerEntity: +def get_container(pk: int) -> Container: """ [ ๐Ÿ›‘ UNSTABLE ] Get a container by its primary key. @@ -293,7 +293,7 @@ def get_container(pk: int) -> ContainerEntity: The container with the given primary key. """ # TODO: should this use with_publishing_relations as in components? - return ContainerEntity.objects.get(pk=pk) + return Container.objects.get(pk=pk) @dataclass(frozen=True) @@ -311,16 +311,16 @@ def entity(self): def get_entities_in_draft_container( - container: ContainerEntity | ContainerEntityMixin, + container: Container | ContainerMixin, ) -> list[ContainerEntityListEntry]: """ [ ๐Ÿ›‘ UNSTABLE ] Get the list of entities and their versions in the draft version of the given container. """ - if isinstance(container, ContainerEntityMixin): - container = container.container_entity - assert isinstance(container, ContainerEntity) + if isinstance(container, ContainerMixin): + container = container.container + assert isinstance(container, Container) entity_list = [] for row in container.versioning.draft.entity_list.entitylistrow_set.order_by("order_num"): entity_version = row.entity_version or row.entity.draft.version @@ -334,24 +334,24 @@ def get_entities_in_draft_container( def get_entities_in_published_container( - container: ContainerEntity | ContainerEntityMixin, + container: Container | ContainerMixin, ) -> list[ContainerEntityListEntry] | None: """ [ ๐Ÿ›‘ UNSTABLE ] Get the list of entities and their versions in the draft version of the given container. """ - if isinstance(container, ContainerEntityMixin): - cev = container.container_entity.versioning.published - elif isinstance(container, ContainerEntity): - cev = container.versioning.published + if isinstance(container, ContainerMixin): + cv = container.container.versioning.published + elif isinstance(container, Container): + cv = container.versioning.published else: - raise TypeError(f"Expected ContainerEntity or ContainerEntityMixin; got {type(container)}") - if cev is None: + raise TypeError(f"Expected Container or ContainerMixin; got {type(container)}") + if cv is None: return None # There is no published version of this container. Should this be an exception? - assert isinstance(cev, ContainerEntityVersion) + assert isinstance(cv, ContainerVersion) entity_list = [] - for row in cev.entity_list.entitylistrow_set.order_by("order_num"): + for row in cv.entity_list.entitylistrow_set.order_by("order_num"): entity_version = row.entity_version or row.entity.published.version if entity_version is not None: # As long as this hasn't been soft-deleted: entity_list.append(ContainerEntityListEntry( @@ -363,7 +363,7 @@ def get_entities_in_published_container( def contains_unpublished_changes( - container: ContainerEntity | ContainerEntityMixin, + container: Container | ContainerMixin, ) -> bool: """ [ ๐Ÿ›‘ UNSTABLE ] @@ -372,18 +372,18 @@ def contains_unpublished_changes( Note: container.versioning.has_unpublished_changes only checks if the container itself has unpublished changes, not if its contents do. """ - if isinstance(container, ContainerEntityMixin): + if isinstance(container, ContainerMixin): # The query below pre-loads the data we need but is otherwise the same thing as: - # container = container.container_entity - container = ContainerEntity.objects.select_related( + # container = container.container + container = Container.objects.select_related( "publishable_entity", "publishable_entity__draft", "publishable_entity__draft__version", - "publishable_entity__draft__version__containerentityversion__entity_list", - ).get(pk=container.container_entity_id) + "publishable_entity__draft__version__containerversion__entity_list", + ).get(pk=container.container_id) else: - pass # TODO: select_related if we're given a raw ContainerEntity rather than a ContainerEntityMixin like Unit? - assert isinstance(container, ContainerEntity) + pass # TODO: select_related if we're given a raw Container rather than a ContainerMixin like Unit? + assert isinstance(container, Container) if container.versioning.has_unpublished_changes: return True @@ -395,16 +395,16 @@ def contains_unpublished_changes( # Once we know it's correct and have a good test suite, then we can optimize. # We will likely change to a tracking-based approach rather than a "scan for changes" based approach. for row in entity_list.entitylistrow_set.filter(entity_version=None).select_related( - "entity__containerentity", + "entity__container", "entity__draft__version", "entity__published__version", ): try: - child_container = row.entity.containerentity - except ContainerEntity.DoesNotExist: + child_container = row.entity.container + except Container.DoesNotExist: child_container = None if child_container: - child_container = row.entity.containerentity + child_container = row.entity.container # This is itself a container - check recursively: if child_container.versioning.has_unpublished_changes or contains_unpublished_changes(child_container): return True @@ -421,7 +421,7 @@ def get_containers_with_entity( publishable_entity_pk: int, *, ignore_pinned=False, -) -> QuerySet[ContainerEntity]: +) -> QuerySet[Container]: """ [ ๐Ÿ›‘ UNSTABLE ] Find all draft containers that directly contain the given entity. @@ -434,18 +434,18 @@ def get_containers_with_entity( ignore_pinned: if true, ignore any pinned references to the entity. """ if ignore_pinned: - qs = ContainerEntity.objects.filter( - publishable_entity__draft__version__containerentityversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501 - publishable_entity__draft__version__containerentityversion__entity_list__entitylistrow__entity_version_id=None, # pylint: disable=line-too-long # noqa: E501 + qs = Container.objects.filter( + publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501 + publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_version_id=None, # pylint: disable=line-too-long # noqa: E501 ).order_by("pk") # Ordering is mostly for consistent test cases. else: - qs = ContainerEntity.objects.filter( - publishable_entity__draft__version__containerentityversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501 + qs = Container.objects.filter( + publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501 ).order_by("pk") # Ordering is mostly for consistent test cases. # Could alternately do this query in two steps. Not sure which is more efficient; depends on how the DB plans it. # # Find all the EntityLists that contain the given entity: # lists = EntityList.objects.filter(entitylistrow__entity_id=publishable_entity_pk).values_list('pk', flat=True) - # qs = ContainerEntity.objects.filter( - # publishable_entity__draft__version__containerentityversion__entity_list__in=lists + # qs = Container.objects.filter( + # publishable_entity__draft__version__containerversion__entity_list__in=lists # ) return qs diff --git a/openedx_learning/apps/authoring/containers/apps.py b/openedx_learning/apps/authoring/containers/apps.py index 95850cac..331ae4d1 100644 --- a/openedx_learning/apps/authoring/containers/apps.py +++ b/openedx_learning/apps/authoring/containers/apps.py @@ -17,9 +17,9 @@ class ContainersConfig(AppConfig): def ready(self): """ - Register ContainerEntity and ContainerEntityVersion. + Register Container and ContainerVersion. """ from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel - from .models import ContainerEntity, ContainerEntityVersion # pylint: disable=import-outside-toplevel + from .models import Container, ContainerVersion # pylint: disable=import-outside-toplevel - register_content_models(ContainerEntity, ContainerEntityVersion) + register_content_models(Container, ContainerVersion) diff --git a/openedx_learning/apps/authoring/containers/migrations/0001_initial.py b/openedx_learning/apps/authoring/containers/migrations/0001_initial.py index ab1068e2..90be54f9 100644 --- a/openedx_learning/apps/authoring/containers/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/containers/migrations/0001_initial.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='ContainerEntity', + name='Container', fields=[ ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')), ], @@ -39,10 +39,10 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='ContainerEntityVersion', + name='ContainerVersion', fields=[ ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')), - ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_containers.containerentity')), + ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_containers.container')), ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='entity_list', to='oel_containers.entitylist')), ], options={ diff --git a/openedx_learning/apps/authoring/containers/models.py b/openedx_learning/apps/authoring/containers/models.py index 9d4ec990..b96ac914 100644 --- a/openedx_learning/apps/authoring/containers/models.py +++ b/openedx_learning/apps/authoring/containers/models.py @@ -8,8 +8,8 @@ from ..publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin __all__ = [ - "ContainerEntity", - "ContainerEntityVersion", + "Container", + "ContainerVersion", ] @@ -20,7 +20,7 @@ class EntityList(models.Model): EntityLists are not PublishableEntities in and of themselves. That's because sometimes we'll want the same kind of data structure for things that we dynamically generate for individual students (e.g. Variants). EntityLists are - anonymous in a senseโ€“they're pointed to by ContainerEntityVersions and + anonymous in a senseโ€“they're pointed to by ContainerVersions and other models, rather than being looked up by their own identifiers. """ @@ -67,7 +67,7 @@ class EntityListRow(models.Model): ) -class ContainerEntity(PublishableEntityMixin): +class Container(PublishableEntityMixin): """ NOTE: We're going to want to eventually have some association between the PublishLog and Containers that were affected in a publish because their @@ -75,9 +75,9 @@ class ContainerEntity(PublishableEntityMixin): """ -class ContainerEntityVersion(PublishableEntityVersionMixin): +class ContainerVersion(PublishableEntityVersionMixin): """ - A version of a ContainerEntity. + A version of a Container. By convention, we would only want to create new versions when the Container itself changes, and not when the Container's child elements change. For @@ -93,11 +93,11 @@ class ContainerEntityVersion(PublishableEntityVersionMixin): changed if we decide to explicitly pin a set of versions for the children, and then later change our minds and move to a different set. It also just makes things easier to reason about if we say that entity_list never - changes for a given ContainerEntityVersion. + changes for a given ContainerVersion. """ container = models.ForeignKey( - ContainerEntity, + Container, on_delete=models.CASCADE, related_name="versions", ) diff --git a/openedx_learning/apps/authoring/containers/models_mixin.py b/openedx_learning/apps/authoring/containers/models_mixin.py index 99ea8cc2..2d76cd25 100644 --- a/openedx_learning/apps/authoring/containers/models_mixin.py +++ b/openedx_learning/apps/authoring/containers/models_mixin.py @@ -7,7 +7,7 @@ from django.db import models -from openedx_learning.apps.authoring.containers.models import ContainerEntity, ContainerEntityVersion +from openedx_learning.apps.authoring.containers.models import Container, ContainerVersion from openedx_learning.apps.authoring.publishing.model_mixins import ( PublishableEntityMixin, PublishableEntityVersionMixin, @@ -15,74 +15,74 @@ from openedx_learning.lib.managers import WithRelationsManager __all__ = [ - "ContainerEntityMixin", - "ContainerEntityVersionMixin", + "ContainerMixin", + "ContainerVersionMixin", ] -class ContainerEntityMixin(PublishableEntityMixin): +class ContainerMixin(PublishableEntityMixin): """ - Convenience mixin to link your models against ContainerEntity. + Convenience mixin to link your models against Container. - Please see docstring for ContainerEntity for more details. + Please see docstring for Container for more details. - If you use this class, you *MUST* also use ContainerEntityVersionMixin + If you use this class, you *MUST* also use ContainerVersionMixin """ # select these related entities by default for all queries - objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager("container_entity") # type: ignore[assignment] + objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager("container") # type: ignore[assignment] - container_entity = models.OneToOneField( - ContainerEntity, + container = models.OneToOneField( + Container, on_delete=models.CASCADE, ) @property def uuid(self): - return self.container_entity.uuid + return self.container.uuid @property def created(self): - return self.container_entity.created + return self.container.created class Meta: abstract = True -class ContainerEntityVersionMixin(PublishableEntityVersionMixin): +class ContainerVersionMixin(PublishableEntityVersionMixin): """ - Convenience mixin to link your models against ContainerEntityVersion. + Convenience mixin to link your models against ContainerVersion. - Please see docstring for ContainerEntityVersion for more details. + Please see docstring for ContainerVersion for more details. - If you use this class, you *MUST* also use ContainerEntityMixin + If you use this class, you *MUST* also use ContainerMixin """ # select these related entities by default for all queries objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager( # type: ignore[assignment] - "container_entity_version", + "container_version", ) - container_entity_version = models.OneToOneField( - ContainerEntityVersion, + container_version = models.OneToOneField( + ContainerVersion, on_delete=models.CASCADE, ) @property def uuid(self): - return self.container_entity_version.uuid + return self.container_version.uuid @property def title(self): - return self.container_entity_version.title + return self.container_version.title @property def created(self): - return self.container_entity_version.created + return self.container_version.created @property def version_num(self): - return self.container_entity_version.version_num + return self.container_version.version_num class Meta: abstract = True diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index 398b684b..9da2703a 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -47,7 +47,7 @@ def create_unit( learning_package_id, key, created, created_by ) unit = Unit.objects.create( - container_entity=container, + container=container, publishable_entity=container.publishable_entity, ) return unit @@ -76,8 +76,8 @@ def create_unit_version( created_by: The user who created the unit. """ with atomic(): - container_entity_version = container_api.create_container_version( - unit.container_entity.pk, + container_version = container_api.create_container_version( + unit.container.pk, version_num, title=title, publishable_entities_pks=publishable_entities_pks, @@ -87,8 +87,8 @@ def create_unit_version( ) unit_version = UnitVersion.objects.create( unit=unit, - container_entity_version=container_entity_version, - publishable_entity_version=container_entity_version.publishable_entity_version, + container_version=container_version, + publishable_entity_version=container_version.publishable_entity_version, ) return unit_version @@ -129,8 +129,8 @@ def create_next_unit_version( publishable_entities_pks = None entity_version_pks = None with atomic(): - container_entity_version = container_api.create_next_container_version( - unit.container_entity.pk, + container_version = container_api.create_next_container_version( + unit.container.pk, title=title, publishable_entities_pks=publishable_entities_pks, entity_version_pks=entity_version_pks, @@ -139,8 +139,8 @@ def create_next_unit_version( ) unit_version = UnitVersion.objects.create( unit=unit, - container_entity_version=container_entity_version, - publishable_entity_version=container_entity_version.publishable_entity_version, + container_version=container_version, + publishable_entity_version=container_version.publishable_entity_version, ) return unit_version @@ -285,7 +285,7 @@ def get_components_in_published_unit_as_of( unit_version = unit_pub_entity_version.unitversion # type: ignore[attr-defined] entity_list = [] - rows = unit_version.container_entity_version.entity_list.entitylistrow_set.order_by("order_num") + rows = unit_version.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 diff --git a/openedx_learning/apps/authoring/units/migrations/0001_initial.py b/openedx_learning/apps/authoring/units/migrations/0001_initial.py index 537264ee..8a72507e 100644 --- a/openedx_learning/apps/authoring/units/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/units/migrations/0001_initial.py @@ -18,7 +18,7 @@ class Migration(migrations.Migration): name='Unit', fields=[ ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')), - ('container_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.containerentity')), + ('container', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.container')), ], options={ 'abstract': False, @@ -28,7 +28,7 @@ class Migration(migrations.Migration): name='UnitVersion', fields=[ ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')), - ('container_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.containerentityversion')), + ('container_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.containerversion')), ('unit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_units.unit')), ], options={ diff --git a/openedx_learning/apps/authoring/units/models.py b/openedx_learning/apps/authoring/units/models.py index 67919a63..3e504491 100644 --- a/openedx_learning/apps/authoring/units/models.py +++ b/openedx_learning/apps/authoring/units/models.py @@ -3,7 +3,7 @@ """ from django.db import models -from ..containers.models_mixin import ContainerEntityMixin, ContainerEntityVersionMixin +from ..containers.models_mixin import ContainerMixin, ContainerVersionMixin __all__ = [ "Unit", @@ -11,13 +11,13 @@ ] -class Unit(ContainerEntityMixin): +class Unit(ContainerMixin): """ A Unit is Container, which is a PublishableEntity. """ -class UnitVersion(ContainerEntityVersionMixin): +class UnitVersion(ContainerVersionMixin): """ A UnitVersion is a ContainerVersion, which is a PublishableEntityVersion. """ diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index 6a486f0a..e7d470d0 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -562,14 +562,14 @@ def test_metadata_change_doesnt_create_entity_list(self): """ unit = self.create_unit_with_components([self.component_1, self.component_2_v1]) - orig_version_num = unit.container_entity.versioning.draft.version_num - orig_entity_list_id = unit.container_entity.versioning.draft.entity_list.pk + orig_version_num = unit.container.versioning.draft.version_num + orig_entity_list_id = unit.container.versioning.draft.entity_list.pk authoring_api.create_next_unit_version(unit, title="New Title", created=self.now) unit.refresh_from_db() - new_version_num = unit.container_entity.versioning.draft.version_num - new_entity_list_id = unit.container_entity.versioning.draft.entity_list.pk + new_version_num = unit.container.versioning.draft.version_num + new_entity_list_id = unit.container.versioning.draft.entity_list.pk assert new_version_num > orig_version_num assert new_entity_list_id == orig_entity_list_id @@ -623,7 +623,7 @@ def test_removing_component(self): # But when we publish the new unit version with the removal, the published version is affected: authoring_api.publish_all_drafts(self.learning_package.id) # FIXME: Refreshing the unit is necessary here because get_entities_in_published_container() accesses - # container_entity.versioning.published, and .versioning is cached with the old version. But this seems like + # 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() From 3afb5b336f9215cdbabcd0aaf8b016b022780029 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 3 Mar 2025 09:56:54 -0800 Subject: [PATCH 11/27] docs: update docstring of Container class --- openedx_learning/apps/authoring/containers/models.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openedx_learning/apps/authoring/containers/models.py b/openedx_learning/apps/authoring/containers/models.py index b96ac914..4bfa0ecb 100644 --- a/openedx_learning/apps/authoring/containers/models.py +++ b/openedx_learning/apps/authoring/containers/models.py @@ -69,6 +69,15 @@ class EntityListRow(models.Model): class Container(PublishableEntityMixin): """ + A Container is a type of PublishableEntity that holds other + PublishableEntities. For example, a "Unit" Container might hold several + Components. + + For now, all containers have a static "entity list" that defines which + containers/components/enities they hold. As we complete the Containers API, + we will also add support for dynamic containers which may contain different + entities for different learners or at different times. + NOTE: We're going to want to eventually have some association between the PublishLog and Containers that were affected in a publish because their child elements were published. From 62d6d1b4b1643dd830d3ac8fddf78bbe22c2d899 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 5 Mar 2025 15:17:33 -0800 Subject: [PATCH 12/27] fix: address review comments --- .../apps/authoring/containers/api.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py index 88396290..3cf4169e 100644 --- a/openedx_learning/apps/authoring/containers/api.py +++ b/openedx_learning/apps/authoring/containers/api.py @@ -129,6 +129,7 @@ def create_container_version( version_num: The version number of the container. title: The title of the container. publishable_entities_pks: The IDs of the members of the container. + entity_version_pks: The IDs of the versions to pin to, if pinning is desired. created: The date and time the container version was created. created_by: The ID of the user who created the container version. @@ -292,7 +293,6 @@ def get_container(pk: int) -> Container: Returns: The container with the given primary key. """ - # TODO: should this use with_publishing_relations as in components? return Container.objects.get(pk=pk) @@ -326,7 +326,7 @@ def get_entities_in_draft_container( entity_version = row.entity_version or 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=row.entity_version or row.entity.draft.version, + entity_version=entity_version, pinned=row.entity_version is not None, )) # else should we indicate somehow a deleted item was here? @@ -338,7 +338,7 @@ def get_entities_in_published_container( ) -> list[ContainerEntityListEntry] | None: """ [ ๐Ÿ›‘ UNSTABLE ] - Get the list of entities and their versions in the draft version of the + Get the list of entities and their versions in the published version of the given container. """ if isinstance(container, ContainerMixin): @@ -369,16 +369,17 @@ def contains_unpublished_changes( [ ๐Ÿ›‘ UNSTABLE ] Check recursively if a container has any unpublished changes. - Note: container.versioning.has_unpublished_changes only checks if the container - itself has unpublished changes, not if its contents do. + Note: unlike this method, the similar-sounding + `container.versioning.has_unpublished_changes` property only reports + if the container itself has unpublished changes, not + if its contents do. So if you change a title or add a new child component, + `has_unpublished_changes` will be `True`, but if you merely edit a component + that's in the container, it will be `False`. This method will return `True` + in either case. """ if isinstance(container, ContainerMixin): - # The query below pre-loads the data we need but is otherwise the same thing as: - # container = container.container + # This is similar to 'get_container(container.container_id)' but pre-loads more data. container = Container.objects.select_related( - "publishable_entity", - "publishable_entity__draft", - "publishable_entity__draft__version", "publishable_entity__draft__version__containerversion__entity_list", ).get(pk=container.container_id) else: @@ -404,9 +405,8 @@ def contains_unpublished_changes( except Container.DoesNotExist: child_container = None if child_container: - child_container = row.entity.container # This is itself a container - check recursively: - if child_container.versioning.has_unpublished_changes or contains_unpublished_changes(child_container): + if contains_unpublished_changes(child_container): return True else: # This is not a container: From c8f10eda476e84b352df33d48c270523f76f2c94 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 5 Mar 2025 15:19:56 -0800 Subject: [PATCH 13/27] fix: preloading with get_unit() was too minimal --- .../apps/authoring/containers/models_mixin.py | 7 +++++- .../apps/authoring/units/test_api.py | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/openedx_learning/apps/authoring/containers/models_mixin.py b/openedx_learning/apps/authoring/containers/models_mixin.py index 2d76cd25..f99624e9 100644 --- a/openedx_learning/apps/authoring/containers/models_mixin.py +++ b/openedx_learning/apps/authoring/containers/models_mixin.py @@ -30,7 +30,12 @@ class ContainerMixin(PublishableEntityMixin): """ # select these related entities by default for all queries - objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager("container") # type: ignore[assignment] + objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager( # type: ignore[assignment] + "container", + "publishable_entity", + "publishable_entity__published", + "publishable_entity__draft", + ) container = models.OneToOneField( Container, diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index e7d470d0..2e669cb3 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -85,6 +85,31 @@ def modify_component( created_by=None, ) + def test_get_unit(self): + """ + Test get_unit() + """ + unit = self.create_unit_with_components([self.component_1, self.component_2]) + result = authoring_api.get_unit(unit.pk) + assert result == unit + # Versioning data should be pre-loaded via select_related() + with self.assertNumQueries(0): + assert result.versioning.has_unpublished_changes + # TODO: (maybe) This currently has extra queries and is not preloaded even though it's the same: + # with self.assertNumQueries(0): + # assert result.container.versioning.has_unpublished_changes + + def test_get_container(self): + """ + Test get_container() + """ + unit = self.create_unit_with_components([self.component_1, self.component_2]) + result = authoring_api.get_container(unit.container_id) + 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 From 84f658de5581c358b28226fe266531f6fa080fc3 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 5 Mar 2025 15:26:59 -0800 Subject: [PATCH 14/27] test: update test_units_containing to catch bad JOINs in the query Both before and after this change, test_units_containing() was working correctly, but without this change in place, it's easy to refactor test_units_containing(..., ignore_pinned=True) in a way that still passes this test but is actually incorrect. e.g. the following implementation is wrong, but was passing the test without this fix in place: ``` qs = Container.objects.filter( publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, ).order_by("pk") # Ordering is mostly for consistent test cases. if ignore_pinned: qs = qs.filter( publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_version_id=None, ) ``` --- .../apps/authoring/units/test_api.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index 2e669cb3..cdf31587 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -855,10 +855,19 @@ def test_units_containing(self): 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: - unit1_1pinned = self.create_unit_with_components([self.component_1_v1], key="u1") # โœ… has it 1 pinned ๐Ÿ“Œ to V1 - unit2_1pinned_v2 = self.create_unit_with_components([component_1_v2], key="u2") # โœ… has it pinned ๐Ÿ“Œ to V2 + # Note: it is important that some of these units contain other components, to ensure the complex JOINs required + # for this query are working correctly, especially in the case of ignore_pinned=True. + # Unit 1 โœ… has component 1, pinned ๐Ÿ“Œ to V1 + unit1_1pinned = self.create_unit_with_components([self.component_1_v1, self.component_2], key="u1") + # Unit 2 โœ… has component 1, pinned ๐Ÿ“Œ to V2 + unit2_1pinned_v2 = self.create_unit_with_components([component_1_v2, self.component_2_v1], key="u2") + # Unit 3 doesn't contain it _unit3_no = self.create_unit_with_components([self.component_2], key="u3") - unit4_unpinned = self.create_unit_with_components([self.component_1], key="u4") # โœ… has component 1, unpinned + # Unit 4 โœ… has component 1, unpinned + unit4_unpinned = self.create_unit_with_components([ + self.component_1, self.component_2, self.component_2_v1, + ], key="u4") + # Units 5/6 don't contain it _unit5_no = self.create_unit_with_components([self.component_2_v1, self.component_2], key="u5") _unit6_no = self.create_unit_with_components([], key="u6") From 580b1f6dd736d5a217780ab0fa054018dc9ca6a6 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 7 Mar 2025 14:33:27 -0800 Subject: [PATCH 15/27] refactor: move 'containers' models into publishing --- openedx_learning/api/authoring.py | 1 - openedx_learning/api/authoring_models.py | 3 +- .../apps/authoring/containers/__init__.py | 0 .../apps/authoring/containers/api.py | 451 --------------- .../apps/authoring/containers/apps.py | 25 - .../containers/migrations/__init__.py | 0 .../apps/authoring/publishing/api.py | 444 ++++++++++++++- .../apps/authoring/publishing/apps.py | 9 + .../migrations/0003_containers.py} | 10 +- .../publishing/model_mixins/__init__.py | 5 + .../model_mixins/container.py} | 29 +- .../publishable_entity.py} | 2 +- .../apps/authoring/publishing/models.py | 517 ------------------ .../authoring/publishing/models/__init__.py | 21 + .../authoring/publishing/models/container.py | 60 ++ .../publishing/models/draft_published.py | 95 ++++ .../models/entity_list.py} | 64 +-- .../publishing/models/learning_package.py | 75 +++ .../publishing/models/publish_log.py | 106 ++++ .../publishing/models/publishable_entity.py | 251 +++++++++ openedx_learning/apps/authoring/units/api.py | 17 +- .../units/migrations/0001_initial.py | 9 +- .../apps/authoring/units/models.py | 2 +- projects/dev.py | 1 - test_settings.py | 1 - 25 files changed, 1102 insertions(+), 1096 deletions(-) delete mode 100644 openedx_learning/apps/authoring/containers/__init__.py delete mode 100644 openedx_learning/apps/authoring/containers/api.py delete mode 100644 openedx_learning/apps/authoring/containers/apps.py delete mode 100644 openedx_learning/apps/authoring/containers/migrations/__init__.py rename openedx_learning/apps/authoring/{containers/migrations/0001_initial.py => publishing/migrations/0003_containers.py} (86%) create mode 100644 openedx_learning/apps/authoring/publishing/model_mixins/__init__.py rename openedx_learning/apps/authoring/{containers/models_mixin.py => publishing/model_mixins/container.py} (75%) rename openedx_learning/apps/authoring/publishing/{model_mixins.py => model_mixins/publishable_entity.py} (99%) delete mode 100644 openedx_learning/apps/authoring/publishing/models.py create mode 100644 openedx_learning/apps/authoring/publishing/models/__init__.py create mode 100644 openedx_learning/apps/authoring/publishing/models/container.py create mode 100644 openedx_learning/apps/authoring/publishing/models/draft_published.py rename openedx_learning/apps/authoring/{containers/models.py => publishing/models/entity_list.py} (53%) create mode 100644 openedx_learning/apps/authoring/publishing/models/learning_package.py create mode 100644 openedx_learning/apps/authoring/publishing/models/publish_log.py create mode 100644 openedx_learning/apps/authoring/publishing/models/publishable_entity.py diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index de756616..c0610487 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -11,7 +11,6 @@ # pylint: disable=wildcard-import from ..apps.authoring.collections.api import * from ..apps.authoring.components.api import * -from ..apps.authoring.containers.api import * from ..apps.authoring.contents.api import * from ..apps.authoring.publishing.api import * from ..apps.authoring.units.api import * diff --git a/openedx_learning/api/authoring_models.py b/openedx_learning/api/authoring_models.py index 8c7752b2..4d6109ff 100644 --- a/openedx_learning/api/authoring_models.py +++ b/openedx_learning/api/authoring_models.py @@ -9,8 +9,7 @@ # pylint: disable=wildcard-import from ..apps.authoring.collections.models import * from ..apps.authoring.components.models import * -from ..apps.authoring.containers.models import * from ..apps.authoring.contents.models import * -from ..apps.authoring.publishing.model_mixins import * +from ..apps.authoring.publishing.model_mixins.publishable_entity import * from ..apps.authoring.publishing.models import * from ..apps.authoring.units.models import * diff --git a/openedx_learning/apps/authoring/containers/__init__.py b/openedx_learning/apps/authoring/containers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openedx_learning/apps/authoring/containers/api.py b/openedx_learning/apps/authoring/containers/api.py deleted file mode 100644 index 3cf4169e..00000000 --- a/openedx_learning/apps/authoring/containers/api.py +++ /dev/null @@ -1,451 +0,0 @@ -""" -Containers API. - -This module provides a set of functions to interact with the containers -models in the Open edX Learning platform. -""" -from dataclasses import dataclass -from datetime import datetime - -from django.core.exceptions import ValidationError -from django.db.models import QuerySet -from django.db.transaction import atomic - -from openedx_learning.apps.authoring.containers.models_mixin import ContainerMixin - -from ..containers.models import Container, ContainerVersion, EntityList, EntityListRow -from ..publishing import api as publishing_api -from ..publishing.models import PublishableEntity, PublishableEntityVersion - -# ๐Ÿ›‘ 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_container", - "create_container_version", - "create_next_container_version", - "create_container_and_version", - "get_container", - "ContainerEntityListEntry", - "get_entities_in_draft_container", - "get_entities_in_published_container", - "contains_unpublished_changes", - "get_containers_with_entity", -] - - -def create_container( - learning_package_id: int, - key: str, - created: datetime, - created_by: int | None, -) -> Container: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Create a new container. - - Args: - learning_package_id: The ID of the learning package that contains the container. - key: The key of the container. - created: The date and time the container was created. - created_by: The ID of the user who created the container - - Returns: - The newly created container. - """ - with atomic(): - publishable_entity = publishing_api.create_publishable_entity( - learning_package_id, key, created, created_by - ) - container = Container.objects.create( - publishable_entity=publishable_entity, - ) - return container - - -def create_entity_list() -> EntityList: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Create a new entity list. This is an structure that holds a list of entities - that will be referenced by the container. - - Returns: - The newly created entity list. - """ - return EntityList.objects.create() - - -def create_entity_list_with_rows( - entity_pks: list[int], - entity_version_pks: list[int | None], -) -> EntityList: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Create new entity list rows for an entity list. - - Args: - entity_pks: The IDs of the publishable entities that the entity list rows reference. - entity_version_pks: The IDs of the versions of the entities - (PublishableEntityVersion) that the entity list rows reference, or - Nones for "unpinned" (default). - - Returns: - The newly created entity list. - """ - order_nums = range(len(entity_pks)) - with atomic(): - entity_list = create_entity_list() - EntityListRow.objects.bulk_create( - [ - EntityListRow( - entity_list=entity_list, - entity_id=entity_pk, - order_num=order_num, - entity_version_id=entity_version_pk, - ) - for order_num, entity_pk, entity_version_pk in zip( - order_nums, entity_pks, entity_version_pks - ) - ] - ) - return entity_list - - -def create_container_version( - container_pk: int, - version_num: int, - *, - title: str, - publishable_entities_pks: list[int], - entity_version_pks: list[int | None] | None, - created: datetime, - created_by: int | None, -) -> ContainerVersion: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Create a new container version. - - Args: - container_pk: The ID of the container that the version belongs to. - version_num: The version number of the container. - title: The title of the container. - publishable_entities_pks: The IDs of the members of the container. - entity_version_pks: The IDs of the versions to pin to, if pinning is desired. - created: The date and time the container version was created. - created_by: The ID of the user who created the container version. - - Returns: - The newly created container version. - """ - with atomic(): - container = Container.objects.select_related("publishable_entity").get(pk=container_pk) - entity = container.publishable_entity - - # Do a quick check that the given entities are in the right learning package: - if PublishableEntity.objects.filter( - pk__in=publishable_entities_pks, - ).exclude( - learning_package_id=entity.learning_package_id, - ).exists(): - raise ValidationError("Container entities must be from the same learning package.") - - assert title is not None - assert publishable_entities_pks is not None - if entity_version_pks is None: - entity_version_pks = [None] * len(publishable_entities_pks) - entity_list = create_entity_list_with_rows( - entity_pks=publishable_entities_pks, - entity_version_pks=entity_version_pks, - ) - publishable_entity_version = publishing_api.create_publishable_entity_version( - entity.pk, - version_num=version_num, - title=title, - created=created, - created_by=created_by, - ) - container_version = ContainerVersion.objects.create( - publishable_entity_version=publishable_entity_version, - container_id=container_pk, - entity_list=entity_list, - ) - - return container_version - - -def create_next_container_version( - container_pk: int, - *, - title: str | None, - publishable_entities_pks: list[int] | None, - entity_version_pks: list[int | None] | None, - created: datetime, - created_by: int | None, -) -> ContainerVersion: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Create the next version of a container. A new version of the container is created - only when its metadata changes: - - * Something was added to the Container. - * We re-ordered the rows in the container. - * Something was removed from the container. - * The Container's metadata changed, e.g. the title. - * We pin to different versions of the Container. - - Args: - container_pk: The ID of the container to create the next version of. - title: The title of the container. None to keep the current title. - publishable_entities_pks: The IDs of the members current members of the container. Or None for no change. - entity_version_pks: The IDs of the versions to pin to, if pinning is desired. - created: The date and time the container version was created. - created_by: The ID of the user who created the container version. - - Returns: - The newly created container version. - """ - with atomic(): - container = Container.objects.select_related("publishable_entity").get(pk=container_pk) - entity = container.publishable_entity - last_version = container.versioning.latest - assert last_version is not None - next_version_num = last_version.version_num + 1 - if publishable_entities_pks is None: - # We're only changing metadata. Keep the same entity list. - next_entity_list = last_version.entity_list - else: - # Do a quick check that the given entities are in the right learning package: - if PublishableEntity.objects.filter( - pk__in=publishable_entities_pks, - ).exclude( - learning_package_id=entity.learning_package_id, - ).exists(): - raise ValidationError("Container entities must be from the same learning package.") - if entity_version_pks is None: - entity_version_pks = [None] * len(publishable_entities_pks) - next_entity_list = create_entity_list_with_rows( - entity_pks=publishable_entities_pks, - entity_version_pks=entity_version_pks, - ) - publishable_entity_version = publishing_api.create_publishable_entity_version( - entity.pk, - version_num=next_version_num, - title=title if title is not None else last_version.title, - created=created, - created_by=created_by, - ) - next_container_version = ContainerVersion.objects.create( - publishable_entity_version=publishable_entity_version, - container_id=container_pk, - entity_list=next_entity_list, - ) - - return next_container_version - - -def create_container_and_version( - learning_package_id: int, - key: str, - *, - created: datetime, - created_by: int | None, - title: str, - publishable_entities_pks: list[int], - entity_version_pks: list[int | None], -) -> tuple[Container, ContainerVersion]: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Create a new container and its first version. - - Args: - learning_package_id: The ID of the learning package that contains the container. - key: The key of the container. - created: The date and time the container was created. - created_by: The ID of the user who created the container. - version_num: The version number of the container. - title: The title of the container. - members_pk: The IDs of the members of the container. - - Returns: - The newly created container version. - """ - with atomic(): - container = create_container(learning_package_id, key, created, created_by) - container_version = create_container_version( - container.publishable_entity.pk, - 1, - title=title, - publishable_entities_pks=publishable_entities_pks, - entity_version_pks=entity_version_pks, - created=created, - created_by=created_by, - ) - return (container, container_version) - - -def get_container(pk: int) -> Container: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Get a container by its primary key. - - Args: - pk: The primary key of the container. - - Returns: - The container with the given primary key. - """ - return Container.objects.get(pk=pk) - - -@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 - - -def get_entities_in_draft_container( - container: Container | ContainerMixin, -) -> list[ContainerEntityListEntry]: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Get the list of entities and their versions in the draft version of the - given container. - """ - if isinstance(container, ContainerMixin): - container = container.container - assert isinstance(container, Container) - entity_list = [] - for row in container.versioning.draft.entity_list.entitylistrow_set.order_by("order_num"): - entity_version = row.entity_version or row.entity.draft.version - if entity_version is not None: # As long as this hasn't been soft-deleted: - entity_list.append(ContainerEntityListEntry( - entity_version=entity_version, - pinned=row.entity_version is not None, - )) - # else should we indicate somehow a deleted item was here? - return entity_list - - -def get_entities_in_published_container( - container: Container | ContainerMixin, -) -> list[ContainerEntityListEntry] | None: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Get the list of entities and their versions in the published version of the - given container. - """ - if isinstance(container, ContainerMixin): - cv = container.container.versioning.published - elif isinstance(container, Container): - cv = container.versioning.published - else: - raise TypeError(f"Expected Container or ContainerMixin; got {type(container)}") - if cv is None: - return None # There is no published version of this container. Should this be an exception? - assert isinstance(cv, ContainerVersion) - entity_list = [] - for row in cv.entity_list.entitylistrow_set.order_by("order_num"): - entity_version = row.entity_version or row.entity.published.version - if entity_version is not None: # As long as this hasn't been soft-deleted: - entity_list.append(ContainerEntityListEntry( - entity_version=entity_version, - pinned=row.entity_version is not None, - )) - # else should we indicate somehow a deleted item was here? - return entity_list - - -def contains_unpublished_changes( - container: Container | ContainerMixin, -) -> bool: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Check recursively if a container has any unpublished changes. - - Note: unlike this method, the similar-sounding - `container.versioning.has_unpublished_changes` property only reports - if the container itself has unpublished changes, not - if its contents do. So if you change a title or add a new child component, - `has_unpublished_changes` will be `True`, but if you merely edit a component - that's in the container, it will be `False`. This method will return `True` - in either case. - """ - if isinstance(container, ContainerMixin): - # This is similar to 'get_container(container.container_id)' but pre-loads more data. - container = Container.objects.select_related( - "publishable_entity__draft__version__containerversion__entity_list", - ).get(pk=container.container_id) - else: - pass # TODO: select_related if we're given a raw Container rather than a ContainerMixin like Unit? - assert isinstance(container, Container) - - if container.versioning.has_unpublished_changes: - return True - - # We only care about children that are un-pinned, since published changes to pinned children don't matter - entity_list = container.versioning.draft.entity_list - - # TODO: This is a naive inefficient implementation but hopefully correct. - # Once we know it's correct and have a good test suite, then we can optimize. - # We will likely change to a tracking-based approach rather than a "scan for changes" based approach. - for row in entity_list.entitylistrow_set.filter(entity_version=None).select_related( - "entity__container", - "entity__draft__version", - "entity__published__version", - ): - try: - child_container = row.entity.container - except Container.DoesNotExist: - child_container = None - if child_container: - # This is itself a container - check recursively: - if contains_unpublished_changes(child_container): - return True - else: - # This is not a container: - draft_pk = row.entity.draft.version_id if row.entity.draft else None - published_pk = row.entity.published.version_id if hasattr(row.entity, "published") else None - if draft_pk != published_pk: - return True - return False - - -def get_containers_with_entity( - publishable_entity_pk: int, - *, - ignore_pinned=False, -) -> QuerySet[Container]: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Find all draft containers that directly contain the given entity. - - They will always be from the same learning package; cross-package containers - are not allowed. - - Args: - publishable_entity_pk: The ID of the PublishableEntity to search for. - ignore_pinned: if true, ignore any pinned references to the entity. - """ - if ignore_pinned: - qs = Container.objects.filter( - publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501 - publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_version_id=None, # pylint: disable=line-too-long # noqa: E501 - ).order_by("pk") # Ordering is mostly for consistent test cases. - else: - qs = Container.objects.filter( - publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501 - ).order_by("pk") # Ordering is mostly for consistent test cases. - # Could alternately do this query in two steps. Not sure which is more efficient; depends on how the DB plans it. - # # Find all the EntityLists that contain the given entity: - # lists = EntityList.objects.filter(entitylistrow__entity_id=publishable_entity_pk).values_list('pk', flat=True) - # qs = Container.objects.filter( - # publishable_entity__draft__version__containerversion__entity_list__in=lists - # ) - return qs diff --git a/openedx_learning/apps/authoring/containers/apps.py b/openedx_learning/apps/authoring/containers/apps.py deleted file mode 100644 index 331ae4d1..00000000 --- a/openedx_learning/apps/authoring/containers/apps.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -Containers Django application initialization. -""" - -from django.apps import AppConfig - - -class ContainersConfig(AppConfig): - """ - Configuration for the containers Django application. - """ - - name = "openedx_learning.apps.authoring.containers" - verbose_name = "Learning Core > Authoring > Containers" - default_auto_field = "django.db.models.BigAutoField" - label = "oel_containers" - - def ready(self): - """ - Register Container and ContainerVersion. - """ - from ..publishing.api import register_content_models # pylint: disable=import-outside-toplevel - from .models import Container, ContainerVersion # pylint: disable=import-outside-toplevel - - register_content_models(Container, ContainerVersion) diff --git a/openedx_learning/apps/authoring/containers/migrations/__init__.py b/openedx_learning/apps/authoring/containers/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index 1a61ed70..0c34c09f 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -6,15 +6,25 @@ """ from __future__ import annotations +from dataclasses import dataclass from datetime import datetime, timezone -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models import F, Q, QuerySet from django.db.transaction import atomic -from .model_mixins import PublishableContentModelRegistry, PublishableEntityMixin, PublishableEntityVersionMixin +from .model_mixins import ( + ContainerMixin, + PublishableContentModelRegistry, + PublishableEntityMixin, + PublishableEntityVersionMixin, +) from .models import ( + Container, + ContainerVersion, Draft, + EntityList, + EntityListRow, LearningPackage, PublishableEntity, PublishableEntityVersion, @@ -51,6 +61,18 @@ "reset_drafts_to_published", "register_content_models", "filter_publishable_entities", + # ๐Ÿ›‘ UNSTABLE: All APIs related to containers are unstable until we've figured + # out our approach to dynamic content (randomized, A/B tests, etc.) + "create_container", + "create_container_version", + "create_next_container_version", + "create_container_and_version", + "get_container", + "ContainerEntityListEntry", + "get_entities_in_draft_container", + "get_entities_in_published_container", + "contains_unpublished_changes", + "get_containers_with_entity", ] @@ -528,3 +550,421 @@ def get_published_version_as_of(entity_id: int, publish_log_id: int) -> Publisha publish_log_id__lte=publish_log_id, ).order_by('-publish_log_id').first() return record.new_version if record else None + + +def create_container( + learning_package_id: int, + key: str, + created: datetime, + created_by: int | None, +) -> Container: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Create a new container. + + Args: + learning_package_id: The ID of the learning package that contains the container. + key: The key of the container. + created: The date and time the container was created. + created_by: The ID of the user who created the container + + Returns: + The newly created container. + """ + with atomic(): + publishable_entity = create_publishable_entity( + learning_package_id, key, created, created_by + ) + container = Container.objects.create( + publishable_entity=publishable_entity, + ) + return container + + +def create_entity_list() -> EntityList: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Create a new entity list. This is an structure that holds a list of entities + that will be referenced by the container. + + Returns: + The newly created entity list. + """ + return EntityList.objects.create() + + +def create_entity_list_with_rows( + entity_pks: list[int], + entity_version_pks: list[int | None], +) -> EntityList: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Create new entity list rows for an entity list. + + Args: + entity_pks: The IDs of the publishable entities that the entity list rows reference. + entity_version_pks: The IDs of the versions of the entities + (PublishableEntityVersion) that the entity list rows reference, or + Nones for "unpinned" (default). + + Returns: + The newly created entity list. + """ + order_nums = range(len(entity_pks)) + with atomic(): + entity_list = create_entity_list() + EntityListRow.objects.bulk_create( + [ + EntityListRow( + entity_list=entity_list, + entity_id=entity_pk, + order_num=order_num, + entity_version_id=entity_version_pk, + ) + for order_num, entity_pk, entity_version_pk in zip( + order_nums, entity_pks, entity_version_pks + ) + ] + ) + return entity_list + + +def create_container_version( + container_pk: int, + version_num: int, + *, + title: str, + publishable_entities_pks: list[int], + entity_version_pks: list[int | None] | None, + created: datetime, + created_by: int | None, +) -> ContainerVersion: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Create a new container version. + + Args: + container_pk: The ID of the container that the version belongs to. + version_num: The version number of the container. + title: The title of the container. + publishable_entities_pks: The IDs of the members of the container. + entity_version_pks: The IDs of the versions to pin to, if pinning is desired. + created: The date and time the container version was created. + created_by: The ID of the user who created the container version. + + Returns: + The newly created container version. + """ + with atomic(): + container = Container.objects.select_related("publishable_entity").get(pk=container_pk) + entity = container.publishable_entity + + # Do a quick check that the given entities are in the right learning package: + if PublishableEntity.objects.filter( + pk__in=publishable_entities_pks, + ).exclude( + learning_package_id=entity.learning_package_id, + ).exists(): + raise ValidationError("Container entities must be from the same learning package.") + + assert title is not None + assert publishable_entities_pks is not None + if entity_version_pks is None: + entity_version_pks = [None] * len(publishable_entities_pks) + entity_list = create_entity_list_with_rows( + entity_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, + ) + publishable_entity_version = create_publishable_entity_version( + entity.pk, + version_num=version_num, + title=title, + created=created, + created_by=created_by, + ) + container_version = ContainerVersion.objects.create( + publishable_entity_version=publishable_entity_version, + container_id=container_pk, + entity_list=entity_list, + ) + + return container_version + + +def create_next_container_version( + container_pk: int, + *, + title: str | None, + publishable_entities_pks: list[int] | None, + entity_version_pks: list[int | None] | None, + created: datetime, + created_by: int | None, +) -> ContainerVersion: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Create the next version of a container. A new version of the container is created + only when its metadata changes: + + * Something was added to the Container. + * We re-ordered the rows in the container. + * Something was removed from the container. + * The Container's metadata changed, e.g. the title. + * We pin to different versions of the Container. + + Args: + container_pk: The ID of the container to create the next version of. + title: The title of the container. None to keep the current title. + publishable_entities_pks: The IDs of the members current members of the container. Or None for no change. + entity_version_pks: The IDs of the versions to pin to, if pinning is desired. + created: The date and time the container version was created. + created_by: The ID of the user who created the container version. + + Returns: + The newly created container version. + """ + with atomic(): + container = Container.objects.select_related("publishable_entity").get(pk=container_pk) + entity = container.publishable_entity + last_version = container.versioning.latest + assert last_version is not None + next_version_num = last_version.version_num + 1 + if publishable_entities_pks is None: + # We're only changing metadata. Keep the same entity list. + next_entity_list = last_version.entity_list + else: + # Do a quick check that the given entities are in the right learning package: + if PublishableEntity.objects.filter( + pk__in=publishable_entities_pks, + ).exclude( + learning_package_id=entity.learning_package_id, + ).exists(): + raise ValidationError("Container entities must be from the same learning package.") + if entity_version_pks is None: + entity_version_pks = [None] * len(publishable_entities_pks) + next_entity_list = create_entity_list_with_rows( + entity_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, + ) + publishable_entity_version = create_publishable_entity_version( + entity.pk, + version_num=next_version_num, + title=title if title is not None else last_version.title, + created=created, + created_by=created_by, + ) + next_container_version = ContainerVersion.objects.create( + publishable_entity_version=publishable_entity_version, + container_id=container_pk, + entity_list=next_entity_list, + ) + + return next_container_version + + +def create_container_and_version( + learning_package_id: int, + key: str, + *, + created: datetime, + created_by: int | None, + title: str, + publishable_entities_pks: list[int], + entity_version_pks: list[int | None], +) -> tuple[Container, ContainerVersion]: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Create a new container and its first version. + + Args: + learning_package_id: The ID of the learning package that contains the container. + key: The key of the container. + created: The date and time the container was created. + created_by: The ID of the user who created the container. + version_num: The version number of the container. + title: The title of the container. + members_pk: The IDs of the members of the container. + + Returns: + The newly created container version. + """ + with atomic(): + container = create_container(learning_package_id, key, created, created_by) + container_version = create_container_version( + container.publishable_entity.pk, + 1, + title=title, + publishable_entities_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, + created=created, + created_by=created_by, + ) + return (container, container_version) + + +def get_container(pk: int) -> Container: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Get a container by its primary key. + + Args: + pk: The primary key of the container. + + Returns: + The container with the given primary key. + """ + return Container.objects.get(pk=pk) + + +@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 + + +def get_entities_in_draft_container( + container: Container | ContainerMixin, +) -> list[ContainerEntityListEntry]: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Get the list of entities and their versions in the draft version of the + given container. + """ + if isinstance(container, ContainerMixin): + container = container.container + assert isinstance(container, Container) + entity_list = [] + for row in container.versioning.draft.entity_list.entitylistrow_set.order_by("order_num"): + entity_version = row.entity_version or row.entity.draft.version + if entity_version is not None: # As long as this hasn't been soft-deleted: + entity_list.append(ContainerEntityListEntry( + entity_version=entity_version, + pinned=row.entity_version is not None, + )) + # else should we indicate somehow a deleted item was here? + return entity_list + + +def get_entities_in_published_container( + container: Container | ContainerMixin, +) -> list[ContainerEntityListEntry] | None: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Get the list of entities and their versions in the published version of the + given container. + """ + if isinstance(container, ContainerMixin): + cv = container.container.versioning.published + elif isinstance(container, Container): + cv = container.versioning.published + else: + raise TypeError(f"Expected Container or ContainerMixin; got {type(container)}") + if cv is None: + return None # There is no published version of this container. Should this be an exception? + assert isinstance(cv, ContainerVersion) + entity_list = [] + for row in cv.entity_list.entitylistrow_set.order_by("order_num"): + entity_version = row.entity_version or row.entity.published.version + if entity_version is not None: # As long as this hasn't been soft-deleted: + entity_list.append(ContainerEntityListEntry( + entity_version=entity_version, + pinned=row.entity_version is not None, + )) + # else should we indicate somehow a deleted item was here? + return entity_list + + +def contains_unpublished_changes( + container: Container | ContainerMixin, +) -> bool: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Check recursively if a container has any unpublished changes. + + Note: unlike this method, the similar-sounding + `container.versioning.has_unpublished_changes` property only reports + if the container itself has unpublished changes, not + if its contents do. So if you change a title or add a new child component, + `has_unpublished_changes` will be `True`, but if you merely edit a component + that's in the container, it will be `False`. This method will return `True` + in either case. + """ + if isinstance(container, ContainerMixin): + # This is similar to 'get_container(container.container_id)' but pre-loads more data. + container = Container.objects.select_related( + "publishable_entity__draft__version__containerversion__entity_list", + ).get(pk=container.container_id) + else: + pass # TODO: select_related if we're given a raw Container rather than a ContainerMixin like Unit? + assert isinstance(container, Container) + + if container.versioning.has_unpublished_changes: + return True + + # We only care about children that are un-pinned, since published changes to pinned children don't matter + entity_list = container.versioning.draft.entity_list + + # TODO: This is a naive inefficient implementation but hopefully correct. + # Once we know it's correct and have a good test suite, then we can optimize. + # We will likely change to a tracking-based approach rather than a "scan for changes" based approach. + for row in entity_list.entitylistrow_set.filter(entity_version=None).select_related( + "entity__container", + "entity__draft__version", + "entity__published__version", + ): + try: + child_container = row.entity.container + except Container.DoesNotExist: + child_container = None + if child_container: + # This is itself a container - check recursively: + if contains_unpublished_changes(child_container): + return True + else: + # This is not a container: + draft_pk = row.entity.draft.version_id if row.entity.draft else None + published_pk = row.entity.published.version_id if hasattr(row.entity, "published") else None + if draft_pk != published_pk: + return True + return False + + +def get_containers_with_entity( + publishable_entity_pk: int, + *, + ignore_pinned=False, +) -> QuerySet[Container]: + """ + [ ๐Ÿ›‘ UNSTABLE ] + Find all draft containers that directly contain the given entity. + + They will always be from the same learning package; cross-package containers + are not allowed. + + Args: + publishable_entity_pk: The ID of the PublishableEntity to search for. + ignore_pinned: if true, ignore any pinned references to the entity. + """ + if ignore_pinned: + qs = Container.objects.filter( + publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501 + publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_version_id=None, # pylint: disable=line-too-long # noqa: E501 + ).order_by("pk") # Ordering is mostly for consistent test cases. + else: + qs = Container.objects.filter( + publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501 + ).order_by("pk") # Ordering is mostly for consistent test cases. + # Could alternately do this query in two steps. Not sure which is more efficient; depends on how the DB plans it. + # # Find all the EntityLists that contain the given entity: + # lists = EntityList.objects.filter(entitylistrow__entity_id=publishable_entity_pk).values_list('pk', flat=True) + # qs = Container.objects.filter( + # publishable_entity__draft__version__containerversion__entity_list__in=lists + # ) + return qs diff --git a/openedx_learning/apps/authoring/publishing/apps.py b/openedx_learning/apps/authoring/publishing/apps.py index bfa1bbe6..f5a8b1f9 100644 --- a/openedx_learning/apps/authoring/publishing/apps.py +++ b/openedx_learning/apps/authoring/publishing/apps.py @@ -14,3 +14,12 @@ class PublishingConfig(AppConfig): verbose_name = "Learning Core > Authoring > Publishing" default_auto_field = "django.db.models.BigAutoField" label = "oel_publishing" + + def ready(self): + """ + Register Container and ContainerVersion. + """ + from .api import register_content_models # pylint: disable=import-outside-toplevel + from .models import Container, ContainerVersion # pylint: disable=import-outside-toplevel + + register_content_models(Container, ContainerVersion) diff --git a/openedx_learning/apps/authoring/containers/migrations/0001_initial.py b/openedx_learning/apps/authoring/publishing/migrations/0003_containers.py similarity index 86% rename from openedx_learning/apps/authoring/containers/migrations/0001_initial.py rename to openedx_learning/apps/authoring/publishing/migrations/0003_containers.py index 90be54f9..7260e2a0 100644 --- a/openedx_learning/apps/authoring/containers/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/publishing/migrations/0003_containers.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.19 on 2025-02-14 23:04 +# Generated by Django 4.2.19 on 2025-03-07 23:09 import django.db.models.deletion from django.db import migrations, models @@ -6,8 +6,6 @@ class Migration(migrations.Migration): - initial = True - dependencies = [ ('oel_publishing', '0002_alter_learningpackage_key_and_more'), ] @@ -34,7 +32,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('order_num', models.PositiveIntegerField()), ('entity', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, to='oel_publishing.publishableentity')), - ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.entitylist')), + ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.entitylist')), ('entity_version', models.ForeignKey(null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='+', to='oel_publishing.publishableentityversion')), ], ), @@ -42,8 +40,8 @@ class Migration(migrations.Migration): name='ContainerVersion', fields=[ ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')), - ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_containers.container')), - ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='entity_list', to='oel_containers.entitylist')), + ('container', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_publishing.container')), + ('entity_list', models.ForeignKey(on_delete=django.db.models.deletion.RESTRICT, related_name='container_versions', to='oel_publishing.entitylist')), ], options={ 'abstract': False, diff --git a/openedx_learning/apps/authoring/publishing/model_mixins/__init__.py b/openedx_learning/apps/authoring/publishing/model_mixins/__init__.py new file mode 100644 index 00000000..47f90297 --- /dev/null +++ b/openedx_learning/apps/authoring/publishing/model_mixins/__init__.py @@ -0,0 +1,5 @@ +""" +Mixins provided by the publishing app +""" +from .container import * +from .publishable_entity import * diff --git a/openedx_learning/apps/authoring/containers/models_mixin.py b/openedx_learning/apps/authoring/publishing/model_mixins/container.py similarity index 75% rename from openedx_learning/apps/authoring/containers/models_mixin.py rename to openedx_learning/apps/authoring/publishing/model_mixins/container.py index f99624e9..944d49ac 100644 --- a/openedx_learning/apps/authoring/containers/models_mixin.py +++ b/openedx_learning/apps/authoring/publishing/model_mixins/container.py @@ -1,19 +1,24 @@ """ -Mixins for models that implement containers +ContainerMixin and ContainerVersionMixin """ from __future__ import annotations -from typing import ClassVar, Self +from datetime import datetime +from typing import TYPE_CHECKING, ClassVar, Self from django.db import models -from openedx_learning.apps.authoring.containers.models import Container, ContainerVersion -from openedx_learning.apps.authoring.publishing.model_mixins import ( - PublishableEntityMixin, - PublishableEntityVersionMixin, -) from openedx_learning.lib.managers import WithRelationsManager +from .publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin + +if TYPE_CHECKING: + from ..models.container import Container, ContainerVersion +else: + # To avoid circular imports, we need to reference these models using strings only + Container = "oel_publishing.Container" + ContainerVersion = "oel_publishing.ContainerVersion" + __all__ = [ "ContainerMixin", "ContainerVersionMixin", @@ -43,11 +48,11 @@ class ContainerMixin(PublishableEntityMixin): ) @property - def uuid(self): + def uuid(self) -> str: return self.container.uuid @property - def created(self): + def created(self) -> datetime: return self.container.created class Meta: @@ -74,15 +79,15 @@ class ContainerVersionMixin(PublishableEntityVersionMixin): ) @property - def uuid(self): + def uuid(self) -> str: return self.container_version.uuid @property - def title(self): + def title(self) -> str: return self.container_version.title @property - def created(self): + def created(self) -> datetime: return self.container_version.created @property diff --git a/openedx_learning/apps/authoring/publishing/model_mixins.py b/openedx_learning/apps/authoring/publishing/model_mixins/publishable_entity.py similarity index 99% rename from openedx_learning/apps/authoring/publishing/model_mixins.py rename to openedx_learning/apps/authoring/publishing/model_mixins/publishable_entity.py index adc8c6c7..49aaac93 100644 --- a/openedx_learning/apps/authoring/publishing/model_mixins.py +++ b/openedx_learning/apps/authoring/publishing/model_mixins/publishable_entity.py @@ -12,7 +12,7 @@ from openedx_learning.lib.managers import WithRelationsManager -from .models import PublishableEntity, PublishableEntityVersion +from ..models.publishable_entity import PublishableEntity, PublishableEntityVersion __all__ = [ "PublishableEntityMixin", diff --git a/openedx_learning/apps/authoring/publishing/models.py b/openedx_learning/apps/authoring/publishing/models.py deleted file mode 100644 index c8b1c34f..00000000 --- a/openedx_learning/apps/authoring/publishing/models.py +++ /dev/null @@ -1,517 +0,0 @@ -""" -The data models here are intended to be used by other apps to publish different -types of content, such as Components, Units, Sections, etc. These models should -support the logic for the management of the publishing process: - -* The relationship between publishable entities and their many versions. -* The management of drafts. -* Publishing specific versions of publishable entities. -* Finding the currently published versions. -* The act of publishing, and doing so atomically. -* Managing reverts. -* Storing and querying publish history. -""" -from django.conf import settings -from django.core.validators import MinValueValidator -from django.db import models - -from openedx_learning.lib.fields import ( - MultiCollationTextField, - case_insensitive_char_field, - immutable_uuid_field, - key_field, - manual_date_time_field, -) - -__all__ = [ - "LearningPackage", - "PublishableEntity", - "PublishableEntityVersion", - "Draft", - "PublishLog", - "PublishLogRecord", - "Published", -] - - -class LearningPackage(models.Model): - """ - Top level container for a grouping of authored content. - - Each PublishableEntity belongs to exactly one LearningPackage. - """ - # Explictly declare a 4-byte ID instead of using the app-default 8-byte ID. - # We do not expect to have more than 2 billion LearningPackages on a given - # site. Furthermore, many, many things have foreign keys to this model and - # uniqueness indexes on those foreign keys + their own fields, so the 4 - # bytes saved will add up over time. - id = models.AutoField(primary_key=True) - - uuid = immutable_uuid_field() - - # "key" is a reserved word for MySQL, so we're temporarily using the column - # name of "_key" to avoid breaking downstream tooling. There's an open - # question as to whether this field needs to exist at all, or whether the - # top level library key it's currently used for should be entirely in the - # LibraryContent model. - key = key_field(db_column="_key") - - title = case_insensitive_char_field(max_length=500, blank=False) - - # TODO: We should probably defer this field, since many things pull back - # LearningPackage as select_related. Usually those relations only care about - # the UUID and key, so maybe it makes sense to separate the model at some - # point. - description = MultiCollationTextField( - blank=True, - null=False, - default="", - max_length=10_000, - # We don't really expect to ever sort by the text column, but we may - # want to do case-insensitive searches, so it's useful to have a case - # and accent insensitive collation. - db_collations={ - "sqlite": "NOCASE", - "mysql": "utf8mb4_unicode_ci", - } - ) - - created = manual_date_time_field() - updated = manual_date_time_field() - - def __str__(self): - return f"{self.key}" - - class Meta: - constraints = [ - # LearningPackage keys must be globally unique. This is something - # that might be relaxed in the future if this system were to be - # extensible to something like multi-tenancy, in which case we'd tie - # it to something like a Site or Org. - models.UniqueConstraint( - fields=["key"], - name="oel_publishing_lp_uniq_key", - ) - ] - verbose_name = "Learning Package" - verbose_name_plural = "Learning Packages" - - -class PublishableEntity(models.Model): - """ - This represents any publishable thing that has ever existed in a - LearningPackage. It serves as a stable model that will not go away even if - these things are later unpublished or deleted. - - A PublishableEntity belongs to exactly one LearningPackage. - - Examples of Publishable Entities - -------------------------------- - - Components (e.g. VideoBlock, ProblemBlock), Units, and Sections/Subsections - would all be considered Publishable Entites. But anything that can be - imported, exported, published, and reverted in a course or library could be - modeled as a PublishableEntity, including things like Grading Policy or - possibly Taxonomies (?). - - How to use this model - --------------------- - - The publishing app understands that publishable entities exist, along with - their drafts and published versions. It has some basic metadata, such as - identifiers, who created it, and when it was created. It's meant to - encapsulate the draft and publishing related aspects of your content, but - the ``publishing`` app doesn't know anything about the actual content being - referenced. - - You have to provide actual meaning to PublishableEntity by creating your own - models that will represent your particular content and associating them to - PublishableEntity via a OneToOneField with primary_key=True. The easiest way - to do this is to have your model inherit from PublishableEntityMixin. - - Identifiers - ----------- - The UUID is globally unique and should be treated as immutable. - - The key field *is* mutable, but changing it will affect all - PublishedEntityVersions. They are locally unique within the LearningPackage. - - If you are referencing this model from within the same process, use a - foreign key to the id. If you are referencing this PublishedEntity from an - external system/service, use the UUID. The key is the part that is most - likely to be human-readable, and may be exported/copied, but try not to rely - on it, since this value may change. - - Note: When we actually implement the ability to change identifiers, we - should make a history table and a modified attribute on this model. - - Why are Identifiers in this Model? - ---------------------------------- - - A PublishableEntity never stands aloneโ€“it's always intended to be used with - a 1:1 model like Component or Unit. So why have all the identifiers in this - model instead of storing them in those other models? Two reasons: - - * Published things need to have the right identifiers so they can be used - throughout the system, and the UUID is serving the role of ISBN in physical - book publishing. - * We want to be able to enforce the idea that "key" is locally unique across - all PublishableEntities within a given LearningPackage. Component and Unit - can't do that without a shared model. - - That being said, models that build on PublishableEntity are free to add - their own identifiers if it's useful to do so. - - Why not Inherit from this Model? - -------------------------------- - - Django supports multi-table inheritance: - - https://docs.djangoproject.com/en/4.2/topics/db/models/#multi-table-inheritance - - We don't use that, primarily because we want to more clearly decouple - publishing concerns from the rest of the logic around Components, Units, - etc. If you made a Component and ComponentVersion models that subclassed - PublishableEntity and PublishableEntityVersion, and then accessed - ``component.versions``, you might expect ComponentVersions to come back and - be surprised when you get EntityVersions instead. - - In general, we want freedom to add new Publishing models, fields, and - methods without having to worry about the downstream name collisions with - other apps (many of which might live in other repositories). The helper - mixins will provide a little syntactic sugar to make common access patterns - more convenient, like file access. - """ - - uuid = immutable_uuid_field() - learning_package = models.ForeignKey( - LearningPackage, - on_delete=models.CASCADE, - related_name="publishable_entities", - ) - - # "key" is a reserved word for MySQL, so we're temporarily using the column - # name of "_key" to avoid breaking downstream tooling. Consider renaming - # this later. - key = key_field(db_column="_key") - - created = manual_date_time_field() - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - ) - - class Meta: - constraints = [ - # Keys are unique within a given LearningPackage. - models.UniqueConstraint( - fields=[ - "learning_package", - "key", - ], - name="oel_pub_ent_uniq_lp_key", - ) - ] - indexes = [ - # Global Key Index: - # * Search by key across all PublishableEntities on the site. This - # would be a support-oriented tool from Django Admin. - models.Index( - fields=["key"], - name="oel_pub_ent_idx_key", - ), - # LearningPackage (reverse) Created Index: - # * Search for most recently *created* PublishableEntities for a - # given LearningPackage, since they're the most likely to be - # actively worked on. - models.Index( - fields=["learning_package", "-created"], - name="oel_pub_ent_idx_lp_rcreated", - ), - ] - # These are for the Django Admin UI. - verbose_name = "Publishable Entity" - verbose_name_plural = "Publishable Entities" - - def __str__(self): - return f"{self.key}" - - -class PublishableEntityVersion(models.Model): - """ - A particular version of a PublishableEntity. - - This model has its own ``uuid`` so that it can be referenced directly. The - ``uuid`` should be treated as immutable. - - PublishableEntityVersions are created once and never updated. So for - instance, the ``title`` should never be modified. - - Like PublishableEntity, the data in this model is only enough to cover the - parts that are most important for the actual process of managing drafts and - publishes. You will want to create your own models to represent the actual - content data that's associated with this PublishableEntityVersion, and - connect them using a OneToOneField with primary_key=True. The easiest way to - do this is to inherit from PublishableEntityVersionMixin. Be sure to treat - these versioned models in your app as immutable as well. - """ - - uuid = immutable_uuid_field() - entity = models.ForeignKey( - PublishableEntity, on_delete=models.CASCADE, related_name="versions" - ) - - # Most publishable things will have some sort of title, but blanks are - # allowed for those that don't require one. - title = case_insensitive_char_field(max_length=500, blank=True, default="") - - # The version_num starts at 1 and increments by 1 with each new version for - # a given PublishableEntity. Doing it this way makes it more convenient for - # users to refer to than a hash or UUID value. It also helps us catch race - # conditions on save, by setting a unique constraint on the entity and - # version_num. - version_num = models.PositiveIntegerField( - null=False, - validators=[MinValueValidator(1)], - ) - - # All PublishableEntityVersions created as part of the same publish should - # have the exact same created datetime (not off by a handful of - # microseconds). - created = manual_date_time_field() - - # User who created the PublishableEntityVersion. This can be null if the - # user is later removed. Open edX in general doesn't let you remove users, - # but we should try to model it so that this is possible eventually. - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - ) - - class Meta: - constraints = [ - # Prevent the situation where we have multiple - # PublishableEntityVersions claiming to be the same version_num for - # a given PublishableEntity. This can happen if there's a race - # condition between concurrent editors in different browsers, - # working on the same Publishable. With this constraint, one of - # those processes will raise an IntegrityError. - models.UniqueConstraint( - fields=[ - "entity", - "version_num", - ], - name="oel_pv_uniq_entity_version_num", - ) - ] - indexes = [ - # LearningPackage (reverse) Created Index: - # * Make it cheap to find the most recently created - # PublishableEntityVersions for a given LearningPackage. This - # represents the most recently saved work for a LearningPackage - # and would be the most likely areas to get worked on next. - models.Index( - fields=["entity", "-created"], - name="oel_pv_idx_entity_rcreated", - ), - # Title Index: - # * Search by title. - models.Index( - fields=[ - "title", - ], - name="oel_pv_idx_title", - ), - ] - - # These are for the Django Admin UI. - verbose_name = "Publishable Entity Version" - verbose_name_plural = "Publishable Entity Versions" - - -class Draft(models.Model): - """ - Find the active draft version of an entity (usually most recently created). - - This model mostly only exists to allow us to join against a bunch of - PublishableEntity objects at once and get all their latest drafts. You might - use this together with Published in order to see which Drafts haven't been - published yet. - - A Draft entry should be created whenever a new PublishableEntityVersion is - created. This means there are three possible states: - - 1. No Draft entry for a PublishableEntity: This means a PublishableEntity - was created, but no PublishableEntityVersion was ever made for it, so - there was never a Draft version. - 2. A Draft entry exists and points to a PublishableEntityVersion: This is - the most common state. - 3. A Draft entry exists and points to a null version: This means a version - used to be the draft, but it's been functionally "deleted". The versions - still exist in our history, but we're done using it. - - It would have saved a little space to add this data to the Published model - (and possibly call the combined model something else). Split Modulestore did - this with its active_versions table. I keep it separate here to get a better - separation of lifecycle events: i.e. this table *only* changes when drafts - are updated, not when publishing happens. The Published model only changes - when something is published. - """ - # If we're removing a PublishableEntity entirely, also remove the Draft - # entry for it. This isn't a normal operation, but can happen if you're - # deleting an entire LearningPackage. - entity = models.OneToOneField( - PublishableEntity, - on_delete=models.CASCADE, - primary_key=True, - ) - version = models.OneToOneField( - PublishableEntityVersion, - on_delete=models.RESTRICT, - null=True, - blank=True, - ) - - -class PublishLog(models.Model): - """ - There is one row in this table for every time content is published. - - Each PublishLog has 0 or more PublishLogRecords describing exactly which - PublishableEntites were published and what the version changes are. A - PublishLog is like a git commit in that sense, with individual - PublishLogRecords representing the files changed. - - Open question: Empty publishes are allowed at this time, and might be useful - for "fake" publishes that are necessary to invoke other post-publish - actions. It's not clear at this point how useful this will actually be. - - The absence of a ``version_num`` field in this model is intentional, because - having one would potentially cause write contention/locking issues when - there are many people working on different entities in a very large library. - We already see some contention issues occuring in ModuleStore for courses, - and we want to support Libraries that are far larger. - - If you need a LearningPackage-wide indicator for version and the only thing - you care about is "has *something* changed?", you can make a foreign key to - the most recent PublishLog, or use the most recent PublishLog's primary key. - This should be monotonically increasing, though there will be large gaps in - values, e.g. (5, 190, 1291, etc.). Be warned that this value will not port - across sites. If you need site-portability, the UUIDs for this model are a - safer bet, though there's a lot about import/export that we haven't fully - mapped out yet. - """ - - uuid = immutable_uuid_field() - learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) - message = case_insensitive_char_field(max_length=500, blank=True, default="") - published_at = manual_date_time_field() - published_by = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - ) - - class Meta: - verbose_name = "Publish Log" - verbose_name_plural = "Publish Logs" - - -class PublishLogRecord(models.Model): - """ - A record for each publishable entity version changed, for each publish. - - To revert a publish, we would make a new publish that swaps ``old_version`` - and ``new_version`` field values. - """ - - publish_log = models.ForeignKey( - PublishLog, - on_delete=models.CASCADE, - related_name="records", - ) - entity = models.ForeignKey(PublishableEntity, on_delete=models.RESTRICT) - old_version = models.ForeignKey( - PublishableEntityVersion, - on_delete=models.RESTRICT, - null=True, - blank=True, - related_name="+", - ) - new_version = models.ForeignKey( - PublishableEntityVersion, on_delete=models.RESTRICT, null=True, blank=True - ) - - class Meta: - constraints = [ - # A Publishable can have only one PublishLogRecord per PublishLog. - # You can't simultaneously publish two different versions of the - # same publishable. - models.UniqueConstraint( - fields=[ - "publish_log", - "entity", - ], - name="oel_plr_uniq_pl_publishable", - ) - ] - indexes = [ - # Publishable (reverse) Publish Log Index: - # * Find the history of publishes for a given Publishable, - # starting with the most recent (since IDs are ascending ints). - models.Index( - fields=["entity", "-publish_log"], - name="oel_plr_idx_entity_rplr", - ), - ] - verbose_name = "Publish Log Record" - verbose_name_plural = "Publish Log Records" - - -class Published(models.Model): - """ - Find the currently published version of an entity. - - Notes: - - * There is only ever one published PublishableEntityVersion per - PublishableEntity at any given time. - * It may be possible for a PublishableEntity to exist only as a Draft (and thus - not show up in this table). - * If a row exists for a PublishableEntity, but the ``version`` field is - None, it means that the entity was published at some point, but is no - longer published nowโ€“i.e. it's functionally "deleted", even though all - the version history is preserved behind the scenes. - - TODO: Do we need to create a (redundant) title field in this model so that - we can more efficiently search across titles within a LearningPackage? - Probably not an immediate concern because the number of rows currently - shouldn't be > 10,000 in the more extreme cases. - - TODO: Do we need to make a "most_recently" published version when an entry - is unpublished/deleted? - """ - - entity = models.OneToOneField( - PublishableEntity, - on_delete=models.CASCADE, - primary_key=True, - ) - version = models.OneToOneField( - PublishableEntityVersion, - on_delete=models.RESTRICT, - null=True, - ) - publish_log_record = models.ForeignKey( - PublishLogRecord, - on_delete=models.RESTRICT, - ) - - class Meta: - verbose_name = "Published Entity" - verbose_name_plural = "Published Entities" diff --git a/openedx_learning/apps/authoring/publishing/models/__init__.py b/openedx_learning/apps/authoring/publishing/models/__init__.py new file mode 100644 index 00000000..ee9a1b37 --- /dev/null +++ b/openedx_learning/apps/authoring/publishing/models/__init__.py @@ -0,0 +1,21 @@ +""" +The data models here are intended to be used by other apps to publish different +types of content, such as Components, Units, Sections, etc. These models should +support the logic for the management of the publishing process: + +* The relationship between publishable entities and their many versions. +* Hierarchical relationships between "container" entities and their children +* The management of drafts. +* Publishing specific versions of publishable entities. +* Finding the currently published versions. +* The act of publishing, and doing so atomically. +* Managing reverts. +* Storing and querying publish history. +""" + +from .container import Container, ContainerVersion +from .draft_published import Draft, Published +from .entity_list import EntityList, EntityListRow +from .learning_package import LearningPackage +from .publish_log import PublishLog, PublishLogRecord +from .publishable_entity import PublishableEntity, PublishableEntityVersion diff --git a/openedx_learning/apps/authoring/publishing/models/container.py b/openedx_learning/apps/authoring/publishing/models/container.py new file mode 100644 index 00000000..4a0df08a --- /dev/null +++ b/openedx_learning/apps/authoring/publishing/models/container.py @@ -0,0 +1,60 @@ +""" +Container and ContainerVersion models +""" +from django.db import models + +from ..model_mixins.publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin +from .entity_list import EntityList + + +class Container(PublishableEntityMixin): + """ + A Container is a type of PublishableEntity that holds other + PublishableEntities. For example, a "Unit" Container might hold several + Components. + + For now, all containers have a static "entity list" that defines which + containers/components/enities they hold. As we complete the Containers API, + we will also add support for dynamic containers which may contain different + entities for different learners or at different times. + + NOTE: We're going to want to eventually have some association between the + PublishLog and Containers that were affected in a publish because their + child elements were published. + """ + + +class ContainerVersion(PublishableEntityVersionMixin): + """ + A version of a Container. + + By convention, we would only want to create new versions when the Container + itself changes, and not when the Container's child elements change. For + example: + + * Something was added to the Container. + * We re-ordered the rows in the container. + * Something was removed to the container. + * The Container's metadata changed, e.g. the title. + * We pin to different versions of the Container. + + The last looks a bit odd, but it's because *how we've defined the Unit* has + changed if we decide to explicitly pin a set of versions for the children, + and then later change our minds and move to a different set. It also just + makes things easier to reason about if we say that entity_list never + changes for a given ContainerVersion. + """ + + container = models.ForeignKey( + Container, + on_delete=models.CASCADE, + related_name="versions", + ) + + # The list of entities (frozen and/or unfrozen) in this container + entity_list = models.ForeignKey( + EntityList, + on_delete=models.RESTRICT, + null=False, + related_name="container_versions", + ) diff --git a/openedx_learning/apps/authoring/publishing/models/draft_published.py b/openedx_learning/apps/authoring/publishing/models/draft_published.py new file mode 100644 index 00000000..c945d807 --- /dev/null +++ b/openedx_learning/apps/authoring/publishing/models/draft_published.py @@ -0,0 +1,95 @@ +""" +Draft and Published models +""" +from django.db import models + +from .publish_log import PublishLogRecord +from .publishable_entity import PublishableEntity, PublishableEntityVersion + + +class Draft(models.Model): + """ + Find the active draft version of an entity (usually most recently created). + + This model mostly only exists to allow us to join against a bunch of + PublishableEntity objects at once and get all their latest drafts. You might + use this together with Published in order to see which Drafts haven't been + published yet. + + A Draft entry should be created whenever a new PublishableEntityVersion is + created. This means there are three possible states: + + 1. No Draft entry for a PublishableEntity: This means a PublishableEntity + was created, but no PublishableEntityVersion was ever made for it, so + there was never a Draft version. + 2. A Draft entry exists and points to a PublishableEntityVersion: This is + the most common state. + 3. A Draft entry exists and points to a null version: This means a version + used to be the draft, but it's been functionally "deleted". The versions + still exist in our history, but we're done using it. + + It would have saved a little space to add this data to the Published model + (and possibly call the combined model something else). Split Modulestore did + this with its active_versions table. I keep it separate here to get a better + separation of lifecycle events: i.e. this table *only* changes when drafts + are updated, not when publishing happens. The Published model only changes + when something is published. + """ + # If we're removing a PublishableEntity entirely, also remove the Draft + # entry for it. This isn't a normal operation, but can happen if you're + # deleting an entire LearningPackage. + entity = models.OneToOneField( + PublishableEntity, + on_delete=models.CASCADE, + primary_key=True, + ) + version = models.OneToOneField( + PublishableEntityVersion, + on_delete=models.RESTRICT, + null=True, + blank=True, + ) + + +class Published(models.Model): + """ + Find the currently published version of an entity. + + Notes: + + * There is only ever one published PublishableEntityVersion per + PublishableEntity at any given time. + * It may be possible for a PublishableEntity to exist only as a Draft (and thus + not show up in this table). + * If a row exists for a PublishableEntity, but the ``version`` field is + None, it means that the entity was published at some point, but is no + longer published nowโ€“i.e. it's functionally "deleted", even though all + the version history is preserved behind the scenes. + + TODO: Do we need to create a (redundant) title field in this model so that + we can more efficiently search across titles within a LearningPackage? + Probably not an immediate concern because the number of rows currently + shouldn't be > 10,000 in the more extreme cases. + + TODO: Do we need to make a "most_recently" published version when an entry + is unpublished/deleted? + """ + + entity = models.OneToOneField( + PublishableEntity, + on_delete=models.CASCADE, + primary_key=True, + ) + version = models.OneToOneField( + PublishableEntityVersion, + on_delete=models.RESTRICT, + null=True, + ) + publish_log_record = models.ForeignKey( + PublishLogRecord, + on_delete=models.RESTRICT, + ) + + class Meta: + verbose_name = "Published Entity" + verbose_name_plural = "Published Entities" diff --git a/openedx_learning/apps/authoring/containers/models.py b/openedx_learning/apps/authoring/publishing/models/entity_list.py similarity index 53% rename from openedx_learning/apps/authoring/containers/models.py rename to openedx_learning/apps/authoring/publishing/models/entity_list.py index 4bfa0ecb..b30e3e9a 100644 --- a/openedx_learning/apps/authoring/containers/models.py +++ b/openedx_learning/apps/authoring/publishing/models/entity_list.py @@ -1,16 +1,9 @@ """ -Models that implement containers +Entity List models """ from django.db import models -from openedx_learning.apps.authoring.publishing.models import PublishableEntity, PublishableEntityVersion - -from ..publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin - -__all__ = [ - "Container", - "ContainerVersion", -] +from .publishable_entity import PublishableEntity, PublishableEntityVersion class EntityList(models.Model): @@ -65,56 +58,3 @@ class EntityListRow(models.Model): null=True, related_name="+", # Do we need the reverse relation? ) - - -class Container(PublishableEntityMixin): - """ - A Container is a type of PublishableEntity that holds other - PublishableEntities. For example, a "Unit" Container might hold several - Components. - - For now, all containers have a static "entity list" that defines which - containers/components/enities they hold. As we complete the Containers API, - we will also add support for dynamic containers which may contain different - entities for different learners or at different times. - - NOTE: We're going to want to eventually have some association between the - PublishLog and Containers that were affected in a publish because their - child elements were published. - """ - - -class ContainerVersion(PublishableEntityVersionMixin): - """ - A version of a Container. - - By convention, we would only want to create new versions when the Container - itself changes, and not when the Container's child elements change. For - example: - - * Something was added to the Container. - * We re-ordered the rows in the container. - * Something was removed to the container. - * The Container's metadata changed, e.g. the title. - * We pin to different versions of the Container. - - The last looks a bit odd, but it's because *how we've defined the Unit* has - changed if we decide to explicitly pin a set of versions for the children, - and then later change our minds and move to a different set. It also just - makes things easier to reason about if we say that entity_list never - changes for a given ContainerVersion. - """ - - container = models.ForeignKey( - Container, - on_delete=models.CASCADE, - related_name="versions", - ) - - # The list of entities (frozen and/or unfrozen) in this container - entity_list = models.ForeignKey( - EntityList, - on_delete=models.RESTRICT, - null=False, - related_name="entity_list", - ) diff --git a/openedx_learning/apps/authoring/publishing/models/learning_package.py b/openedx_learning/apps/authoring/publishing/models/learning_package.py new file mode 100644 index 00000000..3ff5bb4b --- /dev/null +++ b/openedx_learning/apps/authoring/publishing/models/learning_package.py @@ -0,0 +1,75 @@ +""" +LearningPackage model +""" +from django.db import models + +from openedx_learning.lib.fields import ( + MultiCollationTextField, + case_insensitive_char_field, + immutable_uuid_field, + key_field, + manual_date_time_field, +) + + +class LearningPackage(models.Model): + """ + Top level container for a grouping of authored content. + + Each PublishableEntity belongs to exactly one LearningPackage. + """ + # Explictly declare a 4-byte ID instead of using the app-default 8-byte ID. + # We do not expect to have more than 2 billion LearningPackages on a given + # site. Furthermore, many, many things have foreign keys to this model and + # uniqueness indexes on those foreign keys + their own fields, so the 4 + # bytes saved will add up over time. + id = models.AutoField(primary_key=True) + + uuid = immutable_uuid_field() + + # "key" is a reserved word for MySQL, so we're temporarily using the column + # name of "_key" to avoid breaking downstream tooling. There's an open + # question as to whether this field needs to exist at all, or whether the + # top level library key it's currently used for should be entirely in the + # LibraryContent model. + key = key_field(db_column="_key") + + title = case_insensitive_char_field(max_length=500, blank=False) + + # TODO: We should probably defer this field, since many things pull back + # LearningPackage as select_related. Usually those relations only care about + # the UUID and key, so maybe it makes sense to separate the model at some + # point. + description = MultiCollationTextField( + blank=True, + null=False, + default="", + max_length=10_000, + # We don't really expect to ever sort by the text column, but we may + # want to do case-insensitive searches, so it's useful to have a case + # and accent insensitive collation. + db_collations={ + "sqlite": "NOCASE", + "mysql": "utf8mb4_unicode_ci", + } + ) + + created = manual_date_time_field() + updated = manual_date_time_field() + + def __str__(self): + return f"{self.key}" + + class Meta: + constraints = [ + # LearningPackage keys must be globally unique. This is something + # that might be relaxed in the future if this system were to be + # extensible to something like multi-tenancy, in which case we'd tie + # it to something like a Site or Org. + models.UniqueConstraint( + fields=["key"], + name="oel_publishing_lp_uniq_key", + ) + ] + verbose_name = "Learning Package" + verbose_name_plural = "Learning Packages" diff --git a/openedx_learning/apps/authoring/publishing/models/publish_log.py b/openedx_learning/apps/authoring/publishing/models/publish_log.py new file mode 100644 index 00000000..e71f08be --- /dev/null +++ b/openedx_learning/apps/authoring/publishing/models/publish_log.py @@ -0,0 +1,106 @@ +""" +PublishLog and PublishLogRecord models +""" +from django.conf import settings +from django.db import models + +from openedx_learning.lib.fields import case_insensitive_char_field, immutable_uuid_field, manual_date_time_field + +from .learning_package import LearningPackage +from .publishable_entity import PublishableEntity, PublishableEntityVersion + + +class PublishLog(models.Model): + """ + There is one row in this table for every time content is published. + + Each PublishLog has 0 or more PublishLogRecords describing exactly which + PublishableEntites were published and what the version changes are. A + PublishLog is like a git commit in that sense, with individual + PublishLogRecords representing the files changed. + + Open question: Empty publishes are allowed at this time, and might be useful + for "fake" publishes that are necessary to invoke other post-publish + actions. It's not clear at this point how useful this will actually be. + + The absence of a ``version_num`` field in this model is intentional, because + having one would potentially cause write contention/locking issues when + there are many people working on different entities in a very large library. + We already see some contention issues occuring in ModuleStore for courses, + and we want to support Libraries that are far larger. + + If you need a LearningPackage-wide indicator for version and the only thing + you care about is "has *something* changed?", you can make a foreign key to + the most recent PublishLog, or use the most recent PublishLog's primary key. + This should be monotonically increasing, though there will be large gaps in + values, e.g. (5, 190, 1291, etc.). Be warned that this value will not port + across sites. If you need site-portability, the UUIDs for this model are a + safer bet, though there's a lot about import/export that we haven't fully + mapped out yet. + """ + + uuid = immutable_uuid_field() + learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) + message = case_insensitive_char_field(max_length=500, blank=True, default="") + published_at = manual_date_time_field() + published_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + class Meta: + verbose_name = "Publish Log" + verbose_name_plural = "Publish Logs" + + +class PublishLogRecord(models.Model): + """ + A record for each publishable entity version changed, for each publish. + + To revert a publish, we would make a new publish that swaps ``old_version`` + and ``new_version`` field values. + """ + + publish_log = models.ForeignKey( + PublishLog, + on_delete=models.CASCADE, + related_name="records", + ) + entity = models.ForeignKey(PublishableEntity, on_delete=models.RESTRICT) + old_version = models.ForeignKey( + PublishableEntityVersion, + on_delete=models.RESTRICT, + null=True, + blank=True, + related_name="+", + ) + new_version = models.ForeignKey( + PublishableEntityVersion, on_delete=models.RESTRICT, null=True, blank=True + ) + + class Meta: + constraints = [ + # A Publishable can have only one PublishLogRecord per PublishLog. + # You can't simultaneously publish two different versions of the + # same publishable. + models.UniqueConstraint( + fields=[ + "publish_log", + "entity", + ], + name="oel_plr_uniq_pl_publishable", + ) + ] + indexes = [ + # Publishable (reverse) Publish Log Index: + # * Find the history of publishes for a given Publishable, + # starting with the most recent (since IDs are ascending ints). + models.Index( + fields=["entity", "-publish_log"], + name="oel_plr_idx_entity_rplr", + ), + ] + verbose_name = "Publish Log Record" + verbose_name_plural = "Publish Log Records" diff --git a/openedx_learning/apps/authoring/publishing/models/publishable_entity.py b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py new file mode 100644 index 00000000..47d4ef97 --- /dev/null +++ b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py @@ -0,0 +1,251 @@ +""" +PublishableEntity model and PublishableEntityVersion +""" +from django.conf import settings +from django.core.validators import MinValueValidator +from django.db import models + +from openedx_learning.lib.fields import ( + case_insensitive_char_field, + immutable_uuid_field, + key_field, + manual_date_time_field, +) + +from .learning_package import LearningPackage + + +class PublishableEntity(models.Model): + """ + This represents any publishable thing that has ever existed in a + LearningPackage. It serves as a stable model that will not go away even if + these things are later unpublished or deleted. + + A PublishableEntity belongs to exactly one LearningPackage. + + Examples of Publishable Entities + -------------------------------- + + Components (e.g. VideoBlock, ProblemBlock), Units, and Sections/Subsections + would all be considered Publishable Entites. But anything that can be + imported, exported, published, and reverted in a course or library could be + modeled as a PublishableEntity, including things like Grading Policy or + possibly Taxonomies (?). + + How to use this model + --------------------- + + The publishing app understands that publishable entities exist, along with + their drafts and published versions. It has some basic metadata, such as + identifiers, who created it, and when it was created. It's meant to + encapsulate the draft and publishing related aspects of your content, but + the ``publishing`` app doesn't know anything about the actual content being + referenced. + + You have to provide actual meaning to PublishableEntity by creating your own + models that will represent your particular content and associating them to + PublishableEntity via a OneToOneField with primary_key=True. The easiest way + to do this is to have your model inherit from PublishableEntityMixin. + + Identifiers + ----------- + The UUID is globally unique and should be treated as immutable. + + The key field *is* mutable, but changing it will affect all + PublishedEntityVersions. They are locally unique within the LearningPackage. + + If you are referencing this model from within the same process, use a + foreign key to the id. If you are referencing this PublishedEntity from an + external system/service, use the UUID. The key is the part that is most + likely to be human-readable, and may be exported/copied, but try not to rely + on it, since this value may change. + + Note: When we actually implement the ability to change identifiers, we + should make a history table and a modified attribute on this model. + + Why are Identifiers in this Model? + ---------------------------------- + + A PublishableEntity never stands aloneโ€“it's always intended to be used with + a 1:1 model like Component or Unit. So why have all the identifiers in this + model instead of storing them in those other models? Two reasons: + + * Published things need to have the right identifiers so they can be used + throughout the system, and the UUID is serving the role of ISBN in physical + book publishing. + * We want to be able to enforce the idea that "key" is locally unique across + all PublishableEntities within a given LearningPackage. Component and Unit + can't do that without a shared model. + + That being said, models that build on PublishableEntity are free to add + their own identifiers if it's useful to do so. + + Why not Inherit from this Model? + -------------------------------- + + Django supports multi-table inheritance: + + https://docs.djangoproject.com/en/4.2/topics/db/models/#multi-table-inheritance + + We don't use that, primarily because we want to more clearly decouple + publishing concerns from the rest of the logic around Components, Units, + etc. If you made a Component and ComponentVersion models that subclassed + PublishableEntity and PublishableEntityVersion, and then accessed + ``component.versions``, you might expect ComponentVersions to come back and + be surprised when you get EntityVersions instead. + + In general, we want freedom to add new Publishing models, fields, and + methods without having to worry about the downstream name collisions with + other apps (many of which might live in other repositories). The helper + mixins will provide a little syntactic sugar to make common access patterns + more convenient, like file access. + """ + + uuid = immutable_uuid_field() + learning_package = models.ForeignKey( + LearningPackage, + on_delete=models.CASCADE, + related_name="publishable_entities", + ) + + # "key" is a reserved word for MySQL, so we're temporarily using the column + # name of "_key" to avoid breaking downstream tooling. Consider renaming + # this later. + key = key_field(db_column="_key") + + created = manual_date_time_field() + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + class Meta: + constraints = [ + # Keys are unique within a given LearningPackage. + models.UniqueConstraint( + fields=[ + "learning_package", + "key", + ], + name="oel_pub_ent_uniq_lp_key", + ) + ] + indexes = [ + # Global Key Index: + # * Search by key across all PublishableEntities on the site. This + # would be a support-oriented tool from Django Admin. + models.Index( + fields=["key"], + name="oel_pub_ent_idx_key", + ), + # LearningPackage (reverse) Created Index: + # * Search for most recently *created* PublishableEntities for a + # given LearningPackage, since they're the most likely to be + # actively worked on. + models.Index( + fields=["learning_package", "-created"], + name="oel_pub_ent_idx_lp_rcreated", + ), + ] + # These are for the Django Admin UI. + verbose_name = "Publishable Entity" + verbose_name_plural = "Publishable Entities" + + def __str__(self): + return f"{self.key}" + + +class PublishableEntityVersion(models.Model): + """ + A particular version of a PublishableEntity. + + This model has its own ``uuid`` so that it can be referenced directly. The + ``uuid`` should be treated as immutable. + + PublishableEntityVersions are created once and never updated. So for + instance, the ``title`` should never be modified. + + Like PublishableEntity, the data in this model is only enough to cover the + parts that are most important for the actual process of managing drafts and + publishes. You will want to create your own models to represent the actual + content data that's associated with this PublishableEntityVersion, and + connect them using a OneToOneField with primary_key=True. The easiest way to + do this is to inherit from PublishableEntityVersionMixin. Be sure to treat + these versioned models in your app as immutable as well. + """ + + uuid = immutable_uuid_field() + entity = models.ForeignKey( + PublishableEntity, on_delete=models.CASCADE, related_name="versions" + ) + + # Most publishable things will have some sort of title, but blanks are + # allowed for those that don't require one. + title = case_insensitive_char_field(max_length=500, blank=True, default="") + + # The version_num starts at 1 and increments by 1 with each new version for + # a given PublishableEntity. Doing it this way makes it more convenient for + # users to refer to than a hash or UUID value. It also helps us catch race + # conditions on save, by setting a unique constraint on the entity and + # version_num. + version_num = models.PositiveIntegerField( + null=False, + validators=[MinValueValidator(1)], + ) + + # All PublishableEntityVersions created as part of the same publish should + # have the exact same created datetime (not off by a handful of + # microseconds). + created = manual_date_time_field() + + # User who created the PublishableEntityVersion. This can be null if the + # user is later removed. Open edX in general doesn't let you remove users, + # but we should try to model it so that this is possible eventually. + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + class Meta: + constraints = [ + # Prevent the situation where we have multiple + # PublishableEntityVersions claiming to be the same version_num for + # a given PublishableEntity. This can happen if there's a race + # condition between concurrent editors in different browsers, + # working on the same Publishable. With this constraint, one of + # those processes will raise an IntegrityError. + models.UniqueConstraint( + fields=[ + "entity", + "version_num", + ], + name="oel_pv_uniq_entity_version_num", + ) + ] + indexes = [ + # LearningPackage (reverse) Created Index: + # * Make it cheap to find the most recently created + # PublishableEntityVersions for a given LearningPackage. This + # represents the most recently saved work for a LearningPackage + # and would be the most likely areas to get worked on next. + models.Index( + fields=["entity", "-created"], + name="oel_pv_idx_entity_rcreated", + ), + # Title Index: + # * Search by title. + models.Index( + fields=[ + "title", + ], + name="oel_pv_idx_title", + ), + ] + + # These are for the Django Admin UI. + verbose_name = "Publishable Entity Version" + verbose_name_plural = "Publishable Entity Versions" diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index 9da2703a..e8787515 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -9,8 +9,7 @@ from openedx_learning.apps.authoring.components.models import Component, ComponentVersion -from ..containers import api as container_api -from ..publishing.api import get_published_version_as_of +from ..publishing import api as publishing_api from .models import Unit, UnitVersion # ๐Ÿ›‘ UNSTABLE: All APIs related to containers are unstable until we've figured @@ -43,7 +42,7 @@ def create_unit( created_by: The user who created the unit. """ with atomic(): - container = container_api.create_container( + container = publishing_api.create_container( learning_package_id, key, created, created_by ) unit = Unit.objects.create( @@ -76,7 +75,7 @@ def create_unit_version( created_by: The user who created the unit. """ with atomic(): - container_version = container_api.create_container_version( + container_version = publishing_api.create_container_version( unit.container.pk, version_num, title=title, @@ -129,7 +128,7 @@ def create_next_unit_version( publishable_entities_pks = None entity_version_pks = None with atomic(): - container_version = container_api.create_next_container_version( + container_version = publishing_api.create_next_container_version( unit.container.pk, title=title, publishable_entities_pks=publishable_entities_pks, @@ -229,7 +228,7 @@ def get_components_in_draft_unit( """ assert isinstance(unit, Unit) entity_list = [] - for entry in container_api.get_entities_in_draft_container(unit): + for entry in publishing_api.get_entities_in_draft_container(unit): # Convert from generic PublishableEntityVersion to ComponentVersion: component_version = entry.entity_version.componentversion assert isinstance(component_version, ComponentVersion) @@ -248,7 +247,7 @@ def get_components_in_published_unit( Returns None if the unit was never published (TODO: should it throw instead?). """ assert isinstance(unit, Unit) - published_entities = container_api.get_entities_in_published_container(unit) + published_entities = publishing_api.get_entities_in_published_container(unit) if published_entities is None: return None # There is no published version of this unit. Should this be an exception? entity_list = [] @@ -279,7 +278,7 @@ def get_components_in_published_unit_as_of( ancestors of every modified PublishableEntity in the publish. """ assert isinstance(unit, Unit) - unit_pub_entity_version = get_published_version_as_of(unit.publishable_entity_id, publish_log_id) + 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. unit_version = unit_pub_entity_version.unitversion # type: ignore[attr-defined] @@ -294,7 +293,7 @@ def get_components_in_published_unit_as_of( 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 = get_published_version_as_of(row.entity_id, publish_log_id) + 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/openedx_learning/apps/authoring/units/migrations/0001_initial.py b/openedx_learning/apps/authoring/units/migrations/0001_initial.py index 8a72507e..3bfc8a40 100644 --- a/openedx_learning/apps/authoring/units/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/units/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.16 on 2024-10-30 11:36 +# Generated by Django 4.2.19 on 2025-03-07 23:10 import django.db.models.deletion from django.db import migrations, models @@ -9,8 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('oel_containers', '0001_initial'), - ('oel_publishing', '0002_alter_learningpackage_key_and_more'), + ('oel_publishing', '0003_containers'), ] operations = [ @@ -18,7 +17,7 @@ class Migration(migrations.Migration): name='Unit', fields=[ ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')), - ('container', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.container')), + ('container', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.container')), ], options={ 'abstract': False, @@ -28,7 +27,7 @@ class Migration(migrations.Migration): name='UnitVersion', fields=[ ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')), - ('container_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_containers.containerversion')), + ('container_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.containerversion')), ('unit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_units.unit')), ], options={ diff --git a/openedx_learning/apps/authoring/units/models.py b/openedx_learning/apps/authoring/units/models.py index 3e504491..3a12e035 100644 --- a/openedx_learning/apps/authoring/units/models.py +++ b/openedx_learning/apps/authoring/units/models.py @@ -3,7 +3,7 @@ """ from django.db import models -from ..containers.models_mixin import ContainerMixin, ContainerVersionMixin +from ..publishing.model_mixins import ContainerMixin, ContainerVersionMixin __all__ = [ "Unit", diff --git a/projects/dev.py b/projects/dev.py index 094494ab..85d01f4a 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -35,7 +35,6 @@ "openedx_learning.apps.authoring.components.apps.ComponentsConfig", "openedx_learning.apps.authoring.contents.apps.ContentsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", - "openedx_learning.apps.authoring.containers.apps.ContainersConfig", "openedx_learning.apps.authoring.units.apps.UnitsConfig", # Learning Contrib Apps "openedx_learning.contrib.media_server.apps.MediaServerConfig", diff --git a/test_settings.py b/test_settings.py index 9b58f909..71a71c33 100644 --- a/test_settings.py +++ b/test_settings.py @@ -45,7 +45,6 @@ def root(*args): "openedx_learning.apps.authoring.contents.apps.ContentsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", "openedx_tagging.core.tagging.apps.TaggingConfig", - "openedx_learning.apps.authoring.containers.apps.ContainersConfig", "openedx_learning.apps.authoring.units.apps.UnitsConfig", ] From e7539717e7ac77b3bfd1f03795a8efd70fd07288 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 4 Mar 2025 18:05:40 -0800 Subject: [PATCH 16/27] feat: auto-publish children when publishing a container --- .../apps/authoring/publishing/api.py | 24 +++++++++++++++++++ .../apps/authoring/units/test_api.py | 10 ++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index 0c34c09f..895f091f 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -332,6 +332,30 @@ def publish_from_drafts( published_at = datetime.now(tz=timezone.utc) with atomic(): + # If the drafts include any containers, we need to auto-publish their descendants: + # TODO: this only handles one level deep and would need to be updated to support sections > subsections > units + + # Get the IDs of the ContainerVersion for any Containers whose drafts are slated to be published. + container_version_ids = ( + Container.objects.filter(publishable_entity__draft__in=draft_qset) + .values_list("publishable_entity__draft__version__containerversion__pk", flat=True) + ) + if container_version_ids: + # We are publishing at least one container. Check if it has any child components that aren't already slated + # to be published. + unpublished_draft_children = EntityListRow.objects.filter( + entity_list__container_versions__pk__in=container_version_ids, + entity_version=None, # Unpinned entities only + ).exclude( + entity__draft__version=F("entity__published__version") # Exclude already published things + ).values_list("entity__draft__pk", flat=True) + if unpublished_draft_children: + # Force these additional child components to be published at the same time by adding them to the qset: + draft_qset = Draft.objects.filter( + Q(pk__in=draft_qset.values_list("pk", flat=True)) | + Q(pk__in=unpublished_draft_children) + ) + # One PublishLog for this entire publish operation. publish_log = PublishLog( learning_package_id=learning_package_id, diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index cdf31587..9be28f71 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -259,7 +259,6 @@ def test_create_next_unit_version_with_unpinned_and_pinned_components(self): ] assert authoring_api.get_components_in_published_unit(unit) is None - @pytest.mark.skip(reason="FIXME: auto-publishing children is not implemented yet") def test_auto_publish_children(self): """ Test that publishing a unit publishes its child components automatically. @@ -511,16 +510,11 @@ def test_publishing_shared_component(self): c5_v2 = self.modify_component(c5, title="C5 version 2") # 4๏ธโƒฃ The author then publishes Unit 1, and therefore everything in it. - # FIXME: this should only require publishing the unit itself, but we don't yet do auto-publishing children authoring_api.publish_from_drafts( self.learning_package.pk, draft_qset=authoring_api.get_all_drafts(self.learning_package.pk).filter( - entity_id__in=[ - unit1.publishable_entity.id, - c1.publishable_entity.id, - c2.publishable_entity.id, - c3.publishable_entity.id, - ], + # Note: we only publish the unit; the publishing API should auto-publish its components too. + entity_id=unit1.publishable_entity.id, ), ) From f666ba45c318bf78b5a3715bad2919560f4af268 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 10 Mar 2025 17:17:31 -0700 Subject: [PATCH 17/27] feat: allow specifying components when creating a unit --- openedx_learning/apps/authoring/units/api.py | 56 +++++++++++++------ .../apps/authoring/units/test_api.py | 18 +++--- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index e8787515..e18e6b50 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -65,6 +65,10 @@ def create_unit_version( """ [ ๐Ÿ›‘ 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_pk: The unit ID. version_num: The version number. @@ -92,6 +96,34 @@ def create_unit_version( return unit_version +def _pub_entities_for_components( + components: list[Component | ComponentVersion] | None, +) -> tuple[list[int], list[int | None]] | tuple[None, None]: + """ + Helper method: given a list of Component | ComponentVersion, return the + lists of publishable_entities_pks and entity_version_pks 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, None + for c in components: + if not isinstance(c, (Component, ComponentVersion)): + raise TypeError("Unit components must be either Component or ComponentVersion.") + publishable_entities_pks = [ + (c.publishable_entity_id if isinstance(c, Component) else c.component.publishable_entity_id) + for c in components + ] + entity_version_pks = [ + (cv.pk if isinstance(cv, ComponentVersion) else None) + for cv in components + ] + return publishable_entities_pks, entity_version_pks + + def create_next_unit_version( unit: Unit, *, @@ -111,22 +143,7 @@ def create_next_unit_version( created: The creation date. created_by: The user who created the unit. """ - if components is not None: - for c in components: - if not isinstance(c, (Component, ComponentVersion)): - raise TypeError("Unit components must be either Component or ComponentVersion.") - publishable_entities_pks = [ - (c.publishable_entity_id if isinstance(c, Component) else c.component.publishable_entity_id) - for c in components - ] - entity_version_pks = [ - (cv.pk if isinstance(cv, ComponentVersion) else None) - for cv in components - ] - else: - # When these are None, that means don't change the entities in the list. - publishable_entities_pks = None - entity_version_pks = None + publishable_entities_pks, entity_version_pks = _pub_entities_for_components(components) with atomic(): container_version = publishing_api.create_next_container_version( unit.container.pk, @@ -147,7 +164,9 @@ def create_next_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, ) -> tuple[Unit, UnitVersion]: @@ -160,14 +179,15 @@ def create_unit_and_version( created: The creation date. created_by: The user who created the unit. """ + publishable_entities_pks, entity_version_pks = _pub_entities_for_components(components) with atomic(): unit = create_unit(learning_package_id, key, created, created_by) unit_version = create_unit_version( unit, 1, title=title, - publishable_entities_pks=[], - entity_version_pks=[], + publishable_entities_pks=publishable_entities_pks or [], + entity_version_pks=entity_version_pks or [], created=created, created_by=created_by, ) diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index 9be28f71..c063876d 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -54,17 +54,10 @@ def create_unit_with_components( learning_package_id=self.learning_package.id, key=key, title=title, - created=self.now, - created_by=None, - ) - _unit_v2 = authoring_api.create_next_unit_version( - unit=unit, - title=title, components=components, created=self.now, created_by=None, ) - unit.refresh_from_db() return unit def modify_component( @@ -110,6 +103,17 @@ def test_get_container(self): with self.assertNumQueries(0): assert result.versioning.has_unpublished_changes + 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(28): + _empty_unit = self.create_unit_with_components([]) + 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") + def test_create_unit_with_invalid_children(self): """ Verify that only components can be added to units, and a specific From 4972f436bb818affeb0e50c07d3266450c075ddd Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 12 Mar 2025 08:34:16 -0700 Subject: [PATCH 18/27] refactor: Units inherit Containers (Multi-Table Inheritance) --- .../apps/authoring/publishing/api.py | 110 ++++++------------ .../publishing/migrations/0003_containers.py | 2 +- .../publishing/model_mixins/__init__.py | 1 - .../publishing/model_mixins/container.py | 98 ---------------- .../model_mixins/publishable_entity.py | 20 +++- .../authoring/publishing/models/container.py | 10 ++ openedx_learning/apps/authoring/units/api.py | 69 +++++------ .../units/migrations/0001_initial.py | 11 +- .../apps/authoring/units/models.py | 41 +++++-- .../apps/authoring/units/test_api.py | 87 ++++++++------ 10 files changed, 177 insertions(+), 272 deletions(-) delete mode 100644 openedx_learning/apps/authoring/publishing/model_mixins/container.py diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index 895f091f..d73dfbff 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -8,17 +8,13 @@ from dataclasses import dataclass from datetime import datetime, timezone +from typing import TypeVar from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models import F, Q, QuerySet from django.db.transaction import atomic -from .model_mixins import ( - ContainerMixin, - PublishableContentModelRegistry, - PublishableEntityMixin, - PublishableEntityVersionMixin, -) +from .model_mixins import PublishableContentModelRegistry, PublishableEntityMixin, PublishableEntityVersionMixin from .models import ( Container, ContainerVersion, @@ -33,6 +29,9 @@ PublishLogRecord, ) +ContainerModel = TypeVar('ContainerModel', bound=Container) +ContainerVersionModel = TypeVar('ContainerVersionModel', bound=ContainerVersion) + # The public API that will be re-exported by openedx_learning.apps.authoring.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 @@ -66,7 +65,6 @@ "create_container", "create_container_version", "create_next_container_version", - "create_container_and_version", "get_container", "ContainerEntityListEntry", "get_entities_in_draft_container", @@ -581,7 +579,9 @@ def create_container( key: str, created: datetime, created_by: int | None, -) -> Container: + # The types on the following line are correct, but mypy will complain - https://github.com/python/mypy/issues/3737 + container_model: type[ContainerModel] = Container, # type: ignore[assignment] +) -> ContainerModel: """ [ ๐Ÿ›‘ UNSTABLE ] Create a new container. @@ -591,15 +591,17 @@ def create_container( key: The key of the container. created: The date and time the container was created. created_by: The ID of the user who created the container + container_model: The subclass of Container to use, if applicable Returns: The newly created container. """ + assert issubclass(container_model, Container) with atomic(): publishable_entity = create_publishable_entity( learning_package_id, key, created, created_by ) - container = Container.objects.create( + container = container_model.objects.create( publishable_entity=publishable_entity, ) return container @@ -635,7 +637,7 @@ def create_entity_list_with_rows( The newly created entity list. """ order_nums = range(len(entity_pks)) - with atomic(): + with atomic(savepoint=False): entity_list = create_entity_list() EntityListRow.objects.bulk_create( [ @@ -662,7 +664,8 @@ def create_container_version( entity_version_pks: list[int | None] | None, created: datetime, created_by: int | None, -) -> ContainerVersion: + container_version_model: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment] +) -> ContainerVersionModel: """ [ ๐Ÿ›‘ UNSTABLE ] Create a new container version. @@ -675,11 +678,13 @@ def create_container_version( entity_version_pks: The IDs of the versions to pin to, if pinning is desired. created: The date and time the container version was created. created_by: The ID of the user who created the container version. + container_version_model: The subclass of ContainerVersion to use, if applicable. Returns: The newly created container version. """ - with atomic(): + assert issubclass(container_version_model, ContainerVersion) + with atomic(savepoint=False): container = Container.objects.select_related("publishable_entity").get(pk=container_pk) entity = container.publishable_entity @@ -706,7 +711,7 @@ def create_container_version( created=created, created_by=created_by, ) - container_version = ContainerVersion.objects.create( + container_version = container_version_model.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, entity_list=entity_list, @@ -723,7 +728,8 @@ def create_next_container_version( entity_version_pks: list[int | None] | None, created: datetime, created_by: int | None, -) -> ContainerVersion: + container_version_model: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment] +) -> ContainerVersionModel: """ [ ๐Ÿ›‘ UNSTABLE ] Create the next version of a container. A new version of the container is created @@ -742,10 +748,12 @@ def create_next_container_version( entity_version_pks: The IDs of the versions to pin to, if pinning is desired. created: The date and time the container version was created. created_by: The ID of the user who created the container version. + container_version_model: The subclass of ContainerVersion to use, if applicable. Returns: The newly created container version. """ + assert issubclass(container_version_model, ContainerVersion) with atomic(): container = Container.objects.select_related("publishable_entity").get(pk=container_pk) entity = container.publishable_entity @@ -776,7 +784,7 @@ def create_next_container_version( created=created, created_by=created_by, ) - next_container_version = ContainerVersion.objects.create( + next_container_version = container_version_model.objects.create( publishable_entity_version=publishable_entity_version, container_id=container_pk, entity_list=next_entity_list, @@ -785,46 +793,6 @@ def create_next_container_version( return next_container_version -def create_container_and_version( - learning_package_id: int, - key: str, - *, - created: datetime, - created_by: int | None, - title: str, - publishable_entities_pks: list[int], - entity_version_pks: list[int | None], -) -> tuple[Container, ContainerVersion]: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Create a new container and its first version. - - Args: - learning_package_id: The ID of the learning package that contains the container. - key: The key of the container. - created: The date and time the container was created. - created_by: The ID of the user who created the container. - version_num: The version number of the container. - title: The title of the container. - members_pk: The IDs of the members of the container. - - Returns: - The newly created container version. - """ - with atomic(): - container = create_container(learning_package_id, key, created, created_by) - container_version = create_container_version( - container.publishable_entity.pk, - 1, - title=title, - publishable_entities_pks=publishable_entities_pks, - entity_version_pks=entity_version_pks, - created=created, - created_by=created_by, - ) - return (container, container_version) - - def get_container(pk: int) -> Container: """ [ ๐Ÿ›‘ UNSTABLE ] @@ -854,15 +822,13 @@ def entity(self): def get_entities_in_draft_container( - container: Container | ContainerMixin, + container: Container, ) -> list[ContainerEntityListEntry]: """ [ ๐Ÿ›‘ UNSTABLE ] Get the list of entities and their versions in the draft version of the given container. """ - if isinstance(container, ContainerMixin): - container = container.container assert isinstance(container, Container) entity_list = [] for row in container.versioning.draft.entity_list.entitylistrow_set.order_by("order_num"): @@ -877,19 +843,15 @@ def get_entities_in_draft_container( def get_entities_in_published_container( - container: Container | ContainerMixin, + container: Container, ) -> list[ContainerEntityListEntry] | None: """ [ ๐Ÿ›‘ UNSTABLE ] Get the list of entities and their versions in the published version of the given container. """ - if isinstance(container, ContainerMixin): - cv = container.container.versioning.published - elif isinstance(container, Container): - cv = container.versioning.published - else: - raise TypeError(f"Expected Container or ContainerMixin; got {type(container)}") + assert isinstance(container, Container) + cv = container.versioning.published if cv is None: return None # There is no published version of this container. Should this be an exception? assert isinstance(cv, ContainerVersion) @@ -905,9 +867,7 @@ def get_entities_in_published_container( return entity_list -def contains_unpublished_changes( - container: Container | ContainerMixin, -) -> bool: +def contains_unpublished_changes(container_id: int) -> bool: """ [ ๐Ÿ›‘ UNSTABLE ] Check recursively if a container has any unpublished changes. @@ -920,14 +880,10 @@ def contains_unpublished_changes( that's in the container, it will be `False`. This method will return `True` in either case. """ - if isinstance(container, ContainerMixin): - # This is similar to 'get_container(container.container_id)' but pre-loads more data. - container = Container.objects.select_related( - "publishable_entity__draft__version__containerversion__entity_list", - ).get(pk=container.container_id) - else: - pass # TODO: select_related if we're given a raw Container rather than a ContainerMixin like Unit? - assert isinstance(container, Container) + # This is similar to 'get_container(container.container_id)' but pre-loads more data. + container = Container.objects.select_related( + "publishable_entity__draft__version__containerversion__entity_list", + ).get(pk=container_id) if container.versioning.has_unpublished_changes: return True @@ -949,7 +905,7 @@ def contains_unpublished_changes( child_container = None if child_container: # This is itself a container - check recursively: - if contains_unpublished_changes(child_container): + if contains_unpublished_changes(child_container.pk): return True else: # This is not a container: diff --git a/openedx_learning/apps/authoring/publishing/migrations/0003_containers.py b/openedx_learning/apps/authoring/publishing/migrations/0003_containers.py index 7260e2a0..1f759144 100644 --- a/openedx_learning/apps/authoring/publishing/migrations/0003_containers.py +++ b/openedx_learning/apps/authoring/publishing/migrations/0003_containers.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.19 on 2025-03-07 23:09 +# Generated by Django 4.2.19 on 2025-03-11 04:10 import django.db.models.deletion from django.db import migrations, models diff --git a/openedx_learning/apps/authoring/publishing/model_mixins/__init__.py b/openedx_learning/apps/authoring/publishing/model_mixins/__init__.py index 47f90297..14ea9315 100644 --- a/openedx_learning/apps/authoring/publishing/model_mixins/__init__.py +++ b/openedx_learning/apps/authoring/publishing/model_mixins/__init__.py @@ -1,5 +1,4 @@ """ Mixins provided by the publishing app """ -from .container import * from .publishable_entity import * diff --git a/openedx_learning/apps/authoring/publishing/model_mixins/container.py b/openedx_learning/apps/authoring/publishing/model_mixins/container.py deleted file mode 100644 index 944d49ac..00000000 --- a/openedx_learning/apps/authoring/publishing/model_mixins/container.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -ContainerMixin and ContainerVersionMixin -""" -from __future__ import annotations - -from datetime import datetime -from typing import TYPE_CHECKING, ClassVar, Self - -from django.db import models - -from openedx_learning.lib.managers import WithRelationsManager - -from .publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin - -if TYPE_CHECKING: - from ..models.container import Container, ContainerVersion -else: - # To avoid circular imports, we need to reference these models using strings only - Container = "oel_publishing.Container" - ContainerVersion = "oel_publishing.ContainerVersion" - -__all__ = [ - "ContainerMixin", - "ContainerVersionMixin", -] - - -class ContainerMixin(PublishableEntityMixin): - """ - Convenience mixin to link your models against Container. - - Please see docstring for Container for more details. - - If you use this class, you *MUST* also use ContainerVersionMixin - """ - - # select these related entities by default for all queries - objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager( # type: ignore[assignment] - "container", - "publishable_entity", - "publishable_entity__published", - "publishable_entity__draft", - ) - - container = models.OneToOneField( - Container, - on_delete=models.CASCADE, - ) - - @property - def uuid(self) -> str: - return self.container.uuid - - @property - def created(self) -> datetime: - return self.container.created - - class Meta: - abstract = True - - -class ContainerVersionMixin(PublishableEntityVersionMixin): - """ - Convenience mixin to link your models against ContainerVersion. - - Please see docstring for ContainerVersion for more details. - - If you use this class, you *MUST* also use ContainerMixin - """ - - # select these related entities by default for all queries - objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager( # type: ignore[assignment] - "container_version", - ) - - container_version = models.OneToOneField( - ContainerVersion, - on_delete=models.CASCADE, - ) - - @property - def uuid(self) -> str: - return self.container_version.uuid - - @property - def title(self) -> str: - return self.container_version.title - - @property - def created(self) -> datetime: - return self.container_version.created - - @property - def version_num(self): - return self.container_version.version_num - - class Meta: - abstract = True diff --git a/openedx_learning/apps/authoring/publishing/model_mixins/publishable_entity.py b/openedx_learning/apps/authoring/publishing/model_mixins/publishable_entity.py index 49aaac93..84360310 100644 --- a/openedx_learning/apps/authoring/publishing/model_mixins/publishable_entity.py +++ b/openedx_learning/apps/authoring/publishing/model_mixins/publishable_entity.py @@ -135,11 +135,24 @@ def __init__(self, content_obj): field_to_pev = self.content_version_model_cls._meta.get_field( "publishable_entity_version" ) - # Now that we know the field that leads to PublishableEntityVersion, # get the reverse related field name so that we can use that later. self.related_name = field_to_pev.related_query_name() + if field_to_pev.model != self.content_version_model_cls: + # In the case of multi-table inheritance and mixins, this can get tricky. + # Example: + # content_version_model_cls is UnitVersion, which is a subclass of ContainerVersion + # This versioning helper can be accessed via unit_version.versioning (should return UnitVersion) or + # via container_version.versioning (should return ContainerVersion) + intermediate_model = field_to_pev.model # example: ContainerVersion + # This is the field on the subclass (e.g. UnitVersion) that gets + # the intermediate (e.g. ContainerVersion). Example: "UnitVersion.container_version" (1:1 foreign key) + field_to_intermediate = self.content_version_model_cls._meta.get_ancestor_link(intermediate_model) + if field_to_intermediate: + # Example: self.related_name = "containerversion.unitversion" + self.related_name = self.related_name + "." + field_to_intermediate.related_query_name() + def _content_obj_version(self, pub_ent_version: PublishableEntityVersion | None): """ PublishableEntityVersion -> Content object version @@ -149,7 +162,10 @@ def _content_obj_version(self, pub_ent_version: PublishableEntityVersion | None) """ if pub_ent_version is None: return None - return getattr(pub_ent_version, self.related_name) + obj = pub_ent_version + for field_name in self.related_name.split("."): + obj = getattr(obj, field_name) + return obj @property def draft(self): diff --git a/openedx_learning/apps/authoring/publishing/models/container.py b/openedx_learning/apps/authoring/publishing/models/container.py index 4a0df08a..04152f71 100644 --- a/openedx_learning/apps/authoring/publishing/models/container.py +++ b/openedx_learning/apps/authoring/publishing/models/container.py @@ -1,6 +1,7 @@ """ Container and ContainerVersion models """ +from django.core.exceptions import ValidationError from django.db import models from ..model_mixins.publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin @@ -58,3 +59,12 @@ class ContainerVersion(PublishableEntityVersionMixin): null=False, related_name="container_versions", ) + + def clean(self): + """ + Validate this model before saving. Not called normally, but will be + called if anything is edited via a ModelForm like the Django admin. + """ + super().clean() + if self.container_id != self.publishable_entity_version.entity.container.pk: # pylint: disable=no-member + raise ValidationError("Inconsistent foreign keys to Container") diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index e18e6b50..d007a57f 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -41,15 +41,13 @@ def create_unit( created: The creation date. created_by: The user who created the unit. """ - with atomic(): - container = publishing_api.create_container( - learning_package_id, key, created, created_by - ) - unit = Unit.objects.create( - container=container, - publishable_entity=container.publishable_entity, - ) - return unit + return publishing_api.create_container( + learning_package_id, + key, + created, + created_by, + container_model=Unit, + ) def create_unit_version( @@ -78,22 +76,16 @@ def create_unit_version( created: The creation date. created_by: The user who created the unit. """ - with atomic(): - container_version = publishing_api.create_container_version( - unit.container.pk, - version_num, - title=title, - publishable_entities_pks=publishable_entities_pks, - entity_version_pks=entity_version_pks, - created=created, - created_by=created_by, - ) - unit_version = UnitVersion.objects.create( - unit=unit, - container_version=container_version, - publishable_entity_version=container_version.publishable_entity_version, - ) - return unit_version + return publishing_api.create_container_version( + unit.pk, + version_num, + title=title, + publishable_entities_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, + created=created, + created_by=created_by, + container_version_model=UnitVersion, + ) def _pub_entities_for_components( @@ -144,20 +136,15 @@ def create_next_unit_version( created_by: The user who created the unit. """ publishable_entities_pks, entity_version_pks = _pub_entities_for_components(components) - with atomic(): - container_version = publishing_api.create_next_container_version( - unit.container.pk, - title=title, - publishable_entities_pks=publishable_entities_pks, - entity_version_pks=entity_version_pks, - created=created, - created_by=created_by, - ) - unit_version = UnitVersion.objects.create( - unit=unit, - container_version=container_version, - publishable_entity_version=container_version.publishable_entity_version, - ) + unit_version = publishing_api.create_next_container_version( + unit.pk, + title=title, + publishable_entities_pks=publishable_entities_pks, + entity_version_pks=entity_version_pks, + created=created, + created_by=created_by, + container_version_model=UnitVersion, + ) return unit_version @@ -301,10 +288,10 @@ def get_components_in_published_unit_as_of( 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. - unit_version = unit_pub_entity_version.unitversion # type: ignore[attr-defined] + container_version = unit_pub_entity_version.containerversion entity_list = [] - rows = unit_version.container_version.entity_list.entitylistrow_set.order_by("order_num") + 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 diff --git a/openedx_learning/apps/authoring/units/migrations/0001_initial.py b/openedx_learning/apps/authoring/units/migrations/0001_initial.py index 3bfc8a40..52a5b4fb 100644 --- a/openedx_learning/apps/authoring/units/migrations/0001_initial.py +++ b/openedx_learning/apps/authoring/units/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.19 on 2025-03-07 23:10 +# Generated by Django 4.2.19 on 2025-03-11 04:31 import django.db.models.deletion from django.db import migrations, models @@ -16,22 +16,21 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Unit', fields=[ - ('publishable_entity', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentity')), - ('container', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.container')), + ('container', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_publishing.container')), ], options={ 'abstract': False, }, + bases=('oel_publishing.container',), ), migrations.CreateModel( name='UnitVersion', fields=[ - ('publishable_entity_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='oel_publishing.publishableentityversion')), - ('container_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.containerversion')), - ('unit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='oel_units.unit')), + ('container_version', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='oel_publishing.containerversion')), ], options={ 'abstract': False, }, + bases=('oel_publishing.containerversion',), ), ] diff --git a/openedx_learning/apps/authoring/units/models.py b/openedx_learning/apps/authoring/units/models.py index 3a12e035..0c525584 100644 --- a/openedx_learning/apps/authoring/units/models.py +++ b/openedx_learning/apps/authoring/units/models.py @@ -3,7 +3,7 @@ """ from django.db import models -from ..publishing.model_mixins import ContainerMixin, ContainerVersionMixin +from ..publishing.models import Container, ContainerVersion __all__ = [ "Unit", @@ -11,21 +11,40 @@ ] -class Unit(ContainerMixin): +class Unit(Container): """ - A Unit is Container, which is a PublishableEntity. + 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 = models.OneToOneField( + Container, + on_delete=models.CASCADE, + parent_link=True, + primary_key=True, + ) -class UnitVersion(ContainerVersionMixin): - """ - A UnitVersion is a ContainerVersion, which is a PublishableEntityVersion. +class UnitVersion(ContainerVersion): """ + A UnitVersion is a specific version of a Unit. - # Not sure what other metadata goes here, but we want to try to separate things - # like scheduling information and such into different models. - unit = models.ForeignKey( - 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, - related_name="versions", + 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' diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index c063876d..52830206 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -83,34 +83,48 @@ def test_get_unit(self): Test get_unit() """ unit = self.create_unit_with_components([self.component_1, self.component_2]) - result = authoring_api.get_unit(unit.pk) + with self.assertNumQueries(1): + result = authoring_api.get_unit(unit.pk) assert result == unit # Versioning data should be pre-loaded via select_related() with self.assertNumQueries(0): assert result.versioning.has_unpublished_changes - # TODO: (maybe) This currently has extra queries and is not preloaded even though it's the same: - # with self.assertNumQueries(0): - # assert result.container.versioning.has_unpublished_changes def test_get_container(self): """ Test get_container() """ unit = self.create_unit_with_components([self.component_1, self.component_2]) - result = authoring_api.get_container(unit.container_id) + with self.assertNumQueries(1): + result = authoring_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_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(28): + with self.assertNumQueries(22): _empty_unit = self.create_unit_with_components([]) - with self.assertNumQueries(31): + with self.assertNumQueries(25): # And try with a non-empty unit: self.create_unit_with_components([self.component_1, self.component_2_v1], key="u2") @@ -127,6 +141,7 @@ def test_create_unit_with_invalid_children(self): created=self.now, created_by=None, ) + assert unit.versioning.draft == unit_version unit2, _u2v1 = authoring_api.create_unit_and_version( learning_package_id=self.learning_package.id, key="unit:key2", @@ -144,6 +159,8 @@ def test_create_unit_with_invalid_children(self): created_by=None, ) # Check that a new version was not created: + unit.refresh_from_db() + assert authoring_api.get_unit(unit.pk).versioning.draft == unit_version assert unit.versioning.draft == unit_version def test_adding_external_components(self): @@ -272,7 +289,7 @@ def test_auto_publish_children(self): # 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 authoring_api.contains_unpublished_changes(unit) + assert authoring_api.contains_unpublished_changes(unit.pk) assert self.component_1.versioning.published is None assert self.component_2.versioning.published is None @@ -286,7 +303,7 @@ def test_auto_publish_children(self): 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 authoring_api.contains_unpublished_changes(unit) is False # Deep check + assert authoring_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: @@ -331,7 +348,7 @@ def test_add_component_after_publish(self): authoring_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 authoring_api.contains_unpublished_changes(unit) is False # Deeper check + assert authoring_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 @@ -345,7 +362,7 @@ def test_add_component_after_publish(self): # 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 authoring_api.contains_unpublished_changes(unit) # Deeper check + assert authoring_api.contains_unpublished_changes(unit.pk) # Deeper check assert unit.versioning.draft == unit_version_v2 assert unit.versioning.published == unit_version @@ -366,7 +383,7 @@ def test_modify_unpinned_component_after_publish(self): 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 authoring_api.contains_unpublished_changes(unit) is False # Deeper check + assert authoring_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): @@ -376,7 +393,7 @@ def test_modify_unpinned_component_after_publish(self): 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 authoring_api.contains_unpublished_changes(unit) # But unit DOES contain changes + assert authoring_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 @@ -395,7 +412,7 @@ def test_modify_unpinned_component_after_publish(self): assert authoring_api.get_components_in_published_unit(unit) == [ Entry(component_1_v2), # new version ] - assert authoring_api.contains_unpublished_changes(unit) is False # No longer contains unpublished changes + assert authoring_api.contains_unpublished_changes(unit.pk) is False # No longer contains unpublished changes def test_modify_pinned_component(self): """ @@ -420,7 +437,7 @@ def test_modify_pinned_component(self): 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 authoring_api.contains_unpublished_changes(unit) is False # Deep check + assert authoring_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 @@ -496,14 +513,14 @@ def test_publishing_shared_component(self): 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") authoring_api.publish_all_drafts(self.learning_package.id) - assert authoring_api.contains_unpublished_changes(unit1) is False - assert authoring_api.contains_unpublished_changes(unit2) is False + assert authoring_api.contains_unpublished_changes(unit1.pk) is False + assert authoring_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 authoring_api.contains_unpublished_changes(unit1) - assert authoring_api.contains_unpublished_changes(unit2) + assert authoring_api.contains_unpublished_changes(unit1.pk) + assert authoring_api.contains_unpublished_changes(unit2.pk) # (But the units themselves are unchanged:) unit1.refresh_from_db() unit2.refresh_from_db() @@ -539,8 +556,8 @@ def test_publishing_shared_component(self): ] # Result: Unit 2 CONTAINS unpublished changes because of the modified C5. Unit 1 doesn't contain unpub changes. - assert authoring_api.contains_unpublished_changes(unit1) is False - assert authoring_api.contains_unpublished_changes(unit2) + assert authoring_api.contains_unpublished_changes(unit1.pk) is False + assert authoring_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) @@ -550,7 +567,7 @@ def test_publishing_shared_component(self): Entry(c4_v1), # still original version of C4 (it was never modified) Entry(c5_v2), # new published version of C5 ] - assert authoring_api.contains_unpublished_changes(unit2) is False + assert authoring_api.contains_unpublished_changes(unit2.pk) is False def test_query_count_of_contains_unpublished_changes(self): """ @@ -570,12 +587,12 @@ def test_query_count_of_contains_unpublished_changes(self): authoring_api.publish_all_drafts(self.learning_package.id) unit.refresh_from_db() with self.assertNumQueries(2): - assert authoring_api.contains_unpublished_changes(unit) is False + assert authoring_api.contains_unpublished_changes(unit.pk) is False # Modify the most recently created component: self.modify_component(component, title="Modified Component") with self.assertNumQueries(2): - assert authoring_api.contains_unpublished_changes(unit) is True + assert authoring_api.contains_unpublished_changes(unit.pk) is True def test_metadata_change_doesnt_create_entity_list(self): """ @@ -585,14 +602,14 @@ def test_metadata_change_doesnt_create_entity_list(self): """ unit = self.create_unit_with_components([self.component_1, self.component_2_v1]) - orig_version_num = unit.container.versioning.draft.version_num - orig_entity_list_id = unit.container.versioning.draft.entity_list.pk + orig_version_num = unit.versioning.draft.version_num + orig_entity_list_id = unit.versioning.draft.entity_list.pk authoring_api.create_next_unit_version(unit, title="New Title", created=self.now) unit.refresh_from_db() - new_version_num = unit.container.versioning.draft.version_num - new_entity_list_id = unit.container.versioning.draft.entity_list.pk + 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 @@ -636,7 +653,7 @@ def test_removing_component(self): ] unit.refresh_from_db() assert unit.versioning.has_unpublished_changes # The unit itself and its component list have change - assert authoring_api.contains_unpublished_changes(unit) + assert authoring_api.contains_unpublished_changes(unit.pk) # The published version of the unit is not yet affected: assert authoring_api.get_components_in_published_unit(unit) == [ Entry(self.component_1_v1), @@ -650,7 +667,7 @@ def test_removing_component(self): # 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 authoring_api.contains_unpublished_changes(unit) is False + assert authoring_api.contains_unpublished_changes(unit.pk) is False assert authoring_api.get_components_in_published_unit(unit) == [ Entry(self.component_1_v1), ] @@ -672,7 +689,7 @@ def test_soft_deleting_component(self): # reverted? ] assert unit.versioning.has_unpublished_changes is False # The unit itself and its component list is not changed - assert authoring_api.contains_unpublished_changes(unit) # But it CONTAINS an unpublished change (a deletion) + assert authoring_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 authoring_api.get_components_in_published_unit(unit) == [ Entry(self.component_1_v1), @@ -681,7 +698,7 @@ def test_soft_deleting_component(self): # But when we publish the deletion, the published version is affected: authoring_api.publish_all_drafts(self.learning_package.id) - assert authoring_api.contains_unpublished_changes(unit) is False + assert authoring_api.contains_unpublished_changes(unit.pk) is False assert authoring_api.get_components_in_published_unit(unit) == [ Entry(self.component_1_v1), ] @@ -706,7 +723,7 @@ def test_soft_deleting_and_removing_component(self): Entry(self.component_1_v1), ] assert unit.versioning.has_unpublished_changes is True - assert authoring_api.contains_unpublished_changes(unit) + assert authoring_api.contains_unpublished_changes(unit.pk) # The published version of the unit is not yet affected: assert authoring_api.get_components_in_published_unit(unit) == [ Entry(self.component_1_v1), @@ -715,7 +732,7 @@ def test_soft_deleting_and_removing_component(self): # But when we publish the deletion, the published version is affected: authoring_api.publish_all_drafts(self.learning_package.id) - assert authoring_api.contains_unpublished_changes(unit) is False + assert authoring_api.contains_unpublished_changes(unit.pk) is False assert authoring_api.get_components_in_published_unit(unit) == [ Entry(self.component_1_v1), ] @@ -734,7 +751,7 @@ def test_soft_deleting_pinned_component(self): 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 authoring_api.contains_unpublished_changes(unit) is False # nor does it contain changes + assert authoring_api.contains_unpublished_changes(unit.pk) is False # nor does it contain changes # The published version of the unit is also not affected: assert authoring_api.get_components_in_published_unit(unit) == [ Entry(self.component_1_v1, pinned=True), From 84f3b5db9fb9752fbc97d5641515fb6168abf991 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 12 Mar 2025 10:38:16 -0700 Subject: [PATCH 19/27] fix: minor cleanups --- openedx_learning/api/authoring_models.py | 2 +- openedx_learning/apps/authoring/publishing/api.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/openedx_learning/api/authoring_models.py b/openedx_learning/api/authoring_models.py index 4d6109ff..47123c56 100644 --- a/openedx_learning/api/authoring_models.py +++ b/openedx_learning/api/authoring_models.py @@ -10,6 +10,6 @@ from ..apps.authoring.collections.models import * from ..apps.authoring.components.models import * from ..apps.authoring.contents.models import * -from ..apps.authoring.publishing.model_mixins.publishable_entity import * +from ..apps.authoring.publishing.model_mixins import * from ..apps.authoring.publishing.models import * from ..apps.authoring.units.models import * diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index d73dfbff..80cbddc2 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -891,8 +891,9 @@ def contains_unpublished_changes(container_id: int) -> bool: # We only care about children that are un-pinned, since published changes to pinned children don't matter entity_list = container.versioning.draft.entity_list - # TODO: This is a naive inefficient implementation but hopefully correct. - # Once we know it's correct and have a good test suite, then we can optimize. + # This is a naive and inefficient implementation but should be correct. + # TODO: Once we have expanded the containers system to support multiple levels (not just Units and Components but + # also subsections and sections) and we have an expanded test suite for correctness, then we can optimize. # We will likely change to a tracking-based approach rather than a "scan for changes" based approach. for row in entity_list.entitylistrow_set.filter(entity_version=None).select_related( "entity__container", @@ -934,6 +935,7 @@ def get_containers_with_entity( """ if ignore_pinned: qs = Container.objects.filter( + # Note: these two conditions must be in the same filter() call, or the query won't be correct. publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_id=publishable_entity_pk, # pylint: disable=line-too-long # noqa: E501 publishable_entity__draft__version__containerversion__entity_list__entitylistrow__entity_version_id=None, # pylint: disable=line-too-long # noqa: E501 ).order_by("pk") # Ordering is mostly for consistent test cases. From 2f04aca9e421eca3d5571c47820f67378435fe7f Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 12 Mar 2025 10:41:14 -0700 Subject: [PATCH 20/27] feat: consolidate get_enitities_in_[published|draft]_container APIs --- .../apps/authoring/publishing/api.py | 60 +++++++-------- openedx_learning/apps/authoring/units/api.py | 43 ++++------- .../apps/authoring/units/test_api.py | 74 +++++++++---------- 3 files changed, 80 insertions(+), 97 deletions(-) diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index 80cbddc2..d1409c7b 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -67,8 +67,7 @@ "create_next_container_version", "get_container", "ContainerEntityListEntry", - "get_entities_in_draft_container", - "get_entities_in_published_container", + "get_entities_in_container", "contains_unpublished_changes", "get_containers_with_entity", ] @@ -821,49 +820,46 @@ def entity(self): return self.entity_version.entity -def get_entities_in_draft_container( +def get_entities_in_container( container: Container, -) -> list[ContainerEntityListEntry]: + *, + published: bool, +) -> list[ContainerEntityListEntry] | None: """ [ ๐Ÿ›‘ UNSTABLE ] - Get the list of entities and their versions in the draft version of the - given container. - """ - assert isinstance(container, Container) - entity_list = [] - for row in container.versioning.draft.entity_list.entitylistrow_set.order_by("order_num"): - entity_version = row.entity_version or row.entity.draft.version - if entity_version is not None: # As long as this hasn't been soft-deleted: - entity_list.append(ContainerEntityListEntry( - entity_version=entity_version, - pinned=row.entity_version is not None, - )) - # else should we indicate somehow a deleted item was here? - return entity_list + Get the list of entities and their versions in the current draft or + published version of the given container. + Returns `None` if you request the published version and it hasn't been + published yet. -def get_entities_in_published_container( - container: Container, -) -> list[ContainerEntityListEntry] | None: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Get the list of entities and their versions in the published version of the - given container. + Args: + container: The Container, e.g. returned by `get_container()` + published: `True` if we want the published version of the container, or + `False` for the draft version. """ assert isinstance(container, Container) - cv = container.versioning.published - if cv is None: - return None # There is no published version of this container. Should this be an exception? - assert isinstance(cv, ContainerVersion) + if published: + container_version = container.versioning.published + if container_version is None: + return None # There is no published version of this container (yet). Should this be an exception? + else: + container_version = container.versioning.draft + if container_version is None: + raise ContainerVersion.DoesNotExist # This container has been deleted. + assert isinstance(container_version, ContainerVersion) entity_list = [] - for row in cv.entity_list.entitylistrow_set.order_by("order_num"): - entity_version = row.entity_version or row.entity.published.version + for row in container_version.entity_list.entitylistrow_set.order_by("order_num"): + entity_version = row.entity_version # This will be set if pinned + if not entity_version: # If this entity is "unpinned", use the latest published/draft version: + entity_version = row.entity.published.version if published else row.entity.draft.version if entity_version is not None: # As long as this hasn't been soft-deleted: entity_list.append(ContainerEntityListEntry( entity_version=entity_version, pinned=row.entity_version is not None, )) - # else should we indicate somehow a deleted item was here? + # 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 diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index d007a57f..46e3e90c 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -23,8 +23,8 @@ "get_unit_version", "get_latest_unit_version", "UnitListEntry", - "get_components_in_draft_unit", - "get_components_in_published_unit", + "get_components_in_unit", + "get_components_in_unit", "get_components_in_published_unit_as_of", ] @@ -225,40 +225,27 @@ def component(self): return self.component_version.component -def get_components_in_draft_unit( - unit: Unit, -) -> list[UnitListEntry]: - """ - [ ๐Ÿ›‘ UNSTABLE ] - Get the list of entities and their versions in the draft version of the - given container. - """ - assert isinstance(unit, Unit) - entity_list = [] - for entry in publishing_api.get_entities_in_draft_container(unit): - # Convert from generic PublishableEntityVersion to ComponentVersion: - component_version = entry.entity_version.componentversion - assert isinstance(component_version, ComponentVersion) - entity_list.append(UnitListEntry(component_version=component_version, pinned=entry.pinned)) - return entity_list - - -def get_components_in_published_unit( +def get_components_in_unit( unit: Unit, + *, + published: bool, ) -> list[UnitListEntry] | None: """ [ ๐Ÿ›‘ UNSTABLE ] - Get the list of entities and their versions in the published version of the - given container. + Get the list of entities and their versions in the draft or published + version of the given Unit. - Returns None if the unit was never published (TODO: should it throw instead?). + 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) - published_entities = publishing_api.get_entities_in_published_container(unit) - if published_entities is None: - return None # There is no published version of this unit. Should this be an exception? entity_list = [] - for entry in published_entities: + entries = publishing_api.get_entities_in_container(unit, published=published) + if entries is None: + return None # There is no published version of this unit. Should this be an exception? + for entry in entries: # Convert from generic PublishableEntityVersion to ComponentVersion: component_version = entry.entity_version.componentversion assert isinstance(component_version, ComponentVersion) diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index 52830206..2ef3e2c3 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -248,11 +248,11 @@ def test_create_next_unit_version_with_two_unpinned_components(self): ) assert unit_version_v2.version_num == 2 assert unit_version_v2 in unit.versioning.versions.all() - assert authoring_api.get_components_in_draft_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=False) == [ Entry(self.component_1.versioning.draft), Entry(self.component_2.versioning.draft), ] - assert authoring_api.get_components_in_published_unit(unit) is None + assert authoring_api.get_components_in_unit(unit, published=True) is None def test_create_next_unit_version_with_unpinned_and_pinned_components(self): """ @@ -274,11 +274,11 @@ def test_create_next_unit_version_with_unpinned_and_pinned_components(self): ) assert unit_version_v2.version_num == 2 assert unit_version_v2 in unit.versioning.versions.all() - assert authoring_api.get_components_in_draft_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=False) == [ Entry(self.component_1_v1), Entry(self.component_2_v1, pinned=True), # Pinned ๐Ÿ“Œ to v1 ] - assert authoring_api.get_components_in_published_unit(unit) is None + assert authoring_api.get_components_in_unit(unit, published=True) is None def test_auto_publish_children(self): """ @@ -327,7 +327,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 - assert authoring_api.get_components_in_published_unit(unit) is None + assert authoring_api.get_components_in_unit(unit, published=True) is None def test_add_component_after_publish(self): """ @@ -397,19 +397,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 authoring_api.get_components_in_draft_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=False) == [ Entry(component_1_v2), # new version ] - assert authoring_api.get_components_in_published_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=True) == [ Entry(self.component_1_v1), # old version ] # But if we publish the component, the changes will appear in the published version of the unit. self.publish_component(self.component_1) - assert authoring_api.get_components_in_draft_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=False) == [ Entry(component_1_v2), # new version ] - assert authoring_api.get_components_in_published_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=True) == [ Entry(component_1_v2), # new version ] assert authoring_api.contains_unpublished_changes(unit.pk) is False # No longer contains unpublished changes @@ -428,7 +428,7 @@ def test_modify_pinned_component(self): expected_unit_contents = [ Entry(self.component_1_v1, pinned=True), # pinned ๐Ÿ“Œ to v1 ] - assert authoring_api.get_components_in_published_unit(unit) == expected_unit_contents + assert authoring_api.get_components_in_unit(unit, published=True) == expected_unit_contents # Now modify the component by changing its title (it remains a draft): self.modify_component(self.component_1, title="Modified Counting Problem with new title") @@ -441,12 +441,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 authoring_api.get_components_in_draft_unit(unit) == expected_unit_contents - assert authoring_api.get_components_in_published_unit(unit) == expected_unit_contents + assert authoring_api.get_components_in_unit(unit, published=False) == expected_unit_contents + assert authoring_api.get_components_in_unit(unit, published=True) == expected_unit_contents # Even if we publish the component, the unit stays pinned to the specified version: self.publish_component(self.component_1) - assert authoring_api.get_components_in_draft_unit(unit) == expected_unit_contents - assert authoring_api.get_components_in_published_unit(unit) == expected_unit_contents + assert authoring_api.get_components_in_unit(unit, published=False) == expected_unit_contents + assert authoring_api.get_components_in_unit(unit, published=True) == expected_unit_contents def test_create_two_units_with_same_components(self): """ @@ -459,10 +459,10 @@ def test_create_two_units_with_same_components(self): unit2 = self.create_unit_with_components([self.component_1_v1, self.component_2, self.component_1], key="u2") # Check that the contents are as expected: - assert [row.component_version for row in authoring_api.get_components_in_draft_unit(unit1)] == [ + assert [row.component_version for row in authoring_api.get_components_in_unit(unit1, published=False)] == [ self.component_2_v1, self.component_2_v1, self.component_1_v1, ] - assert [row.component_version for row in authoring_api.get_components_in_draft_unit(unit2)] == [ + assert [row.component_version for row in authoring_api.get_components_in_unit(unit2, published=False)] == [ self.component_1_v1, self.component_2_v1, self.component_1_v1, ] @@ -474,24 +474,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 authoring_api.get_components_in_draft_unit(unit1) == [ + assert authoring_api.get_components_in_unit(unit1, published=False) == [ Entry(component_2_v2), # v2 in the draft version Entry(self.component_2_v1, pinned=True), # pinned ๐Ÿ“Œ to v1 Entry(component_1_v2), # v2 ] - assert authoring_api.get_components_in_draft_unit(unit2) == [ + assert authoring_api.get_components_in_unit(unit2, published=False) == [ Entry(self.component_1_v1, pinned=True), # pinned ๐Ÿ“Œ to v1 Entry(component_2_v2), # v2 in the draft version Entry(component_1_v2), # v2 ] # Check that the published contents are as expected: - assert authoring_api.get_components_in_published_unit(unit1) == [ + assert authoring_api.get_components_in_unit(unit1, published=True) == [ Entry(self.component_2_v1), # v1 in the published version Entry(self.component_2_v1, pinned=True), # pinned ๐Ÿ“Œ to v1 Entry(component_1_v2), # v2 ] - assert authoring_api.get_components_in_published_unit(unit2) == [ + assert authoring_api.get_components_in_unit(unit2, published=True) == [ Entry(self.component_1_v1, pinned=True), # pinned ๐Ÿ“Œ to v1 Entry(self.component_2_v1), # v1 in the published version Entry(component_1_v2), # v2 @@ -540,7 +540,7 @@ def test_publishing_shared_component(self): ) # Result: Unit 1 will show the newly published version of C2: - assert authoring_api.get_components_in_published_unit(unit1) == [ + assert authoring_api.get_components_in_unit(unit1, published=True) == [ Entry(c1_v1), Entry(c2_v2), # new published version of C2 Entry(c3_v1), @@ -549,7 +549,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 authoring_api.get_components_in_published_unit(unit2) == [ + assert authoring_api.get_components_in_unit(unit2, published=True) == [ Entry(c2_v2), # new published version of C2 Entry(c4_v1), # still original version of C4 (it was never modified) Entry(c5_v1), # still original version of C5 (it hasn't been published) @@ -562,7 +562,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 authoring_api.get_components_in_published_unit(unit2) == [ + assert authoring_api.get_components_in_unit(unit2, published=True) == [ Entry(c2_v2), # new published version of C2 Entry(c4_v1), # still original version of C4 (it was never modified) Entry(c5_v2), # new published version of C5 @@ -648,14 +648,14 @@ def test_removing_component(self): ) # Now it should not be listed in the unit: - assert authoring_api.get_components_in_draft_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=False) == [ Entry(self.component_1_v1), ] unit.refresh_from_db() assert unit.versioning.has_unpublished_changes # The unit itself and its component list have change assert authoring_api.contains_unpublished_changes(unit.pk) # The published version of the unit is not yet affected: - assert authoring_api.get_components_in_published_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=True) == [ Entry(self.component_1_v1), Entry(self.component_2_v1), ] @@ -668,7 +668,7 @@ def test_removing_component(self): # but that would involve additional database lookup(s). unit.refresh_from_db() assert authoring_api.contains_unpublished_changes(unit.pk) is False - assert authoring_api.get_components_in_published_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=True) == [ Entry(self.component_1_v1), ] @@ -681,7 +681,7 @@ def test_soft_deleting_component(self): authoring_api.soft_delete_draft(self.component_2.pk) # Now it should not be listed in the unit: - assert authoring_api.get_components_in_draft_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=False) == [ Entry(self.component_1_v1), # component 2 is soft deleted from the draft. # TODO: should we return some kind of placeholder here, to indicate that a component is still listed in the @@ -691,7 +691,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 authoring_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 authoring_api.get_components_in_published_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=True) == [ Entry(self.component_1_v1), Entry(self.component_2_v1), ] @@ -699,7 +699,7 @@ def test_soft_deleting_component(self): # But when we publish the deletion, the published version is affected: authoring_api.publish_all_drafts(self.learning_package.id) assert authoring_api.contains_unpublished_changes(unit.pk) is False - assert authoring_api.get_components_in_published_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=True) == [ Entry(self.component_1_v1), ] @@ -719,13 +719,13 @@ def test_soft_deleting_and_removing_component(self): ) # Now it should not be listed in the unit: - assert authoring_api.get_components_in_draft_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=False) == [ Entry(self.component_1_v1), ] assert unit.versioning.has_unpublished_changes is True assert authoring_api.contains_unpublished_changes(unit.pk) # The published version of the unit is not yet affected: - assert authoring_api.get_components_in_published_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=True) == [ Entry(self.component_1_v1), Entry(self.component_2_v1), ] @@ -733,7 +733,7 @@ def test_soft_deleting_and_removing_component(self): # But when we publish the deletion, the published version is affected: authoring_api.publish_all_drafts(self.learning_package.id) assert authoring_api.contains_unpublished_changes(unit.pk) is False - assert authoring_api.get_components_in_published_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=True) == [ Entry(self.component_1_v1), ] @@ -746,14 +746,14 @@ def test_soft_deleting_pinned_component(self): authoring_api.soft_delete_draft(self.component_2.pk) # Now it should still be listed in the unit: - assert authoring_api.get_components_in_draft_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=False) == [ Entry(self.component_1_v1, pinned=True), Entry(self.component_2_v1, pinned=True), ] assert unit.versioning.has_unpublished_changes is False # The unit itself and its component list is not changed assert authoring_api.contains_unpublished_changes(unit.pk) is False # nor does it contain changes # The published version of the unit is also not affected: - assert authoring_api.get_components_in_published_unit(unit) == [ + assert authoring_api.get_components_in_unit(unit, published=True) == [ Entry(self.component_1_v1, pinned=True), Entry(self.component_2_v1, pinned=True), ] @@ -778,7 +778,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 authoring_api.get_components_in_draft_unit(other_unit) == [Entry(self.component_1_v1)] + assert authoring_api.get_components_in_unit(other_unit, published=False) == [Entry(self.component_1_v1)] # Publish everything: authoring_api.publish_all_drafts(self.learning_package.id) @@ -789,8 +789,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 authoring_api.get_components_in_draft_unit(other_unit) == [Entry(self.component_1_v1)] - assert authoring_api.get_components_in_published_unit(other_unit) == [Entry(self.component_1_v1)] + assert authoring_api.get_components_in_unit(other_unit, published=False) == [Entry(self.component_1_v1)] + assert authoring_api.get_components_in_unit(other_unit, published=True) == [Entry(self.component_1_v1)] def test_snapshots_of_published_unit(self): """ From 1ae08af29edf1919a5d9ecf308b266398e7beb3a Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 12 Mar 2025 11:01:51 -0700 Subject: [PATCH 21/27] refactor: consolidate some repetition --- .../apps/authoring/publishing/api.py | 102 +++++++++++------- 1 file changed, 64 insertions(+), 38 deletions(-) diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index d1409c7b..c600571a 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -621,6 +621,8 @@ def create_entity_list() -> EntityList: def create_entity_list_with_rows( entity_pks: list[int], entity_version_pks: list[int | None], + *, + learning_package_id: int | None, ) -> EntityList: """ [ ๐Ÿ›‘ UNSTABLE ] @@ -631,12 +633,24 @@ def create_entity_list_with_rows( entity_version_pks: The IDs of the versions of the entities (PublishableEntityVersion) that the entity list rows reference, or Nones for "unpinned" (default). + learning_package_id: Optional. Verify that all the entities are from + the specified learning package. Returns: The newly created entity list. """ + # Do a quick check that the given entities are in the right learning package: + if learning_package_id: + if PublishableEntity.objects.filter( + pk__in=entity_pks, + ).exclude( + learning_package_id=learning_package_id, + ).exists(): + raise ValidationError("Container entities must be from the same learning package.") + order_nums = range(len(entity_pks)) with atomic(savepoint=False): + entity_list = create_entity_list() EntityListRow.objects.bulk_create( [ @@ -654,8 +668,40 @@ def create_entity_list_with_rows( return entity_list +def _create_container_version( + container: Container, + version_num: int, + *, + title: str, + entity_list: EntityList, + created: datetime, + created_by: int | None, + container_version_model: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment] +) -> ContainerVersionModel: + """ + Private internal method for logic shared by create_container_version() and + create_next_container_version(). + """ + assert issubclass(container_version_model, ContainerVersion) + with atomic(savepoint=False): # Make sure this will happen atomically but we don't need to create a new savepoint. + publishable_entity_version = create_publishable_entity_version( + container.publishable_entity_id, + version_num=version_num, + title=title, + created=created, + created_by=created_by, + ) + container_version = container_version_model.objects.create( + publishable_entity_version=publishable_entity_version, + container_id=container.pk, + entity_list=entity_list, + ) + + return container_version + + def create_container_version( - container_pk: int, + container_id: int, version_num: int, *, title: str, @@ -670,7 +716,7 @@ def create_container_version( Create a new container version. Args: - container_pk: The ID of the container that the version belongs to. + 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. publishable_entities_pks: The IDs of the members of the container. @@ -682,38 +728,27 @@ def create_container_version( Returns: The newly created container version. """ - assert issubclass(container_version_model, ContainerVersion) + assert title is not None + assert publishable_entities_pks is not None + with atomic(savepoint=False): - container = Container.objects.select_related("publishable_entity").get(pk=container_pk) + container = Container.objects.select_related("publishable_entity").get(pk=container_id) entity = container.publishable_entity - - # Do a quick check that the given entities are in the right learning package: - if PublishableEntity.objects.filter( - pk__in=publishable_entities_pks, - ).exclude( - learning_package_id=entity.learning_package_id, - ).exists(): - raise ValidationError("Container entities must be from the same learning package.") - - assert title is not None - assert publishable_entities_pks is not None if entity_version_pks is None: entity_version_pks = [None] * len(publishable_entities_pks) entity_list = create_entity_list_with_rows( entity_pks=publishable_entities_pks, entity_version_pks=entity_version_pks, + learning_package_id=entity.learning_package_id, ) - publishable_entity_version = create_publishable_entity_version( - entity.pk, - version_num=version_num, + container_version = _create_container_version( + container, + version_num, title=title, + entity_list=entity_list, created=created, created_by=created_by, - ) - container_version = container_version_model.objects.create( - publishable_entity_version=publishable_entity_version, - container_id=container_pk, - entity_list=entity_list, + container_version_model=container_version_model, ) return container_version @@ -763,30 +798,21 @@ def create_next_container_version( # We're only changing metadata. Keep the same entity list. next_entity_list = last_version.entity_list else: - # Do a quick check that the given entities are in the right learning package: - if PublishableEntity.objects.filter( - pk__in=publishable_entities_pks, - ).exclude( - learning_package_id=entity.learning_package_id, - ).exists(): - raise ValidationError("Container entities must be from the same learning package.") if entity_version_pks is None: entity_version_pks = [None] * len(publishable_entities_pks) next_entity_list = create_entity_list_with_rows( entity_pks=publishable_entities_pks, entity_version_pks=entity_version_pks, + learning_package_id=entity.learning_package_id, ) - publishable_entity_version = create_publishable_entity_version( - entity.pk, - version_num=next_version_num, + next_container_version = _create_container_version( + container, + next_version_num, title=title if title is not None else last_version.title, + entity_list=next_entity_list, created=created, created_by=created_by, - ) - next_container_version = container_version_model.objects.create( - publishable_entity_version=publishable_entity_version, - container_id=container_pk, - entity_list=next_entity_list, + container_version_model=container_version_model, ) return next_container_version From 466b44b1b2981765e4e787e761251393507743f0 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 12 Mar 2025 13:26:10 -0700 Subject: [PATCH 22/27] refactor: move model_mixins back where it was --- .../{model_mixins/publishable_entity.py => model_mixins.py} | 2 +- .../apps/authoring/publishing/model_mixins/__init__.py | 4 ---- .../apps/authoring/publishing/models/container.py | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) rename openedx_learning/apps/authoring/publishing/{model_mixins/publishable_entity.py => model_mixins.py} (99%) delete mode 100644 openedx_learning/apps/authoring/publishing/model_mixins/__init__.py diff --git a/openedx_learning/apps/authoring/publishing/model_mixins/publishable_entity.py b/openedx_learning/apps/authoring/publishing/model_mixins.py similarity index 99% rename from openedx_learning/apps/authoring/publishing/model_mixins/publishable_entity.py rename to openedx_learning/apps/authoring/publishing/model_mixins.py index 84360310..d41e556d 100644 --- a/openedx_learning/apps/authoring/publishing/model_mixins/publishable_entity.py +++ b/openedx_learning/apps/authoring/publishing/model_mixins.py @@ -12,7 +12,7 @@ from openedx_learning.lib.managers import WithRelationsManager -from ..models.publishable_entity import PublishableEntity, PublishableEntityVersion +from .models.publishable_entity import PublishableEntity, PublishableEntityVersion __all__ = [ "PublishableEntityMixin", diff --git a/openedx_learning/apps/authoring/publishing/model_mixins/__init__.py b/openedx_learning/apps/authoring/publishing/model_mixins/__init__.py deleted file mode 100644 index 14ea9315..00000000 --- a/openedx_learning/apps/authoring/publishing/model_mixins/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -""" -Mixins provided by the publishing app -""" -from .publishable_entity import * diff --git a/openedx_learning/apps/authoring/publishing/models/container.py b/openedx_learning/apps/authoring/publishing/models/container.py index 04152f71..84b5fa9f 100644 --- a/openedx_learning/apps/authoring/publishing/models/container.py +++ b/openedx_learning/apps/authoring/publishing/models/container.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from django.db import models -from ..model_mixins.publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin +from ..model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin from .entity_list import EntityList From e6334fb0a79598887e9945d85cd1d6a17c954da5 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 12 Mar 2025 13:34:01 -0700 Subject: [PATCH 23/27] fix: ensure order_num is unique on each entity list row --- .../authoring/publishing/migrations/0003_containers.py | 4 ++++ .../apps/authoring/publishing/models/entity_list.py | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/openedx_learning/apps/authoring/publishing/migrations/0003_containers.py b/openedx_learning/apps/authoring/publishing/migrations/0003_containers.py index 1f759144..55e96777 100644 --- a/openedx_learning/apps/authoring/publishing/migrations/0003_containers.py +++ b/openedx_learning/apps/authoring/publishing/migrations/0003_containers.py @@ -47,4 +47,8 @@ class Migration(migrations.Migration): 'abstract': False, }, ), + migrations.AddConstraint( + model_name='entitylistrow', + constraint=models.UniqueConstraint(fields=('entity_list', 'order_num'), name='oel_publishing_elist_row_order'), + ), ] diff --git a/openedx_learning/apps/authoring/publishing/models/entity_list.py b/openedx_learning/apps/authoring/publishing/models/entity_list.py index b30e3e9a..af7d0eca 100644 --- a/openedx_learning/apps/authoring/publishing/models/entity_list.py +++ b/openedx_learning/apps/authoring/publishing/models/entity_list.py @@ -58,3 +58,12 @@ class EntityListRow(models.Model): null=True, related_name="+", # Do we need the reverse relation? ) + + class Meta: + constraints = [ + # If (entity_list, order_num) is not unique, it likely indicates a race condition - so force uniqueness. + models.UniqueConstraint( + fields=["entity_list", "order_num"], + name="oel_publishing_elist_row_order", + ), + ] From dcb0a1ba03fa90a908cd70b91e38a117308af17c Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Wed, 12 Mar 2025 18:49:57 -0400 Subject: [PATCH 24/27] refactor: move mixins to publishable_entity.py to avoid circular imports --- openedx_learning/api/authoring_models.py | 1 - .../apps/authoring/components/models.py | 3 +- .../apps/authoring/publishing/api.py | 6 +- .../apps/authoring/publishing/model_mixins.py | 379 ------------------ .../authoring/publishing/models/__init__.py | 8 +- .../authoring/publishing/models/container.py | 2 +- .../publishing/models/publishable_entity.py | 366 ++++++++++++++++- 7 files changed, 378 insertions(+), 387 deletions(-) delete mode 100644 openedx_learning/apps/authoring/publishing/model_mixins.py diff --git a/openedx_learning/api/authoring_models.py b/openedx_learning/api/authoring_models.py index 47123c56..2413fc2b 100644 --- a/openedx_learning/api/authoring_models.py +++ b/openedx_learning/api/authoring_models.py @@ -10,6 +10,5 @@ from ..apps.authoring.collections.models import * from ..apps.authoring.components.models import * from ..apps.authoring.contents.models import * -from ..apps.authoring.publishing.model_mixins import * from ..apps.authoring.publishing.models import * from ..apps.authoring.units.models import * diff --git a/openedx_learning/apps/authoring/components/models.py b/openedx_learning/apps/authoring/components/models.py index 5639e026..85af54d9 100644 --- a/openedx_learning/apps/authoring/components/models.py +++ b/openedx_learning/apps/authoring/components/models.py @@ -24,8 +24,7 @@ from ....lib.fields import case_sensitive_char_field, immutable_uuid_field, key_field from ....lib.managers import WithRelationsManager from ..contents.models import Content -from ..publishing.model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin -from ..publishing.models import LearningPackage +from ..publishing.models import LearningPackage, PublishableEntityMixin, PublishableEntityVersionMixin __all__ = [ "ComponentType", diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index c600571a..5c82c2f1 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -14,7 +14,6 @@ from django.db.models import F, Q, QuerySet from django.db.transaction import atomic -from .model_mixins import PublishableContentModelRegistry, PublishableEntityMixin, PublishableEntityVersionMixin from .models import ( Container, ContainerVersion, @@ -22,8 +21,11 @@ EntityList, EntityListRow, LearningPackage, + PublishableContentModelRegistry, PublishableEntity, + PublishableEntityMixin, PublishableEntityVersion, + PublishableEntityVersionMixin, Published, PublishLog, PublishLogRecord, @@ -520,7 +522,7 @@ def register_content_models( This is so that we can provide convenience links between content models and content version models *through* the publishing apps, so that you can do things like finding the draft version of a content model more easily. See - the model_mixins.py module for more details. + the publishable_entity.py module for more details. This should only be imported and run from the your app's AppConfig.ready() method. For example, in the components app, this looks like: diff --git a/openedx_learning/apps/authoring/publishing/model_mixins.py b/openedx_learning/apps/authoring/publishing/model_mixins.py deleted file mode 100644 index d41e556d..00000000 --- a/openedx_learning/apps/authoring/publishing/model_mixins.py +++ /dev/null @@ -1,379 +0,0 @@ -""" -Helper mixin classes for content apps that want to use the publishing app. -""" -from __future__ import annotations - -from datetime import datetime -from functools import cached_property -from typing import ClassVar, Self - -from django.core.exceptions import ImproperlyConfigured -from django.db import models - -from openedx_learning.lib.managers import WithRelationsManager - -from .models.publishable_entity import PublishableEntity, PublishableEntityVersion - -__all__ = [ - "PublishableEntityMixin", - "PublishableEntityVersionMixin", - "PublishableContentModelRegistry", -] - - -class PublishableEntityMixin(models.Model): - """ - Convenience mixin to link your models against PublishableEntity. - - Please see docstring for PublishableEntity for more details. - - If you use this class, you *MUST* also use PublishableEntityVersionMixin and - the publishing app's api.register_content_models (see its docstring for - details). - """ - # select these related entities by default for all queries - objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager( - "publishable_entity", - "publishable_entity__published", - "publishable_entity__draft", - ) - - publishable_entity = models.OneToOneField( - PublishableEntity, on_delete=models.CASCADE, primary_key=True - ) - - @cached_property - def versioning(self): - return self.VersioningHelper(self) - - @property - def uuid(self) -> str: - return self.publishable_entity.uuid - - @property - def key(self) -> str: - return self.publishable_entity.key - - @property - def created(self) -> datetime: - return self.publishable_entity.created - - @property - def created_by(self): - return self.publishable_entity.created_by - - class Meta: - abstract = True - - class VersioningHelper: - """ - Helper class to link content models to their versions. - - The publishing app has PublishableEntity and PublishableEntityVersion. - This is a helper class so that if you mix PublishableEntityMixin into - a content model like Component, then you can do something like:: - - component.versioning.draft # current draft ComponentVersion - component.versioning.published # current published ComponentVersion - - It links the relationships between content models and their versioned - counterparts *through* the connection between PublishableEntity and - PublishableEntityVersion. So ``component.versioning.draft`` ends up - querying: Component -> PublishableEntity -> Draft -> - PublishableEntityVersion -> ComponentVersion. But the people writing - Component don't need to understand how the publishing models work to do - these common queries. - - Caching Warning - --------------- - Note that because we're just using the underlying model's relations, - calling this a second time will returned the cached relation, and - not cause a fetch of new data from the database. So for instance, if - you do:: - - # Create a new Component + ComponentVersion - component, component_version = create_component_and_version( - learning_package_id=learning_package.id, - namespace="xblock.v1", - type="problem", - local_key="monty_hall", - title="Monty Hall Problem", - created=now, - created_by=None, - ) - - # This will work, because it's never been published - assert component.versioning.published is None - - # Publishing happens - publish_all_drafts(learning_package.id, published_at=now) - - # This will FAIL because it's going to use the relation value - # cached on component instead of going to the database again. - # You need to re-fetch the component for this to work. - assert component.versioning.published == component_version - - # You need to manually refetch it from the database to see the new - # publish status: - component = get_component(component.pk) - - # Now this will work: - assert component.versioning.published == component_version - - TODO: This probably means we should use a custom Manager to select - related fields. - """ - - def __init__(self, content_obj): - self.content_obj = content_obj - - self.content_version_model_cls = PublishableContentModelRegistry.get_versioned_model_cls( - type(content_obj) - ) - # Get the field that points from the *versioned* content model - # (e.g. ComponentVersion) to the PublishableEntityVersion. - field_to_pev = self.content_version_model_cls._meta.get_field( - "publishable_entity_version" - ) - # Now that we know the field that leads to PublishableEntityVersion, - # get the reverse related field name so that we can use that later. - self.related_name = field_to_pev.related_query_name() - - if field_to_pev.model != self.content_version_model_cls: - # In the case of multi-table inheritance and mixins, this can get tricky. - # Example: - # content_version_model_cls is UnitVersion, which is a subclass of ContainerVersion - # This versioning helper can be accessed via unit_version.versioning (should return UnitVersion) or - # via container_version.versioning (should return ContainerVersion) - intermediate_model = field_to_pev.model # example: ContainerVersion - # This is the field on the subclass (e.g. UnitVersion) that gets - # the intermediate (e.g. ContainerVersion). Example: "UnitVersion.container_version" (1:1 foreign key) - field_to_intermediate = self.content_version_model_cls._meta.get_ancestor_link(intermediate_model) - if field_to_intermediate: - # Example: self.related_name = "containerversion.unitversion" - self.related_name = self.related_name + "." + field_to_intermediate.related_query_name() - - def _content_obj_version(self, pub_ent_version: PublishableEntityVersion | None): - """ - PublishableEntityVersion -> Content object version - - Given a reference to a PublishableEntityVersion, return the version - of the content object that we've been mixed into. - """ - if pub_ent_version is None: - return None - obj = pub_ent_version - for field_name in self.related_name.split("."): - obj = getattr(obj, field_name) - return obj - - @property - def draft(self): - """ - Return the content version object that is the current draft. - - So if you mix ``PublishableEntityMixin`` into ``Component``, then - ``component.versioning.draft`` will return you the - ``ComponentVersion`` that is the current draft (not the underlying - ``PublishableEntityVersion``). - - If this is causing many queries, it might be the case that you need - to add ``select_related('publishable_entity__draft__version')`` to - the queryset. - """ - # Check if there's an entry in Drafts, i.e. has there ever been a - # draft version of this PublishableEntity? - if hasattr(self.content_obj.publishable_entity, 'draft'): - # This can still be None if a draft existed at one point, but - # was then "deleted". When deleting, the Draft row stays, but - # the version it points to becomes None. - draft_pub_ent_version = self.content_obj.publishable_entity.draft.version - else: - draft_pub_ent_version = None - - # The Draft.version points to a PublishableEntityVersion, so convert - # that over to the class we actually want (were mixed into), e.g. - # a ComponentVersion. - return self._content_obj_version(draft_pub_ent_version) - - @property - def latest(self): - """ - Return the most recently created version for this content object. - - This can be None if no versions have been created. - - This is often the same as the draft version, but can differ if the - content object was soft deleted or the draft was reverted. - """ - return self.versions.order_by('-publishable_entity_version__version_num').first() - - @property - def published(self): - """ - Return the content version object that is currently published. - - So if you mix ``PublishableEntityMixin`` into ``Component``, then - ``component.versioning.published`` will return you the - ``ComponentVersion`` that is currently published (not the underlying - ``PublishableEntityVersion``). - - If this is causing many queries, it might be the case that you need - to add ``select_related('publishable_entity__published__version')`` - to the queryset. - """ - # Check if there's an entry in Published, i.e. has there ever been a - # published version of this PublishableEntity? - if hasattr(self.content_obj.publishable_entity, 'published'): - # This can still be None if something was published and then - # later "deleted". When deleting, the Published row stays, but - # the version it points to becomes None. - published_pub_ent_version = self.content_obj.publishable_entity.published.version - else: - published_pub_ent_version = None - - # The Published.version points to a PublishableEntityVersion, so - # convert that over to the class we actually want (were mixed into), - # e.g. a ComponentVersion. - return self._content_obj_version(published_pub_ent_version) - - @property - def has_unpublished_changes(self): - """ - Do we have unpublished changes? - - The simplest way to implement this would be to check self.published - vs. self.draft, but that would cause unnecessary queries. This - implementation should require no extra queries provided that the - model was instantiated using a queryset that used a select related - that has at least ``publishable_entity__draft`` and - ``publishable_entity__published``. - """ - pub_entity = self.content_obj.publishable_entity - if hasattr(pub_entity, 'draft'): - draft_version_id = pub_entity.draft.version_id - else: - draft_version_id = None - if hasattr(pub_entity, 'published'): - published_version_id = pub_entity.published.version_id - else: - published_version_id = None - - return draft_version_id != published_version_id - - @property - def last_publish_log(self): - """ - Return the most recent PublishLog for this component. - - Return None if the component is not published. - """ - pub_entity = self.content_obj.publishable_entity - if hasattr(pub_entity, 'published'): - return pub_entity.published.publish_log_record.publish_log - return None - - @property - def versions(self): - """ - Return a QuerySet of content version models for this content model. - - Example: If you mix PublishableEntityMixin into a Component model, - This would return you a QuerySet of ComponentVersion models. - """ - pub_ent = self.content_obj.publishable_entity - return self.content_version_model_cls.objects.filter( - publishable_entity_version__entity_id=pub_ent.id - ) - - def version_num(self, version_num): - """ - Return a specific numbered version model. - """ - pub_ent = self.content_obj.publishable_entity - return self.content_version_model_cls.objects.get( - publishable_entity_version__entity_id=pub_ent.id, - publishable_entity_version__version_num=version_num, - ) - - -class PublishableEntityVersionMixin(models.Model): - """ - Convenience mixin to link your models against PublishableEntityVersion. - - Please see docstring for PublishableEntityVersion for more details. - - If you use this class, you *MUST* also use PublishableEntityMixin and the - publishing app's api.register_content_models (see its docstring for - details). - """ - - # select these related entities by default for all queries - objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager( - "publishable_entity_version", - ) - - publishable_entity_version = models.OneToOneField( - PublishableEntityVersion, on_delete=models.CASCADE, primary_key=True - ) - - @property - def uuid(self) -> str: - return self.publishable_entity_version.uuid - - @property - def title(self) -> str: - return self.publishable_entity_version.title - - @property - def created(self) -> datetime: - return self.publishable_entity_version.created - - @property - def version_num(self) -> int: - return self.publishable_entity_version.version_num - - class Meta: - abstract = True - - -class PublishableContentModelRegistry: - """ - This class tracks content models built on PublishableEntity(Version). - """ - - _unversioned_to_versioned: dict[type[PublishableEntityMixin], type[PublishableEntityVersionMixin]] = {} - _versioned_to_unversioned: dict[type[PublishableEntityVersionMixin], type[PublishableEntityMixin]] = {} - - @classmethod - def register( - cls, - content_model_cls: type[PublishableEntityMixin], - content_version_model_cls: type[PublishableEntityVersionMixin], - ): - """ - Register what content model maps to what content version model. - - If you want to call this from another app, please use the - ``register_content_models`` function in this app's ``api`` module - instead. - """ - if not issubclass(content_model_cls, PublishableEntityMixin): - raise ImproperlyConfigured( - f"{content_model_cls} must inherit from PublishableEntityMixin" - ) - if not issubclass(content_version_model_cls, PublishableEntityVersionMixin): - raise ImproperlyConfigured( - f"{content_version_model_cls} must inherit from PublishableEntityMixin" - ) - - cls._unversioned_to_versioned[content_model_cls] = content_version_model_cls - cls._versioned_to_unversioned[content_version_model_cls] = content_model_cls - - @classmethod - def get_versioned_model_cls(cls, content_model_cls): - return cls._unversioned_to_versioned[content_model_cls] - - @classmethod - def get_unversioned_model_cls(cls, content_version_model_cls): - return cls._versioned_to_unversioned[content_version_model_cls] diff --git a/openedx_learning/apps/authoring/publishing/models/__init__.py b/openedx_learning/apps/authoring/publishing/models/__init__.py index ee9a1b37..27329076 100644 --- a/openedx_learning/apps/authoring/publishing/models/__init__.py +++ b/openedx_learning/apps/authoring/publishing/models/__init__.py @@ -18,4 +18,10 @@ from .entity_list import EntityList, EntityListRow from .learning_package import LearningPackage from .publish_log import PublishLog, PublishLogRecord -from .publishable_entity import PublishableEntity, PublishableEntityVersion +from .publishable_entity import ( + PublishableContentModelRegistry, + PublishableEntity, + PublishableEntityMixin, + PublishableEntityVersion, + PublishableEntityVersionMixin, +) diff --git a/openedx_learning/apps/authoring/publishing/models/container.py b/openedx_learning/apps/authoring/publishing/models/container.py index 84b5fa9f..e34bb6a7 100644 --- a/openedx_learning/apps/authoring/publishing/models/container.py +++ b/openedx_learning/apps/authoring/publishing/models/container.py @@ -4,8 +4,8 @@ from django.core.exceptions import ValidationError from django.db import models -from ..model_mixins import PublishableEntityMixin, PublishableEntityVersionMixin from .entity_list import EntityList +from .publishable_entity import PublishableEntityMixin, PublishableEntityVersionMixin class Container(PublishableEntityMixin): diff --git a/openedx_learning/apps/authoring/publishing/models/publishable_entity.py b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py index 47d4ef97..e05172bf 100644 --- a/openedx_learning/apps/authoring/publishing/models/publishable_entity.py +++ b/openedx_learning/apps/authoring/publishing/models/publishable_entity.py @@ -1,7 +1,12 @@ """ -PublishableEntity model and PublishableEntityVersion +PublishableEntity model and PublishableEntityVersion + mixins """ +from datetime import datetime +from functools import cached_property +from typing import ClassVar, Self + from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.core.validators import MinValueValidator from django.db import models @@ -11,6 +16,7 @@ key_field, manual_date_time_field, ) +from openedx_learning.lib.managers import WithRelationsManager from .learning_package import LearningPackage @@ -249,3 +255,361 @@ class Meta: # These are for the Django Admin UI. verbose_name = "Publishable Entity Version" verbose_name_plural = "Publishable Entity Versions" + + +class PublishableEntityMixin(models.Model): + """ + Convenience mixin to link your models against PublishableEntity. + + Please see docstring for PublishableEntity for more details. + + If you use this class, you *MUST* also use PublishableEntityVersionMixin and + the publishing app's api.register_content_models (see its docstring for + details). + """ + # select these related entities by default for all queries + objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager( + "publishable_entity", + "publishable_entity__published", + "publishable_entity__draft", + ) + + publishable_entity = models.OneToOneField( + PublishableEntity, on_delete=models.CASCADE, primary_key=True + ) + + @cached_property + def versioning(self): + return self.VersioningHelper(self) + + @property + def uuid(self) -> str: + return self.publishable_entity.uuid + + @property + def key(self) -> str: + return self.publishable_entity.key + + @property + def created(self) -> datetime: + return self.publishable_entity.created + + @property + def created_by(self): + return self.publishable_entity.created_by + + class Meta: + abstract = True + + class VersioningHelper: + """ + Helper class to link content models to their versions. + + The publishing app has PublishableEntity and PublishableEntityVersion. + This is a helper class so that if you mix PublishableEntityMixin into + a content model like Component, then you can do something like:: + + component.versioning.draft # current draft ComponentVersion + component.versioning.published # current published ComponentVersion + + It links the relationships between content models and their versioned + counterparts *through* the connection between PublishableEntity and + PublishableEntityVersion. So ``component.versioning.draft`` ends up + querying: Component -> PublishableEntity -> Draft -> + PublishableEntityVersion -> ComponentVersion. But the people writing + Component don't need to understand how the publishing models work to do + these common queries. + + Caching Warning + --------------- + Note that because we're just using the underlying model's relations, + calling this a second time will returned the cached relation, and + not cause a fetch of new data from the database. So for instance, if + you do:: + + # Create a new Component + ComponentVersion + component, component_version = create_component_and_version( + learning_package_id=learning_package.id, + namespace="xblock.v1", + type="problem", + local_key="monty_hall", + title="Monty Hall Problem", + created=now, + created_by=None, + ) + + # This will work, because it's never been published + assert component.versioning.published is None + + # Publishing happens + publish_all_drafts(learning_package.id, published_at=now) + + # This will FAIL because it's going to use the relation value + # cached on component instead of going to the database again. + # You need to re-fetch the component for this to work. + assert component.versioning.published == component_version + + # You need to manually refetch it from the database to see the new + # publish status: + component = get_component(component.pk) + + # Now this will work: + assert component.versioning.published == component_version + + TODO: This probably means we should use a custom Manager to select + related fields. + """ + + def __init__(self, content_obj): + self.content_obj = content_obj + + self.content_version_model_cls = PublishableContentModelRegistry.get_versioned_model_cls( + type(content_obj) + ) + # Get the field that points from the *versioned* content model + # (e.g. ComponentVersion) to the PublishableEntityVersion. + field_to_pev = self.content_version_model_cls._meta.get_field( + "publishable_entity_version" + ) + # Now that we know the field that leads to PublishableEntityVersion, + # get the reverse related field name so that we can use that later. + self.related_name = field_to_pev.related_query_name() + + if field_to_pev.model != self.content_version_model_cls: + # In the case of multi-table inheritance and mixins, this can get tricky. + # Example: + # content_version_model_cls is UnitVersion, which is a subclass of ContainerVersion + # This versioning helper can be accessed via unit_version.versioning (should return UnitVersion) or + # via container_version.versioning (should return ContainerVersion) + intermediate_model = field_to_pev.model # example: ContainerVersion + # This is the field on the subclass (e.g. UnitVersion) that gets + # the intermediate (e.g. ContainerVersion). Example: "UnitVersion.container_version" (1:1 foreign key) + field_to_intermediate = self.content_version_model_cls._meta.get_ancestor_link(intermediate_model) + if field_to_intermediate: + # Example: self.related_name = "containerversion.unitversion" + self.related_name = self.related_name + "." + field_to_intermediate.related_query_name() + + def _content_obj_version(self, pub_ent_version: PublishableEntityVersion | None): + """ + PublishableEntityVersion -> Content object version + + Given a reference to a PublishableEntityVersion, return the version + of the content object that we've been mixed into. + """ + if pub_ent_version is None: + return None + obj = pub_ent_version + for field_name in self.related_name.split("."): + obj = getattr(obj, field_name) + return obj + + @property + def draft(self): + """ + Return the content version object that is the current draft. + + So if you mix ``PublishableEntityMixin`` into ``Component``, then + ``component.versioning.draft`` will return you the + ``ComponentVersion`` that is the current draft (not the underlying + ``PublishableEntityVersion``). + + If this is causing many queries, it might be the case that you need + to add ``select_related('publishable_entity__draft__version')`` to + the queryset. + """ + # Check if there's an entry in Drafts, i.e. has there ever been a + # draft version of this PublishableEntity? + if hasattr(self.content_obj.publishable_entity, 'draft'): + # This can still be None if a draft existed at one point, but + # was then "deleted". When deleting, the Draft row stays, but + # the version it points to becomes None. + draft_pub_ent_version = self.content_obj.publishable_entity.draft.version + else: + draft_pub_ent_version = None + + # The Draft.version points to a PublishableEntityVersion, so convert + # that over to the class we actually want (were mixed into), e.g. + # a ComponentVersion. + return self._content_obj_version(draft_pub_ent_version) + + @property + def latest(self): + """ + Return the most recently created version for this content object. + + This can be None if no versions have been created. + + This is often the same as the draft version, but can differ if the + content object was soft deleted or the draft was reverted. + """ + return self.versions.order_by('-publishable_entity_version__version_num').first() + + @property + def published(self): + """ + Return the content version object that is currently published. + + So if you mix ``PublishableEntityMixin`` into ``Component``, then + ``component.versioning.published`` will return you the + ``ComponentVersion`` that is currently published (not the underlying + ``PublishableEntityVersion``). + + If this is causing many queries, it might be the case that you need + to add ``select_related('publishable_entity__published__version')`` + to the queryset. + """ + # Check if there's an entry in Published, i.e. has there ever been a + # published version of this PublishableEntity? + if hasattr(self.content_obj.publishable_entity, 'published'): + # This can still be None if something was published and then + # later "deleted". When deleting, the Published row stays, but + # the version it points to becomes None. + published_pub_ent_version = self.content_obj.publishable_entity.published.version + else: + published_pub_ent_version = None + + # The Published.version points to a PublishableEntityVersion, so + # convert that over to the class we actually want (were mixed into), + # e.g. a ComponentVersion. + return self._content_obj_version(published_pub_ent_version) + + @property + def has_unpublished_changes(self): + """ + Do we have unpublished changes? + + The simplest way to implement this would be to check self.published + vs. self.draft, but that would cause unnecessary queries. This + implementation should require no extra queries provided that the + model was instantiated using a queryset that used a select related + that has at least ``publishable_entity__draft`` and + ``publishable_entity__published``. + """ + pub_entity = self.content_obj.publishable_entity + if hasattr(pub_entity, 'draft'): + draft_version_id = pub_entity.draft.version_id + else: + draft_version_id = None + if hasattr(pub_entity, 'published'): + published_version_id = pub_entity.published.version_id + else: + published_version_id = None + + return draft_version_id != published_version_id + + @property + def last_publish_log(self): + """ + Return the most recent PublishLog for this component. + + Return None if the component is not published. + """ + pub_entity = self.content_obj.publishable_entity + if hasattr(pub_entity, 'published'): + return pub_entity.published.publish_log_record.publish_log + return None + + @property + def versions(self): + """ + Return a QuerySet of content version models for this content model. + + Example: If you mix PublishableEntityMixin into a Component model, + This would return you a QuerySet of ComponentVersion models. + """ + pub_ent = self.content_obj.publishable_entity + return self.content_version_model_cls.objects.filter( + publishable_entity_version__entity_id=pub_ent.id + ) + + def version_num(self, version_num): + """ + Return a specific numbered version model. + """ + pub_ent = self.content_obj.publishable_entity + return self.content_version_model_cls.objects.get( + publishable_entity_version__entity_id=pub_ent.id, + publishable_entity_version__version_num=version_num, + ) + + +class PublishableEntityVersionMixin(models.Model): + """ + Convenience mixin to link your models against PublishableEntityVersion. + + Please see docstring for PublishableEntityVersion for more details. + + If you use this class, you *MUST* also use PublishableEntityMixin and the + publishing app's api.register_content_models (see its docstring for + details). + """ + + # select these related entities by default for all queries + objects: ClassVar[WithRelationsManager[Self]] = WithRelationsManager( + "publishable_entity_version", + ) + + publishable_entity_version = models.OneToOneField( + PublishableEntityVersion, on_delete=models.CASCADE, primary_key=True + ) + + @property + def uuid(self) -> str: + return self.publishable_entity_version.uuid + + @property + def title(self) -> str: + return self.publishable_entity_version.title + + @property + def created(self) -> datetime: + return self.publishable_entity_version.created + + @property + def version_num(self) -> int: + return self.publishable_entity_version.version_num + + class Meta: + abstract = True + + +class PublishableContentModelRegistry: + """ + This class tracks content models built on PublishableEntity(Version). + """ + + _unversioned_to_versioned: dict[type[PublishableEntityMixin], type[PublishableEntityVersionMixin]] = {} + _versioned_to_unversioned: dict[type[PublishableEntityVersionMixin], type[PublishableEntityMixin]] = {} + + @classmethod + def register( + cls, + content_model_cls: type[PublishableEntityMixin], + content_version_model_cls: type[PublishableEntityVersionMixin], + ): + """ + Register what content model maps to what content version model. + + If you want to call this from another app, please use the + ``register_content_models`` function in this app's ``api`` module + instead. + """ + if not issubclass(content_model_cls, PublishableEntityMixin): + raise ImproperlyConfigured( + f"{content_model_cls} must inherit from PublishableEntityMixin" + ) + if not issubclass(content_version_model_cls, PublishableEntityVersionMixin): + raise ImproperlyConfigured( + f"{content_version_model_cls} must inherit from PublishableEntityMixin" + ) + + cls._unversioned_to_versioned[content_model_cls] = content_version_model_cls + cls._versioned_to_unversioned[content_version_model_cls] = content_model_cls + + @classmethod + def get_versioned_model_cls(cls, content_model_cls): + return cls._unversioned_to_versioned[content_model_cls] + + @classmethod + def get_unversioned_model_cls(cls, content_version_model_cls): + return cls._versioned_to_unversioned[content_version_model_cls] From ceaca78e7dda39ea6730f1f33dad7f74041025a7 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 13 Mar 2025 16:35:12 -0700 Subject: [PATCH 25/27] docs: address review - minor clarifications and renames --- .../apps/authoring/publishing/api.py | 31 ++++++++++--------- openedx_learning/apps/authoring/units/api.py | 6 ++-- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index 5c82c2f1..25fbd22b 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -31,6 +31,9 @@ PublishLogRecord, ) +# 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) @@ -581,7 +584,7 @@ def create_container( created: datetime, created_by: int | None, # The types on the following line are correct, but mypy will complain - https://github.com/python/mypy/issues/3737 - container_model: type[ContainerModel] = Container, # type: ignore[assignment] + container_cls: type[ContainerModel] = Container, # type: ignore[assignment] ) -> ContainerModel: """ [ ๐Ÿ›‘ UNSTABLE ] @@ -592,17 +595,17 @@ def create_container( key: The key of the container. created: The date and time the container was created. created_by: The ID of the user who created the container - container_model: The subclass of Container to use, if applicable + container_cls: The subclass of Container to use, if applicable Returns: The newly created container. """ - assert issubclass(container_model, Container) + assert issubclass(container_cls, Container) with atomic(): publishable_entity = create_publishable_entity( learning_package_id, key, created, created_by ) - container = container_model.objects.create( + container = container_cls.objects.create( publishable_entity=publishable_entity, ) return container @@ -678,13 +681,13 @@ def _create_container_version( entity_list: EntityList, created: datetime, created_by: int | None, - container_version_model: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment] + container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment] ) -> ContainerVersionModel: """ Private internal method for logic shared by create_container_version() and create_next_container_version(). """ - assert issubclass(container_version_model, ContainerVersion) + assert issubclass(container_version_cls, ContainerVersion) with atomic(savepoint=False): # Make sure this will happen atomically but we don't need to create a new savepoint. publishable_entity_version = create_publishable_entity_version( container.publishable_entity_id, @@ -693,7 +696,7 @@ def _create_container_version( created=created, created_by=created_by, ) - container_version = container_version_model.objects.create( + container_version = container_version_cls.objects.create( publishable_entity_version=publishable_entity_version, container_id=container.pk, entity_list=entity_list, @@ -711,7 +714,7 @@ def create_container_version( entity_version_pks: list[int | None] | None, created: datetime, created_by: int | None, - container_version_model: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment] + container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment] ) -> ContainerVersionModel: """ [ ๐Ÿ›‘ UNSTABLE ] @@ -725,7 +728,7 @@ def create_container_version( entity_version_pks: The IDs of the versions to pin to, if pinning is desired. created: The date and time the container version was created. created_by: The ID of the user who created the container version. - container_version_model: The subclass of ContainerVersion to use, if applicable. + container_version_cls: The subclass of ContainerVersion to use, if applicable. Returns: The newly created container version. @@ -750,7 +753,7 @@ def create_container_version( entity_list=entity_list, created=created, created_by=created_by, - container_version_model=container_version_model, + container_version_cls=container_version_cls, ) return container_version @@ -764,7 +767,7 @@ def create_next_container_version( entity_version_pks: list[int | None] | None, created: datetime, created_by: int | None, - container_version_model: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment] + container_version_cls: type[ContainerVersionModel] = ContainerVersion, # type: ignore[assignment] ) -> ContainerVersionModel: """ [ ๐Ÿ›‘ UNSTABLE ] @@ -784,12 +787,12 @@ def create_next_container_version( entity_version_pks: The IDs of the versions to pin to, if pinning is desired. created: The date and time the container version was created. created_by: The ID of the user who created the container version. - container_version_model: The subclass of ContainerVersion to use, if applicable. + container_version_cls: The subclass of ContainerVersion to use, if applicable. Returns: The newly created container version. """ - assert issubclass(container_version_model, ContainerVersion) + assert issubclass(container_version_cls, ContainerVersion) with atomic(): container = Container.objects.select_related("publishable_entity").get(pk=container_pk) entity = container.publishable_entity @@ -814,7 +817,7 @@ def create_next_container_version( entity_list=next_entity_list, created=created, created_by=created_by, - container_version_model=container_version_model, + container_version_cls=container_version_cls, ) return next_container_version diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index 46e3e90c..ec837af9 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -46,7 +46,7 @@ def create_unit( key, created, created_by, - container_model=Unit, + container_cls=Unit, ) @@ -84,7 +84,7 @@ def create_unit_version( entity_version_pks=entity_version_pks, created=created, created_by=created_by, - container_version_model=UnitVersion, + container_version_cls=UnitVersion, ) @@ -143,7 +143,7 @@ def create_next_unit_version( entity_version_pks=entity_version_pks, created=created, created_by=created_by, - container_version_model=UnitVersion, + container_version_cls=UnitVersion, ) return unit_version From 7945315448fa9122611cde229787157b9e007864 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 13 Mar 2025 16:42:32 -0700 Subject: [PATCH 26/27] feat: change get_components_in_unit() to raise if no published version --- .../apps/authoring/publishing/api.py | 16 ++++------------ openedx_learning/apps/authoring/units/api.py | 13 +++++-------- .../apps/authoring/units/test_api.py | 12 +++++++++--- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index 25fbd22b..231e42c4 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -855,29 +855,21 @@ def get_entities_in_container( container: Container, *, published: bool, -) -> list[ContainerEntityListEntry] | None: +) -> list[ContainerEntityListEntry]: """ [ ๐Ÿ›‘ UNSTABLE ] Get the list of entities and their versions in the current draft or published version of the given container. - Returns `None` if you request the published version and it hasn't been - published yet. - Args: container: The Container, e.g. returned by `get_container()` published: `True` if we want the published version of the container, or `False` for the draft version. """ assert isinstance(container, Container) - if published: - container_version = container.versioning.published - if container_version is None: - return None # There is no published version of this container (yet). Should this be an exception? - else: - container_version = container.versioning.draft - if container_version is None: - raise ContainerVersion.DoesNotExist # This container has been deleted. + container_version = container.versioning.published if published else container.versioning.draft + if container_version is None: + raise ContainerVersion.DoesNotExist # This container has not been published yet, or has been deleted. assert isinstance(container_version, ContainerVersion) entity_list = [] for row in container_version.entity_list.entitylistrow_set.order_by("order_num"): diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py index ec837af9..93058dd3 100644 --- a/openedx_learning/apps/authoring/units/api.py +++ b/openedx_learning/apps/authoring/units/api.py @@ -229,7 +229,7 @@ def get_components_in_unit( unit: Unit, *, published: bool, -) -> list[UnitListEntry] | None: +) -> list[UnitListEntry]: """ [ ๐Ÿ›‘ UNSTABLE ] Get the list of entities and their versions in the draft or published @@ -241,16 +241,13 @@ def get_components_in_unit( `False` for the draft version. """ assert isinstance(unit, Unit) - entity_list = [] - entries = publishing_api.get_entities_in_container(unit, published=published) - if entries is None: - return None # There is no published version of this unit. Should this be an exception? - for entry in entries: + components = [] + for entry in publishing_api.get_entities_in_container(unit, published=published): # Convert from generic PublishableEntityVersion to ComponentVersion: component_version = entry.entity_version.componentversion assert isinstance(component_version, ComponentVersion) - entity_list.append(UnitListEntry(component_version=component_version, pinned=entry.pinned)) - return entity_list + components.append(UnitListEntry(component_version=component_version, pinned=entry.pinned)) + return components def get_components_in_published_unit_as_of( diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py index 2ef3e2c3..1556a34b 100644 --- a/tests/openedx_learning/apps/authoring/units/test_api.py +++ b/tests/openedx_learning/apps/authoring/units/test_api.py @@ -252,7 +252,9 @@ def test_create_next_unit_version_with_two_unpinned_components(self): Entry(self.component_1.versioning.draft), Entry(self.component_2.versioning.draft), ] - assert authoring_api.get_components_in_unit(unit, published=True) is None + with pytest.raises(authoring_models.ContainerVersion.DoesNotExist): + # There is no published version of the unit: + authoring_api.get_components_in_unit(unit, published=True) def test_create_next_unit_version_with_unpinned_and_pinned_components(self): """ @@ -278,7 +280,9 @@ 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 ] - assert authoring_api.get_components_in_unit(unit, published=True) is None + with pytest.raises(authoring_models.ContainerVersion.DoesNotExist): + # There is no published version of the unit: + authoring_api.get_components_in_unit(unit, published=True) def test_auto_publish_children(self): """ @@ -327,7 +331,9 @@ 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 - assert authoring_api.get_components_in_unit(unit, published=True) is None + with pytest.raises(authoring_models.ContainerVersion.DoesNotExist): + # There is no published version of the unit: + authoring_api.get_components_in_unit(unit, published=True) def test_add_component_after_publish(self): """ From 07a9385e70f100de067e8fd98a904f96834e8736 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 14 Mar 2025 09:20:46 -0700 Subject: [PATCH 27/27] chore: version bump to 0.19.0 --- openedx_learning/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openedx_learning/__init__.py b/openedx_learning/__init__.py index 9f57f2a2..f2c501ee 100644 --- a/openedx_learning/__init__.py +++ b/openedx_learning/__init__.py @@ -2,4 +2,4 @@ Open edX Learning ("Learning Core"). """ -__version__ = "0.18.3" +__version__ = "0.19.0"