Skip to content

Commit

Permalink
feat: adds api and models for adding PublishableEntities to Collections
Browse files Browse the repository at this point in the history
and tests.
  • Loading branch information
pomegranited committed Aug 23, 2024
1 parent ebe0c1f commit 341efc4
Show file tree
Hide file tree
Showing 4 changed files with 302 additions and 9 deletions.
81 changes: 74 additions & 7 deletions openedx_learning/apps/authoring/collections/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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'),
),
]
36 changes: 35 additions & 1 deletion openedx_learning/apps/authoring/collections/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]


Expand Down Expand Up @@ -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 = [
Expand All @@ -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",
)
]
162 changes: 161 additions & 1 deletion tests/openedx_learning/apps/authoring/collections/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down

0 comments on commit 341efc4

Please sign in to comment.