Skip to content

Commit 0eab0e1

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

4 files changed

Lines changed: 168 additions & 133 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: 113 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,22 @@ def setUpTestData(cls) -> None:
7474
description="Description of Collection 2",
7575
)
7676
cls.collection3 = api.create_collection(
77-
cls.learning_package_2.id,
77+
cls.learning_package.id,
7878
key="COL3",
7979
created_by=None,
8080
title="Collection 3",
8181
description="Description of Collection 3",
8282
)
83+
cls.another_library_collection = api.create_collection(
84+
cls.learning_package_2.id,
85+
key="COL4",
86+
created_by=None,
87+
title="Collection 4",
88+
description="Description of Collection 4",
89+
)
8390
cls.disabled_collection = api.create_collection(
8491
cls.learning_package.id,
85-
key="COL4",
92+
key="another_library",
8693
created_by=None,
8794
title="Disabled Collection",
8895
description="Description of Disabled Collection",
@@ -113,7 +120,7 @@ def test_get_collection_wrong_learning_package(self):
113120
Test getting a collection that doesn't exist in the requested learning package.
114121
"""
115122
with self.assertRaises(ObjectDoesNotExist):
116-
api.get_collection(self.learning_package.pk, self.collection3.key)
123+
api.get_collection(self.learning_package.pk, self.another_library_collection.key)
117124

118125
def test_get_collections(self):
119126
"""
@@ -123,6 +130,7 @@ def test_get_collections(self):
123130
assert list(collections) == [
124131
self.collection1,
125132
self.collection2,
133+
self.collection3,
126134
]
127135

128136
def test_get_invalid_collections(self):
@@ -140,6 +148,7 @@ def test_get_all_collections(self):
140148
self.assertQuerySetEqual(collections, [
141149
self.collection1,
142150
self.collection2,
151+
self.collection3,
143152
self.disabled_collection,
144153
], ordered=True)
145154

@@ -151,6 +160,7 @@ def test_get_all_enabled_collections(self):
151160
self.assertQuerySetEqual(collections, [
152161
self.collection1,
153162
self.collection2,
163+
self.collection3,
154164
], ordered=True)
155165

156166
def test_get_all_disabled_collections(self):
@@ -301,6 +311,7 @@ def test_create_collection_entities(self):
301311
self.draft_component.publishable_entity,
302312
]
303313
assert not list(self.collection3.entities.all())
314+
assert not list(self.another_library_collection.entities.all())
304315

305316
def test_add_to_collection(self):
306317
"""
@@ -352,13 +363,13 @@ def test_add_to_collection_wrong_learning_package(self):
352363
with self.assertRaises(ValidationError):
353364
api.add_to_collection(
354365
self.learning_package_2.id,
355-
self.collection3.key,
366+
self.another_library_collection.key,
356367
PublishableEntity.objects.filter(id__in=[
357368
self.published_component.pk,
358369
]),
359370
)
360371

361-
assert not list(self.collection3.entities.all())
372+
assert not list(self.another_library_collection.entities.all())
362373

363374
def test_remove_from_collection(self):
364375
"""
@@ -405,6 +416,10 @@ def test_get_collection_components(self):
405416
self.learning_package.id,
406417
self.collection3.key,
407418
))
419+
assert not list(api.get_collection_components(
420+
self.learning_package.id,
421+
self.another_library_collection.key,
422+
))
408423

409424

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

0 commit comments

Comments
 (0)