Repository: openedx/openedx-learning
Pull request #292: feat: api for updating children in container [FC-0083]
Author: login: navinkarkera
Labels: name: open-source-contribution
description: PR author is not from Axim or 2U
name: FC
description: Relates to an Axim Funded Contribution project
Created: 2025-03-24T07:10:01Z | Merged: 2025-03-31T11:12:14Z

**Description:**

Updates `create_next_container_version` api to handle addition or removal of children entities.

**Related to:** https://github.com/openedx/frontend-app-authoring/issues/1706 and https://github.com/openedx/frontend-app-authoring/issues/1705

`Private-ref`: https://tasks.opencraft.com/browse/FAL-4109

**Test instructions:**

* Read code
* Verify that tests are passing



Diff:
diff --git a/openedx_learning/__init__.py b/openedx_learning/__init__.py
index 387eb5606..5f85ec83b 100644
--- a/openedx_learning/__init__.py
+++ b/openedx_learning/__init__.py
@@ -2,4 +2,4 @@
 Open edX Learning ("Learning Core").
 """
 
-__version__ = "0.19.1"
+__version__ = "0.19.2"
diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py
index 1f24ca672..e9641dc14 100644
--- a/openedx_learning/apps/authoring/publishing/api.py
+++ b/openedx_learning/apps/authoring/publishing/api.py
@@ -8,6 +8,7 @@
 
 from dataclasses import dataclass
 from datetime import datetime, timezone
+from enum import Enum
 from typing import TypeVar
 
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
@@ -73,10 +74,12 @@
     "get_container",
     "get_container_by_key",
     "get_containers",
+    "ChildrenEntitiesAction",
     "ContainerEntityListEntry",
     "get_entities_in_container",
     "contains_unpublished_changes",
     "get_containers_with_entity",
+    "get_container_children_count",
 ]
 
 
@@ -771,6 +774,70 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
+    """Possible actions for children entities"""
+
+    APPEND = "append"
+    REMOVE = "remove"
+    REPLACE = "replace"
+
+
+def create_next_entity_list(
+    learning_package_id: int,
+    last_version: ContainerVersion,
+    publishable_entities_pks: list[int],
+    entity_version_pks: list[int | None] | None,
+    entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
+) -> EntityList:
+    """
+    Creates next entity list based on the given entities_action.
+
+    Args:
+        learning_package_id: Learning package ID
+        last_version: Last version of container.
+        publishable_entities_pks: The IDs of the members current members of the container.
+        entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
+        entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
+
+    Returns:
+        The newly created entity list.
+    """
+    if entity_version_pks is None:
+        entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks)  # type: ignore[no-redef]
+    if entities_action == ChildrenEntitiesAction.APPEND:
+        # get previous entity list rows
+        last_entities = last_version.entity_list.entitylistrow_set.only(
+            "entity_id",
+            "entity_version_id"
+        ).order_by("order_num")
+        # append given publishable_entities_pks and entity_version_pks
+        publishable_entities_pks = [entity.entity_id for entity in last_entities] + publishable_entities_pks
+        entity_version_pks = [  # type: ignore[operator, assignment]
+            entity.entity_version_id
+            for entity in last_entities
+        ] + entity_version_pks
+    elif entities_action == ChildrenEntitiesAction.REMOVE:
+        # get previous entity list rows
+        last_entities = last_version.entity_list.entitylistrow_set.only(
+            "entity_id",
+            "entity_version_id"
+        ).order_by("order_num")
+        # Remove entities that are in publishable_entities_pks
+        new_entities = [
+            entity
+            for entity in last_entities
+            if entity.entity_id not in publishable_entities_pks
+        ]
+        publishable_entities_pks = [entity.entity_id for entity in new_entities]
+        entity_version_pks = [entity.entity_version_id for entity in new_entities]
+    next_entity_list = create_entity_list_with_rows(
+        entity_pks=publishable_entities_pks,
+        entity_version_pks=entity_version_pks,  # type: ignore[arg-type]
+        learning_package_id=learning_package_id,
+    )
+    return next_entity_list
+
+
 def create_next_container_version(
     container_pk: int,
     *,
@@ -780,6 +847,7 @@ def create_next_container_version(
     created: datetime,
     created_by: int | None,
     container_version_cls: type[ContainerVersionModel] = ContainerVersion,  # type: ignore[assignment]
+    entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
 ) -> ContainerVersionModel:
     """
     [ 🛑 UNSTABLE ]
@@ -815,13 +883,14 @@ def create_next_container_version(
             # We're only changing metadata. Keep the same entity list.
             next_entity_list = last_version.entity_list
         else:
-            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,
+            next_entity_list = create_next_entity_list(
+                entity.learning_package_id,
+                last_version,
+                publishable_entities_pks,
+                entity_version_pks,
+                entities_action
             )
+
         next_container_version = _create_container_version(
             container,
             next_version_num,
@@ -1018,3 +1087,29 @@ def get_containers_with_entity(
     #     publishable_entity__draft__version__containerversion__entity_list__in=lists
     # )
     return qs
+
+
+def get_container_children_count(
+    container: Container,
+    *,
+    published: bool,
+):
+    """
+    [ 🛑 UNSTABLE ]
+    Get the count of entities in the current draft or published version of the given container.
+
+    Args:
+        container: The Container, e.g. returned by `get_container()`
+        published: `True` if we want the published version of the container, or
+            `False` for the draft version.
+    """
+    assert isinstance(container, Container)
+    container_version = container.versioning.published if published else container.versioning.draft
+    if container_version is None:
+        raise ContainerVersion.DoesNotExist  # This container has not been published yet, or has been deleted.
+    assert isinstance(container_version, ContainerVersion)
+    if published:
+        filter_deleted = {"entity__published__version__isnull": False}
+    else:
+        filter_deleted = {"entity__draft__version__isnull": False}
+    return container_version.entity_list.entitylistrow_set.filter(**filter_deleted).count()
diff --git a/openedx_learning/apps/authoring/publishing/models/entity_list.py b/openedx_learning/apps/authoring/publishing/models/entity_list.py
index af7d0eca2..19dab09ae 100644
--- a/openedx_learning/apps/authoring/publishing/models/entity_list.py
+++ b/openedx_learning/apps/authoring/publishing/models/entity_list.py
@@ -60,6 +60,7 @@ class EntityListRow(models.Model):
     )
 
     class Meta:
+        ordering = ["order_num"]
         constraints = [
             # If (entity_list, order_num) is not unique, it likely indicates a race condition - so force uniqueness.
             models.UniqueConstraint(
diff --git a/openedx_learning/apps/authoring/units/api.py b/openedx_learning/apps/authoring/units/api.py
index 8c6a37d92..6d19306d1 100644
--- a/openedx_learning/apps/authoring/units/api.py
+++ b/openedx_learning/apps/authoring/units/api.py
@@ -130,6 +130,7 @@ def create_next_unit_version(
     components: list[Component | ComponentVersion] | None = None,
     created: datetime,
     created_by: int | None = None,
+    entities_action: publishing_api.ChildrenEntitiesAction = publishing_api.ChildrenEntitiesAction.REPLACE,
 ) -> UnitVersion:
     """
     [ 🛑 UNSTABLE ] Create the next unit version.
@@ -151,6 +152,7 @@ def create_next_unit_version(
         created=created,
         created_by=created_by,
         container_version_cls=UnitVersion,
+        entities_action=entities_action,
     )
     return unit_version
 
diff --git a/tests/openedx_learning/apps/authoring/units/test_api.py b/tests/openedx_learning/apps/authoring/units/test_api.py
index 35b59d81b..a07024db9 100644
--- a/tests/openedx_learning/apps/authoring/units/test_api.py
+++ b/tests/openedx_learning/apps/authoring/units/test_api.py
@@ -412,6 +412,7 @@ def test_add_component_after_publish(self):
             components=[self.component_1],
             created=self.now,
             created_by=None,
+            entities_action=authoring_api.ChildrenEntitiesAction.APPEND,
         )
         # Now the unit should have unpublished changes:
         unit.refresh_from_db()  # Reloading the unit is necessary
@@ -697,8 +698,9 @@ def test_removing_component(self):
         authoring_api.create_next_unit_version(
             unit=unit,
             title="Revised with component 2 deleted",
-            components=[self.component_1],  # component 2 is gone
+            components=[self.component_2],
             created=self.now,
+            entities_action=authoring_api.ChildrenEntitiesAction.REMOVE,
         )
 
         # Now it should not be listed in the unit:
@@ -768,8 +770,9 @@ def test_soft_deleting_and_removing_component(self):
         authoring_api.create_next_unit_version(
             unit=unit,
             title="Revised with component 2 deleted",
-            components=[self.component_1],
+            components=[self.component_2],
             created=self.now,
+            entities_action=authoring_api.ChildrenEntitiesAction.REMOVE,
         )
 
         # Now it should not be listed in the unit:
@@ -965,6 +968,94 @@ def test_units_containing(self):
             ]
         assert result2 == [unit4_unpinned]
 
+    def test_add_remove_container_children(self):
+        """
+        Test adding and removing children components from containers.
+        """
+        unit, unit_version = authoring_api.create_unit_and_version(
+            learning_package_id=self.learning_package.id,
+            key="unit:key",
+            title="Unit",
+            components=[self.component_1],
+            created=self.now,
+            created_by=None,
+        )
+        assert authoring_api.get_components_in_unit(unit, published=False) == [
+            Entry(self.component_1.versioning.draft),
+        ]
+        component_3, _ = self.create_component(
+            key="Query Counting (3)",
+            title="Querying Counting Problem (3)",
+        )
+        # Add component_2 and component_3
+        unit_version_v2 = authoring_api.create_next_unit_version(
+            unit=unit,
+            title=unit_version.title,
+            components=[self.component_2, component_3],
+            created=self.now,
+            created_by=None,
+            entities_action=authoring_api.ChildrenEntitiesAction.APPEND,
+        )
+        unit.refresh_from_db()
+        assert unit_version_v2.version_num == 2
+        assert unit_version_v2 in unit.versioning.versions.all()
+        # Verify that component_2 and component_3 is added to end
+        assert authoring_api.get_components_in_unit(unit, published=False) == [
+            Entry(self.component_1.versioning.draft),
+            Entry(self.component_2.versioning.draft),
+            Entry(component_3.versioning.draft),
+        ]
+
+        # Remove component_1
+        authoring_api.create_next_unit_version(
+            unit=unit,
+            title=unit_version.title,
+            components=[self.component_1],
+            created=self.now,
+            created_by=None,
+            entities_action=authoring_api.ChildrenEntitiesAction.REMOVE,
+        )
+        unit.refresh_from_db()
+        # Verify that component_1 is removed
+        assert authoring_api.get_components_in_unit(unit, published=False) == [
+            Entry(self.component_2.versioning.draft),
+            Entry(component_3.versioning.draft),
+        ]
+
+    def test_get_container_children_count(self):
+        """
+        Test get_container_children_count()
+        """
+        unit = self.create_unit_with_components([self.component_1])
+        assert authoring_api.get_container_children_count(unit.container, published=False) == 1
+        # publish
+        authoring_api.publish_all_drafts(self.learning_package.id)
+        unit_version = unit.versioning.draft
+        authoring_api.create_next_unit_version(
+            unit=unit,
+            title=unit_version.title,
+            components=[self.component_2],
+            created=self.now,
+            created_by=None,
+            entities_action=authoring_api.ChildrenEntitiesAction.APPEND,
+        )
+        unit.refresh_from_db()
+        # Should have two components in draft version and 1 in published version
+        assert authoring_api.get_container_children_count(unit.container, published=False) == 2
+        assert authoring_api.get_container_children_count(unit.container, published=True) == 1
+        # publish
+        authoring_api.publish_all_drafts(self.learning_package.id)
+        unit.refresh_from_db()
+        assert authoring_api.get_container_children_count(unit.container, published=True) == 2
+        # Soft delete component_1
+        authoring_api.soft_delete_draft(self.component_1.pk)
+        unit.refresh_from_db()
+        # Should contain only 1 child
+        assert authoring_api.get_container_children_count(unit.container, published=False) == 1
+        authoring_api.publish_all_drafts(self.learning_package.id)
+        unit.refresh_from_db()
+        assert authoring_api.get_container_children_count(unit.container, published=True) == 1
+
     # Tests TODO:
     # Test that I can get a [PublishLog] history of a given unit and all its children, including children that aren't
     #     currently in the unit and excluding children that are only in other units.


Reviews:
user: login: pomegranited
body: @navinkarkera  I really like how you added `get_next_entity_list` and the different `ChildrenEntitiesAction` -- very clean!

But I've got some concerns about keeping the APIs consistent. What do you think?
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T01:33:13Z
user: login: navinkarkera
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T05:52:04Z
user: login: navinkarkera
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T06:08:50Z
user: login: pomegranited
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T06:14:13Z
user: login: pomegranited
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T06:14:32Z
user: login: pomegranited
body: :+1: left some stylistic nits, but this looks good @navinkarkera :)

- [x] I tested this with https://github.com/openedx/edx-platform/pull/36434#pullrequestreview-2719008668
- [x] I read through the code
- [x] <strike>I checked for accessibility issues by using my keyboard to navigate</strike> N/A
- [x] Includes documentation
- [x] User-facing strings are extracted for translation
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T07:26:58Z
user: login: navinkarkera
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T10:09:37Z
user: login: navinkarkera
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T10:09:55Z
user: login: navinkarkera
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T10:10:03Z
user: login: ormsbee
body: At a high level, I'm not clear on why we need knowledge of append/remove/replace at this level. As someone who hasn't kept up with the edx-platform, it seems like that could all be done there, leaving the Learning Core API a bit dumber and less opinionated about how the next entity list is calculated.

That being said, I'll defer to you folks on the necessity of this, since you're much more deeply into this right now. If we do decide to pull it out, that can be done as a follow-up thing anyhow. We've marked all the container stuff as experimental anyhow.

I have two relatively small concerns that I'd like to see addressed:

1. That we're using an API call with `get_` that has a side-effect of creating something.
2. A possible edge case condition around deleted children in the count. This might just be my misunderstanding of the code.

Please address these and squash, and I'll merge.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T17:27:32Z
user: login: pomegranited
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T23:35:42Z
user: login: pomegranited
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T23:43:53Z
user: login: navinkarkera
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T10:51:06Z
user: login: navinkarkera
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T11:05:34Z
user: login: navinkarkera
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T11:06:23Z
user: login: navinkarkera
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T11:06:38Z
user: login: ormsbee
body: Please squash and add any relevant context to your commit message, and I'll merge it. Were you planning to add a version bump with this, or to wait for other changes to land before incrementing?
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-28T19:42:46Z
user: login: bradenmacdonald
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T19:07:29Z

Review comments:
diff_hunk: @@ -1018,3 +1087,25 @@ def get_containers_with_entity(
     #     publishable_entity__draft__version__containerversion__entity_list__in=lists
     # )
     return qs
+
+
+def get_container_children_count(
+    container: Container,
path: openedx_learning/apps/authoring/publishing/api.py
user: login: pomegranited
body: The other container API methods take a `container_key` instead of a `Container` object -- can this one too?
created_at: 2025-03-27T01:18:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,6 +152,7 @@ def create_next_unit_version(
         created=created,
         created_by=created_by,
         container_version_cls=UnitVersion,
+        entities_action=entities_action,
     )
     return unit_version
 
path: openedx_learning/apps/authoring/units/api.py
user: login: pomegranited
body: Ugh.. it's inconsistent that we require a `Unit` object to call Unit API methods, when we require a `container_key` for the Container API methods.

I'd much rather we passed in a `container_key` for _all_ API calls, to prevent users of this API from having to fetch objects when they don't need to -- and I think it would be a simple refactor to get that happening here.

@ormsbee what do you think?
created_at: 2025-03-27T01:22:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1018,3 +1087,25 @@ def get_containers_with_entity(
     #     publishable_entity__draft__version__containerversion__entity_list__in=lists
     # )
     return qs
+
+
+def get_container_children_count(
+    container: Container,
path: openedx_learning/apps/authoring/publishing/api.py
user: login: navinkarkera
body: All container API dealing with entities/children under it expect a `Container` object:
* [Ref 1](https://github.com/open-craft/openedx-learning/blob/7552d7a762707110d255034bd4fcd6c042f3c16d/openedx_learning/apps/authoring/publishing/api.py#L972-L973)
* [Ref 2](https://github.com/open-craft/openedx-learning/blob/7552d7a762707110d255034bd4fcd6c042f3c16d/openedx_learning/apps/authoring/units/api.py#L245)
* [Ref 3](https://github.com/open-craft/openedx-learning/blob/7552d7a762707110d255034bd4fcd6c042f3c16d/openedx_learning/apps/authoring/units/api.py#L270)
created_at: 2025-03-27T05:52:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,6 +152,7 @@ def create_next_unit_version(
         created=created,
         created_by=created_by,
         container_version_cls=UnitVersion,
+        entities_action=entities_action,
     )
     return unit_version
 
path: openedx_learning/apps/authoring/units/api.py
user: login: navinkarkera
body: Yes, containers `create_next_container_version` api expects [id](https://github.com/open-craft/openedx-learning/blob/7552d7a762707110d255034bd4fcd6c042f3c16d/openedx_learning/apps/authoring/publishing/api.py#L846) while `create_next_unit_version` expects unit object. It is probably due to this [statement](https://github.com/open-craft/openedx-learning/blob/7552d7a762707110d255034bd4fcd6c042f3c16d/openedx_learning/apps/authoring/publishing/api.py#L881) that selects related publishable_entities.

I think `create_next_container_version` is low level API as all its arguments are native types like str, int and datetime, while `create_next_unit_version` is a higher level api that wraps the former and accepts objects like `unit` and `components` instead of ids.
created_at: 2025-03-27T06:08:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1018,3 +1087,25 @@ def get_containers_with_entity(
     #     publishable_entity__draft__version__containerversion__entity_list__in=lists
     # )
     return qs
+
+
+def get_container_children_count(
+    container: Container,
path: openedx_learning/apps/authoring/publishing/api.py
user: login: pomegranited
body: Ah ok.. I guess it's fine as is. Will continue my review!
created_at: 2025-03-27T06:14:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,6 +152,7 @@ def create_next_unit_version(
         created=created,
         created_by=created_by,
         container_version_cls=UnitVersion,
+        entities_action=entities_action,
     )
     return unit_version
 
path: openedx_learning/apps/authoring/units/api.py
user: login: pomegranited
body: Sorry for the noise here -- [see above](https://github.com/openedx/openedx-learning/pull/292#discussion_r2015697664).
created_at: 2025-03-27T06:14:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,74 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
+    """Possible actions for children entities"""
+
+    APPEND = "append"
+    REMOVE = "remove"
+    REPLACE = "replace"
+
+
+def get_next_entity_list(
+    learning_package_id: int,
+    last_version: ContainerVersion,
+    publishable_entities_pks: list[int] | None,
+    entity_version_pks: list[int | None] | None,
+    entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
+) -> EntityList:
+    """
+    Creates next entity list based on the given entities_action.
+
+    Args:
+        learning_package_id: Learning package ID
+        last_version: Last version of container.
+        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.
+        entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
+
+    Returns:
+        The newly created entity list.
+    """
+    if publishable_entities_pks is None:
+        # We're only changing metadata. Keep the same entity list.
+        next_entity_list = last_version.entity_list
+    else:
+        if entity_version_pks is None:
+            entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks)  # type: ignore[no-redef]
+        if entities_action == ChildrenEntitiesAction.APPEND:
path: openedx_learning/apps/authoring/publishing/api.py
user: login: pomegranited
body: nit: It's a little confusing that the `if publishable_entities_pks is None` clause is in this method, rather than keeping it in `create_next_container_version` below. But I could go either way there.

I do think it's worth reducing indentation by returning immediately `if publishable_entities_pks is None`:

```suggestion
    if publishable_entities_pks is None:
        # We're only changing metadata. Keep the same entity list.
        return last_version.entity_list
        
    if entity_version_pks is None:
        entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks)  # type: ignore[no-redef]
    if entities_action == ChildrenEntitiesAction.APPEND:
        ...
```

...and so forth.
created_at: 2025-03-27T06:28:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,74 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
+    """Possible actions for children entities"""
+
+    APPEND = "append"
+    REMOVE = "remove"
+    REPLACE = "replace"
+
+
+def get_next_entity_list(
+    learning_package_id: int,
+    last_version: ContainerVersion,
+    publishable_entities_pks: list[int] | None,
+    entity_version_pks: list[int | None] | None,
+    entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
+) -> EntityList:
+    """
+    Creates next entity list based on the given entities_action.
+
+    Args:
+        learning_package_id: Learning package ID
+        last_version: Last version of container.
+        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.
+        entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
+
+    Returns:
+        The newly created entity list.
+    """
+    if publishable_entities_pks is None:
+        # We're only changing metadata. Keep the same entity list.
+        next_entity_list = last_version.entity_list
+    else:
+        if entity_version_pks is None:
+            entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks)  # type: ignore[no-redef]
+        if entities_action == ChildrenEntitiesAction.APPEND:
+            # get previous entity list rows
+            last_entities = last_version.entity_list.entitylistrow_set.values_list(
+                "entity_id",
+                "entity_version_id"
+            )
+            # append given publishable_entities_pks and entity_version_pks
+            publishable_entities_pks = [entity[0] for entity in last_entities] + publishable_entities_pks
+            entity_version_pks = [  # type: ignore[operator, assignment]
+                entity[1]
+                for entity in last_entities
+            ] + entity_version_pks
path: openedx_learning/apps/authoring/publishing/api.py
user: login: pomegranited
body: nit: Can we get around this little bit of awkwardness (and still keep the query data small) by using [only()](https://docs.djangoproject.com/en/5.1/ref/models/querysets/#django.db.models.query.QuerySet.only) instead of `values_list`?

```suggestion
            last_entities = last_version.entity_list.entitylistrow_set.only('entity_id', 'entity_version_id')
            # append given publishable_entities_pks and entity_version_pks
            publishable_entities_pks = [entity.entity_id for entity in last_entities] + publishable_entities_pks
            entity_version_pks = [  # type: ignore[operator, assignment]
                entity.entity_version_id
                for entity in last_entities
            ] + entity_version_pks
```
created_at: 2025-03-27T06:45:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,74 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
+    """Possible actions for children entities"""
+
+    APPEND = "append"
+    REMOVE = "remove"
+    REPLACE = "replace"
+
+
+def get_next_entity_list(
+    learning_package_id: int,
+    last_version: ContainerVersion,
+    publishable_entities_pks: list[int] | None,
+    entity_version_pks: list[int | None] | None,
+    entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
+) -> EntityList:
+    """
+    Creates next entity list based on the given entities_action.
+
+    Args:
+        learning_package_id: Learning package ID
+        last_version: Last version of container.
+        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.
+        entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
+
+    Returns:
+        The newly created entity list.
+    """
+    if publishable_entities_pks is None:
+        # We're only changing metadata. Keep the same entity list.
+        next_entity_list = last_version.entity_list
+    else:
+        if entity_version_pks is None:
+            entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks)  # type: ignore[no-redef]
+        if entities_action == ChildrenEntitiesAction.APPEND:
+            # get previous entity list rows
+            last_entities = last_version.entity_list.entitylistrow_set.values_list(
+                "entity_id",
+                "entity_version_id"
+            )
+            # append given publishable_entities_pks and entity_version_pks
+            publishable_entities_pks = [entity[0] for entity in last_entities] + publishable_entities_pks
+            entity_version_pks = [  # type: ignore[operator, assignment]
+                entity[1]
+                for entity in last_entities
+            ] + entity_version_pks
+        elif entities_action == ChildrenEntitiesAction.REMOVE:
+            # get previous entity list rows
+            last_entities = last_version.entity_list.entitylistrow_set.values_list(
+                "entity_id",
+                "entity_version_id"
+            )
+            # Remove entities that are in publishable_entities_pks
+            new_entities = [
+                entity
+                for entity in last_entities
+                if entity[0] not in publishable_entities_pks
+            ]
+            publishable_entities_pks = [entity[0] for entity in new_entities]
+            entity_version_pks = [entity[1] for entity in new_entities]
path: openedx_learning/apps/authoring/publishing/api.py
user: login: pomegranited
body: ditto here:
```suggestion
            last_entities = last_version.entity_list.entitylistrow_set.only('entity_id', 'entity_version_id')
            # Remove entities that are in publishable_entities_pks
            new_entities = [
                entity
                for entity in last_entities
                if entity.entity_id not in publishable_entities_pks
            ]
            publishable_entities_pks = [entity.entity_id for entity in new_entities]
            entity_version_pks = [entity.entity_version_id for entity in new_entities]
```
created_at: 2025-03-27T06:50:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,74 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
+    """Possible actions for children entities"""
+
+    APPEND = "append"
+    REMOVE = "remove"
+    REPLACE = "replace"
+
+
+def get_next_entity_list(
+    learning_package_id: int,
+    last_version: ContainerVersion,
+    publishable_entities_pks: list[int] | None,
+    entity_version_pks: list[int | None] | None,
+    entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
+) -> EntityList:
+    """
+    Creates next entity list based on the given entities_action.
+
+    Args:
+        learning_package_id: Learning package ID
+        last_version: Last version of container.
+        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.
+        entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
+
+    Returns:
+        The newly created entity list.
+    """
+    if publishable_entities_pks is None:
+        # We're only changing metadata. Keep the same entity list.
+        next_entity_list = last_version.entity_list
+    else:
+        if entity_version_pks is None:
+            entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks)  # type: ignore[no-redef]
+        if entities_action == ChildrenEntitiesAction.APPEND:
path: openedx_learning/apps/authoring/publishing/api.py
user: login: navinkarkera
body: @pomegranited Nice suggestion, moved `publishable_entities_pks` None check out of `get_next_entity_list` function.
created_at: 2025-03-27T10:09:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,74 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
+    """Possible actions for children entities"""
+
+    APPEND = "append"
+    REMOVE = "remove"
+    REPLACE = "replace"
+
+
+def get_next_entity_list(
+    learning_package_id: int,
+    last_version: ContainerVersion,
+    publishable_entities_pks: list[int] | None,
+    entity_version_pks: list[int | None] | None,
+    entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
+) -> EntityList:
+    """
+    Creates next entity list based on the given entities_action.
+
+    Args:
+        learning_package_id: Learning package ID
+        last_version: Last version of container.
+        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.
+        entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
+
+    Returns:
+        The newly created entity list.
+    """
+    if publishable_entities_pks is None:
+        # We're only changing metadata. Keep the same entity list.
+        next_entity_list = last_version.entity_list
+    else:
+        if entity_version_pks is None:
+            entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks)  # type: ignore[no-redef]
+        if entities_action == ChildrenEntitiesAction.APPEND:
+            # get previous entity list rows
+            last_entities = last_version.entity_list.entitylistrow_set.values_list(
+                "entity_id",
+                "entity_version_id"
+            )
+            # append given publishable_entities_pks and entity_version_pks
+            publishable_entities_pks = [entity[0] for entity in last_entities] + publishable_entities_pks
+            entity_version_pks = [  # type: ignore[operator, assignment]
+                entity[1]
+                for entity in last_entities
+            ] + entity_version_pks
path: openedx_learning/apps/authoring/publishing/api.py
user: login: navinkarkera
body: Nice! Updated.
created_at: 2025-03-27T10:09:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,74 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
+    """Possible actions for children entities"""
+
+    APPEND = "append"
+    REMOVE = "remove"
+    REPLACE = "replace"
+
+
+def get_next_entity_list(
+    learning_package_id: int,
+    last_version: ContainerVersion,
+    publishable_entities_pks: list[int] | None,
+    entity_version_pks: list[int | None] | None,
+    entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
+) -> EntityList:
+    """
+    Creates next entity list based on the given entities_action.
+
+    Args:
+        learning_package_id: Learning package ID
+        last_version: Last version of container.
+        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.
+        entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
+
+    Returns:
+        The newly created entity list.
+    """
+    if publishable_entities_pks is None:
+        # We're only changing metadata. Keep the same entity list.
+        next_entity_list = last_version.entity_list
+    else:
+        if entity_version_pks is None:
+            entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks)  # type: ignore[no-redef]
+        if entities_action == ChildrenEntitiesAction.APPEND:
+            # get previous entity list rows
+            last_entities = last_version.entity_list.entitylistrow_set.values_list(
+                "entity_id",
+                "entity_version_id"
+            )
+            # append given publishable_entities_pks and entity_version_pks
+            publishable_entities_pks = [entity[0] for entity in last_entities] + publishable_entities_pks
+            entity_version_pks = [  # type: ignore[operator, assignment]
+                entity[1]
+                for entity in last_entities
+            ] + entity_version_pks
+        elif entities_action == ChildrenEntitiesAction.REMOVE:
+            # get previous entity list rows
+            last_entities = last_version.entity_list.entitylistrow_set.values_list(
+                "entity_id",
+                "entity_version_id"
+            )
+            # Remove entities that are in publishable_entities_pks
+            new_entities = [
+                entity
+                for entity in last_entities
+                if entity[0] not in publishable_entities_pks
+            ]
+            publishable_entities_pks = [entity[0] for entity in new_entities]
+            entity_version_pks = [entity[1] for entity in new_entities]
path: openedx_learning/apps/authoring/publishing/api.py
user: login: navinkarkera
body: Nice! Updated
created_at: 2025-03-27T10:10:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1018,3 +1081,25 @@ def get_containers_with_entity(
     #     publishable_entity__draft__version__containerversion__entity_list__in=lists
     # )
     return qs
+
+
+def get_container_children_count(
+    container: Container,
+    *,
+    published: bool,
+):
+    """
+    [ 🛑 UNSTABLE ]
+    Get the count of entities in the current draft or published version of the given container.
+
+    Args:
+        container: The Container, e.g. returned by `get_container()`
+        published: `True` if we want the published version of the container, or
+            `False` for the draft version.
+    """
+    assert isinstance(container, Container)
+    container_version = container.versioning.published if published else container.versioning.draft
+    if container_version is None:
+        raise ContainerVersion.DoesNotExist  # This container has not been published yet, or has been deleted.
+    assert isinstance(container_version, ContainerVersion)
+    return container_version.entity_list.entitylistrow_set.count()
path: openedx_learning/apps/authoring/publishing/api.py
user: login: ormsbee
body: Does this work in the edge case where we've soft-deleted a child element and published that soft-delete, but haven't removed that element from the container? Don't we need to filter those out of the entry list?
created_at: 2025-03-27T17:09:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,64 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
+    """Possible actions for children entities"""
+
+    APPEND = "append"
+    REMOVE = "remove"
+    REPLACE = "replace"
+
+
+def get_next_entity_list(
path: openedx_learning/apps/authoring/publishing/api.py
user: login: ormsbee
body: `get_` implies a read operation, when this is actually creating something. Please rename it to clearly indicate that it creates something.
created_at: 2025-03-27T17:12:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,64 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
path: openedx_learning/apps/authoring/publishing/api.py
user: login: ormsbee
body: Do we persist this data structure anywhere, or is it just used during API calls?
created_at: 2025-03-27T17:13:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,64 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
+    """Possible actions for children entities"""
+
+    APPEND = "append"
+    REMOVE = "remove"
+    REPLACE = "replace"
+
+
+def get_next_entity_list(
+    learning_package_id: int,
+    last_version: ContainerVersion,
+    publishable_entities_pks: list[int],
+    entity_version_pks: list[int | None] | None,
+    entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
+) -> EntityList:
+    """
+    Creates next entity list based on the given entities_action.
+
+    Args:
+        learning_package_id: Learning package ID
+        last_version: Last version of container.
+        publishable_entities_pks: The IDs of the members current members of the container.
+        entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
+        entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
+
+    Returns:
+        The newly created entity list.
+    """
+    if entity_version_pks is None:
+        entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks)  # type: ignore[no-redef]
+    if entities_action == ChildrenEntitiesAction.APPEND:
+        # get previous entity list rows
+        last_entities = last_version.entity_list.entitylistrow_set.only("entity_id", "entity_version_id")
path: openedx_learning/apps/authoring/publishing/api.py
user: login: pomegranited
body: I forgot something: the existing `entity_list` needs to be ordered by `order_num` to maintain the original sort order, both here and with REMOVE below.
created_at: 2025-03-27T23:35:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1018,3 +1081,25 @@ def get_containers_with_entity(
     #     publishable_entity__draft__version__containerversion__entity_list__in=lists
     # )
     return qs
+
+
+def get_container_children_count(
+    container: Container,
+    *,
+    published: bool,
+):
+    """
+    [ 🛑 UNSTABLE ]
+    Get the count of entities in the current draft or published version of the given container.
+
+    Args:
+        container: The Container, e.g. returned by `get_container()`
+        published: `True` if we want the published version of the container, or
+            `False` for the draft version.
+    """
+    assert isinstance(container, Container)
+    container_version = container.versioning.published if published else container.versioning.draft
+    if container_version is None:
+        raise ContainerVersion.DoesNotExist  # This container has not been published yet, or has been deleted.
+    assert isinstance(container_version, ContainerVersion)
+    return container_version.entity_list.entitylistrow_set.count()
path: openedx_learning/apps/authoring/publishing/api.py
user: login: pomegranited
body: Ah great catch, thank you @ormsbee . We should add a test for this case too.
created_at: 2025-03-27T23:43:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,64 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
path: openedx_learning/apps/authoring/publishing/api.py
user: login: navinkarkera
body: No it is just used in API calls.
created_at: 2025-03-28T10:51:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,64 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
+    """Possible actions for children entities"""
+
+    APPEND = "append"
+    REMOVE = "remove"
+    REPLACE = "replace"
+
+
+def get_next_entity_list(
+    learning_package_id: int,
+    last_version: ContainerVersion,
+    publishable_entities_pks: list[int],
+    entity_version_pks: list[int | None] | None,
+    entities_action: ChildrenEntitiesAction = ChildrenEntitiesAction.REPLACE,
+) -> EntityList:
+    """
+    Creates next entity list based on the given entities_action.
+
+    Args:
+        learning_package_id: Learning package ID
+        last_version: Last version of container.
+        publishable_entities_pks: The IDs of the members current members of the container.
+        entity_version_pks: The IDs of the versions to pin to, if pinning is desired.
+        entities_action: APPEND, REMOVE or REPLACE given entities from/to the container
+
+    Returns:
+        The newly created entity list.
+    """
+    if entity_version_pks is None:
+        entity_version_pks: list[int | None] = [None] * len(publishable_entities_pks)  # type: ignore[no-redef]
+    if entities_action == ChildrenEntitiesAction.APPEND:
+        # get previous entity list rows
+        last_entities = last_version.entity_list.entitylistrow_set.only("entity_id", "entity_version_id")
path: openedx_learning/apps/authoring/publishing/api.py
user: login: navinkarkera
body: Nice catch! Updated here: ddbae25683bd and also set default ordering in EntityListRow model to avoid this issue in future: 0c7730a9eb9a
created_at: 2025-03-28T11:05:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1018,3 +1081,25 @@ def get_containers_with_entity(
     #     publishable_entity__draft__version__containerversion__entity_list__in=lists
     # )
     return qs
+
+
+def get_container_children_count(
+    container: Container,
+    *,
+    published: bool,
+):
+    """
+    [ 🛑 UNSTABLE ]
+    Get the count of entities in the current draft or published version of the given container.
+
+    Args:
+        container: The Container, e.g. returned by `get_container()`
+        published: `True` if we want the published version of the container, or
+            `False` for the draft version.
+    """
+    assert isinstance(container, Container)
+    container_version = container.versioning.published if published else container.versioning.draft
+    if container_version is None:
+        raise ContainerVersion.DoesNotExist  # This container has not been published yet, or has been deleted.
+    assert isinstance(container_version, ContainerVersion)
+    return container_version.entity_list.entitylistrow_set.count()
path: openedx_learning/apps/authoring/publishing/api.py
user: login: navinkarkera
body: Yes, thank you. Updated: a11f1bdfee83
created_at: 2025-03-28T11:06:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -771,6 +774,64 @@ def create_container_version(
     return container_version
 
 
+class ChildrenEntitiesAction(Enum):
+    """Possible actions for children entities"""
+
+    APPEND = "append"
+    REMOVE = "remove"
+    REPLACE = "replace"
+
+
+def get_next_entity_list(
path: openedx_learning/apps/authoring/publishing/api.py
user: login: navinkarkera
body: Updated: e1222ea27274
created_at: 2025-03-28T11:06:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -151,6 +152,7 @@ def create_next_unit_version(
         created=created,
         created_by=created_by,
         container_version_cls=UnitVersion,
+        entities_action=entities_action,
     )
     return unit_version
 
path: openedx_learning/apps/authoring/units/api.py
user: login: bradenmacdonald
body: > it's inconsistent that we require a Unit object to call Unit API methods, when we require a container_key for the Container API methods.

For what it's worth, we also kind of discussed this here on the previous PR: https://github.com/openedx/openedx-learning/pull/278#discussion_r1980520531
created_at: 2025-04-03T19:07:28Z
author_association: CONTRIBUTOR

PR comments:
user: login: openedx-webhooks
created_at: 2025-03-24T07:10:05Z
body: Thanks for the pull request, @navinkarkera!


This repository is currently maintained by `@axim-engineering`.


Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

<details><summary>:radio_button: Get product approval</summary>

If you haven't already, [check this list](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review#Does-my-contribution-require-Product-Review%3F) to see if your contribution needs to go through the product review process.

- If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the [Product Working Group](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3449028609/Product+Working+Group).
    - This process (including the steps you'll need to take) is documented [here](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review#Product-Review-Process).
- If it doesn't, simply proceed with the next step.
</details>

<details><summary>:radio_button: Provide context</summary>

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

- Dependencies
  > This PR must be merged before / after / at the same time as ...
- Blockers
  > This PR is waiting for OEP-1234 to be accepted.
- Timeline information
  > This PR must be merged by XX date **because** ...
- Partner information
  > This is for a course on edx.org.
- Supporting documentation
- Relevant [Open edX discussion forum](https://discuss.openedx.org/) threads
</details>





<details><summary>:radio_button: Get a green build</summary>

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.
</details><details>


---

<details><summary>Where can I find more information?</summary>

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

- [Overview of Review Process for Community Contributions](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/FAQ-about-pull-requests.html)
- [Pull Request Status Guide](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/pull-request-statuses.html)
- [Making changes to your pull request](https://docs.openedx.org/en/latest/documentors/how-tos/make_changes_to_your_pull_request.html)
</details>

<details><summary>When can I expect my changes to be merged?</summary>

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

- The size and impact of the changes that it introduces
- The need for product review
- Maintenance status of the parent repository

:bulb: *As a result it may take up to several weeks or months to complete a review and merge your PR.*
</details>
<!-- comment:external_pr -->
<!-- data: eyJkcmFmdCI6IGZhbHNlfQ== -->

author_association: NONE
user: login: navinkarkera
created_at: 2025-03-28T11:12:11Z
body: > At a high level, I'm not clear on why we need knowledge of append/remove/replace at this level. As someone who hasn't kept up with the edx-platform, it seems like that could all be done there, leaving the Learning Core API a bit dumber and less opinionated about how the next entity list is calculated.

@ormsbee The only reason was to avoid (re)fetching children entities before calling `create_next_container_version` in which we are already getting the [last version](https://github.com/open-craft/openedx-learning/blob/0c7730a9eb9aac68bc1a81c6e80f85d58bad68b9/openedx_learning/apps/authoring/publishing/api.py#L879).
author_association: CONTRIBUTOR
user: login: navinkarkera
created_at: 2025-03-31T09:25:05Z
body: @ormsbee Thanks!, squashed, updated version and ready to merge.
author_association: CONTRIBUTOR
user: login: bradenmacdonald
created_at: 2025-04-09T18:45:03Z
body: FYI @navinkarkera it looks like this PR missed a migration for a change to ordering - see https://github.com/openedx/openedx-learning/pull/298#discussion_r2033479243
author_association: CONTRIBUTOR
user: login: navinkarkera
created_at: 2025-04-10T05:47:43Z
body: @bradenmacdonald Oops, missed it.
author_association: CONTRIBUTOR