diff --git a/openedx_learning/apps/authoring/collections/api.py b/openedx_learning/apps/authoring/collections/api.py index 0539dc33..325f0a66 100644 --- a/openedx_learning/apps/authoring/collections/api.py +++ b/openedx_learning/apps/authoring/collections/api.py @@ -4,8 +4,10 @@ from __future__ import annotations from django.db.models import QuerySet +from django.db.transaction import atomic -from .models import Collection +from ..publishing.models import PublishableEntity +from .models import Collection, CollectionObject # The public API that will be re-exported by openedx_learning.apps.authoring.api # is listed in the __all__ entries below. Internal helper functions that are @@ -25,16 +27,25 @@ def create_collection( title: str, created_by: int | None, description: str = "", + contents_qset: QuerySet[PublishableEntity] = PublishableEntity.objects.none(), # default to empty set, ) -> Collection: """ Create a new Collection """ - collection = Collection.objects.create( - learning_package_id=learning_package_id, - title=title, - created_by_id=created_by, - description=description, - ) + + with atomic(): + collection = Collection.objects.create( + learning_package_id=learning_package_id, + title=title, + created_by_id=created_by, + description=description, + ) + + add_to_collections( + Collection.objects.filter(id=collection.id), + contents_qset, + ) + return collection @@ -69,6 +80,62 @@ def update_collection( return collection +def add_to_collections( + collections_qset: QuerySet[Collection], + contents_qset: QuerySet[PublishableEntity], +) -> int: + """ + Adds a QuerySet of PublishableEntities to a QuerySet of Collections. + + Records are created in bulk, and so integrity errors are deliberately ignored: they indicate that the content(s) + have already been added to the collection(s). + + Returns the number of entities added (including any that already exist). + """ + collection_pub_entities = [] + pub_ent_ids = contents_qset.values_list("id", flat=True) + + for collection in collections_qset.only("id").all(): + for pub_ent_id in pub_ent_ids: + collection_pub_entities.append( + CollectionObject( + collection_id=collection.id, + object_id=pub_ent_id, + ) + ) + + created = CollectionObject.objects.bulk_create( + collection_pub_entities, + ignore_conflicts=True, + ) + return len(created) + + +def remove_from_collections( + collections_qset: QuerySet, + contents_qset: QuerySet, +) -> int: + """ + Removes a QuerySet of PublishableEntities from a QuerySet of Collections. + + PublishableEntities are deleted from each Collection, in bulk. + + Returns the total number of entities deleted. + """ + total_deleted = 0 + pub_ent_ids = contents_qset.values_list("id", flat=True) + + for collection in collections_qset.only("id").all(): + num_deleted, _ = CollectionObject.objects.filter( + collection_id=collection.id, + object_id__in=pub_ent_ids, + ).delete() + + total_deleted += num_deleted + + return total_deleted + + def get_learning_package_collections(learning_package_id: int) -> QuerySet[Collection]: """ Get all collections for a given learning package diff --git a/openedx_learning/apps/authoring/collections/migrations/0003_collection_contents.py b/openedx_learning/apps/authoring/collections/migrations/0003_collection_contents.py new file mode 100644 index 00000000..b88a87d5 --- /dev/null +++ b/openedx_learning/apps/authoring/collections/migrations/0003_collection_contents.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.14 on 2024-08-21 07:15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oel_publishing', '0002_alter_learningpackage_key_and_more'), + ('oel_collections', '0002_remove_collection_name_collection_created_by_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='CollectionObject', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_collections.collection')), + ('object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.publishableentity')), + ], + ), + migrations.AddField( + model_name='collection', + name='contents', + field=models.ManyToManyField(related_name='collections', through='oel_collections.CollectionObject', to='oel_publishing.publishableentity'), + ), + migrations.AddConstraint( + model_name='collectionobject', + constraint=models.UniqueConstraint(fields=('collection', 'object'), name='oel_collections_cpe_uniq_col_obj'), + ), + ] diff --git a/openedx_learning/apps/authoring/collections/models.py b/openedx_learning/apps/authoring/collections/models.py index 06f4e44c..1f902498 100644 --- a/openedx_learning/apps/authoring/collections/models.py +++ b/openedx_learning/apps/authoring/collections/models.py @@ -72,10 +72,11 @@ from ....lib.fields import MultiCollationTextField, case_insensitive_char_field from ....lib.validators import validate_utc_datetime -from ..publishing.models import LearningPackage +from ..publishing.models import LearningPackage, PublishableEntity __all__ = [ "Collection", + "CollectionObject", ] @@ -142,6 +143,12 @@ class Collection(models.Model): ], ) + contents: models.ManyToManyField[PublishableEntity, "CollectionObject"] = models.ManyToManyField( + PublishableEntity, + through="CollectionObject", + related_name="collections", + ) + class Meta: verbose_name_plural = "Collections" indexes = [ @@ -159,3 +166,30 @@ def __str__(self) -> str: User-facing string representation of a Collection. """ return f"<{self.__class__.__name__}> ({self.id}:{self.title})" + + +class CollectionObject(models.Model): + """ + Collection -> PublishableEntity association. + """ + collection = models.ForeignKey( + Collection, + on_delete=models.CASCADE, + ) + object = models.ForeignKey( + PublishableEntity, + on_delete=models.CASCADE, + ) + + class Meta: + constraints = [ + # Prevent race conditions from making multiple rows associating the + # same Collection to the same Entity. + models.UniqueConstraint( + fields=[ + "collection", + "object", + ], + name="oel_collections_cpe_uniq_col_obj", + ) + ] diff --git a/tests/openedx_learning/apps/authoring/collections/test_api.py b/tests/openedx_learning/apps/authoring/collections/test_api.py index cf355541..97254e27 100644 --- a/tests/openedx_learning/apps/authoring/collections/test_api.py +++ b/tests/openedx_learning/apps/authoring/collections/test_api.py @@ -10,7 +10,11 @@ from openedx_learning.apps.authoring.collections import api as collection_api from openedx_learning.apps.authoring.collections.models import Collection from openedx_learning.apps.authoring.publishing import api as publishing_api -from openedx_learning.apps.authoring.publishing.models import LearningPackage +from openedx_learning.apps.authoring.publishing.models import ( + LearningPackage, + PublishableEntity, + PublishableEntityVersion, +) from openedx_learning.lib.test_utils import TestCase User = get_user_model() @@ -142,6 +146,162 @@ def test_create_collection_without_description(self): assert collection.enabled +class CollectionContentsTestCase(CollectionTestCase): + """ + Test collections that contain publishable entitites. + """ + published_entity: PublishableEntity + pe_version: PublishableEntityVersion + draft_entity: PublishableEntity + de_version: PublishableEntityVersion + collection0: Collection + collection1: Collection + collection2: Collection + + @classmethod + def setUpTestData(cls) -> None: + """ + Initialize our content data (all our tests are read only). + """ + super().setUpTestData() + + # Make and Publish one PublishableEntity + cls.published_entity = publishing_api.create_publishable_entity( + cls.learning_package.id, + key="my_entity_published_example", + created=cls.now, + created_by=None, + ) + cls.pe_version = publishing_api.create_publishable_entity_version( + cls.published_entity.id, + version_num=1, + title="An Entity that we'll Publish 🌴", + created=cls.now, + created_by=None, + ) + publishing_api.publish_all_drafts( + cls.learning_package.id, + message="Publish from CollectionTestCase.setUpTestData", + published_at=cls.now, + ) + + # Leave another PublishableEntity in Draft. + cls.draft_entity = publishing_api.create_publishable_entity( + cls.learning_package.id, + key="my_entity_draft_example", + created=cls.now, + created_by=None, + ) + cls.de_version = publishing_api.create_publishable_entity_version( + cls.draft_entity.id, + version_num=1, + title="An Entity that we'll keep in Draft 🌴", + created=cls.now, + created_by=None, + ) + + # Create collections with some shared contents + cls.collection0 = collection_api.create_collection( + cls.learning_package.id, + title="Collection Empty", + created_by=None, + description="This collection contains 0 objects", + ) + cls.collection1 = collection_api.create_collection( + cls.learning_package.id, + title="Collection One", + created_by=None, + description="This collection contains 1 object", + contents_qset=PublishableEntity.objects.filter(id__in=[ + cls.published_entity.id, + ]), + ) + cls.collection2 = collection_api.create_collection( + cls.learning_package.id, + title="Collection Two", + created_by=None, + description="This collection contains 2 objects", + contents_qset=PublishableEntity.objects.filter(id__in=[ + cls.published_entity.id, + cls.draft_entity.id, + ]), + ) + + def test_create_collection_contents(self): + """ + Ensure the collections were pre-populated with the expected publishable entities. + """ + assert not list(self.collection0.contents.all()) + assert list(self.collection1.contents.all()) == [ + self.published_entity, + ] + assert list(self.collection2.contents.all()) == [ + self.published_entity, + self.draft_entity, + ] + + def test_add_to_collections(self): + """ + Test adding objects to collections. + """ + count = collection_api.add_to_collections( + Collection.objects.filter(id__in=[ + self.collection1.id, + ]), + PublishableEntity.objects.filter(id__in=[ + self.draft_entity.id, + ]), + ) + assert count == 1 + assert list(self.collection1.contents.all()) == [ + self.published_entity, + self.draft_entity, + ] + + def test_add_to_collections_again(self): + """ + Test that re-adding objects to collections doesn't throw an error. + """ + count = collection_api.add_to_collections( + Collection.objects.filter(id__in=[ + self.collection1.id, + self.collection2.id, + ]), + PublishableEntity.objects.filter(id__in=[ + self.published_entity.id, + ]), + ) + assert count == 2 + assert list(self.collection1.contents.all()) == [ + self.published_entity, + ] + assert list(self.collection2.contents.all()) == [ + self.published_entity, + self.draft_entity, + ] + + def test_remove_from_collections(self): + """ + Test removing objects from collections. + """ + count = collection_api.remove_from_collections( + Collection.objects.filter(id__in=[ + self.collection0.id, + self.collection1.id, + self.collection2.id, + ]), + PublishableEntity.objects.filter(id__in=[ + self.published_entity.id, + ]), + ) + assert count == 2 + assert not list(self.collection0.contents.all()) + assert not list(self.collection1.contents.all()) + assert list(self.collection2.contents.all()) == [ + self.draft_entity, + ] + + class UpdateCollectionTestCase(CollectionTestCase): """ Test updating a collection.