Commit ef483650 by cewing

MIT CCX: Use CCX Keys - responses to code review

remove references to middleware that were missed previously

use key apis rather than local implementation of key conversion.  remove local implementationa

remove spurious test for attribute

fix test setUp to avoid unneeded flattening

code quality fixes

add security check ensuring that the coach is coach for *this* CCX.

prevent ccx/deprecated course id problems

1.  do not allow ccx objects to be created if the course id is deprecated
2.  filter out any ccx memberships that involve deprecated course ids (in case there are bad ccxs in the database)

Fix test failures and errors arising from incorrect code path execution

Create context manager to handle unwrapping and restoring ccx values for the modulestore wrapper, employ it throughout modulestore wrapper implementation
parent 6a0c9aee
......@@ -10,35 +10,21 @@ returned from the modulestore will have their keys updated to be the CCX
version that was passed in.
"""
from contextlib import contextmanager
from functools import partial
from ccx_keys.locator import CCXLocator, CCXBlockUsageLocator
from opaque_keys.edx.locator import CourseLocator, BlockUsageLocator
def ccx_locator_to_course_locator(ccx_locator):
return CourseLocator(
org=ccx_locator.org,
course=ccx_locator.course,
run=ccx_locator.run,
branch=ccx_locator.branch,
version_guid=ccx_locator.version_guid,
)
def strip_ccx(val):
retval = val
ccx_id = None
if hasattr(retval, 'ccx'):
if isinstance(retval, CCXLocator):
ccx_id = retval.ccx
retval = ccx_locator_to_course_locator(retval)
elif isinstance(object, CCXBlockUsageLocator):
ccx_locator = retval.course_key
ccx_id = ccx_locator.ccx
course_locator = ccx_locator_to_course_locator(ccx_locator)
retval = BlockUsageLocator(
course_locator, retval.block_type, retval.block_id
)
if hasattr(retval, 'location'):
if isinstance(retval, CCXLocator):
ccx_id = retval.ccx
retval = retval.to_course_locator()
elif isinstance(object, CCXBlockUsageLocator):
ccx_id = retval.course_key.ccx
retval = retval.to_block_locator()
elif hasattr(retval, 'location'):
retval.location, ccx_id = strip_ccx(retval.location)
return retval, ccx_id
......@@ -56,7 +42,9 @@ def restore_ccx(val, ccx_id):
return val
def restore_ccx_collection(field_value, ccx_id):
def restore_ccx_collection(field_value, ccx_id=None):
if ccx_id is None:
return field_value
if isinstance(field_value, list):
field_value = [restore_ccx(fv, ccx_id) for fv in field_value]
elif isinstance(field_value, dict):
......@@ -67,6 +55,12 @@ def restore_ccx_collection(field_value, ccx_id):
return field_value
@contextmanager
def remove_ccx(to_strip):
stripped, ccx = strip_ccx(to_strip)
yield stripped, partial(restore_ccx_collection, ccx_id=ccx)
class CCXModulestoreWrapper(object):
def __init__(self, modulestore):
......@@ -78,11 +72,10 @@ class CCXModulestoreWrapper(object):
return getattr(self._modulestore, name)
def _clean_locator_for_mapping(self, locator):
locator, ccx = strip_ccx(locator)
retval = self._modulestore._clean_locator_for_mapping(locator)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(locator) as stripped:
locator, restore = stripped
retval = self._modulestore._clean_locator_for_mapping(locator)
return restore(retval)
def _get_modulestore_for_courselike(self, locator=None):
if locator is not None:
......@@ -94,11 +87,10 @@ class CCXModulestoreWrapper(object):
Some course_keys are used without runs. This function calls the corresponding
fill_in_run function on the appropriate modulestore.
"""
course_key, ccx = strip_ccx(course_key)
retval = self._modulestore.fill_in_run(course_key)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
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):
"""
......@@ -111,35 +103,32 @@ class CCXModulestoreWrapper(object):
"""
see parent doc
"""
usage_key, ccx = strip_ccx(usage_key)
retval = self._modulestore.get_item(usage_key, depth, **kwargs)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(usage_key) as stripped:
usage_key, restore = stripped
retval = self._modulestore.get_item(usage_key, depth, **kwargs)
return restore(retval)
def get_items(self, course_key, **kwargs):
course_key, ccx = strip_ccx(course_key)
retval = self._modulestore.get_items(course_key, **kwargs)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(course_key) as stripped:
course_key, restore = stripped
retval = self._modulestore.get_items(course_key, **kwargs)
return restore(retval)
def get_course(self, course_key, depth=0, **kwargs):
# from nose.tools import set_trace; set_trace()
course_key, ccx = strip_ccx(course_key)
retval = self._modulestore.get_course(course_key, depth=depth, **kwargs)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(course_key) as stripped:
course_key, restore = stripped
retval = self._modulestore.get_course(
course_key, depth=depth, **kwargs
)
return restore(retval)
def has_course(self, course_id, ignore_case=False, **kwargs):
course_id, ccx = strip_ccx(course_id)
retval = self._modulestore.has_course(
course_id, ignore_case=ignore_case, **kwargs
)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(course_id) as stripped:
course_id, restore = stripped
retval = self._modulestore.has_course(
course_id, ignore_case=ignore_case, **kwargs
)
return restore(retval)
def delete_course(self, course_key, user_id):
"""
......@@ -149,32 +138,28 @@ class CCXModulestoreWrapper(object):
return self._modulestore.delete_course(course_key, user_id)
def get_parent_location(self, location, **kwargs):
location, ccx = strip_ccx(location)
retval = self._modulestore.get_parent_location(location, **kwargs)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(location) as stripped:
location, restore = stripped
retval = self._modulestore.get_parent_location(location, **kwargs)
return restore(retval)
def get_block_original_usage(self, usage_key):
usage_key, ccx = strip_ccx(usage_key)
orig_key, version = self._modulestore.get_block_original_usage(usage_key)
if orig_key and ccx:
orig_key = restore_ccx_collection(orig_key, ccx)
return orig_key, version
with remove_ccx(usage_key) as stripped:
usage_key, restore = stripped
orig_key, version = self._modulestore.get_block_original_usage(usage_key)
return restore(orig_key), version
def get_modulestore_type(self, course_id):
course_id, ccx = strip_ccx(course_id)
retval = self._modulestore.get_modulestore_type(course_id)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(course_id) as stripped:
course_id, restore = stripped
retval = self._modulestore.get_modulestore_type(course_id)
return restore(retval)
def get_orphans(self, course_key, **kwargs):
course_key, ccx = strip_ccx(course_key)
retval = self._modulestore.get_orphans(course_key, **kwargs)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(course_key) as stripped:
course_key, restore = stripped
retval = 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):
source_course_id, source_ccx = strip_ccx(source_course_id)
......@@ -187,109 +172,94 @@ class CCXModulestoreWrapper(object):
return retval
def create_item(self, user_id, course_key, block_type, block_id=None, fields=None, **kwargs):
course_key, ccx = strip_ccx(course_key)
retval = self._modulestore.create_item(
user_id, course_key, block_type, block_id=block_id, fields=fields, **kwargs
)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(course_key) as stripped:
course_key, restore = stripped
retval = self._modulestore.create_item(
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):
parent_usage_key, ccx = strip_ccx(parent_usage_key)
retval = self._modulestore.create_child(
user_id, parent_usage_key, block_type, block_id=block_id, fields=fields, **kwargs
)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(parent_usage_key) as stripped:
parent_usage_key, restore = stripped
retval = self._modulestore.create_child(
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):
course_key, ccx = strip_ccx(course_key)
retval = self._modulestore.import_xblock(
user_id, course_key, block_type, block_id, fields=fields, runtime=runtime, **kwargs
)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(course_key) as stripped:
course_key, restore = stripped
retval = self._modulestore.import_xblock(
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):
dest_key, ccx = strip_ccx(dest_key)
retval = self._modulestore.copy_from_template(
source_keys, dest_key, user_id, **kwargs
)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(dest_key) as stripped:
dest_key, restore = stripped
retval = self._modulestore.copy_from_template(
source_keys, dest_key, user_id, **kwargs
)
return restore(retval)
def update_item(self, xblock, user_id, allow_not_found=False, **kwargs):
xblock, ccx = strip_ccx(xblock)
retval = self._modulestore.update_item(
xblock, user_id, allow_not_found=allow_not_found, **kwargs
)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(xblock) as stripped:
xblock, restore = stripped
retval = self._modulestore.update_item(
xblock, user_id, allow_not_found=allow_not_found, **kwargs
)
return restore(retval)
def delete_item(self, location, user_id, **kwargs):
location, ccx = strip_ccx(location)
retval = self._modulestore.delete_item(location, user_id, **kwargs)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(location) as stripped:
location, restore = stripped
retval = self._modulestore.delete_item(location, user_id, **kwargs)
return restore(retval)
def revert_to_published(self, location, user_id):
location, ccx = strip_ccx(location)
retval = self._modulestore.revert_to_published(location, user_id)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(location) as stripped:
location, restore = stripped
retval = self._modulestore.revert_to_published(location, user_id)
return restore(retval)
def create_xblock(self, runtime, course_key, block_type, block_id=None, fields=None, **kwargs):
course_key, ccx = strip_ccx(course_key)
retval = self._modulestore.create_xblock(
runtime, course_key, block_type, block_id=block_id, fields=fields, **kwargs
)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(course_key) as stripped:
course_key, restore = stripped
retval = self._modulestore.create_xblock(
runtime, course_key, block_type, block_id=block_id, fields=fields, **kwargs
)
return restore(retval)
def has_published_version(self, xblock):
xblock.scope_ids.usage_id.course_key, ccx = strip_ccx(
xblock.scope_ids.usage_id.course_key
)
retval = self._modulestore.has_published_version(xblock)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(xblock) as stripped:
xblock, restore = stripped
retval = self._modulestore.has_published_version(xblock)
return restore(retval)
def publish(self, location, user_id, **kwargs):
location, ccx = strip_ccx(location)
retval = self._modulestore.publish(location, user_id, **kwargs)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(location) as stripped:
location, restore = stripped
retval = self._modulestore.publish(location, user_id, **kwargs)
return restore(retval)
def unpublish(self, location, user_id, **kwargs):
location, ccx = strip_ccx(location)
retval = self._modulestore.unpublish(location, user_id, **kwargs)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(location) as stripped:
location, restore = stripped
retval = self._modulestore.unpublish(location, user_id, **kwargs)
return restore(retval)
def convert_to_draft(self, location, user_id):
location, ccx = strip_ccx(location)
retval = self._modulestore.convert_to_draft(location, user_id)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(location) as stripped:
location, restore = stripped
retval = self._modulestore.convert_to_draft(location, user_id)
return restore(retval)
def has_changes(self, xblock):
xblock.location, ccx = strip_ccx(xblock.location)
retval = self._modulestore.has_changes(xblock)
if ccx:
retval = restore_ccx_collection(retval, ccx)
return retval
with remove_ccx(xblock) as stripped:
xblock, restore = stripped
retval = self._modulestore.has_changes(xblock)
return restore(retval)
def check_supports(self, course_key, method):
course_key, _ = strip_ccx(course_key)
......
......@@ -48,23 +48,17 @@ class TestCCXModulestoreWrapper(ModuleStoreTestCase):
2010, 7, 7, 0, 0, tzinfo=pytz.UTC)
chapters = [ItemFactory.create(start=start, parent=course)
for _ in xrange(2)]
sequentials = flatten([
[
ItemFactory.create(parent=chapter) for _ in xrange(2)
] for chapter in chapters
])
verticals = flatten([
[
ItemFactory.create(
due=due, parent=sequential, graded=True, format='Homework'
) for _ in xrange(2)
] for sequential in sequentials
])
blocks = flatten([ # pylint: disable=unused-variable
[
ItemFactory.create(parent=vertical) for _ in xrange(2)
] for vertical in verticals
])
sequentials = [
ItemFactory.create(parent=c) for _ in xrange(2) for c in chapters
]
verticals = [
ItemFactory.create(
due=due, parent=s, graded=True, format='Homework'
) for _ in xrange(2) for s in sequentials
]
blocks = [
ItemFactory.create(parent=v) for _ in xrange(2) for v in verticals
]
self.ccx = ccx = CustomCourseForEdX(
course_id=course.id,
......
......@@ -69,9 +69,9 @@ class TestEmailEnrollmentState(ModuleStoreTestCase):
"""verify behavior for non-user email address
"""
ee_state = self.create_one(email='nobody@nowhere.com')
for attr in ['user', 'member', 'full_name', 'in_ccx']:
value = getattr(ee_state, attr, 'missing attribute')
self.assertFalse(value, "{}: {}".format(value, attr))
for attribute in ['user', 'member', 'full_name', 'in_ccx']:
value = getattr(ee_state, attribute, 'missing attribute')
self.assertFalse(value, "{}: {}".format(value, attribute))
def test_enrollment_state_for_non_member_user(self):
"""verify behavior for email address of user who is not a ccx memeber
......@@ -89,10 +89,10 @@ class TestEmailEnrollmentState(ModuleStoreTestCase):
self.create_user()
self.register_user_in_ccx()
ee_state = self.create_one()
for attr in ['user', 'in_ccx']:
for attribute in ['user', 'in_ccx']:
self.assertTrue(
getattr(ee_state, attr, False),
"attribute {} is missing or False".format(attr)
getattr(ee_state, attribute, False),
"attribute {} is missing or False".format(attribute)
)
self.assertEqual(ee_state.member, self.user)
self.assertEqual(ee_state.full_name, self.user.profile.name)
......
......@@ -262,6 +262,17 @@ def get_ccx_membership_triplets(user, course_org_filter, org_filter_out_set):
elif course.location.org in org_filter_out_set:
continue
# If, somehow, we've got a ccx that has been created for a
# course with a deprecated ID, we must filter it out. Emit a
# warning to the log so we can clean up.
if course.location.deprecated:
log.warning(
"CCX {} exists for course {} with deprecated id".format(
ccx, ccx.course_id
)
)
continue
yield (ccx, membership, course)
else:
log.error("User {0} enrolled in {2} course {1}".format( # pylint: disable=logging-format-interpolation
......
......@@ -18,6 +18,7 @@ from django.http import (
HttpResponseForbidden,
HttpResponseRedirect,
)
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.http import Http404
......@@ -74,8 +75,6 @@ def coach_dashboard(view):
course_key = CourseKey.from_string(course_id)
ccx = None
if isinstance(course_key, CCXLocator):
# is there a security leak here in not checking that this user is
# the coach for this ccx?
ccx_id = course_key.ccx
ccx = CustomCourseForEdX.objects.get(pk=ccx_id)
course_key = ccx.course_id
......@@ -86,6 +85,15 @@ def coach_dashboard(view):
_('You must be a CCX Coach to access this view.'))
course = get_course_by_id(course_key, depth=None)
# if there is a ccx, we must validate that it is the ccx for this coach
if ccx is not None:
coach_ccx = get_ccx_for_coach(course, request.user)
if coach_ccx is None or coach_ccx.id != ccx.id:
return HttpResponseForbidden(
_('You must be the coach for this ccx to access this view')
)
return view(request, course, ccx)
return wrapper
......@@ -140,6 +148,17 @@ def create_ccx(request, course, ccx=None):
Create a new CCX
"""
name = request.POST.get('name')
# prevent CCX objects from being created for deprecated course ids.
if course.id.deprecated:
messages.error(_(
"You cannot create a CCX from a course using a deprecated id. "
"Please create a rerun of this course in the studio to allow "
"this action.")
)
url = reverse('ccx_coach_dashboard', kwargs={'course_id', course.id})
return redirect(url)
ccx = CustomCourseForEdX(
course_id=course.id,
coach=request.user,
......
......@@ -110,9 +110,10 @@ def has_access(user, action, obj, course_key=None):
if isinstance(obj, XBlock):
return _has_access_descriptor(user, action, obj, course_key)
if isinstance(obj, CCXLocator):
return _has_access_ccx_key(user, action, obj)
if isinstance(obj, CourseKey):
if isinstance(obj, CCXLocator):
obj = obj.to_course_locator()
return _has_access_course_key(user, action, obj)
if isinstance(obj, UsageKey):
......@@ -493,6 +494,10 @@ def _has_access_course_key(user, action, course_key):
return _dispatch(checkers, action, user, course_key)
def _has_access_ccx_key(user, action, ccx_key):
course_key = ccx_key.to_course_locator()
return _has_access_course_key(user, action, course_key)
def _has_access_string(user, action, perm):
"""
......
......@@ -43,6 +43,7 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.xml import XMLModuleStore
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from ccx.modulestore import CCXModulestoreWrapper
log = logging.getLogger(__name__)
......@@ -59,9 +60,13 @@ class SysadminDashboardView(TemplateView):
modulestore_type and return msg
"""
self.def_ms = modulestore()
self.def_ms = check_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
if isinstance(self.def_ms, XMLModuleStore):
if isinstance(check_ms, XMLModuleStore):
self.is_using_mongo = False
self.msg = u''
self.datatable = []
......
......@@ -33,6 +33,7 @@ from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
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 = {
......@@ -315,8 +316,12 @@ class TestSysadmin(SysadminBaseTestCase):
# Create git loaded course
response = self._add_edx4edx()
def_ms = modulestore()
self.assertIn('xml', str(def_ms.__class__))
def_ms = check_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__))
course = def_ms.courses.get('{0}/edx4edx_lite'.format(
os.path.abspath(settings.DATA_DIR)), None)
self.assertIsNotNone(course)
......
......@@ -612,7 +612,6 @@ ECOMMERCE_API_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_API_TIMEOUT', ECOMMERCE_API_TI
##### Custom Courses for EdX #####
if FEATURES.get('CUSTOM_COURSES_EDX'):
INSTALLED_APPS += ('ccx',)
MIDDLEWARE_CLASSES += ('ccx.overrides.CcxMiddleware',)
FIELD_OVERRIDE_PROVIDERS += (
'ccx.overrides.CustomCoursesForEdxOverrideProvider',
)
......
......@@ -307,7 +307,6 @@ GRADES_DOWNLOAD_ROUTING_KEY = HIGH_MEM_QUEUE
##### Custom Courses for EdX #####
if FEATURES.get('CUSTOM_COURSES_EDX'):
INSTALLED_APPS += ('ccx',)
MIDDLEWARE_CLASSES += ('ccx.overrides.CcxMiddleware',)
FIELD_OVERRIDE_PROVIDERS += (
'ccx.overrides.CustomCoursesForEdxOverrideProvider',
)
......
......@@ -22,6 +22,18 @@ from django.core.urlresolvers import reverse
<section class="instructor-dashboard-content-2" id="ccx-coach-dashboard-content">
<h1>${_("CCX Coach Dashboard")}</h1>
% if messages:
<ul class="messages">
% for message in messages:
% if message.tags:
<li class="${message.tags}">${message}</li>
% else:
<li>${message}</li>
% endif
% endfor
</ul>
% endif
%if not ccx:
<section>
<form action="${create_ccx_url}" method="POST">
......
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