From 9d919762ac854a59b51d1e5f37679524a8cc6cfa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Chris=20Ch=C3=A1vez?= <xnpiochv@gmail.com>
Date: Tue, 12 Nov 2024 12:42:18 -0500
Subject: [PATCH] feat: add filter functions to publishing API [FC-0062] (#257)

Adds filter_publishable_entities() to openedx.apps.authoring.publishing.api
(and the public openedx.api.authoring package). This also bumps the
version to 0.18.0

The motivation for this change is so that other apps can filter their
PublishableEntity querysets without having to dig into the internals of
the "publishing" app's data model relations. For instance, the
"collections" app could already answer the question, "What Publishable
Entities are in this Collection?" But to answer the question of, "What
are the Publishable Entities in this Collection that have a published
version?" requires filtering on "published__version__isnull", which is
a level of detail that we don't want to burden other apps with.

With this commit, they could call something like this instead:

    published_entities = filter_publishable_entities(
        collection.entities(),
        has_published=True,
    )

It's possible that this could be done in a more natural way with custom
Managers/QuerySets. The main concern there would be to make sure that
those come across correctly in various RelatedManagers, e.g. to make
sure that something like "Collection.entities" returns the customized
version. This is something we can follow up on in the future.
---
 openedx_learning/__init__.py                  |  2 +-
 .../apps/authoring/publishing/api.py          | 20 +++++
 .../apps/authoring/publishing/test_api.py     | 74 +++++++++++++++++++
 3 files changed, 95 insertions(+), 1 deletion(-)

diff --git a/openedx_learning/__init__.py b/openedx_learning/__init__.py
index 23b5c7c2..f99dcab8 100644
--- a/openedx_learning/__init__.py
+++ b/openedx_learning/__init__.py
@@ -2,4 +2,4 @@
 Open edX Learning ("Learning Core").
 """
 
-__version__ = "0.17.0"
+__version__ = "0.18.0"
diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py
index 7e2c6ba9..3facc891 100644
--- a/openedx_learning/apps/authoring/publishing/api.py
+++ b/openedx_learning/apps/authoring/publishing/api.py
@@ -50,6 +50,7 @@
     "soft_delete_draft",
     "reset_drafts_to_published",
     "register_content_models",
+    "filter_publishable_entities",
 ]
 
 
@@ -493,3 +494,22 @@ def ready(self):
     return PublishableContentModelRegistry.register(
         content_model_cls, content_version_model_cls
     )
+
+
+def filter_publishable_entities(
+    entities: QuerySet[PublishableEntity],
+    has_draft=None,
+    has_published=None
+) -> QuerySet[PublishableEntity]:
+    """
+    Filter an entities query set.
+
+    has_draft: You can filter by entities that has a draft or not.
+    has_published: You can filter by entities that has a published version or not.
+    """
+    if has_draft is not None:
+        entities = entities.filter(draft__version__isnull=not has_draft)
+    if has_published is not None:
+        entities = entities.filter(published__version__isnull=not has_published)
+
+    return entities
diff --git a/tests/openedx_learning/apps/authoring/publishing/test_api.py b/tests/openedx_learning/apps/authoring/publishing/test_api.py
index 421dd66c..119a5548 100644
--- a/tests/openedx_learning/apps/authoring/publishing/test_api.py
+++ b/tests/openedx_learning/apps/authoring/publishing/test_api.py
@@ -368,6 +368,80 @@ def test_get_entities_with_unpublished_changes(self) -> None:
         # should not return published soft-deleted entities.
         assert len(entities) == 0
 
+    def test_filter_publishable_entities(self) -> None:
+        count_published = 7
+        count_drafts = 6
+        count_no_drafts = 3
+
+        for index in range(count_published):
+            # Create entities to publish
+            entity = publishing_api.create_publishable_entity(
+                self.learning_package.id,
+                f"entity_published_{index}",
+                created=self.now,
+                created_by=None,
+            )
+
+            publishing_api.create_publishable_entity_version(
+                entity.id,
+                version_num=1,
+                title=f"Entity_published_{index}",
+                created=self.now,
+                created_by=None,
+            )
+
+        publishing_api.publish_all_drafts(self.learning_package.id)
+
+        for index in range(count_drafts):
+            # Create entities with drafts
+            entity = publishing_api.create_publishable_entity(
+                self.learning_package.id,
+                f"entity_draft_{index}",
+                created=self.now,
+                created_by=None,
+            )
+
+            publishing_api.create_publishable_entity_version(
+                entity.id,
+                version_num=1,
+                title=f"Entity_draft_{index}",
+                created=self.now,
+                created_by=None,
+            )
+
+        for index in range(count_no_drafts):
+            # Create entities without drafts
+            entity = publishing_api.create_publishable_entity(
+                self.learning_package.id,
+                f"entity_no_draft_{index}",
+                created=self.now,
+                created_by=None,
+            )
+
+        drafts = publishing_api.filter_publishable_entities(
+            PublishableEntity.objects.all(),
+            has_draft=True,
+        )
+        assert drafts.count() == (count_published + count_drafts)
+
+        no_drafts = publishing_api.filter_publishable_entities(
+            PublishableEntity.objects.all(),
+            has_draft=False,
+        )
+        assert no_drafts.count() == count_no_drafts
+
+        published = publishing_api.filter_publishable_entities(
+            PublishableEntity.objects.all(),
+            has_published=True,
+        )
+        assert published.count() == count_published
+
+        no_published = publishing_api.filter_publishable_entities(
+            PublishableEntity.objects.all(),
+            has_published=False,
+        )
+        assert no_published.count() == (count_drafts + count_no_drafts)
+
     def _get_published_version_num(self, entity: PublishableEntity) -> int | None:
         published_version = publishing_api.get_published_version(entity.id)
         if published_version is not None: