Commit cb431ccb by cewing

MIT CCX: Use CCX Keys: further revisions in response to code review

only require ccx-keys once

get_current_ccx will now expect a CourseKey instance as its argument, and will raise a value error if this expectation is not met.

document reason for local import

add special methods to pass attribute setting and deletion through to the wrapped modulestore

add __setattr__ and __delattr__ per code review, update __init__ to work with new methods

style change per code review

clean up context manager usage as recommended by code review

remove unused code and imports

convert modulestore type tests to use the `get_modulestore_type` api, remove unused imports

code quality: add docstrings

increase coverage for utils tests

fix bug found in testing.

increase test coverage on modulestore wrapper

code quality fixes

code-quality: ignore import error, but mark site for future consideration
parent ef483650
...@@ -194,7 +194,12 @@ def modulestore(): ...@@ -194,7 +194,12 @@ def modulestore():
) )
if settings.FEATURES.get('CUSTOM_COURSES_EDX'): if settings.FEATURES.get('CUSTOM_COURSES_EDX'):
from ccx.modulestore import CCXModulestoreWrapper # TODO: This import prevents a circular import issue, but is
# symptomatic of a lib having a dependency on code in lms. This
# should be updated to have a setting that enumerates modulestore
# wrappers and then uses that setting to wrap the modulestore in
# appropriate wrappers depending on enabled features.
from ccx.modulestore import CCXModulestoreWrapper # pylint: disable=import-error
_MIXED_MODULESTORE = CCXModulestoreWrapper(_MIXED_MODULESTORE) _MIXED_MODULESTORE = CCXModulestoreWrapper(_MIXED_MODULESTORE)
return _MIXED_MODULESTORE return _MIXED_MODULESTORE
......
...@@ -16,12 +16,16 @@ from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator ...@@ -16,12 +16,16 @@ from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator
def strip_ccx(val): def strip_ccx(val):
"""remove any reference to a CCX from the incoming value
return a tuple of the stripped value and the id of the ccx
"""
retval = val retval = val
ccx_id = None ccx_id = None
if isinstance(retval, CCXLocator): if isinstance(retval, CCXLocator):
ccx_id = retval.ccx ccx_id = retval.ccx
retval = retval.to_course_locator() retval = retval.to_course_locator()
elif isinstance(object, CCXBlockUsageLocator): elif isinstance(retval, CCXBlockUsageLocator):
ccx_id = retval.course_key.ccx ccx_id = retval.course_key.ccx
retval = retval.to_block_locator() retval = retval.to_block_locator()
elif hasattr(retval, 'location'): elif hasattr(retval, 'location'):
...@@ -30,6 +34,10 @@ def strip_ccx(val): ...@@ -30,6 +34,10 @@ def strip_ccx(val):
def restore_ccx(val, ccx_id): def restore_ccx(val, ccx_id):
"""restore references to a CCX to the incoming value
returns the value converted to a CCX-aware state, using the provided ccx_id
"""
if isinstance(val, CourseLocator): if isinstance(val, CourseLocator):
return CCXLocator.from_course_locator(val, ccx_id) return CCXLocator.from_course_locator(val, ccx_id)
elif isinstance(val, BlockUsageLocator): elif isinstance(val, BlockUsageLocator):
...@@ -43,6 +51,11 @@ def restore_ccx(val, ccx_id): ...@@ -43,6 +51,11 @@ def restore_ccx(val, ccx_id):
def restore_ccx_collection(field_value, ccx_id=None): def restore_ccx_collection(field_value, ccx_id=None):
"""restore references to a CCX to collections of incoming values
returns the original collection with all values converted to a ccx-aware
state, using the provided ccx_id
"""
if ccx_id is None: if ccx_id is None:
return field_value return field_value
if isinstance(field_value, list): if isinstance(field_value, list):
...@@ -57,226 +70,229 @@ def restore_ccx_collection(field_value, ccx_id=None): ...@@ -57,226 +70,229 @@ def restore_ccx_collection(field_value, ccx_id=None):
@contextmanager @contextmanager
def remove_ccx(to_strip): def remove_ccx(to_strip):
"""A context manager for wrapping modulestore api methods.
yields a stripped value and a function suitable for restoring it
"""
stripped, ccx = strip_ccx(to_strip) stripped, ccx = strip_ccx(to_strip)
yield stripped, partial(restore_ccx_collection, ccx_id=ccx) yield stripped, partial(restore_ccx_collection, ccx_id=ccx)
class CCXModulestoreWrapper(object): class CCXModulestoreWrapper(object):
"""This class wraps a modulestore
The purpose is to remove ccx-specific identifiers during lookup and restore
it after retrieval so that data can be stored local to a course, but
referenced in app context as ccx-specific
"""
def __init__(self, modulestore): def __init__(self, modulestore):
self._modulestore = modulestore """wrap the provided modulestore"""
self.__dict__['_modulestore'] = modulestore
def __getattr__(self, name): def __getattr__(self, name):
"""pass missing attributes through to _modulestore """look up missing attributes on the wrapped modulestore"""
"""
return getattr(self._modulestore, name) return getattr(self._modulestore, name)
def __setattr__(self, name, value):
"""set attributes only on the wrapped modulestore"""
setattr(self._modulestore, name, value)
def __delattr__(self, name):
"""delete attributes only on the wrapped modulestore"""
delattr(self._modulestore, name)
def _clean_locator_for_mapping(self, locator): def _clean_locator_for_mapping(self, locator):
with remove_ccx(locator) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
locator, restore = stripped with remove_ccx(locator) as (locator, restore):
retval = self._modulestore._clean_locator_for_mapping(locator) # pylint: disable=protected-access
return restore(retval) return restore(
self._modulestore._clean_locator_for_mapping(locator)
)
def _get_modulestore_for_courselike(self, locator=None): def _get_modulestore_for_courselike(self, locator=None):
"""See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
if locator is not None: if locator is not None:
locator, _ = strip_ccx(locator) locator, _ = strip_ccx(locator)
# pylint: disable=protected-access
return self._modulestore._get_modulestore_for_courselike(locator) return self._modulestore._get_modulestore_for_courselike(locator)
def fill_in_run(self, course_key): def fill_in_run(self, course_key):
""" """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
Some course_keys are used without runs. This function calls the corresponding with remove_ccx(course_key) as (course_key, restore):
fill_in_run function on the appropriate modulestore. return restore(self._modulestore.fill_in_run(course_key))
"""
with remove_ccx(course_key) as stripped:
course_key, restore = stripped
retval = self._modulestore.fill_in_run(course_key)
return restore(retval)
def has_item(self, usage_key, **kwargs): def has_item(self, usage_key, **kwargs):
""" """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
Does the course include the xblock who's id is reference? usage_key, _ = strip_ccx(usage_key)
"""
usage_key, ccx = strip_ccx(usage_key)
return self._modulestore.has_item(usage_key, **kwargs) return self._modulestore.has_item(usage_key, **kwargs)
def get_item(self, usage_key, depth=0, **kwargs): def get_item(self, usage_key, depth=0, **kwargs):
""" """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
see parent doc with remove_ccx(usage_key) as (usage_key, restore):
""" return restore(
with remove_ccx(usage_key) as stripped: self._modulestore.get_item(usage_key, depth, **kwargs)
usage_key, restore = stripped )
retval = self._modulestore.get_item(usage_key, depth, **kwargs)
return restore(retval)
def get_items(self, course_key, **kwargs): def get_items(self, course_key, **kwargs):
with remove_ccx(course_key) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
course_key, restore = stripped with remove_ccx(course_key) as (course_key, restore):
retval = self._modulestore.get_items(course_key, **kwargs) return restore(self._modulestore.get_items(course_key, **kwargs))
return restore(retval)
def get_course(self, course_key, depth=0, **kwargs): def get_course(self, course_key, depth=0, **kwargs):
with remove_ccx(course_key) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
course_key, restore = stripped with remove_ccx(course_key) as (course_key, restore):
retval = self._modulestore.get_course( return restore(self._modulestore.get_course(
course_key, depth=depth, **kwargs course_key, depth=depth, **kwargs
) ))
return restore(retval)
def has_course(self, course_id, ignore_case=False, **kwargs): def has_course(self, course_id, ignore_case=False, **kwargs):
with remove_ccx(course_id) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
course_id, restore = stripped with remove_ccx(course_id) as (course_id, restore):
retval = self._modulestore.has_course( return restore(self._modulestore.has_course(
course_id, ignore_case=ignore_case, **kwargs course_id, ignore_case=ignore_case, **kwargs
) ))
return restore(retval)
def delete_course(self, course_key, user_id): def delete_course(self, course_key, user_id):
""" """
See xmodule.modulestore.__init__.ModuleStoreWrite.delete_course See xmodule.modulestore.__init__.ModuleStoreWrite.delete_course
""" """
course_key, ccx = strip_ccx(course_key) course_key, _ = strip_ccx(course_key)
return self._modulestore.delete_course(course_key, user_id) return self._modulestore.delete_course(course_key, user_id)
def get_parent_location(self, location, **kwargs): def get_parent_location(self, location, **kwargs):
with remove_ccx(location) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
location, restore = stripped with remove_ccx(location) as (location, restore):
retval = self._modulestore.get_parent_location(location, **kwargs) return restore(
return restore(retval) self._modulestore.get_parent_location(location, **kwargs)
)
def get_block_original_usage(self, usage_key): def get_block_original_usage(self, usage_key):
with remove_ccx(usage_key) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
usage_key, restore = stripped with remove_ccx(usage_key) as (usage_key, restore):
orig_key, version = self._modulestore.get_block_original_usage(usage_key) orig_key, version = self._modulestore.get_block_original_usage(usage_key)
return restore(orig_key), version return restore(orig_key), version
def get_modulestore_type(self, course_id): def get_modulestore_type(self, course_id):
with remove_ccx(course_id) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
course_id, restore = stripped with remove_ccx(course_id) as (course_id, restore):
retval = self._modulestore.get_modulestore_type(course_id) return restore(self._modulestore.get_modulestore_type(course_id))
return restore(retval)
def get_orphans(self, course_key, **kwargs): def get_orphans(self, course_key, **kwargs):
with remove_ccx(course_key) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
course_key, restore = stripped with remove_ccx(course_key) as (course_key, restore):
retval = self._modulestore.get_orphans(course_key, **kwargs) return restore(self._modulestore.get_orphans(course_key, **kwargs))
return restore(retval)
def clone_course(self, source_course_id, dest_course_id, user_id, fields=None, **kwargs): def clone_course(self, source_course_id, dest_course_id, user_id, fields=None, **kwargs):
source_course_id, source_ccx = strip_ccx(source_course_id) """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
dest_course_id, dest_ccx = strip_ccx(dest_course_id) with remove_ccx(source_course_id) as (source_course_id, _):
retval = self._modulestore.clone_course( with remove_ccx(dest_course_id) as (dest_course_id, dest_restore):
source_course_id, dest_course_id, user_id, fields=fields, **kwargs return dest_restore(self._modulestore.clone_course(
) source_course_id, dest_course_id, user_id, fields=fields, **kwargs
if dest_ccx: ))
retval = restore_ccx_collection(retval, dest_ccx)
return retval
def create_item(self, user_id, course_key, block_type, block_id=None, fields=None, **kwargs): def create_item(self, user_id, course_key, block_type, block_id=None, fields=None, **kwargs):
with remove_ccx(course_key) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
course_key, restore = stripped with remove_ccx(course_key) as (course_key, restore):
retval = self._modulestore.create_item( return restore(self._modulestore.create_item(
user_id, course_key, block_type, block_id=block_id, fields=fields, **kwargs user_id, course_key, block_type, block_id=block_id, fields=fields, **kwargs
) ))
return restore(retval)
def create_child(self, user_id, parent_usage_key, block_type, block_id=None, fields=None, **kwargs): def create_child(self, user_id, parent_usage_key, block_type, block_id=None, fields=None, **kwargs):
with remove_ccx(parent_usage_key) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
parent_usage_key, restore = stripped with remove_ccx(parent_usage_key) as (parent_usage_key, restore):
retval = self._modulestore.create_child( return restore(self._modulestore.create_child(
user_id, parent_usage_key, block_type, block_id=block_id, fields=fields, **kwargs user_id, parent_usage_key, block_type, block_id=block_id, fields=fields, **kwargs
) ))
return restore(retval)
def import_xblock(self, user_id, course_key, block_type, block_id, fields=None, runtime=None, **kwargs): def import_xblock(self, user_id, course_key, block_type, block_id, fields=None, runtime=None, **kwargs):
with remove_ccx(course_key) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
course_key, restore = stripped with remove_ccx(course_key) as (course_key, restore):
retval = self._modulestore.import_xblock( return restore(self._modulestore.import_xblock(
user_id, course_key, block_type, block_id, fields=fields, runtime=runtime, **kwargs user_id, course_key, block_type, block_id, fields=fields, runtime=runtime, **kwargs
) ))
return restore(retval)
def copy_from_template(self, source_keys, dest_key, user_id, **kwargs): def copy_from_template(self, source_keys, dest_key, user_id, **kwargs):
with remove_ccx(dest_key) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
dest_key, restore = stripped with remove_ccx(dest_key) as (dest_key, restore):
retval = self._modulestore.copy_from_template( return restore(self._modulestore.copy_from_template(
source_keys, dest_key, user_id, **kwargs source_keys, dest_key, user_id, **kwargs
) ))
return restore(retval)
def update_item(self, xblock, user_id, allow_not_found=False, **kwargs): def update_item(self, xblock, user_id, allow_not_found=False, **kwargs):
with remove_ccx(xblock) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
xblock, restore = stripped with remove_ccx(xblock) as (xblock, restore):
retval = self._modulestore.update_item( return restore(self._modulestore.update_item(
xblock, user_id, allow_not_found=allow_not_found, **kwargs xblock, user_id, allow_not_found=allow_not_found, **kwargs
) ))
return restore(retval)
def delete_item(self, location, user_id, **kwargs): def delete_item(self, location, user_id, **kwargs):
with remove_ccx(location) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
location, restore = stripped with remove_ccx(location) as (location, restore):
retval = self._modulestore.delete_item(location, user_id, **kwargs) return restore(
return restore(retval) self._modulestore.delete_item(location, user_id, **kwargs)
)
def revert_to_published(self, location, user_id): def revert_to_published(self, location, user_id):
with remove_ccx(location) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
location, restore = stripped with remove_ccx(location) as (location, restore):
retval = self._modulestore.revert_to_published(location, user_id) return restore(
return restore(retval) self._modulestore.revert_to_published(location, user_id)
)
def create_xblock(self, runtime, course_key, block_type, block_id=None, fields=None, **kwargs): def create_xblock(self, runtime, course_key, block_type, block_id=None, fields=None, **kwargs):
with remove_ccx(course_key) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
course_key, restore = stripped with remove_ccx(course_key) as (course_key, restore):
retval = self._modulestore.create_xblock( return restore(self._modulestore.create_xblock(
runtime, course_key, block_type, block_id=block_id, fields=fields, **kwargs runtime, course_key, block_type, block_id=block_id, fields=fields, **kwargs
) ))
return restore(retval)
def has_published_version(self, xblock): def has_published_version(self, xblock):
with remove_ccx(xblock) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
xblock, restore = stripped with remove_ccx(xblock) as (xblock, restore):
retval = self._modulestore.has_published_version(xblock) return restore(self._modulestore.has_published_version(xblock))
return restore(retval)
def publish(self, location, user_id, **kwargs): def publish(self, location, user_id, **kwargs):
with remove_ccx(location) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
location, restore = stripped with remove_ccx(location) as (location, restore):
retval = self._modulestore.publish(location, user_id, **kwargs) return restore(
return restore(retval) self._modulestore.publish(location, user_id, **kwargs)
)
def unpublish(self, location, user_id, **kwargs): def unpublish(self, location, user_id, **kwargs):
with remove_ccx(location) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
location, restore = stripped with remove_ccx(location) as (location, restore):
retval = self._modulestore.unpublish(location, user_id, **kwargs) return restore(
return restore(retval) self._modulestore.unpublish(location, user_id, **kwargs)
)
def convert_to_draft(self, location, user_id): def convert_to_draft(self, location, user_id):
with remove_ccx(location) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
location, restore = stripped with remove_ccx(location) as (location, restore):
retval = self._modulestore.convert_to_draft(location, user_id) return restore(
return restore(retval) self._modulestore.convert_to_draft(location, user_id)
)
def has_changes(self, xblock): def has_changes(self, xblock):
with remove_ccx(xblock) as stripped: """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
xblock, restore = stripped with remove_ccx(xblock) as (xblock, restore):
retval = self._modulestore.has_changes(xblock) return restore(self._modulestore.has_changes(xblock))
return restore(retval)
def check_supports(self, course_key, method): def check_supports(self, course_key, method):
"""See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
course_key, _ = strip_ccx(course_key) course_key, _ = strip_ccx(course_key)
return self._modulestore.check_supports(course_key, method) return self._modulestore.check_supports(course_key, method)
@contextmanager @contextmanager
def branch_setting(self, branch_setting, course_id=None): def branch_setting(self, branch_setting, course_id=None):
""" """See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
A context manager for temporarily setting the branch value for the given course' store
to the given branch_setting. If course_id is None, the default store is used.
"""
course_id, _ = strip_ccx(course_id) course_id, _ = strip_ccx(course_id)
with self._modulestore.branch_setting(branch_setting, course_id): with self._modulestore.branch_setting(branch_setting, course_id):
yield yield
@contextmanager @contextmanager
def bulk_operations(self, course_id, emit_signals=True): def bulk_operations(self, course_id, emit_signals=True):
"""See the docs for xmodule.modulestore.mixed.MixedModuleStore"""
course_id, _ = strip_ccx(course_id) course_id, _ = strip_ccx(course_id)
with self._modulestore.bulk_operations(course_id, emit_signals=emit_signals): with self._modulestore.bulk_operations(course_id, emit_signals=emit_signals):
yield yield
...@@ -11,7 +11,7 @@ from courseware.field_overrides import FieldOverrideProvider # pylint: disable= ...@@ -11,7 +11,7 @@ from courseware.field_overrides import FieldOverrideProvider # pylint: disable=
from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.keys import CourseKey, UsageKey
from ccx_keys.locator import CCXLocator, CCXBlockUsageLocator from ccx_keys.locator import CCXLocator, CCXBlockUsageLocator
from .models import CcxMembership, CcxFieldOverride, CustomCourseForEdX from .models import CcxFieldOverride, CustomCourseForEdX
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -30,39 +30,38 @@ class CustomCoursesForEdxOverrideProvider(FieldOverrideProvider): ...@@ -30,39 +30,38 @@ class CustomCoursesForEdxOverrideProvider(FieldOverrideProvider):
# The incoming block might be a CourseKey instance of some type, a # The incoming block might be a CourseKey instance of some type, a
# UsageKey instance of some type, or it might be something that has a # UsageKey instance of some type, or it might be something that has a
# location attribute. That location attribute will be a UsageKey # location attribute. That location attribute will be a UsageKey
ccx = course_id = None ccx = course_key = None
identifier = getattr(block, 'id', None) identifier = getattr(block, 'id', None)
if isinstance(identifier, CourseKey): if isinstance(identifier, CourseKey):
course_id = block.id course_key = block.id
elif isinstance(identifier, UsageKey): elif isinstance(identifier, UsageKey):
course_id = block.id.course_key course_key = block.id.course_key
elif hasattr(block, 'location'): elif hasattr(block, 'location'):
course_id = block.location.course_key course_key = block.location.course_key
else: else:
msg = "Unable to get course id when calculating ccx overide for block type {}" msg = "Unable to get course id when calculating ccx overide for block type %r"
log.error(msg.format(type(block))) log.error(msg, type(block))
if course_id is not None: if course_key is not None:
ccx = get_current_ccx(course_id) ccx = get_current_ccx(course_key)
if ccx: if ccx:
return get_override_for_ccx(ccx, block, name, default) return get_override_for_ccx(ccx, block, name, default)
return default return default
def get_current_ccx(course_id): def get_current_ccx(course_key):
""" """
Return the ccx that is active for this course. Return the ccx that is active for this course.
course_key is expected to be an instance of an opaque CourseKey, a
ValueError is raised if this expectation is not met.
""" """
# ensure that the ID passed in is a CourseKey instance of some type. if not isinstance(course_key, CourseKey):
if isinstance(course_id, CourseKey): raise ValueError("get_current_ccx requires a CourseKey instance")
course_key = course_id
else: if not isinstance(course_key, CCXLocator):
course_key = CourseKey.from_string(course_id) return None
ccx = None return CustomCourseForEdX.objects.get(pk=course_key.ccx)
if isinstance(course_key, CCXLocator):
ccx_id = course_key.ccx
ccx = CustomCourseForEdX.objects.get(pk=ccx_id)
return ccx
def get_override_for_ccx(ccx, block, name, default=None): def get_override_for_ccx(ccx, block, name, default=None):
......
...@@ -2,30 +2,20 @@ ...@@ -2,30 +2,20 @@
Test the CCXModulestoreWrapper Test the CCXModulestoreWrapper
""" """
from collections import deque from collections import deque
from ccx_keys.locator import CCXLocator, CCXBlockUsageLocator from ccx_keys.locator import CCXLocator
import datetime import datetime
from itertools import izip_longest from itertools import izip_longest, chain
import pytz import pytz
from student.tests.factories import ( # pylint: disable=import-error from student.tests.factories import AdminFactory
AdminFactory,
CourseEnrollmentFactory,
UserFactory,
)
from xmodule.modulestore.tests.django_utils import ( from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, ModuleStoreTestCase,
TEST_DATA_SPLIT_MODULESTORE) TEST_DATA_SPLIT_MODULESTORE
)
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from ..models import CustomCourseForEdX from ..models import CustomCourseForEdX
def flatten(seq):
"""
For [[1, 2], [3, 4]] returns [1, 2, 3, 4]. Does not recurse.
"""
return [x for sub in seq for x in sub]
class TestCCXModulestoreWrapper(ModuleStoreTestCase): class TestCCXModulestoreWrapper(ModuleStoreTestCase):
"""tests for a modulestore wrapped by CCXModulestoreWrapper """tests for a modulestore wrapped by CCXModulestoreWrapper
""" """
...@@ -36,7 +26,7 @@ class TestCCXModulestoreWrapper(ModuleStoreTestCase): ...@@ -36,7 +26,7 @@ class TestCCXModulestoreWrapper(ModuleStoreTestCase):
Set up tests Set up tests
""" """
super(TestCCXModulestoreWrapper, self).setUp() super(TestCCXModulestoreWrapper, self).setUp()
course = CourseFactory.create() self.course = course = CourseFactory.create()
# Create instructor account # Create instructor account
coach = AdminFactory.create() coach = AdminFactory.create()
...@@ -46,17 +36,18 @@ class TestCCXModulestoreWrapper(ModuleStoreTestCase): ...@@ -46,17 +36,18 @@ class TestCCXModulestoreWrapper(ModuleStoreTestCase):
2010, 5, 12, 2, 42, tzinfo=pytz.UTC) 2010, 5, 12, 2, 42, tzinfo=pytz.UTC)
self.mooc_due = due = datetime.datetime( self.mooc_due = due = datetime.datetime(
2010, 7, 7, 0, 0, tzinfo=pytz.UTC) 2010, 7, 7, 0, 0, tzinfo=pytz.UTC)
chapters = [ItemFactory.create(start=start, parent=course) self.chapters = chapters = [
for _ in xrange(2)] ItemFactory.create(start=start, parent=course) for _ in xrange(2)
sequentials = [ ]
self.sequentials = sequentials = [
ItemFactory.create(parent=c) for _ in xrange(2) for c in chapters ItemFactory.create(parent=c) for _ in xrange(2) for c in chapters
] ]
verticals = [ self.verticals = verticals = [
ItemFactory.create( ItemFactory.create(
due=due, parent=s, graded=True, format='Homework' due=due, parent=s, graded=True, format='Homework'
) for _ in xrange(2) for s in sequentials ) for _ in xrange(2) for s in sequentials
] ]
blocks = [ self.blocks = [
ItemFactory.create(parent=v) for _ in xrange(2) for v in verticals ItemFactory.create(parent=v) for _ in xrange(2) for v in verticals
] ]
...@@ -67,9 +58,10 @@ class TestCCXModulestoreWrapper(ModuleStoreTestCase): ...@@ -67,9 +58,10 @@ class TestCCXModulestoreWrapper(ModuleStoreTestCase):
) )
ccx.save() ccx.save()
self.ccx_locator = CCXLocator.from_course_locator(course.id, ccx.id) self.ccx_locator = CCXLocator.from_course_locator(course.id, ccx.id) # pylint: disable=no-member
def get_all_children_bf(self, block): def get_all_children_bf(self, block):
"""traverse the children of block in a breadth-first order"""
queue = deque([block]) queue = deque([block])
while queue: while queue:
item = queue.popleft() item = queue.popleft()
...@@ -97,9 +89,10 @@ class TestCCXModulestoreWrapper(ModuleStoreTestCase): ...@@ -97,9 +89,10 @@ class TestCCXModulestoreWrapper(ModuleStoreTestCase):
course_key = self.ccx_locator.to_course_locator() course_key = self.ccx_locator.to_course_locator()
course = self.get_course(course_key) course = self.get_course(course_key)
ccx = self.get_course(self.ccx_locator) ccx = self.get_course(self.ccx_locator)
for expected, actual in izip_longest( test_fodder = izip_longest(
self.get_all_children_bf(course), self.get_all_children_bf(ccx) self.get_all_children_bf(course), self.get_all_children_bf(ccx)
): )
for expected, actual in test_fodder:
if expected is None: if expected is None:
self.fail('course children exhausted before ccx children') self.fail('course children exhausted before ccx children')
if actual is None: if actual is None:
...@@ -108,3 +101,36 @@ class TestCCXModulestoreWrapper(ModuleStoreTestCase): ...@@ -108,3 +101,36 @@ class TestCCXModulestoreWrapper(ModuleStoreTestCase):
self.assertEqual(expected.location.course_key, course_key) self.assertEqual(expected.location.course_key, course_key)
self.assertEqual(actual.location.course_key, self.ccx_locator) self.assertEqual(actual.location.course_key, self.ccx_locator)
def test_has_item(self):
"""can verify that a location exists, using ccx block usage key"""
for item in chain(self.chapters, self.sequentials, self.verticals, self.blocks):
block_key = self.ccx_locator.make_usage_key(
item.location.block_type, item.location.block_id
)
self.assertTrue(self.store.has_item(block_key))
def test_get_item(self):
"""can retrieve an item by a location key, using a ccx block usage key
the retrieved item should be the same as the the one read without ccx
info
"""
for expected in chain(self.chapters, self.sequentials, self.verticals, self.blocks):
block_key = self.ccx_locator.make_usage_key(
expected.location.block_type, expected.location.block_id
)
actual = self.store.get_item(block_key)
self.assertEqual(expected.display_name, actual.display_name)
self.assertEqual(expected.location, actual.location.to_block_locator())
def test_publication_api(self):
"""verify that we can correctly discern a published item by ccx key"""
for expected in self.blocks:
block_key = self.ccx_locator.make_usage_key(
expected.location.block_type, expected.location.block_id
)
self.assertTrue(self.store.has_published_version(expected))
self.store.unpublish(block_key, self.user.id)
self.assertFalse(self.store.has_published_version(expected))
self.store.publish(block_key, self.user.id)
self.assertTrue(self.store.has_published_version(expected))
...@@ -28,6 +28,7 @@ class TestFieldOverrides(ModuleStoreTestCase): ...@@ -28,6 +28,7 @@ class TestFieldOverrides(ModuleStoreTestCase):
Make sure field overrides behave in the expected manner. Make sure field overrides behave in the expected manner.
""" """
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def setUp(self): def setUp(self):
""" """
Set up tests Set up tests
......
...@@ -130,6 +130,7 @@ class TestGetEmailParams(ModuleStoreTestCase): ...@@ -130,6 +130,7 @@ class TestGetEmailParams(ModuleStoreTestCase):
"""tests for ccx.utils.get_email_params """tests for ccx.utils.get_email_params
""" """
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def setUp(self): def setUp(self):
""" """
Set up tests Set up tests
...@@ -188,6 +189,7 @@ class TestEnrollEmail(ModuleStoreTestCase): ...@@ -188,6 +189,7 @@ class TestEnrollEmail(ModuleStoreTestCase):
"""tests for the enroll_email function from ccx.utils """tests for the enroll_email function from ccx.utils
""" """
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def setUp(self): def setUp(self):
super(TestEnrollEmail, self).setUp() super(TestEnrollEmail, self).setUp()
# unbind the user created by the parent, so we can create our own when # unbind the user created by the parent, so we can create our own when
...@@ -369,6 +371,7 @@ class TestEnrollEmail(ModuleStoreTestCase): ...@@ -369,6 +371,7 @@ class TestEnrollEmail(ModuleStoreTestCase):
class TestUnenrollEmail(ModuleStoreTestCase): class TestUnenrollEmail(ModuleStoreTestCase):
"""Tests for the unenroll_email function from ccx.utils""" """Tests for the unenroll_email function from ccx.utils"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def setUp(self): def setUp(self):
super(TestUnenrollEmail, self).setUp() super(TestUnenrollEmail, self).setUp()
# unbind the user created by the parent, so we can create our own when # unbind the user created by the parent, so we can create our own when
...@@ -512,3 +515,63 @@ class TestUnenrollEmail(ModuleStoreTestCase): ...@@ -512,3 +515,63 @@ class TestUnenrollEmail(ModuleStoreTestCase):
self.check_enrollment_state(before, True, self.user, True) self.check_enrollment_state(before, True, self.user, True)
# no email was sent to the student # no email was sent to the student
self.assertEqual(self.outbox, []) self.assertEqual(self.outbox, [])
@attr('shard_1')
class TestGetMembershipTriplets(ModuleStoreTestCase):
"""Verify that get_ccx_membership_triplets functions properly"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def setUp(self):
"""Set up a course, coach, ccx and user"""
super(TestGetMembershipTriplets, self).setUp()
self.course = CourseFactory.create()
coach = AdminFactory.create()
role = CourseCcxCoachRole(self.course.id)
role.add_users(coach)
self.ccx = CcxFactory(course_id=self.course.id, coach=coach)
def make_ccx_membership(self, active=True):
"""create registration of self.user in self.ccx
registration will be inactive
"""
CcxMembershipFactory.create(ccx=self.ccx, student=self.user, active=active)
def call_fut(self, org_filter=None, org_filter_out=()):
"""call the function under test in this test case"""
from ccx.utils import get_ccx_membership_triplets
return list(
get_ccx_membership_triplets(self.user, org_filter, org_filter_out)
)
def test_no_membership(self):
"""verify that no triplets are returned if there are no memberships
"""
triplets = self.call_fut()
self.assertEqual(len(triplets), 0)
def test_has_membership(self):
"""verify that a triplet is returned when a membership exists
"""
self.make_ccx_membership()
triplets = self.call_fut()
self.assertEqual(len(triplets), 1)
ccx, membership, course = triplets[0]
self.assertEqual(ccx.id, self.ccx.id)
self.assertEqual(unicode(course.id), unicode(self.course.id))
self.assertEqual(membership.student, self.user)
def test_has_membership_org_filtered(self):
"""verify that microsite org filter prevents seeing microsite ccx"""
self.make_ccx_membership()
bad_org = self.course.location.org + 'foo'
triplets = self.call_fut(org_filter=bad_org)
self.assertEqual(len(triplets), 0)
def test_has_membership_org_filtered_out(self):
"""verify that microsite ccxs not seen in non-microsite view"""
self.make_ccx_membership()
filter_list = [self.course.location.org]
triplets = self.call_fut(org_filter_out=filter_list)
self.assertEqual(len(triplets), 0)
...@@ -6,7 +6,6 @@ import json ...@@ -6,7 +6,6 @@ import json
import re import re
import pytz import pytz
import ddt import ddt
import unittest
from mock import patch, MagicMock from mock import patch, MagicMock
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
...@@ -27,7 +26,6 @@ from student.tests.factories import ( # pylint: disable=import-error ...@@ -27,7 +26,6 @@ from student.tests.factories import ( # pylint: disable=import-error
UserFactory, UserFactory,
) )
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.x_module import XModuleMixin from xmodule.x_module import XModuleMixin
from xmodule.modulestore.tests.django_utils import ( from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, ModuleStoreTestCase,
...@@ -36,7 +34,6 @@ from xmodule.modulestore.tests.factories import ( ...@@ -36,7 +34,6 @@ from xmodule.modulestore.tests.factories import (
CourseFactory, CourseFactory,
ItemFactory, ItemFactory,
) )
import xmodule.tabs as tabs
from ccx_keys.locator import CCXLocator from ccx_keys.locator import CCXLocator
from ..models import ( from ..models import (
...@@ -83,6 +80,7 @@ class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -83,6 +80,7 @@ class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase):
Tests for Custom Courses views. Tests for Custom Courses views.
""" """
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def setUp(self): def setUp(self):
""" """
Set up tests Set up tests
...@@ -481,6 +479,7 @@ class TestCCXGrades(ModuleStoreTestCase, LoginEnrollmentTestCase): ...@@ -481,6 +479,7 @@ class TestCCXGrades(ModuleStoreTestCase, LoginEnrollmentTestCase):
category="sequential", category="sequential",
metadata={'graded': True, 'format': 'Homework'}) metadata={'graded': True, 'format': 'Homework'})
for _ in xrange(4)] for _ in xrange(4)]
# pylint: disable=unused-variable
problems = [ problems = [
[ [
ItemFactory.create( ItemFactory.create(
......
...@@ -4,8 +4,6 @@ CCX Enrollment operations for use by Coach APIs. ...@@ -4,8 +4,6 @@ CCX Enrollment operations for use by Coach APIs.
Does not include any access control, be sure to check access before calling. Does not include any access control, be sure to check access before calling.
""" """
import logging import logging
from courseware.courses import get_course_about_section # pylint: disable=import-error
from courseware.courses import get_course_by_id # pylint: disable=import-error
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
...@@ -20,7 +18,6 @@ from .models import ( ...@@ -20,7 +18,6 @@ from .models import (
CcxMembership, CcxMembership,
CcxFutureMembership, CcxFutureMembership,
) )
from .overrides import get_current_ccx
log = logging.getLogger("edx.ccx") log = logging.getLogger("edx.ccx")
...@@ -154,7 +151,7 @@ def get_email_params(ccx, auto_enroll, secure=True): ...@@ -154,7 +151,7 @@ def get_email_params(ccx, auto_enroll, secure=True):
site=stripped_site_name, site=stripped_site_name,
path=reverse( path=reverse(
'course_root', 'course_root',
kwargs={'course_id': CCXLocator.from_course_locator(ccx.course_id, ccx.id)} kwargs={'course_id': CCXLocator.from_course_locator(ccx.course_id, ccx.id)}
) )
) )
...@@ -165,7 +162,7 @@ def get_email_params(ccx, auto_enroll, secure=True): ...@@ -165,7 +162,7 @@ def get_email_params(ccx, auto_enroll, secure=True):
site=stripped_site_name, site=stripped_site_name,
path=reverse( path=reverse(
'about_course', 'about_course',
kwargs={'course_id': CCXLocator.from_course_locator(ccx.course_id, ccx.id)} kwargs={'course_id': CCXLocator.from_course_locator(ccx.course_id, ccx.id)}
) )
) )
...@@ -267,9 +264,9 @@ def get_ccx_membership_triplets(user, course_org_filter, org_filter_out_set): ...@@ -267,9 +264,9 @@ def get_ccx_membership_triplets(user, course_org_filter, org_filter_out_set):
# warning to the log so we can clean up. # warning to the log so we can clean up.
if course.location.deprecated: if course.location.deprecated:
log.warning( log.warning(
"CCX {} exists for course {} with deprecated id".format( "CCX %s exists for course %s with deprecated id",
ccx, ccx.course_id ccx,
) ccx.course_id
) )
continue continue
......
...@@ -110,7 +110,10 @@ def dashboard(request, course, ccx=None): ...@@ -110,7 +110,10 @@ def dashboard(request, course, ccx=None):
if ccx is None: if ccx is None:
ccx = get_ccx_for_coach(course, request.user) ccx = get_ccx_for_coach(course, request.user)
if ccx: if ccx:
url = reverse('ccx_coach_dashboard', kwargs={'course_id': CCXLocator.from_course_locator(course.id, ccx.id)}) url = reverse(
'ccx_coach_dashboard',
kwargs={'course_id': CCXLocator.from_course_locator(course.id, ccx.id)}
)
return redirect(url) return redirect(url)
context = { context = {
...@@ -151,11 +154,10 @@ def create_ccx(request, course, ccx=None): ...@@ -151,11 +154,10 @@ def create_ccx(request, course, ccx=None):
# prevent CCX objects from being created for deprecated course ids. # prevent CCX objects from being created for deprecated course ids.
if course.id.deprecated: if course.id.deprecated:
messages.error(_( messages.error(request, _(
"You cannot create a CCX from a course using a deprecated id. " "You cannot create a CCX from a course using a deprecated id. "
"Please create a rerun of this course in the studio to allow " "Please create a rerun of this course in the studio to allow "
"this action.") "this action."))
)
url = reverse('ccx_coach_dashboard', kwargs={'course_id', course.id}) url = reverse('ccx_coach_dashboard', kwargs={'course_id', course.id})
return redirect(url) return redirect(url)
...@@ -179,7 +181,7 @@ def create_ccx(request, course, ccx=None): ...@@ -179,7 +181,7 @@ def create_ccx(request, course, ccx=None):
for vertical in sequential.get_children(): for vertical in sequential.get_children():
override_field_for_ccx(ccx, vertical, hidden, True) override_field_for_ccx(ccx, vertical, hidden, True)
ccx_id = CCXLocator.from_course_locator(course.id, ccx.id) ccx_id = CCXLocator.from_course_locator(course.id, ccx.id) # pylint: disable=no-member
url = reverse('ccx_coach_dashboard', kwargs={'course_id': ccx_id}) url = reverse('ccx_coach_dashboard', kwargs={'course_id': ccx_id})
return redirect(url) return redirect(url)
...@@ -369,7 +371,7 @@ def get_ccx_schedule(course, ccx): ...@@ -369,7 +371,7 @@ def get_ccx_schedule(course, ccx):
@ensure_csrf_cookie @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
@coach_dashboard @coach_dashboard
def ccx_schedule(request, course, ccx=None): def ccx_schedule(request, course, ccx=None): # pylint: disable=unused-argument
""" """
get json representation of ccx schedule get json representation of ccx schedule
""" """
...@@ -464,6 +466,8 @@ def ccx_student_management(request, course, ccx=None): ...@@ -464,6 +466,8 @@ def ccx_student_management(request, course, ccx=None):
@contextmanager @contextmanager
def ccx_course(ccx_locator): def ccx_course(ccx_locator):
"""Create a context in which the course identified by course_locator exists
"""
course = get_course_by_id(ccx_locator) course = get_course_by_id(ccx_locator)
yield course yield course
......
...@@ -494,7 +494,13 @@ def _has_access_course_key(user, action, course_key): ...@@ -494,7 +494,13 @@ def _has_access_course_key(user, action, course_key):
return _dispatch(checkers, action, user, course_key) return _dispatch(checkers, action, user, course_key)
def _has_access_ccx_key(user, action, ccx_key): def _has_access_ccx_key(user, action, ccx_key):
"""Check if user has access to the course for this ccx_key
Delegates checking to _has_access_course_key
Valid actions: same as for that function
"""
course_key = ccx_key.to_course_locator() course_key = ccx_key.to_course_locator()
return _has_access_course_key(user, action, course_key) return _has_access_course_key(user, action, course_key)
......
...@@ -41,9 +41,7 @@ from student.models import CourseEnrollment, UserProfile, Registration ...@@ -41,9 +41,7 @@ from student.models import CourseEnrollment, UserProfile, Registration
import track.views import track.views
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.xml import XMLModuleStore
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from ccx.modulestore import CCXModulestoreWrapper
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -60,13 +58,10 @@ class SysadminDashboardView(TemplateView): ...@@ -60,13 +58,10 @@ class SysadminDashboardView(TemplateView):
modulestore_type and return msg modulestore_type and return msg
""" """
self.def_ms = check_ms = modulestore() self.def_ms = modulestore()
# if the modulestore is wrapped by CCX, unwrap it for checking purposes
if isinstance(check_ms, CCXModulestoreWrapper):
check_ms = check_ms._modulestore
self.is_using_mongo = True self.is_using_mongo = True
if isinstance(check_ms, XMLModuleStore): if self.def_ms.get_modulestore_type(None) == 'xml':
self.is_using_mongo = False self.is_using_mongo = False
self.msg = u'' self.msg = u''
self.datatable = [] self.datatable = []
......
...@@ -32,8 +32,6 @@ from student.tests.factories import UserFactory ...@@ -32,8 +32,6 @@ from student.tests.factories import UserFactory
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST from xmodule.modulestore.tests.mongo_connection import MONGO_PORT_NUM, MONGO_HOST
from xmodule.modulestore.xml import XMLModuleStore
from ccx.modulestore import CCXModulestoreWrapper
TEST_MONGODB_LOG = { TEST_MONGODB_LOG = {
...@@ -316,12 +314,9 @@ class TestSysadmin(SysadminBaseTestCase): ...@@ -316,12 +314,9 @@ class TestSysadmin(SysadminBaseTestCase):
# Create git loaded course # Create git loaded course
response = self._add_edx4edx() response = self._add_edx4edx()
def_ms = check_ms = modulestore() def_ms = modulestore()
# if the modulestore is wrapped by CCX, unwrap it for testing purposes
if isinstance(check_ms, CCXModulestoreWrapper):
check_ms = check_ms._modulestore
self.assertIn('xml', str(check_ms.__class__)) self.assertEqual('xml', def_ms.get_modulestore_type(None))
course = def_ms.courses.get('{0}/edx4edx_lite'.format( course = def_ms.courses.get('{0}/edx4edx_lite'.format(
os.path.abspath(settings.DATA_DIR)), None) os.path.abspath(settings.DATA_DIR)), None)
self.assertIsNotNone(course) self.assertIsNotNone(course)
...@@ -465,7 +460,7 @@ class TestSysAdminMongoCourseImport(SysadminBaseTestCase): ...@@ -465,7 +460,7 @@ class TestSysAdminMongoCourseImport(SysadminBaseTestCase):
self._mkdir(getattr(settings, 'GIT_REPO_DIR')) self._mkdir(getattr(settings, 'GIT_REPO_DIR'))
def_ms = modulestore() def_ms = modulestore()
self.assertFalse(isinstance(def_ms, XMLModuleStore)) self.assertFalse('xml' == def_ms.get_modulestore_type(None))
self._add_edx4edx() self._add_edx4edx()
course = def_ms.get_course(SlashSeparatedCourseKey('MITx', 'edx4edx', 'edx4edx')) course = def_ms.get_course(SlashSeparatedCourseKey('MITx', 'edx4edx', 'edx4edx'))
......
...@@ -54,7 +54,6 @@ git+https://github.com/edx/edx-lint.git@ed8c8d2a0267d4d42f43642d193e25f8bd575d9b ...@@ -54,7 +54,6 @@ git+https://github.com/edx/edx-lint.git@ed8c8d2a0267d4d42f43642d193e25f8bd575d9b
-e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive -e git+https://github.com/edx-solutions/xblock-google-drive.git@138e6fa0bf3a2013e904a085b9fed77dab7f3f21#egg=xblock-google-drive
-e git+https://github.com/edx/edx-reverification-block.git@6e2834c5f7e998ad9b81170e7ceb4d8a64900eb0#egg=edx-reverification-block -e git+https://github.com/edx/edx-reverification-block.git@6e2834c5f7e998ad9b81170e7ceb4d8a64900eb0#egg=edx-reverification-block
git+https://github.com/edx/ecommerce-api-client.git@1.0.0#egg=ecommerce-api-client==1.0.0 git+https://github.com/edx/ecommerce-api-client.git@1.0.0#egg=ecommerce-api-client==1.0.0
-e git+https://github.com/jazkarta/ccx-keys.git@e6b03704b1bb97c1d2f31301ecb4e3a687c536ea#egg=ccx-keys
# Third Party XBlocks # Third Party XBlocks
-e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga -e git+https://github.com/mitodl/edx-sga@172a90fd2738f8142c10478356b2d9ed3e55334a#egg=edx-sga
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment