diff --git a/openedx_learning/apps/authoring/publishing/api.py b/openedx_learning/apps/authoring/publishing/api.py index 801f9505..548130c8 100644 --- a/openedx_learning/apps/authoring/publishing/api.py +++ b/openedx_learning/apps/authoring/publishing/api.py @@ -216,10 +216,36 @@ def get_all_drafts(learning_package_id: int, /) -> QuerySet[Draft]: ) -def get_entities_with_unpublished_changes(learning_package_id: int, /) -> QuerySet[PublishableEntity]: - return PublishableEntity.objects \ - .filter(learning_package_id=learning_package_id) \ - .exclude(draft__version=F('published__version')) +def get_entities_with_unpublished_changes( + learning_package_id: int, + /, + include_deleted_drafts: bool = False +) -> QuerySet[PublishableEntity]: + """ + Fetch entities that have unpublished changes. + + By default, this excludes soft-deleted drafts but can be included using include_deleted_drafts option. + """ + entities_qs = ( + PublishableEntity.objects + .filter(learning_package_id=learning_package_id) + .exclude(draft__version=F('published__version')) + ) + + if include_deleted_drafts: + # This means that we should also return PublishableEntities where the draft + # has been soft-deleted, but that deletion has not been published yet. Just + # excluding records where the Draft and Published versions don't match won't + # be enough here, because that will return soft-deletes that have already + # been published (since NULL != NULL in SQL). + # + # So we explicitly exclude already-published soft-deletes: + return entities_qs.exclude( + Q(draft__version__isnull=True) & Q(published__version__isnull=True) + ) + + # Simple case: exclude all entities that have been soft-deleted. + return entities_qs.exclude(draft__version__isnull=True) def get_entities_with_unpublished_deletes(learning_package_id: int, /) -> QuerySet[PublishableEntity]: diff --git a/tests/openedx_learning/apps/authoring/publishing/test_api.py b/tests/openedx_learning/apps/authoring/publishing/test_api.py index a9e7bf85..421dd66c 100644 --- a/tests/openedx_learning/apps/authoring/publishing/test_api.py +++ b/tests/openedx_learning/apps/authoring/publishing/test_api.py @@ -327,6 +327,47 @@ def test_reset_drafts_to_published(self) -> None: pub_version_num ) + def test_get_entities_with_unpublished_changes(self) -> None: + """Test fetching entities with unpublished changes after soft deletes.""" + entity = publishing_api.create_publishable_entity( + self.learning_package.id, + "my_entity", + created=self.now, + created_by=None, + ) + publishing_api.create_publishable_entity_version( + entity.id, + version_num=1, + title="An Entity 🌴", + created=self.now, + created_by=None, + ) + + # Fetch unpublished entities + entities = publishing_api.get_entities_with_unpublished_changes(self.learning_package.id) + records = list(entities.all()) + assert len(records) == 1 + record = records[0] + assert record.id == entity.id + + # Initial publish + publishing_api.publish_all_drafts(self.learning_package.id) + + # soft-delete entity + publishing_api.soft_delete_draft(entity.id) + entities = publishing_api.get_entities_with_unpublished_changes(self.learning_package.id) + assert len(entities) == 0 + entities = publishing_api.get_entities_with_unpublished_changes(self.learning_package.id, + include_deleted_drafts=True) + assert len(entities) == 1 + + # publish soft-delete + publishing_api.publish_all_drafts(self.learning_package.id) + entities = publishing_api.get_entities_with_unpublished_changes(self.learning_package.id, + include_deleted_drafts=True) + # should not return published soft-deleted entities. + assert len(entities) == 0 + 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: