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

ENT-211 add course key v2 #87

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
38 changes: 38 additions & 0 deletions opaque_keys/edx/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ def make_asset_key(self, asset_type, path): # pragma: no cover
"""
raise NotImplementedError()

@abstractmethod
def make_course_key_v2(self): # pragma: no cover
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this really necessary given that you already have CourseKeyV2.from_course_run_key()?

Copy link
Author

Choose a reason for hiding this comment

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

@cpennington @clintonb just to confirm, I have to remove this method only or do I need to remove the make_course_run_key in class CourseKeyV2 too?

Copy link
Contributor

Choose a reason for hiding this comment

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

@zubair-arbi: You should have some a method to go from CourseKeyV2 to CourseKey and back again, but only one of each. I would suggest using regular methods, rather than class methods, in general. So, probably CourseKeyV2.make_course_run_key, and CourseKey.make_course_key_v2.

"""
Returns a course key v2 object without run information.
"""
raise NotImplementedError()


class DefinitionKey(OpaqueKey):
"""
Expand All @@ -77,6 +84,37 @@ def block_type(self): # pragma: no cover
raise NotImplementedError()


class CourseKeyV2(OpaqueKey):
"""
An :class:`opaque_keys.OpaqueKey` identifying a
serialized Course Key object.
"""
KEY_TYPE = 'course_key_v2'
__slots__ = ()

@abstractproperty
def from_course_run_key(self, course_key): # pragma: no cover
"""
Get course key v2 from the course run key.

Arguments:
course_key (:class:`CourseKey`): The course identifier.

"""
raise NotImplementedError()

@abstractproperty
def make_course_run_key(self, course_run): # pragma: no cover
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is this needed? We already have existing methods of creating course run keys.

Copy link
Contributor

Choose a reason for hiding this comment

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

These were my suggestion. The existing keys typically are related by giving the more general key a way to create more specific keys by hydrating additional information.

Copy link
Contributor

Choose a reason for hiding this comment

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

That said, I agree that it doesn't make much sense to have both methods. We should pick one, and stick to it.

"""
Get course run key (course_key) from the course key v2.

Arguments:
course_run (str): The course run for course run identifier.

"""
raise NotImplementedError()


class CourseObjectMixin(object):
"""
An abstract :class:`opaque_keys.OpaqueKey` mixin
Expand Down
92 changes: 87 additions & 5 deletions opaque_keys/edx/locator.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from six import string_types, text_type
from opaque_keys import OpaqueKey, InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey, DefinitionKey, AssetKey
from opaque_keys.edx.keys import CourseKey, UsageKey, DefinitionKey, AssetKey, CourseKeyV2

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -380,6 +380,12 @@ def _from_deprecated_string(cls, serialized):

return cls(*serialized.split('/'), deprecated=True)

def make_course_key_v2(self):
"""
Returns a course key v2 object without run information.
"""
return CourseLocatorV2(org=self.org, course=self.course)

CourseKey.set_deprecated_fallback(CourseLocator)


Expand Down Expand Up @@ -574,10 +580,11 @@ def _to_deprecated_string(self):
""" LibraryLocators are never deprecated. """
raise NotImplementedError

@classmethod
def _from_deprecated_string(cls, serialized):
""" LibraryLocators are never deprecated. """
raise NotImplementedError
def make_course_key_v2(self): # pragma: no cover
"""
Returns a course key v2 object without run information.
"""
raise NotImplementedError()


class BlockUsageLocator(BlockLocatorBase, UsageKey):
Expand Down Expand Up @@ -1330,3 +1337,78 @@ def to_deprecated_list_repr(self):

# Register AssetLocator as the deprecated fallback for AssetKey
AssetKey.set_deprecated_fallback(AssetLocator)


class CourseLocatorV2(CourseKeyV2): # pylint: disable=abstract-method
"""
An CourseKeyV2 implementation class.
"""
CANONICAL_NAMESPACE = 'course-v2'
KEY_FIELDS = ('org', 'course')
__slots__ = KEY_FIELDS

KEY_REGEX = re.compile(
r'^(?P<org>{ALLOWED_ID_CHARS}+)\+(?P<course>{ALLOWED_ID_CHARS}+)$'.format(
ALLOWED_ID_CHARS=Locator.ALLOWED_ID_CHARS
)
)

def __init__(self, org=None, course=None, **kwargs):
"""
Construct a CourseLocatorV2.

Arguments:
org (string): Organization identifier for the course
course (string): Course number

"""
super(CourseLocatorV2, self).__init__(org=org, course=course, **kwargs)

if not (self.org and self.course):
raise InvalidKeyError(self.__class__, 'Both org and course must be set.')

@classmethod
def from_course_run_key(cls, course_key):
"""
Get course key v2 from the course run key object.

Arguments:
course_key (:class:`CourseKey`): The course identifier.

"""
return cls(**{key: getattr(course_key, key) for key in cls.KEY_FIELDS})

def make_course_run_key(self, course_run):
"""
Get course run key (course_key) from the course key v2.

Arguments:
course_run (str): The course run for course run identifier.

"""
return CourseLocator(org=self.org, course=self.course, run=course_run)

@classmethod
def _from_string(cls, serialized):
"""
Return a CourseLocatorV2 parsing the given serialized string.

Arguments:
serialized: string for matching

"""
parse = cls.KEY_REGEX.match(serialized)
if not parse:
raise InvalidKeyError(cls, serialized)

parse = parse.groupdict()
return cls(**{key: parse.get(key) for key in cls.KEY_FIELDS})

def _to_string(self):
"""
Return a string representing this location.
"""
return '{org}+{course}'.format(
org=self.org,
course=self.course
)
14 changes: 13 additions & 1 deletion opaque_keys/edx/tests/test_course_locators.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey

from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, CourseLocatorV2

from opaque_keys.edx.tests import LocatorBaseTest, TestDeprecated

Expand Down Expand Up @@ -279,3 +279,15 @@ def test_empty_run(self):
'org/course/',
text_type(CourseLocator('org', 'course', '', deprecated=True))
)

def test_make_course_key_v2(self):
"""
Verify that the method `make_course_key_v2` of class `CourseLocator`
returns a course key v2 object without course run information.
"""
organization = 'org'
course_number = 'course'
course_run = 'run'
course_run_key = CourseLocator(org=organization, course=course_number, run=course_run)
expected_course_key_v2 = CourseLocatorV2(org=organization, course=course_number)
self.assertEqual(expected_course_key_v2, course_run_key.make_course_key_v2())
9 changes: 9 additions & 0 deletions opaque_keys/edx/tests/test_library_locators.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,12 @@ def test_course_agnostic(self):
self.assertEqual(lib_key2, lib_key3)
self.assertEqual(lib_key3.org, None)
self.assertEqual(lib_key3.library, None)

def test_make_course_key_v2(self):
"""
Verify that the method `make_course_key_v2` of class `LibraryLocator`
raises exception `NotImplementedError`.
"""
lib_key = CourseKey.from_string('library-v1:TestX+lib1')
with self.assertRaises(NotImplementedError):
lib_key.make_course_key_v2() # pylint: disable=protected-access
70 changes: 68 additions & 2 deletions opaque_keys/edx/tests/test_locators.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@

import random

import ddt
from six import text_type
from bson.objectid import ObjectId

from opaque_keys.edx.locator import Locator, CourseLocator, DefinitionLocator, VersionTree
from opaque_keys.edx.keys import DefinitionKey
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import DefinitionKey, CourseKey, CourseKeyV2
from opaque_keys.edx.locator import Locator, CourseLocator, DefinitionLocator, VersionTree, CourseLocatorV2


class LocatorTests(TestCase):
Expand Down Expand Up @@ -59,3 +61,67 @@ def test_version_tree(self):
test_id = ObjectId(test_id_loc)
valid_locator = CourseLocator(version_guid=test_id)
self.assertEqual(VersionTree(valid_locator).children, [])


@ddt.ddt
class CourseLocatorV2Tests(TestCase):
"""
Tests for :class:`.CourseLocatorV2`
"""

def test_from_string(self):
"""
Verify that the method `from_string` of class `CourseKeyV2`
returns an object of `CourseLocatorV2` for a valid course key.
"""
course_key_v2 = 'course-v2:org+course'
course_locator_v2 = CourseKeyV2.from_string(course_key_v2)
expected_course_locator = CourseLocatorV2(org='org', course='course')
self.assertEqual(expected_course_locator, course_locator_v2)

def test_from_course_run_key(self):
"""
Verify that the method `from_course_run_key` of class `CourseLocatorV2`
coverts a valid course run key to a course key v2.
"""
course_key = CourseKey.from_string('course-v1:org+course+run')
expected_course_key = CourseLocatorV2(org=course_key.org, course=course_key.course)
actual_course_key = CourseLocatorV2.from_course_run_key(course_key)
self.assertEqual(expected_course_key, actual_course_key)

def test_make_course_run_key(self):
"""
Verify that the method `make_course_run_key` of class `CourseLocatorV2`
returns a course run key with provided run.
"""
organization = 'org'
course_number = 'course'
course_run = 'run'
course_key_v2 = CourseLocatorV2(org=organization, course=course_number)
expected_course_run_key = CourseLocator(org=organization, course=course_number, run=course_run)
self.assertEqual(expected_course_run_key, course_key_v2.make_course_run_key(course_run))

def test_serialize_to_string(self):
"""
Verify that the method `_to_string` of class `CourseLocatorV2`
serializes a course key v2 to a string with expected format.
"""
organization = 'org'
course_number = 'course'
course_locator_v2 = CourseLocatorV2(org=organization, course=course_number)
expected_serialized_key = 'course-v2:{org}+{course}'.format(org=organization, course=course_number)
self.assertEqual(expected_serialized_key, str(course_locator_v2))

@ddt.data(
'org/course/run',
'org+course+run',
'org+course+run+foo',
'course-v2:org+course+run',
)
def test_from_string_with_invalid_input(self, course_key):
"""
Verify that the method `from_string` of class `CourseKeyV2`
raises exception `InvalidKeyError` for unsupported key formats.
"""
with self.assertRaises(InvalidKeyError):
CourseKeyV2.from_string(course_key)
5 changes: 4 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

setup(
name='edx-opaque-keys',
version='0.4',
version='0.5.0',
author='edX',
url='https://github.com/edx/opaque-keys',
classifiers=[
Expand Down Expand Up @@ -31,6 +31,9 @@
# don't use slashes in any new code
'slashes = opaque_keys.edx.locator:CourseLocator',
],
'course_key_v2': [
'course-v2 = opaque_keys.edx.locator:CourseLocatorV2',
],
'usage_key': [
'block-v1 = opaque_keys.edx.locator:BlockUsageLocator',
'lib-block-v1 = opaque_keys.edx.locator:LibraryUsageLocator',
Expand Down