diff --git a/openedx_learning/__init__.py b/openedx_learning/__init__.py index 7262add03..9bf5b684e 100644 --- a/openedx_learning/__init__.py +++ b/openedx_learning/__init__.py @@ -1,4 +1,4 @@ """ Open edX Learning ("Learning Core"). """ -__version__ = "0.10.1" +__version__ = "0.11.0" diff --git a/openedx_learning/api/authoring.py b/openedx_learning/api/authoring.py index 57f317adc..749deb99f 100644 --- a/openedx_learning/api/authoring.py +++ b/openedx_learning/api/authoring.py @@ -9,6 +9,7 @@ """ # These wildcard imports are okay because these api modules declare __all__. # pylint: disable=wildcard-import +from ..apps.authoring.collections.api import * from ..apps.authoring.components.api import * from ..apps.authoring.contents.api import * from ..apps.authoring.publishing.api import * diff --git a/openedx_learning/apps/authoring/collections/__init__.py b/openedx_learning/apps/authoring/collections/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openedx_learning/apps/authoring/collections/api.py b/openedx_learning/apps/authoring/collections/api.py new file mode 100644 index 000000000..229740a4c --- /dev/null +++ b/openedx_learning/apps/authoring/collections/api.py @@ -0,0 +1,80 @@ +""" +Collections API (warning: UNSTABLE, in progress API) +""" +from __future__ import annotations + +from django.db.models import QuerySet + +from .models import Collection + +# 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 +# private to this module should start with an underscore. If a function does not +# start with an underscore AND it is not in __all__, that function is considered +# to be callable only by other apps in the authoring package. +__all__ = [ + "create_collection", + "get_collection", + "get_learning_package_collections", + "update_collection", +] + + +def create_collection( + learning_package_id: int, + name: str, + description: str = "", +) -> Collection: + """ + Create a new Collection + """ + collection = Collection.objects.create( + learning_package_id=learning_package_id, + name=name, + description=description, + ) + return collection + + +def get_collection(collection_id: int) -> Collection: + """ + Get a Collection by ID + """ + return Collection.objects.get(id=collection_id) + + +def update_collection( + collection_id: int, + name: str | None = None, + description: str | None = None, +) -> Collection: + """ + Update a Collection + """ + lp = Collection.objects.get(id=collection_id) + + # If no changes were requested, there's nothing to update, so just return + # the Collection as-is + if all(field is None for field in [name, description]): + return lp + + if name is not None: + lp.name = name + if description is not None: + lp.description = description + + lp.save() + return lp + + +def get_learning_package_collections(learning_package_id: int) -> QuerySet[Collection]: + """ + Get all collections for a given learning package + + Only enabled collections are returned + """ + return ( + Collection.objects + .filter(learning_package_id=learning_package_id, enabled=True) + .select_related("learning_package") + ) diff --git a/openedx_learning/apps/authoring/collections/apps.py b/openedx_learning/apps/authoring/collections/apps.py new file mode 100644 index 000000000..b1ca50c49 --- /dev/null +++ b/openedx_learning/apps/authoring/collections/apps.py @@ -0,0 +1,15 @@ +""" +Django metadata for the Collections Django application. +""" +from django.apps import AppConfig + + +class CollectionsConfig(AppConfig): + """ + Configuration for the Collections Django application. + """ + + name = "openedx_learning.apps.authoring.collections" + verbose_name = "Learning Core > Authoring > Collections" + default_auto_field = "django.db.models.BigAutoField" + label = "oel_collections" diff --git a/openedx_learning/apps/authoring/collections/migrations/0001_initial.py b/openedx_learning/apps/authoring/collections/migrations/0001_initial.py new file mode 100644 index 000000000..0fdee2734 --- /dev/null +++ b/openedx_learning/apps/authoring/collections/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.14 on 2024-08-05 20:42 + +import django.db.models.deletion +from django.db import migrations, models + +import openedx_learning.lib.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('oel_publishing', '0002_alter_learningpackage_key_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Collection', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, db_index=True, help_text='The name of the collection.', max_length=255)), + ('description', openedx_learning.lib.fields.MultiCollationCharField(blank=True, db_collations={'mysql': 'utf8mb4_unicode_ci', 'sqlite': 'NOCASE'}, help_text='Provides extra information for the user about this collection.', max_length=10000)), + ('enabled', models.BooleanField(default=True, help_text='Whether the collection is enabled or not.')), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('learning_package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_publishing.learningpackage')), + ], + options={ + 'verbose_name_plural': 'Collections', + }, + ), + ] diff --git a/openedx_learning/apps/authoring/collections/migrations/__init__.py b/openedx_learning/apps/authoring/collections/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openedx_learning/apps/authoring/collections/models.py b/openedx_learning/apps/authoring/collections/models.py new file mode 100644 index 000000000..412ce9d3d --- /dev/null +++ b/openedx_learning/apps/authoring/collections/models.py @@ -0,0 +1,63 @@ +""" +Core models for Collections +""" +from __future__ import annotations + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from ....lib.fields import case_insensitive_char_field +from ..publishing.models import LearningPackage + + +class Collection(models.Model): + """ + Represents a collection of library components + """ + + id = models.AutoField(primary_key=True) + + # Each collection belongs to a learning package + learning_package = models.ForeignKey(LearningPackage, on_delete=models.CASCADE) + + name = case_insensitive_char_field( + null=False, + max_length=255, + db_index=True, + help_text=_( + "The name of the collection." + ), + ) + description = case_insensitive_char_field( + null=False, + blank=True, + max_length=10_000, + help_text=_( + "Provides extra information for the user about this collection." + ), + ) + # We don't have api functions to handle the enabled field. This is a placeholder for future use and + # a way to "soft delete" collections. + enabled = models.BooleanField( + default=True, + help_text=_( + "Whether the collection is enabled or not." + ), + ) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name_plural = "Collections" + + def __repr__(self) -> str: + """ + Developer-facing representation of a Collection. + """ + return str(self) + + def __str__(self) -> str: + """ + User-facing string representation of a Collection. + """ + return f"<{self.__class__.__name__}> ({self.id}:{self.name})" diff --git a/openedx_learning/apps/authoring/collections/readme.rst b/openedx_learning/apps/authoring/collections/readme.rst new file mode 100644 index 000000000..5207bb511 --- /dev/null +++ b/openedx_learning/apps/authoring/collections/readme.rst @@ -0,0 +1,14 @@ +Collections App +============== + +The ``collections`` app will enable content authors to group content together for +organizing and re-using content. Components can be part of several different collections. + +Motivation +---------- + +With the Legacy Libraries ("v1"), people didn't have any way to organize content in libraries, so they +had to create many small libraries. + +For the Libraries Relaunch ("v2"), we want to encourage people to create large libraries with lots of content, +and organize that content using tags and collections. diff --git a/projects/dev.py b/projects/dev.py index 5fa8034f2..ada76e5e9 100644 --- a/projects/dev.py +++ b/projects/dev.py @@ -31,6 +31,7 @@ "django.contrib.admin", "django.contrib.admindocs", # Learning Core Apps + "openedx_learning.apps.authoring.collections.apps.CollectionsConfig", "openedx_learning.apps.authoring.components.apps.ComponentsConfig", "openedx_learning.apps.authoring.contents.apps.ContentsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", diff --git a/test_settings.py b/test_settings.py index 6be51e88e..db27f354a 100644 --- a/test_settings.py +++ b/test_settings.py @@ -40,6 +40,7 @@ def root(*args): # django-rules based authorization 'rules.apps.AutodiscoverRulesConfig', # Our own apps + "openedx_learning.apps.authoring.collections.apps.CollectionsConfig", "openedx_learning.apps.authoring.components.apps.ComponentsConfig", "openedx_learning.apps.authoring.contents.apps.ContentsConfig", "openedx_learning.apps.authoring.publishing.apps.PublishingConfig", diff --git a/tests/openedx_learning/apps/authoring/collections/__init__.py b/tests/openedx_learning/apps/authoring/collections/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/openedx_learning/apps/authoring/collections/test_api.py b/tests/openedx_learning/apps/authoring/collections/test_api.py new file mode 100644 index 000000000..893760fc9 --- /dev/null +++ b/tests/openedx_learning/apps/authoring/collections/test_api.py @@ -0,0 +1,197 @@ +""" +Basic tests of the Collections API. +""" +from datetime import datetime, timezone + +from django.core.exceptions import ObjectDoesNotExist +from freezegun import freeze_time + +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.lib.test_utils import TestCase + + +class CollectionTestCase(TestCase): + """ + Base-class for setting up commonly used test data. + """ + learning_package: LearningPackage + now: datetime + + @classmethod + def setUpTestData(cls) -> None: + cls.learning_package = publishing_api.create_learning_package( + key="ComponentTestCase-test-key", + title="Components Test Case Learning Package", + ) + cls.now = datetime(2024, 8, 5, tzinfo=timezone.utc) + + +class GetCollectionTestCase(CollectionTestCase): + """ + Test grabbing a queryset of Collections. + """ + collection1: Collection + collection2: Collection + disabled_collection: Collection + + @classmethod + def setUpTestData(cls) -> None: + """ + Initialize our content data (all our tests are read only). + """ + super().setUpTestData() + cls.collection1 = collection_api.create_collection( + cls.learning_package.id, + name="Collection 1", + description="Description of Collection 1", + ) + cls.collection2 = collection_api.create_collection( + cls.learning_package.id, + name="Collection 2", + description="Description of Collection 2", + ) + cls.disabled_collection = collection_api.create_collection( + cls.learning_package.id, + name="Disabled Collection", + description="Description of Disabled Collection", + ) + cls.disabled_collection.enabled = False + cls.disabled_collection.save() + + def test_get_collection(self): + """ + Test getting a single collection. + """ + collection = collection_api.get_collection(self.collection1.pk) + assert collection == self.collection1 + + def test_get_collection_not_found(self): + """ + Test getting a collection that doesn't exist. + """ + with self.assertRaises(ObjectDoesNotExist): + collection_api.get_collection(12345) + + def test_get_learning_package_collections(self): + """ + Test getting all ENABLED collections for a learning package. + """ + collections = collection_api.get_learning_package_collections(self.learning_package.id) + assert list(collections) == [ + self.collection1, + self.collection2, + ] + + def test_get_invalid_learning_package_collections(self): + """ + Test getting collections for an invalid learning package should return an empty queryset. + """ + collections = collection_api.get_learning_package_collections(12345) + assert not list(collections) + + +class CollectionCreateTestCase(CollectionTestCase): + """ + Test creating a collection. + """ + + def test_create_collection(self): + """ + Test creating a collection. + """ + created_time = datetime(2024, 8, 8, tzinfo=timezone.utc) + with freeze_time(created_time): + collection = collection_api.create_collection( + self.learning_package.id, + name="My Collection", + description="This is my collection", + ) + + assert collection.name == "My Collection" + assert collection.description == "This is my collection" + assert collection.enabled + assert collection.created == created_time + assert collection.modified == created_time + + def test_create_collection_without_description(self): + """ + Test creating a collection without a description. + """ + collection = collection_api.create_collection( + self.learning_package.id, + name="My Collection", + ) + assert collection.name == "My Collection" + assert collection.description == "" + assert collection.enabled + + +class UpdateCollectionTestCase(CollectionTestCase): + """ + Test updating a collection. + """ + collection: Collection + + def setUp(self) -> None: + """ + Initialize our content data + """ + super().setUp() + self.collection = collection_api.create_collection( + self.learning_package.id, + name="Collection", + description="Description of Collection", + ) + + def test_update_collection(self): + """ + Test updating a collection's name and description. + """ + modified_time = datetime(2024, 8, 8, tzinfo=timezone.utc) + with freeze_time(modified_time): + collection = collection_api.update_collection( + self.collection.pk, + name="New Name", + description="", + ) + + assert collection.name == "New Name" + assert collection.description == "" + assert collection.modified == modified_time + assert collection.created == self.collection.created # unchanged + + def test_update_collection_partial(self): + """ + Test updating a collection's name. + """ + collection = collection_api.update_collection( + self.collection.pk, + name="New Name", + ) + + assert collection.name == "New Name" + assert collection.description == self.collection.description # unchanged + + def test_update_collection_empty(self): + """ + Test empty update. + """ + modified_time = datetime(2024, 8, 8, tzinfo=timezone.utc) + with freeze_time(modified_time): + collection = collection_api.update_collection( + self.collection.pk, + ) + + assert collection.name == self.collection.name # unchanged + assert collection.description == self.collection.description # unchanged + assert collection.modified == self.collection.modified # unchanged + + def test_update_collection_not_found(self): + """ + Test updating a collection that doesn't exist. + """ + with self.assertRaises(ObjectDoesNotExist): + collection_api.update_collection(12345, name="New Name") diff --git a/tests/openedx_learning/apps/authoring/components/test_api.py b/tests/openedx_learning/apps/authoring/components/test_api.py index eb5a09787..7eedffdb9 100644 --- a/tests/openedx_learning/apps/authoring/components/test_api.py +++ b/tests/openedx_learning/apps/authoring/components/test_api.py @@ -92,7 +92,7 @@ def setUpTestData(cls) -> None: Initialize our content data (all our tests are read only). We don't actually need to add content to the ComponentVersions, since - for this we only care about the metadata on Compnents, their versions, + for this we only care about the metadata on Components, their versions, and the associated draft/publish status. """ super().setUpTestData()