Skip to content

Commit ca8a355

Browse files
committed
refactor: move set_collection function to make it generic
1 parent 640c1df commit ca8a355

4 files changed

Lines changed: 174 additions & 137 deletions

File tree

openedx_learning/apps/authoring/collections/api.py

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from ..publishing import api as publishing_api
1212
from ..publishing.models import PublishableEntity
13-
from .models import Collection
13+
from .models import Collection, CollectionPublishableEntity
1414

1515
# The public API that will be re-exported by openedx_learning.apps.authoring.api
1616
# is listed in the __all__ entries below. Internal helper functions that are
@@ -27,6 +27,7 @@
2727
"remove_from_collection",
2828
"restore_collection",
2929
"update_collection",
30+
"set_collections",
3031
]
3132

3233

@@ -204,3 +205,55 @@ def get_collections(learning_package_id: int, enabled: bool | None = True) -> Qu
204205
if enabled is not None:
205206
qs = qs.filter(enabled=enabled)
206207
return qs.select_related("learning_package").order_by('pk')
208+
209+
210+
def set_collections(
211+
learning_package_id: int,
212+
publishable_entity: PublishableEntity,
213+
collection_qset: QuerySet[Collection],
214+
created_by: int | None = None,
215+
) -> set[Collection]:
216+
"""
217+
Set collections for a given publishable entity.
218+
219+
These Collections must belong to the same LearningPackage as the PublishableEntity,
220+
or a ValidationError will be raised.
221+
222+
Modified date of all collections related to entity is updated.
223+
224+
Returns the updated collections.
225+
"""
226+
# Disallow adding entities outside the collection's learning package
227+
invalid_collection = collection_qset.exclude(learning_package_id=learning_package_id).first()
228+
if invalid_collection:
229+
raise ValidationError(
230+
f"Cannot add collection {invalid_collection.pk} in learning package "
231+
f"{invalid_collection.learning_package_id} to entity {publishable_entity} in "
232+
f"learning package {learning_package_id}."
233+
)
234+
current_relations = CollectionPublishableEntity.objects.filter(
235+
entity=publishable_entity
236+
).select_related('collection')
237+
# Clear other collections for given entity and add only new collections from collection_qset
238+
removed_collections = set(
239+
r.collection for r in current_relations.exclude(collection__in=collection_qset)
240+
)
241+
new_collections = set(collection_qset.exclude(
242+
id__in=current_relations.values_list('collection', flat=True)
243+
))
244+
# Use `remove` instead of `CollectionPublishableEntity.delete()` to trigger m2m_changed signal which will handle
245+
# updating entity index.
246+
publishable_entity.collections.remove(*removed_collections)
247+
publishable_entity.collections.add(
248+
*new_collections,
249+
through_defaults={"created_by_id": created_by},
250+
)
251+
# Update modified date via update to avoid triggering post_save signal for collections
252+
# The signal triggers index update for each collection synchronously which will be very slow in this case.
253+
# Instead trigger the index update in the caller function asynchronously.
254+
affected_collection = removed_collections | new_collections
255+
Collection.objects.filter(
256+
id__in=[collection.id for collection in affected_collection]
257+
).update(modified=datetime.now(tz=timezone.utc))
258+
259+
return affected_collection

openedx_learning/apps/authoring/components/api.py

Lines changed: 1 addition & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,16 @@
1313
from __future__ import annotations
1414

1515
import mimetypes
16-
from datetime import datetime, timezone
16+
from datetime import datetime
1717
from enum import StrEnum, auto
1818
from logging import getLogger
1919
from pathlib import Path
2020
from uuid import UUID
2121

22-
from django.core.exceptions import ValidationError
2322
from django.db.models import Q, QuerySet
2423
from django.db.transaction import atomic
2524
from django.http.response import HttpResponse, HttpResponseNotFound
2625

27-
from ..collections.models import Collection, CollectionPublishableEntity
2826
from ..contents import api as contents_api
2927
from ..publishing import api as publishing_api
3028
from .models import Component, ComponentType, ComponentVersion, ComponentVersionContent
@@ -51,7 +49,6 @@
5149
"look_up_component_version_content",
5250
"AssetError",
5351
"get_redirect_response_for_component_asset",
54-
"set_collections",
5552
]
5653

5754

@@ -605,54 +602,3 @@ def _error_header(error: AssetError) -> dict[str, str]:
605602
)
606603

607604
return HttpResponse(headers={**info_headers, **redirect_headers})
608-
609-
610-
def set_collections(
611-
learning_package_id: int,
612-
component: Component,
613-
collection_qset: QuerySet[Collection],
614-
created_by: int | None = None,
615-
) -> set[Collection]:
616-
"""
617-
Set collections for a given component.
618-
619-
These Collections must belong to the same LearningPackage as the Component, or a ValidationError will be raised.
620-
621-
Modified date of all collections related to component is updated.
622-
623-
Returns the updated collections.
624-
"""
625-
# Disallow adding entities outside the collection's learning package
626-
invalid_collection = collection_qset.exclude(learning_package_id=learning_package_id).first()
627-
if invalid_collection:
628-
raise ValidationError(
629-
f"Cannot add collection {invalid_collection.pk} in learning package "
630-
f"{invalid_collection.learning_package_id} to component {component} in "
631-
f"learning package {learning_package_id}."
632-
)
633-
current_relations = CollectionPublishableEntity.objects.filter(
634-
entity=component.publishable_entity
635-
).select_related('collection')
636-
# Clear other collections for given component and add only new collections from collection_qset
637-
removed_collections = set(
638-
r.collection for r in current_relations.exclude(collection__in=collection_qset)
639-
)
640-
new_collections = set(collection_qset.exclude(
641-
id__in=current_relations.values_list('collection', flat=True)
642-
))
643-
# Use `remove` instead of `CollectionPublishableEntity.delete()` to trigger m2m_changed signal which will handle
644-
# updating component index.
645-
component.publishable_entity.collections.remove(*removed_collections)
646-
component.publishable_entity.collections.add(
647-
*new_collections,
648-
through_defaults={"created_by_id": created_by},
649-
)
650-
# Update modified date via update to avoid triggering post_save signal for collections
651-
# The signal triggers index update for each collection synchronously which will be very slow in this case.
652-
# Instead trigger the index update in the caller function asynchronously.
653-
affected_collection = removed_collections | new_collections
654-
Collection.objects.filter(
655-
id__in=[collection.id for collection in affected_collection]
656-
).update(modified=datetime.now(tz=timezone.utc))
657-
658-
return affected_collection

tests/openedx_learning/apps/authoring/collections/test_api.py

Lines changed: 117 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class CollectionsTestCase(CollectionTestCase):
5151
collection1: Collection
5252
collection2: Collection
5353
collection3: Collection
54+
another_library_collection: Collection
5455
disabled_collection: Collection
5556

5657
@classmethod
@@ -74,15 +75,22 @@ def setUpTestData(cls) -> None:
7475
description="Description of Collection 2",
7576
)
7677
cls.collection3 = api.create_collection(
77-
cls.learning_package_2.id,
78+
cls.learning_package.id,
7879
key="COL3",
7980
created_by=None,
8081
title="Collection 3",
8182
description="Description of Collection 3",
8283
)
84+
cls.another_library_collection = api.create_collection(
85+
cls.learning_package_2.id,
86+
key="another_library",
87+
created_by=None,
88+
title="Collection 4",
89+
description="Description of Collection 4",
90+
)
8391
cls.disabled_collection = api.create_collection(
8492
cls.learning_package.id,
85-
key="COL4",
93+
key="disabled_collection",
8694
created_by=None,
8795
title="Disabled Collection",
8896
description="Description of Disabled Collection",
@@ -113,7 +121,7 @@ def test_get_collection_wrong_learning_package(self):
113121
Test getting a collection that doesn't exist in the requested learning package.
114122
"""
115123
with self.assertRaises(ObjectDoesNotExist):
116-
api.get_collection(self.learning_package.pk, self.collection3.key)
124+
api.get_collection(self.learning_package.pk, self.another_library_collection.key)
117125

118126
def test_get_collections(self):
119127
"""
@@ -123,6 +131,7 @@ def test_get_collections(self):
123131
assert list(collections) == [
124132
self.collection1,
125133
self.collection2,
134+
self.collection3,
126135
]
127136

128137
def test_get_invalid_collections(self):
@@ -140,6 +149,7 @@ def test_get_all_collections(self):
140149
self.assertQuerySetEqual(collections, [
141150
self.collection1,
142151
self.collection2,
152+
self.collection3,
143153
self.disabled_collection,
144154
], ordered=True)
145155

@@ -151,6 +161,7 @@ def test_get_all_enabled_collections(self):
151161
self.assertQuerySetEqual(collections, [
152162
self.collection1,
153163
self.collection2,
164+
self.collection3,
154165
], ordered=True)
155166

156167
def test_get_all_disabled_collections(self):
@@ -301,6 +312,7 @@ def test_create_collection_entities(self):
301312
self.draft_component.publishable_entity,
302313
]
303314
assert not list(self.collection3.entities.all())
315+
assert not list(self.another_library_collection.entities.all())
304316

305317
def test_add_to_collection(self):
306318
"""
@@ -352,13 +364,13 @@ def test_add_to_collection_wrong_learning_package(self):
352364
with self.assertRaises(ValidationError):
353365
api.add_to_collection(
354366
self.learning_package_2.id,
355-
self.collection3.key,
367+
self.another_library_collection.key,
356368
PublishableEntity.objects.filter(id__in=[
357369
self.published_component.pk,
358370
]),
359371
)
360372

361-
assert not list(self.collection3.entities.all())
373+
assert not list(self.another_library_collection.entities.all())
362374

363375
def test_remove_from_collection(self):
364376
"""
@@ -405,6 +417,10 @@ def test_get_collection_components(self):
405417
self.learning_package.id,
406418
self.collection3.key,
407419
))
420+
assert not list(api.get_collection_components(
421+
self.learning_package.id,
422+
self.another_library_collection.key,
423+
))
408424

409425

410426
class UpdateCollectionTestCase(CollectionTestCase):
@@ -573,3 +589,99 @@ def test_restore(self):
573589
self.published_component.publishable_entity,
574590
self.draft_component.publishable_entity,
575591
]
592+
593+
594+
class SetCollectionsTestCase(CollectionEntitiesTestCase):
595+
"""
596+
Test setting multiple collections in a component.
597+
"""
598+
def test_set_collections(self):
599+
"""
600+
Test setting collections in a component
601+
"""
602+
modified_time = datetime(2024, 8, 8, tzinfo=timezone.utc)
603+
with freeze_time(modified_time):
604+
api.set_collections(
605+
self.learning_package.id,
606+
self.draft_component.publishable_entity,
607+
collection_qset=Collection.objects.filter(id__in=[
608+
self.collection1.pk,
609+
self.collection2.pk,
610+
]),
611+
created_by=self.user.id,
612+
)
613+
assert list(self.collection1.entities.all()) == [
614+
self.published_component.publishable_entity,
615+
self.draft_component.publishable_entity,
616+
]
617+
assert list(self.collection2.entities.all()) == [
618+
self.published_component.publishable_entity,
619+
self.draft_component.publishable_entity,
620+
]
621+
622+
for collection_entity in CollectionPublishableEntity.objects.filter(
623+
entity=self.draft_component.publishable_entity
624+
):
625+
if collection_entity.collection == self.collection1:
626+
# The collection1 was newly associated, so it has a created_by
627+
assert collection_entity.created_by == self.user
628+
else:
629+
# The collection2 was already associated, with no created_by
630+
assert collection_entity.created_by is None
631+
632+
# The collection1 was newly associated, so the modified time is set
633+
assert Collection.objects.get(id=self.collection1.pk).modified == modified_time
634+
# The collection2 was already associated, so the modified time is unchanged
635+
assert Collection.objects.get(id=self.collection2.pk).modified != modified_time
636+
637+
# Set collections again, but this time remove collection1 and add collection3
638+
# Expected result: collection2 & collection3 associated to component and collection1 is excluded.
639+
new_modified_time = datetime(2024, 8, 8, tzinfo=timezone.utc)
640+
with freeze_time(new_modified_time):
641+
api.set_collections(
642+
self.learning_package.id,
643+
self.draft_component.publishable_entity,
644+
collection_qset=Collection.objects.filter(id__in=[
645+
self.collection3.pk,
646+
self.collection2.pk,
647+
]),
648+
created_by=self.user.id,
649+
)
650+
assert list(self.collection1.entities.all()) == [
651+
self.published_component.publishable_entity,
652+
]
653+
assert list(self.collection2.entities.all()) == [
654+
self.published_component.publishable_entity,
655+
self.draft_component.publishable_entity,
656+
]
657+
assert list(self.collection3.entities.all()) == [
658+
self.draft_component.publishable_entity,
659+
]
660+
# update modified time of all three collections as they were all updated
661+
assert Collection.objects.get(id=self.collection1.pk).modified == new_modified_time
662+
# collection2 was unchanged, so it should have the same modified time as before
663+
assert Collection.objects.get(id=self.collection2.pk).modified != new_modified_time
664+
assert Collection.objects.get(id=self.collection3.pk).modified == new_modified_time
665+
666+
def test_set_collection_wrong_learning_package(self):
667+
"""
668+
We cannot set collections with a different learning package than the component.
669+
"""
670+
learning_package_3 = api.create_learning_package(
671+
key="ComponentTestCase-test-key-3",
672+
title="Components Test Case Learning Package-3",
673+
)
674+
675+
with self.assertRaises(ValidationError):
676+
api.set_collections(
677+
learning_package_3.id,
678+
self.draft_component.publishable_entity,
679+
collection_qset=Collection.objects.filter(id__in=[
680+
self.collection1.pk,
681+
]),
682+
created_by=self.user.id,
683+
)
684+
685+
assert list(self.collection1.entities.all()) == [
686+
self.published_component.publishable_entity,
687+
]

0 commit comments

Comments
 (0)