From 6f4b1dcb8e05b816d315bbd42608f10d6252559d Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 16 Mar 2026 18:10:27 -0500 Subject: [PATCH 1/6] feat: `get_entity_draft_history` added --- src/openedx_content/applets/publishing/api.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 64b190a09..ce92985cb 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -71,6 +71,7 @@ "publish_from_drafts", "get_draft_version", "get_published_version", + "get_entity_draft_history", "set_draft_version", "soft_delete_draft", "reset_drafts_to_published", @@ -584,6 +585,45 @@ def get_published_version(publishable_entity_or_id: PublishableEntity | int, /) return published.version +def get_entity_draft_history( + publishable_entity_or_id: PublishableEntity | int, / +) -> QuerySet[DraftChangeLogRecord]: + """ + Return DraftChangeLogRecords for a PublishableEntity since its last publication, + ordered from most recent to oldest. + + If the entity has never been published, all DraftChangeLogRecords are returned. + """ + if isinstance(publishable_entity_or_id, int): + entity_id = publishable_entity_or_id + else: + entity_id = publishable_entity_or_id.pk + + qs = ( + DraftChangeLogRecord.objects + .filter(entity_id=entity_id) + .select_related( + "draft_change_log__changed_by", + "old_version", + "new_version", + ) + .order_by("-draft_change_log__changed_at") + ) + + # Narrow to changes since the last publication + try: + published = Published.objects.select_related( + "publish_log_record__publish_log" + ).get(entity_id=entity_id) + qs = qs.filter( + draft_change_log__changed_at__gt=published.publish_log_record.publish_log.published_at + ) + except Published.DoesNotExist: + pass + + return qs + + def set_draft_version( draft_or_id: Draft | int, publishable_entity_version_pk: int | None, From c492314bbc6fd8e944ea0fd8de1c200c4ebeb926 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 20 Mar 2026 18:46:58 -0500 Subject: [PATCH 2/6] feat: contributors, publish groups and publish entries functions added --- src/openedx_content/applets/publishing/api.py | 169 ++++++- .../applets/publishing/test_api.py | 446 ++++++++++++++++++ 2 files changed, 614 insertions(+), 1 deletion(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index ce92985cb..826ca7b16 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -12,6 +12,7 @@ from enum import Enum from typing import ContextManager, Optional, TypeVar +from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models import F, Prefetch, Q, QuerySet from django.db.transaction import atomic @@ -72,6 +73,9 @@ "get_draft_version", "get_published_version", "get_entity_draft_history", + "get_entity_publish_history", + "get_entity_publish_history_entries", + "get_entity_version_contributors", "set_draft_version", "soft_delete_draft", "reset_drafts_to_published", @@ -592,7 +596,17 @@ def get_entity_draft_history( Return DraftChangeLogRecords for a PublishableEntity since its last publication, ordered from most recent to oldest. - If the entity has never been published, all DraftChangeLogRecords are returned. + Edge cases: + - Never published, no versions: returns an empty queryset. + - Never published, has versions: returns all DraftChangeLogRecords. + - No changes since the last publish: returns an empty queryset. + - Last publish was a soft-delete (Published.version=None): the Published row + still exists and its published_at timestamp is used as the lower bound, so + only draft changes made after that soft-delete publish are returned. If + there are no subsequent changes, the queryset is empty. + - Unpublished soft-delete (soft-delete in draft, not yet published): the + soft-delete DraftChangeLogRecord (new_version=None) is included because + it was made after the last real publish. """ if isinstance(publishable_entity_or_id, int): entity_id = publishable_entity_or_id @@ -624,6 +638,159 @@ def get_entity_draft_history( return qs +def get_entity_publish_history( + publishable_entity_or_id: PublishableEntity | int, / +) -> QuerySet[PublishLogRecord]: + """ + Return all PublishLogRecords for a PublishableEntity, ordered most recent first. + + Each record represents one publish event for this entity. old_version and + new_version are pre-fetched so callers can compute version bounds without + extra queries. + + Edge cases: + - Never published: returns an empty queryset. + - Soft-delete published (new_version=None): the record is included with + old_version pointing to the last published version and new_version=None, + indicating the entity was removed from the published state. + - Multiple draft versions created between two publishes are compacted: each + PublishLogRecord captures only the version that was actually published, + not the intermediate draft versions. + """ + if isinstance(publishable_entity_or_id, int): + entity_id = publishable_entity_or_id + else: + entity_id = publishable_entity_or_id.pk + + return ( + PublishLogRecord.objects + .filter(entity_id=entity_id) + .select_related( + "publish_log__published_by", + "old_version", + "new_version", + ) + .order_by("-publish_log__published_at") + ) + + +def get_entity_publish_history_entries( + publishable_entity_or_id: PublishableEntity | int, + /, + publish_log_uuid: str, +) -> QuerySet[DraftChangeLogRecord]: + """ + Return the DraftChangeLogRecords associated with a specific PublishLog. + + Finds the PublishLogRecord for the given entity and publish_log_uuid, then + returns all DraftChangeLogRecords whose changed_at falls between the previous + publish for this entity (exclusive) and this publish (inclusive), ordered + most-recent-first. + + Time bounds are used instead of version bounds because DraftChangeLogRecord + has no single version_num field (soft-delete records have new_version=None), + and using published_at timestamps cleanly handles all cases without extra + joins. + + Edge cases: + - Each publish group is independent: only the DraftChangeLogRecords that + belong to the requested publish_log_uuid are returned; changes attributed + to other publish groups are excluded. + - Soft-delete publish (PublishLogRecord.new_version=None): the soft-delete + DraftChangeLogRecord (new_version=None) is included in the entries because + it falls within the time window of that publish group. + + Raises PublishLogRecord.DoesNotExist if publish_log_uuid is not found for + this entity. + """ + if isinstance(publishable_entity_or_id, int): + entity_id = publishable_entity_or_id + else: + entity_id = publishable_entity_or_id.pk + + # Fetch the PublishLogRecord for the requested PublishLog + pub_record = ( + PublishLogRecord.objects + .filter(entity_id=entity_id, publish_log__uuid=publish_log_uuid) + .select_related("publish_log") + .get() + ) + published_at = pub_record.publish_log.published_at + + # Find the previous publish for this entity to use as the lower time bound + prev_pub_record = ( + PublishLogRecord.objects + .filter(entity_id=entity_id, publish_log__published_at__lt=published_at) + .select_related("publish_log") + .order_by("-publish_log__published_at") + .first() + ) + prev_published_at = prev_pub_record.publish_log.published_at if prev_pub_record else None + + # All draft changes up to (and including) this publish's timestamp + draft_qs = ( + DraftChangeLogRecord.objects + .filter(entity_id=entity_id, draft_change_log__changed_at__lte=published_at) + .select_related("draft_change_log__changed_by", "old_version", "new_version") + .order_by("-draft_change_log__changed_at") + ) + # Exclude changes that belong to an earlier PublishLog's window + if prev_published_at: + draft_qs = draft_qs.filter(draft_change_log__changed_at__gt=prev_published_at) + + return draft_qs + + +def get_entity_version_contributors( + publishable_entity_or_id: PublishableEntity | int, + /, + old_version_num: int, + new_version_num: int | None, +) -> QuerySet: + """ + Return distinct User queryset of contributors (changed_by) for + DraftChangeLogRecords of a PublishableEntity after old_version_num. + + If new_version_num is not None (normal publish), captures records where + new_version is between old_version_num (exclusive) and new_version_num (inclusive). + + If new_version_num is None (soft delete published), captures both normal + edits after old_version_num AND the soft-delete record itself (identified + by new_version=None and old_version >= old_version_num). A soft-delete + record whose old_version falls before old_version_num is excluded. + + Edge cases: + - If no DraftChangeLogRecords fall in the range, returns an empty queryset. + - Records with changed_by=None (system changes with no associated user) are + always excluded. + - A user who contributed multiple versions in the range appears only once + (results are deduplicated with DISTINCT). + """ + entity_id = publishable_entity_or_id if isinstance(publishable_entity_or_id, int) else publishable_entity_or_id.pk + + if new_version_num is not None: + version_filter = Q( + new_version__version_num__gt=old_version_num, + new_version__version_num__lte=new_version_num, + ) + else: + # Soft delete: include edits after old_version_num + the soft-delete record + version_filter = ( + Q(new_version__version_num__gt=old_version_num) | + Q(new_version__isnull=True, old_version__version_num__gte=old_version_num) + ) + + contributor_ids = ( + DraftChangeLogRecord.objects + .filter(entity_id=entity_id) + .filter(version_filter) + .exclude(draft_change_log__changed_by=None) + .values_list("draft_change_log__changed_by", flat=True) + .distinct() + ) + return get_user_model().objects.filter(pk__in=contributor_ids) + + def set_draft_version( draft_or_id: Draft | int, publishable_entity_version_pk: int | None, diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index c0f113787..7725dd8a3 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timezone +from typing import Any from uuid import UUID import pytest @@ -22,6 +23,7 @@ LearningPackage, PublishableEntity, PublishLog, + PublishLogRecord, ) User = get_user_model() @@ -1424,3 +1426,447 @@ def test_get_publishable_entities_n_plus_problem(self) -> None: published = getattr(e, 'published', None) assert draft and draft.version.version_num == 1 assert published and published.version.version_num == 1 + + +class PublishingHistoryMixin: + """ + Shared setup for history-related TestCases. + + Provides timestamps and a setUpTestData that creates a single + LearningPackage and PublishableEntity reused across all tests in the class. + """ + learning_package: LearningPackage + entity: PublishableEntity + + time_1 = datetime(2026, 6, 1, 10, 0, 0, tzinfo=timezone.utc) + time_2 = datetime(2026, 6, 1, 11, 0, 0, tzinfo=timezone.utc) + time_3 = datetime(2026, 6, 1, 12, 0, 0, tzinfo=timezone.utc) + time_4 = datetime(2026, 6, 1, 13, 0, 0, tzinfo=timezone.utc) + time_5 = datetime(2026, 6, 1, 14, 0, 0, tzinfo=timezone.utc) + + @classmethod + def setUpTestData(cls) -> None: + """Create a shared LearningPackage and PublishableEntity for all tests in the class.""" + cls.learning_package = publishing_api.create_learning_package( + "history_pkg", + "History Test Package", + created=cls.time_1, + ) + cls.entity = publishing_api.create_publishable_entity( + cls.learning_package.id, + "test_entity", + created=cls.time_1, + created_by=None, + ) + + def _make_version(self, version_num: int, at: datetime, created_by=None): + return publishing_api.create_publishable_entity_version( + self.entity.id, + version_num=version_num, + title=f"v{version_num}", + created=at, + created_by=created_by, + ) + + def _publish(self, at: datetime) -> PublishLog: + return publishing_api.publish_all_drafts(self.learning_package.id, published_at=at) + + +class GetEntityDraftHistoryTestCase(PublishingHistoryMixin, TestCase): + """ + Tests for get_entity_draft_history. + """ + # Publish timestamps sit strictly between draft-change timestamps + publish_time_1 = datetime(2026, 6, 1, 10, 30, 0, tzinfo=timezone.utc) + publish_time_2 = datetime(2026, 6, 1, 11, 30, 0, tzinfo=timezone.utc) + + def test_no_versions_never_published(self) -> None: + """Returns empty queryset when the entity has no versions and has never been published.""" + history = publishing_api.get_entity_draft_history(self.entity.id) + + assert history.count() == 0 + + def test_never_published(self) -> None: + """Returns all draft records when the entity has never been published.""" + self._make_version(1, self.time_1) + self._make_version(2, self.time_2) + + history = publishing_api.get_entity_draft_history(self.entity.id) + + assert history.count() == 2 + # most-recent-first ordering + assert list(history.values_list("new_version__version_num", flat=True)) == [2, 1] + + def test_no_changes_since_publish(self) -> None: + """Returns empty queryset when no draft changes have been made after the last publish.""" + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + + history = publishing_api.get_entity_draft_history(self.entity.id) + + assert history.count() == 0 + + def test_changes_since_publish(self) -> None: + """Returns only draft records made after the last publish, ordered most-recent-first.""" + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + self._make_version(2, self.time_2) + self._make_version(3, self.time_3) + + history = publishing_api.get_entity_draft_history(self.entity.id) + + assert history.count() == 2 + assert list(history.values_list("new_version__version_num", flat=True)) == [3, 2] + + def test_unpublished_soft_delete(self) -> None: + """ + A soft-delete that is still pending (not yet published) is included in + the draft history since the last real publish. + """ + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + publishing_api.set_draft_version(self.entity.id, None, set_at=self.time_2) + + history = publishing_api.get_entity_draft_history(self.entity.id) + + assert history.count() == 1 + record = history.get() + assert record.new_version is None + + def test_after_published_soft_delete_no_new_changes(self) -> None: + """ + When the last publish was a soft-delete (Published.version=None) and + there are no subsequent draft changes, history is empty. + """ + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + publishing_api.set_draft_version(self.entity.id, None, set_at=self.time_2) + self._publish(self.publish_time_2) # publish the soft-delete + + history = publishing_api.get_entity_draft_history(self.entity.id) + + assert history.count() == 0 + + def test_after_published_soft_delete_with_new_changes(self) -> None: + """ + When the last publish was a soft-delete, only the draft changes made + after that publish are returned (i.e. the post-delete edits). + """ + version_1 = self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + publishing_api.set_draft_version(self.entity.id, None, set_at=self.time_2) + self._publish(self.publish_time_2) # publish the soft-delete + # Restore: point draft back to v1 after the delete was published + publishing_api.set_draft_version(self.entity.id, version_1.id, set_at=self.time_3) + self._make_version(2, self.time_4) + + history = publishing_api.get_entity_draft_history(self.entity.id) + + assert history.count() == 2 + assert list(history.values_list("new_version__version_num", flat=True)) == [2, 1] + + def test_accepts_entity_or_int(self) -> None: + """Works identically when called with a PublishableEntity or its int pk.""" + self._make_version(1, self.time_1) + + history_by_int = publishing_api.get_entity_draft_history(self.entity.id) + history_by_entity = publishing_api.get_entity_draft_history(self.entity) + + assert list(history_by_int) == list(history_by_entity) + + +class GetEntityPublishHistoryTestCase(PublishingHistoryMixin, TestCase): + """ + Tests for get_entity_publish_history. + """ + publish_time_1 = datetime(2026, 6, 1, 10, 30, 0, tzinfo=timezone.utc) + publish_time_2 = datetime(2026, 6, 1, 12, 30, 0, tzinfo=timezone.utc) + publish_time_3 = datetime(2026, 6, 1, 14, 30, 0, tzinfo=timezone.utc) + + def test_never_published(self) -> None: + """Returns empty queryset when the entity has never been published.""" + history = publishing_api.get_entity_publish_history(self.entity.id) + + assert history.count() == 0 + + def test_single_publish(self) -> None: + """Returns one record with correct old/new versions after the first publish.""" + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + + history = publishing_api.get_entity_publish_history(self.entity.id) + + assert history.count() == 1 + record = history.get() + assert record.old_version is None + assert record.new_version is not None + assert record.new_version.version_num == 1 + + def test_multiple_publishes_ordered_most_recent_first(self) -> None: + """ + Returns one record per publish ordered most-recent-first, with the + correct old/new versions. Multiple draft versions created between + publishes are compacted: the record only captures the version that was + actually published, not the intermediate ones. + """ + # First publish: v1 + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + + # Create v2 and v3 between the first and second publish; only v3 lands in the record. + self._make_version(2, self.time_2) + self._make_version(3, self.time_3) + self._publish(self.publish_time_2) + + # Create v4 and v5 before the third publish; only v5 lands in the record. + self._make_version(4, self.time_4) + self._make_version(5, self.time_5) + self._publish(self.publish_time_3) + + history = list(publishing_api.get_entity_publish_history(self.entity.id)) + + assert len(history) == 3 + # most recent publish: v3 -> v5 + assert history[0].old_version is not None + assert history[0].new_version is not None + assert history[0].old_version.version_num == 3 + assert history[0].new_version.version_num == 5 + # second publish: v1 -> v3 + assert history[1].old_version is not None + assert history[1].new_version is not None + assert history[1].old_version.version_num == 1 + assert history[1].new_version.version_num == 3 + # first publish: None -> v1 + assert history[2].old_version is None + assert history[2].new_version is not None + assert history[2].new_version.version_num == 1 + + def test_soft_delete_publish(self) -> None: + """ + Publishing a soft-delete produces a record with new_version=None, + reflecting that the entity was removed from the published state. + """ + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + publishing_api.set_draft_version(self.entity.id, None, set_at=self.time_2) + self._publish(self.publish_time_2) + + history = list(publishing_api.get_entity_publish_history(self.entity.id)) + + assert len(history) == 2 + # most recent: the soft-delete publish + assert history[0].old_version is not None + assert history[0].old_version.version_num == 1 + assert history[0].new_version is None + # original publish + assert history[1].old_version is None + assert history[1].new_version is not None + assert history[1].new_version.version_num == 1 + + def test_accepts_entity_or_int(self) -> None: + """Works identically when called with a PublishableEntity or its int pk.""" + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + + history_by_int = publishing_api.get_entity_publish_history(self.entity.id) + history_by_entity = publishing_api.get_entity_publish_history(self.entity) + + assert list(history_by_int) == list(history_by_entity) + + +class GetEntityVersionContributorsTestCase(PublishingHistoryMixin, TestCase): + """ + Tests for get_entity_version_contributors. + """ + user_1: Any + user_2: Any + user_3: Any + + @classmethod + def setUpTestData(cls) -> None: + super().setUpTestData() + cls.user_1 = User.objects.create(username="contributor_1") + cls.user_2 = User.objects.create(username="contributor_2") + cls.user_3 = User.objects.create(username="contributor_3") + + def test_no_changes_in_range(self) -> None: + """Returns empty queryset when no draft changes fall within the version range.""" + self._make_version(1, self.time_1, created_by=self.user_1.id) + + contributors = publishing_api.get_entity_version_contributors( + self.entity.id, old_version_num=1, new_version_num=1 + ) + + assert contributors.count() == 0 + + def test_single_contributor(self) -> None: + """Returns the user who made changes in the version range.""" + self._make_version(1, self.time_1) + self._make_version(2, self.time_2, created_by=self.user_1.id) + + contributors = publishing_api.get_entity_version_contributors( + self.entity.id, old_version_num=1, new_version_num=2 + ) + + assert contributors.count() == 1 + assert contributors.get() == self.user_1 + + def test_multiple_contributors_are_distinct(self) -> None: + """Returns distinct users even if one user contributed multiple versions in the range.""" + self._make_version(1, self.time_1) + self._make_version(2, self.time_2, created_by=self.user_1.id) + self._make_version(3, self.time_3, created_by=self.user_2.id) + self._make_version(4, self.time_4, created_by=self.user_1.id) # user_1 again + + contributors = publishing_api.get_entity_version_contributors( + self.entity.id, old_version_num=1, new_version_num=4 + ) + + assert contributors.count() == 2 + assert set(contributors) == {self.user_1, self.user_2} + + def test_excludes_changes_outside_version_range(self) -> None: + """Changes at or before old_version_num and after new_version_num are excluded.""" + self._make_version(1, self.time_1, created_by=self.user_1.id) # at boundary, excluded + self._make_version(2, self.time_2, created_by=self.user_2.id) # inside range + self._make_version(3, self.time_3, created_by=self.user_3.id) # after range, excluded + + contributors = publishing_api.get_entity_version_contributors( + self.entity.id, old_version_num=1, new_version_num=2 + ) + + assert contributors.count() == 1 + assert contributors.get() == self.user_2 + + def test_excludes_null_changed_by(self) -> None: + """Changes with no associated user (changed_by=None) are never returned.""" + self._make_version(1, self.time_1, created_by=None) + self._make_version(2, self.time_2, created_by=None) + + contributors = publishing_api.get_entity_version_contributors( + self.entity.id, old_version_num=1, new_version_num=2 + ) + + assert contributors.count() == 0 + + def test_soft_delete_includes_edits_and_delete_record(self) -> None: + """ + When new_version_num is None (soft-delete publish), both regular edits + after old_version_num and the soft-delete record itself are included. + """ + self._make_version(1, self.time_1) + self._make_version(2, self.time_2, created_by=self.user_1.id) + self._make_version(3, self.time_3, created_by=self.user_2.id) + # Soft-delete from v3 by user_3 + publishing_api.set_draft_version( + self.entity.id, None, set_at=self.time_4, set_by=self.user_3.id + ) + + contributors = publishing_api.get_entity_version_contributors( + self.entity.id, old_version_num=1, new_version_num=None + ) + + assert set(contributors) == {self.user_1, self.user_2, self.user_3} + + def test_soft_delete_excludes_changes_before_range(self) -> None: + """ + When new_version_num is None, changes at or before old_version_num + are still excluded, including a soft-delete record whose old_version + falls before the range. + """ + self._make_version(1, self.time_1, created_by=self.user_1.id) + # Soft-delete from v1 — old_version_num=1, so old_version(1) < 1 is false, + # but old_version_num >= old_version_num means 1 >= 2 → excluded + publishing_api.set_draft_version( + self.entity.id, None, set_at=self.time_2, set_by=self.user_2.id + ) + + contributors = publishing_api.get_entity_version_contributors( + self.entity.id, old_version_num=2, new_version_num=None + ) + + assert contributors.count() == 0 + + def test_accepts_entity_or_int(self) -> None: + """Works identically when called with a PublishableEntity or its int pk.""" + self._make_version(1, self.time_1, created_by=self.user_1.id) + self._make_version(2, self.time_2, created_by=self.user_2.id) + + contributors_by_int = publishing_api.get_entity_version_contributors( + self.entity.id, old_version_num=1, new_version_num=2 + ) + contributors_by_entity = publishing_api.get_entity_version_contributors( + self.entity, old_version_num=1, new_version_num=2 + ) + + assert list(contributors_by_int) == list(contributors_by_entity) + + +class GetEntityPublishHistoryEntriesTestCase(PublishingHistoryMixin, TestCase): + """ + Tests for get_entity_publish_history_entries. + """ + publish_time_1 = datetime(2026, 6, 1, 10, 30, 0, tzinfo=timezone.utc) + publish_time_2 = datetime(2026, 6, 1, 12, 30, 0, tzinfo=timezone.utc) + + def test_returns_draft_changes_for_the_requested_publish_group(self) -> None: + """ + Returns only the DraftChangeLogRecords that belong to the requested + publish group (identified by its uuid), not those from other groups. + """ + self._make_version(1, self.time_1) + first_publish = self._publish(self.publish_time_1) + self._make_version(2, self.time_2) + self._make_version(3, self.time_3) + second_publish = self._publish(self.publish_time_2) + + entries_first = publishing_api.get_entity_publish_history_entries( + self.entity.id, str(first_publish.uuid) + ) + entries_second = publishing_api.get_entity_publish_history_entries( + self.entity.id, str(second_publish.uuid) + ) + + assert list(entries_first.values_list("new_version__version_num", flat=True)) == [1] + assert list(entries_second.values_list("new_version__version_num", flat=True)) == [3, 2] + + def test_soft_delete_publish_includes_delete_record(self) -> None: + """ + When the requested publish group was a soft-delete, the soft-delete + DraftChangeLogRecord (new_version=None) is included in the entries. + """ + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + publishing_api.set_draft_version(self.entity.id, None, set_at=self.time_2) + soft_delete_publish = self._publish(self.publish_time_2) + + entries = publishing_api.get_entity_publish_history_entries( + self.entity.id, str(soft_delete_publish.uuid) + ) + + assert entries.count() == 1 + assert entries.get().new_version is None + + def test_raises_if_publish_log_uuid_not_found(self) -> None: + """Raises PublishLogRecord.DoesNotExist for a uuid not associated with this entity.""" + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + + with pytest.raises(PublishLogRecord.DoesNotExist): + publishing_api.get_entity_publish_history_entries( + self.entity.id, "00000000-0000-0000-0000-000000000000" + ) + + def test_accepts_entity_or_int(self) -> None: + """Works identically when called with a PublishableEntity or its int pk.""" + self._make_version(1, self.time_1) + publish_log = self._publish(self.publish_time_1) + + entries_by_int = publishing_api.get_entity_publish_history_entries( + self.entity.id, str(publish_log.uuid) + ) + entries_by_entity = publishing_api.get_entity_publish_history_entries( + self.entity, str(publish_log.uuid) + ) + + assert list(entries_by_int) == list(entries_by_entity) From 3823f5ae4f9ec20432a05edcb4048109d7e76c39 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 24 Mar 2026 14:37:42 -0500 Subject: [PATCH 3/6] fix: return draft and published entries of a component with discarted changes --- src/openedx_content/applets/publishing/api.py | 49 ++++++++++++- .../applets/publishing/test_api.py | 71 +++++++++++++++++++ 2 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 826ca7b16..c87536a0f 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -624,14 +624,32 @@ def get_entity_draft_history( .order_by("-draft_change_log__changed_at") ) - # Narrow to changes since the last publication + # Narrow to changes since the last publication (or last reset to published) try: published = Published.objects.select_related( "publish_log_record__publish_log" ).get(entity_id=entity_id) - qs = qs.filter( - draft_change_log__changed_at__gt=published.publish_log_record.publish_log.published_at + published_at = published.publish_log_record.publish_log.published_at + published_version_id = published.version_id + + # If reset_drafts_to_published() was called after the last publish, + # there will be a DraftChangeLogRecord where new_version == published + # version. Use the most recent such record's timestamp as the lower + # bound so that discarded entries no longer appear in the draft history. + last_reset_at = ( + DraftChangeLogRecord.objects + .filter( + entity_id=entity_id, + new_version_id=published_version_id, + draft_change_log__changed_at__gt=published_at, + ) + .order_by("-draft_change_log__changed_at") + .values_list("draft_change_log__changed_at", flat=True) + .first() ) + + lower_bound = last_reset_at if last_reset_at else published_at + qs = qs.filter(draft_change_log__changed_at__gt=lower_bound) except Published.DoesNotExist: pass @@ -738,6 +756,31 @@ def get_entity_publish_history_entries( if prev_published_at: draft_qs = draft_qs.filter(draft_change_log__changed_at__gt=prev_published_at) + # Find the baseline: the version that was published in the previous publish group + # (None if this is the first publish for this entity). + baseline_version_id = prev_pub_record.new_version_id if prev_pub_record else None + + # If reset_drafts_to_published() was called within this publish window, there + # will be a DraftChangeLogRecord where new_version == baseline. Use the most + # recent such record as the new lower bound so discarded entries are excluded. + reset_filter = { + "entity_id": entity_id, + "new_version_id": baseline_version_id, + "draft_change_log__changed_at__lte": published_at, + } + if prev_published_at: + reset_filter["draft_change_log__changed_at__gt"] = prev_published_at + + last_reset_at = ( + DraftChangeLogRecord.objects + .filter(**reset_filter) + .order_by("-draft_change_log__changed_at") + .values_list("draft_change_log__changed_at", flat=True) + .first() + ) + if last_reset_at: + draft_qs = draft_qs.filter(draft_change_log__changed_at__gt=last_reset_at) + return draft_qs diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index 7725dd8a3..215a0d59b 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -1574,6 +1574,53 @@ def test_accepts_entity_or_int(self) -> None: assert list(history_by_int) == list(history_by_entity) + def test_reset_to_published_clears_draft_history(self) -> None: + """After reset_drafts_to_published, the draft history is empty.""" + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + self._make_version(2, self.time_2) + publishing_api.reset_drafts_to_published( + self.learning_package.id, reset_at=self.time_3 + ) + + history = publishing_api.get_entity_draft_history(self.entity.id) + + assert history.count() == 0 + + def test_reset_to_published_then_new_changes(self) -> None: + """After reset + new edits, only the post-reset changes appear.""" + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + self._make_version(2, self.time_2) + publishing_api.reset_drafts_to_published( + self.learning_package.id, reset_at=self.time_3 + ) + self._make_version(3, self.time_4) + + history = publishing_api.get_entity_draft_history(self.entity.id) + + assert history.count() == 1 + record = history.get() + assert record.new_version is not None + assert record.new_version.version_num == 3 + + def test_multiple_resets_use_latest(self) -> None: + """When reset is called multiple times, the latest reset time is used as lower bound.""" + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + self._make_version(2, self.time_2) + publishing_api.reset_drafts_to_published( + self.learning_package.id, reset_at=self.time_3 + ) + self._make_version(3, self.time_4) + publishing_api.reset_drafts_to_published( + self.learning_package.id, reset_at=self.time_5 + ) + + history = publishing_api.get_entity_draft_history(self.entity.id) + + assert history.count() == 0 + class GetEntityPublishHistoryTestCase(PublishingHistoryMixin, TestCase): """ @@ -1808,6 +1855,7 @@ class GetEntityPublishHistoryEntriesTestCase(PublishingHistoryMixin, TestCase): """ publish_time_1 = datetime(2026, 6, 1, 10, 30, 0, tzinfo=timezone.utc) publish_time_2 = datetime(2026, 6, 1, 12, 30, 0, tzinfo=timezone.utc) + publish_time_3 = datetime(2026, 6, 1, 13, 30, 0, tzinfo=timezone.utc) def test_returns_draft_changes_for_the_requested_publish_group(self) -> None: """ @@ -1870,3 +1918,26 @@ def test_accepts_entity_or_int(self) -> None: ) assert list(entries_by_int) == list(entries_by_entity) + + def test_reset_within_publish_window_excluded(self) -> None: + """ + Draft entries from a reset_drafts_to_published() call within the publish + window are excluded. Only entries made after the last reset appear. + """ + self._make_version(1, self.time_1) + self._publish(self.publish_time_1) + self._make_version(2, self.time_2) + publishing_api.reset_drafts_to_published( + self.learning_package.id, reset_at=self.time_3 + ) + self._make_version(3, self.time_4) + second_publish = self._publish(self.publish_time_3) + + entries = publishing_api.get_entity_publish_history_entries( + self.entity.id, str(second_publish.uuid) + ) + + assert entries.count() == 1 + entry = entries.get() + assert entry.new_version is not None + assert entry.new_version.version_num == 3 From 8e270a792bba684c7428167cac559d84344c2da9 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 24 Mar 2026 18:21:42 -0500 Subject: [PATCH 4/6] feat: Pre-fetch the component type in the queries --- src/openedx_content/applets/publishing/api.py | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index c87536a0f..98b1aa1f2 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -596,6 +596,11 @@ def get_entity_draft_history( Return DraftChangeLogRecords for a PublishableEntity since its last publication, ordered from most recent to oldest. + Each record pre-fetches ``entity__component__component_type`` so callers can + access ``record.entity.component.component_type`` (namespace and name) without + extra queries. Note: accessing ``.component`` on a record whose entity backs a + Container rather than a Component will raise ``RelatedObjectDoesNotExist``. + Edge cases: - Never published, no versions: returns an empty queryset. - Never published, has versions: returns all DraftChangeLogRecords. @@ -618,6 +623,7 @@ def get_entity_draft_history( .filter(entity_id=entity_id) .select_related( "draft_change_log__changed_by", + "entity__component__component_type", "old_version", "new_version", ) @@ -662,9 +668,12 @@ def get_entity_publish_history( """ Return all PublishLogRecords for a PublishableEntity, ordered most recent first. - Each record represents one publish event for this entity. old_version and - new_version are pre-fetched so callers can compute version bounds without - extra queries. + Each record represents one publish event for this entity. old_version, + new_version, and ``entity__component__component_type`` are pre-fetched so + callers can access ``record.entity.component.component_type`` (namespace and + name) without extra queries. Note: accessing ``.component`` on a record whose + entity backs a Container rather than a Component will raise + ``RelatedObjectDoesNotExist``. Edge cases: - Never published: returns an empty queryset. @@ -685,6 +694,7 @@ def get_entity_publish_history( .filter(entity_id=entity_id) .select_related( "publish_log__published_by", + "entity__component__component_type", "old_version", "new_version", ) @@ -710,6 +720,11 @@ def get_entity_publish_history_entries( and using published_at timestamps cleanly handles all cases without extra joins. + Each record pre-fetches ``entity__component__component_type`` so callers can + access ``record.entity.component.component_type`` (namespace and name) without + extra queries. Note: accessing ``.component`` on a record whose entity backs a + Container rather than a Component will raise ``RelatedObjectDoesNotExist``. + Edge cases: - Each publish group is independent: only the DraftChangeLogRecords that belong to the requested publish_log_uuid are returned; changes attributed @@ -749,7 +764,12 @@ def get_entity_publish_history_entries( draft_qs = ( DraftChangeLogRecord.objects .filter(entity_id=entity_id, draft_change_log__changed_at__lte=published_at) - .select_related("draft_change_log__changed_by", "old_version", "new_version") + .select_related( + "draft_change_log__changed_by", + "entity__component__component_type", + "old_version", + "new_version", + ) .order_by("-draft_change_log__changed_at") ) # Exclude changes that belong to an earlier PublishLog's window From 723fe11ba2b222ec693f671217d3ef915d24cd4a Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 27 Mar 2026 13:05:13 -0500 Subject: [PATCH 5/6] feat: get_descendant_component_entity_ids function added --- src/openedx_content/applets/publishing/api.py | 60 +++++++++++ .../applets/publishing/test_api.py | 101 ++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/src/openedx_content/applets/publishing/api.py b/src/openedx_content/applets/publishing/api.py index 98b1aa1f2..c8132c9f5 100644 --- a/src/openedx_content/applets/publishing/api.py +++ b/src/openedx_content/applets/publishing/api.py @@ -72,6 +72,7 @@ "publish_from_drafts", "get_draft_version", "get_published_version", + "get_descendant_component_entity_ids", "get_entity_draft_history", "get_entity_publish_history", "get_entity_publish_history_entries", @@ -589,6 +590,65 @@ def get_published_version(publishable_entity_or_id: PublishableEntity | int, /) return published.version +def get_descendant_component_entity_ids(container: Container) -> list[int]: + """ + Return the entity IDs of all leaf (non-Container) descendants of ``container``. + + Intermediate containers (e.g. Subsections, Units) are never included in the + result; only leaf component entities are returned. + + The traversal follows draft state only. Soft-deleted children + (``Draft.version = None``) are skipped because they have no + ``ContainerVersion`` to walk into. + + Edge cases: + - A container whose draft was soft-deleted has no children to traverse and + contributes no entity IDs. + - An entity that appears as a child of multiple containers is deduplicated + because the result is built from a set. + - A cycle-guard (``visited_container_pks``) prevents infinite loops, which + cannot occur in practice but is included for safety. + """ + all_component_ids: set[int] = set() + current_level_pks: list[int] = [container.pk] + visited_container_pks: set[int] = {container.pk} + + while current_level_pks: + # Step A: resolve entity_list IDs for this level's containers via their draft version + entity_list_ids = list( + Draft.objects + .filter(entity_id__in=current_level_pks, version__isnull=False) + .exclude(version__containerversion__isnull=True) + .values_list('version__containerversion__entity_list_id', flat=True) + ) + if not entity_list_ids: + break + + # Step B: collect all child entity IDs + child_entity_ids = list( + EntityListRow.objects + .filter(entity_list_id__in=entity_list_ids) + .values_list('entity_id', flat=True) + .distinct() + ) + if not child_entity_ids: + break + + # Step C: separate sub-containers from leaf entities + sub_container_pks = set( + Container.objects + .filter(publishable_entity_id__in=child_entity_ids) + .values_list('publishable_entity_id', flat=True) + ) + all_component_ids.update(set(child_entity_ids) - sub_container_pks) + + next_level = sub_container_pks - visited_container_pks + visited_container_pks.update(next_level) + current_level_pks = list(next_level) + + return list(all_component_ids) + + def get_entity_draft_history( publishable_entity_or_id: PublishableEntity | int, / ) -> QuerySet[DraftChangeLogRecord]: diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index 215a0d59b..036d5a078 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -1941,3 +1941,104 @@ def test_reset_within_publish_window_excluded(self) -> None: entry = entries.get() assert entry.new_version is not None assert entry.new_version.version_num == 3 + + +class GetDescendantComponentEntityIdsTestCase(PublishingHistoryMixin, TestCase): + """ + Tests for get_descendant_component_entity_ids. + """ + + def _make_extra_entity(self, key: str) -> PublishableEntity: + """Create an additional PublishableEntity (beyond self.entity).""" + return publishing_api.create_publishable_entity( + self.learning_package.id, key, created=self.time_1, created_by=None, + ) + + def _make_container(self, key: str, children: list) -> Container: + """Create a Container with a v1 version pointing at the given children.""" + container: Container = publishing_api.create_container( + self.learning_package.id, key, created=self.time_1, created_by=None, + ) + publishing_api.create_container_version( + container.pk, + 1, + title=key, + entity_rows=[ + publishing_api.ContainerEntityRow(entity_pk=child.pk) + for child in children + ], + created=self.time_1, + created_by=None, + ) + return container + + def test_no_children_returns_empty(self) -> None: + """A container with no children returns an empty list.""" + container = self._make_container("empty_container", children=[]) + result = publishing_api.get_descendant_component_entity_ids(container) + assert not result + + def test_direct_component_children(self) -> None: + """Direct component children are returned.""" + second_component = self._make_extra_entity("second_component") + unit = self._make_container("unit_direct", children=[self.entity, second_component]) + + result = publishing_api.get_descendant_component_entity_ids(unit) + + assert set(result) == {self.entity.pk, second_component.pk} + + def test_nested_returns_only_leaf_components(self) -> None: + """ + Section → Subsection → Unit → Component hierarchy. + Only the leaf component entity ID is returned; intermediate containers + (subsection, unit) are excluded. + """ + unit = self._make_container("unit_nested", children=[self.entity]) + subsection = self._make_container("subsection_nested", children=[unit]) + section = self._make_container("section_nested", children=[subsection]) + + result = publishing_api.get_descendant_component_entity_ids(section) + + assert set(result) == {self.entity.pk} + assert unit.pk not in result + assert subsection.pk not in result + + def test_multiple_components_across_sub_containers(self) -> None: + """All leaf components across multiple sub-containers are collected.""" + second_component = self._make_extra_entity("second_component_multi") + third_component = self._make_extra_entity("third_component_multi") + first_unit = self._make_container("first_unit_multi", children=[self.entity, second_component]) + second_unit = self._make_container("second_unit_multi", children=[third_component]) + section = self._make_container("section_multi", children=[first_unit, second_unit]) + + result = publishing_api.get_descendant_component_entity_ids(section) + + assert set(result) == {self.entity.pk, second_component.pk, third_component.pk} + + def test_soft_deleted_sub_container_stops_traversal(self) -> None: + """ + When a sub-container's draft is soft-deleted, the BFS skips it and its + descendants are not included. + """ + unit = self._make_container("unit_soft_deleted", children=[self.entity]) + section = self._make_container("section_with_deleted_unit", children=[unit]) + + publishing_api.soft_delete_draft(unit.pk) + + result = publishing_api.get_descendant_component_entity_ids(section) + + assert self.entity.pk not in result + + def test_container_without_version_returns_empty(self) -> None: + """ + A container created with no ContainerVersion has no Draft.version, + so the BFS returns nothing. + """ + container: Container = publishing_api.create_container( + self.learning_package.id, "no_version_container", + created=self.time_1, created_by=None, + ) + + result = publishing_api.get_descendant_component_entity_ids(container) + + assert not result From 4ac8d44b26692ce744b77f3fcf626030c965dd91 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 13 Apr 2026 13:23:42 -0500 Subject: [PATCH 6/6] refactor: Update `get_descendant_component_entity_ids` to avoid use `Draft` --- src/openedx_content/applets/containers/api.py | 59 +++++++------------ .../applets/publishing/test_api.py | 32 +++++----- 2 files changed, 37 insertions(+), 54 deletions(-) diff --git a/src/openedx_content/applets/containers/api.py b/src/openedx_content/applets/containers/api.py index be6ae370a..03f8039bb 100644 --- a/src/openedx_content/applets/containers/api.py +++ b/src/openedx_content/applets/containers/api.py @@ -17,7 +17,6 @@ from ..publishing import api as publishing_api from ..publishing.models import ( - Draft, LearningPackage, PublishableContentModelRegistry, PublishableEntity, @@ -890,9 +889,8 @@ def get_descendant_component_entity_ids(container: Container) -> list[int]: Intermediate containers (e.g. Subsections, Units) are never included in the result; only leaf component entities are returned. - The traversal follows draft state only. Soft-deleted children - (``Draft.version = None``) are skipped because they have no - ``ContainerVersion`` to walk into. + The traversal follows draft state only. Soft-deleted children are skipped + automatically because ``get_entities_in_container`` omits them. Edge cases: - A container whose draft was soft-deleted has no children to traverse and @@ -903,40 +901,27 @@ def get_descendant_component_entity_ids(container: Container) -> list[int]: cannot occur in practice but is included for safety. """ all_component_ids: set[int] = set() - current_level_pks: list[int] = [container.pk] + containers_to_visit: list[Container] = [container] visited_container_pks: set[int] = {container.pk} - while current_level_pks: - # Step A: resolve entity_list IDs for this level's containers via their draft version - entity_list_ids = list( - Draft.objects - .filter(entity_id__in=current_level_pks, version__isnull=False) - .exclude(version__containerversion__isnull=True) - .values_list('version__containerversion__entity_list_id', flat=True) - ) - if not entity_list_ids: - break - - # Step B: collect all child entity IDs - child_entity_ids = list( - EntityListRow.objects - .filter(entity_list_id__in=entity_list_ids) - .values_list('entity_id', flat=True) - .distinct() - ) - if not child_entity_ids: - break - - # Step C: separate sub-containers from leaf entities - sub_container_pks = set( - Container.objects - .filter(publishable_entity_id__in=child_entity_ids) - .values_list('publishable_entity_id', flat=True) - ) - all_component_ids.update(set(child_entity_ids) - sub_container_pks) - - next_level = sub_container_pks - visited_container_pks - visited_container_pks.update(next_level) - current_level_pks = list(next_level) + while containers_to_visit: + current = containers_to_visit.pop() + try: + children = get_entities_in_container( + current, + published=False, + select_related_version="containerversion__container", + ) + except ContainerVersion.DoesNotExist: + continue + + for entry in children: + try: + child_container = entry.entity_version.containerversion.container + if child_container.pk not in visited_container_pks: + visited_container_pks.add(child_container.pk) + containers_to_visit.append(child_container) + except ContainerVersion.DoesNotExist: + all_component_ids.add(entry.entity.pk) return list(all_component_ids) diff --git a/tests/openedx_content/applets/publishing/test_api.py b/tests/openedx_content/applets/publishing/test_api.py index be0378019..d3e4327b9 100644 --- a/tests/openedx_content/applets/publishing/test_api.py +++ b/tests/openedx_content/applets/publishing/test_api.py @@ -12,6 +12,7 @@ from django.core.exceptions import ValidationError from django.test import TestCase +from openedx_content.applets.containers import api as containers_api from openedx_content.applets.publishing import api as publishing_api from openedx_content.applets.publishing.models import ( Draft, @@ -23,6 +24,8 @@ PublishLog, PublishLogRecord, ) +from openedx_content.models_api import Container +from tests.test_django_app.models import TestContainer User = get_user_model() @@ -1547,17 +1550,15 @@ def _make_extra_entity(self, key: str) -> PublishableEntity: def _make_container(self, key: str, children: list) -> Container: """Create a Container with a v1 version pointing at the given children.""" - container: Container = publishing_api.create_container( + container: Container = containers_api.create_container( self.learning_package.id, key, created=self.time_1, created_by=None, + container_cls=TestContainer, ) - publishing_api.create_container_version( + containers_api.create_container_version( container.pk, 1, title=key, - entity_rows=[ - publishing_api.ContainerEntityRow(entity_pk=child.pk) - for child in children - ], + entities=children, created=self.time_1, created_by=None, ) @@ -1566,7 +1567,7 @@ def _make_container(self, key: str, children: list) -> Container: def test_no_children_returns_empty(self) -> None: """A container with no children returns an empty list.""" container = self._make_container("empty_container", children=[]) - result = publishing_api.get_descendant_component_entity_ids(container) + result = containers_api.get_descendant_component_entity_ids(container) assert not result def test_direct_component_children(self) -> None: @@ -1574,7 +1575,7 @@ def test_direct_component_children(self) -> None: second_component = self._make_extra_entity("second_component") unit = self._make_container("unit_direct", children=[self.entity, second_component]) - result = publishing_api.get_descendant_component_entity_ids(unit) + result = containers_api.get_descendant_component_entity_ids(unit) assert set(result) == {self.entity.pk, second_component.pk} @@ -1588,7 +1589,7 @@ def test_nested_returns_only_leaf_components(self) -> None: subsection = self._make_container("subsection_nested", children=[unit]) section = self._make_container("section_nested", children=[subsection]) - result = publishing_api.get_descendant_component_entity_ids(section) + result = containers_api.get_descendant_component_entity_ids(section) assert set(result) == {self.entity.pk} assert unit.pk not in result @@ -1602,7 +1603,7 @@ def test_multiple_components_across_sub_containers(self) -> None: second_unit = self._make_container("second_unit_multi", children=[third_component]) section = self._make_container("section_multi", children=[first_unit, second_unit]) - result = publishing_api.get_descendant_component_entity_ids(section) + result = containers_api.get_descendant_component_entity_ids(section) assert set(result) == {self.entity.pk, second_component.pk, third_component.pk} @@ -1616,7 +1617,7 @@ def test_soft_deleted_sub_container_stops_traversal(self) -> None: publishing_api.soft_delete_draft(unit.pk) - result = publishing_api.get_descendant_component_entity_ids(section) + result = containers_api.get_descendant_component_entity_ids(section) assert self.entity.pk not in result @@ -1625,22 +1626,19 @@ def test_container_without_version_returns_empty(self) -> None: A container created with no ContainerVersion has no Draft.version, so the BFS returns nothing. """ - container: Container = publishing_api.create_container( + container: Container = containers_api.create_container( self.learning_package.id, "no_version_container", created=self.time_1, created_by=None, + container_cls=TestContainer, ) - result = publishing_api.get_descendant_component_entity_ids(container) + result = containers_api.get_descendant_component_entity_ids(container) assert not result # TODO: refactor these tests to use a "fake" container model so there's no dependency on the containers applet? # All we need is a similar generic publishableentity with dependencies. -# pylint: disable=wrong-import-position -from openedx_content.applets.containers import api as containers_api # noqa -from openedx_content.models_api import Container # noqa -from tests.test_django_app.models import TestContainer # noqa class TestContainerSideEffects(TestCase):