Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: LibraryCollectionKey and LibraryCollectionLocator created [FC-0062] #335

Merged
31 changes: 31 additions & 0 deletions opaque_keys/edx/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,37 @@ def make_asset_key(self, asset_type: str, path: str) -> AssetKey: # pragma: no
raise NotImplementedError()


class LibraryCollectionKey(OpaqueKey):
"""
An :class:`opaque_keys.OpaqueKey` identifying a particular Library Collection object.
"""
KEY_TYPE = 'collection_key'
__slots__ = ()

@property
@abstractmethod
def org(self) -> str | None: # pragma: no cover
"""
The organization that this collection belongs to.
"""
raise NotImplementedError()

@property
@abstractmethod
def lib(self) -> str | None: # pragma: no cover
"""
The lib for this collection.
"""
raise NotImplementedError()

@property
def library_key(self) -> LearningContextKey: # pragma: no cover
"""
Get the key of the library that this collection belongs to.
"""
raise NotImplementedError()


class DefinitionKey(OpaqueKey):
"""
An :class:`opaque_keys.OpaqueKey` identifying an XBlock definition.
Expand Down
59 changes: 58 additions & 1 deletion opaque_keys/edx/locator.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
from typing_extensions import Self # For python 3.11 plus, can just use "from typing import Self"

from opaque_keys import OpaqueKey, InvalidKeyError
from opaque_keys.edx.keys import AssetKey, CourseKey, DefinitionKey, LearningContextKey, UsageKey, UsageKeyV2
from opaque_keys.edx.keys import AssetKey, CourseKey, DefinitionKey, \
LearningContextKey, UsageKey, UsageKeyV2, LibraryCollectionKey

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -1620,3 +1621,59 @@
# HTML5 allows ID values to contain any characters at all other than spaces.
# These key types don't allow spaces either, so no transform is needed.
return str(self)


class LibraryCollectionLocator(CheckFieldMixin, LibraryCollectionKey):
"""
When serialized, these keys look like:
lib-collection:org:lib:collection-id
"""
CANONICAL_NAMESPACE = 'lib-collection'
KEY_FIELDS = ('org', 'lib', 'usage_id')
org: str
lib: str
lib_key: LibraryLocatorV2
usage_id: str

__slots__ = KEY_FIELDS
CHECKED_INIT = False

# Allow usage IDs to contian unicode characters
USAGE_ID_REGEXP = re.compile(r'^[\w\-.]+$', flags=re.UNICODE)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the collection id (a number), but I'm unsure if we want to make it stricter here.

Maybe for clarity? What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, it's the ID? As in the primary key? We can't use that as part of the key if there's ever an intention to make it importable into a different instance.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment we don't have any other identifier for collections; we'll need to add either a UUID or a slug (or both). If a slug, it only needs to be unique within a library. I prefer slugs for readability. For now the slug can be auto-generated by slugifying the collection name and appending a number until it's unique.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think a slug is fine, as long as we write it to the database–say, a key field on Collection with a unique constraint on the combo of that and the LearningPackage (like how PublishableEntities work today).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, let's do that ^ @rpenido . Sorry for not realizing the need for a slug sooner. CC @pomegranited


def __init__(self, lib_key: LibraryLocatorV2, usage_id: str):
"""
Construct a CollectionLocator
"""
if not isinstance(lib_key, LibraryLocatorV2):
raise TypeError("lib_key must be a LibraryLocatorV2")

self._check_key_string_field("usage_id", usage_id, regexp=self.USAGE_ID_REGEXP)
super().__init__(
org=lib_key.org,
lib=lib_key.slug,
lib_key=lib_key,
usage_id=usage_id,
)

@property
def library_key(self) -> LibraryLocatorV2:
return self.lib_key

def _to_string(self) -> str:
"""
Serialize this key as a string
"""
return ":".join((self.org, self.lib, self.usage_id))

@classmethod
def _from_string(cls, serialized: str) -> Self:
"""
Instantiate this key from a serialized string
"""
try:
(org, lib, usage_id) = serialized.split(':')
lib_key = LibraryLocatorV2(org, lib)
return cls(lib_key, usage_id)
except (ValueError, TypeError) as error:
raise InvalidKeyError(cls, serialized) from error

Check warning on line 1679 in opaque_keys/edx/locator.py

View check run for this annotation

Codecov / codecov/patch

opaque_keys/edx/locator.py#L1678-L1679

Added lines #L1678 - L1679 were not covered by tests
66 changes: 66 additions & 0 deletions opaque_keys/edx/tests/test_collection_locators.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
Tests of LibraryCollectionLocator
"""
import ddt
from opaque_keys import InvalidKeyError
from opaque_keys.edx.tests import LocatorBaseTest
from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryLocatorV2


@ddt.ddt
class TestLibraryCollectionLocator(LocatorBaseTest):
"""
Tests of :class:`.LibraryCollectionLocator`
"""
@ddt.data(
"org/lib/id/foo",
"org/lib/id",
"org+lib+id",
"org+lib+",
"org+lib++id@library",
"org+ne@t",
"per%ent+sign",
)
def test_coll_key_from_invalid_string(self, coll_id_str):
with self.assertRaises(InvalidKeyError):
LibraryCollectionLocator.from_string(coll_id_str)

def test_coll_key_constructor(self):
org = 'TestX'
lib = 'LibraryX'
code = 'test-problem-bank'
lib_key = LibraryLocatorV2(org=org, slug=lib)
coll_key = LibraryCollectionLocator(lib_key=lib_key, usage_id=code)
lib_key = coll_key.library_key
self.assertEqual(str(coll_key), "lib-collection:TestX:LibraryX:test-problem-bank")
self.assertEqual(coll_key.org, org)
self.assertEqual(coll_key.lib, lib)
self.assertEqual(coll_key.usage_id, code)
self.assertEqual(lib_key.org, org)
self.assertEqual(lib_key.slug, lib)

def test_coll_key_constructor_bad_ids(self):
lib_key = LibraryLocatorV2(org="TestX", slug="lib1")

with self.assertRaises(ValueError):
LibraryCollectionLocator(lib_key=lib_key, usage_id='usage-!@#{$%^&*}')
with self.assertRaises(TypeError):
LibraryCollectionLocator(lib_key=None, usage_id='usage')

def test_coll_key_from_string(self):
org = 'TestX'
lib = 'LibraryX'
code = 'test-problem-bank'
str_key = f"lib-collection:{org}:{lib}:{code}"
coll_key = LibraryCollectionLocator.from_string(str_key)
lib_key = coll_key.library_key
self.assertEqual(str(coll_key), str_key)
self.assertEqual(coll_key.org, org)
self.assertEqual(coll_key.lib, lib)
self.assertEqual(coll_key.usage_id, code)
self.assertEqual(lib_key.org, org)
self.assertEqual(lib_key.slug, lib)

def test_coll_key_invalid_from_string(self):
with self.assertRaises(InvalidKeyError):
LibraryCollectionLocator.from_string("this-is-a-great-test")
5 changes: 4 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ def get_version(*file_paths):
],
'block_type': [
'block-type-v1 = opaque_keys.edx.block_types:BlockTypeKeyV1',
]
],
'collection_key': [
'lib-collection = opaque_keys.edx.locator:LibraryCollectionLocator',
],
}
)