Commit ec28a75f by zubair-arbi Committed by Will Daly

In-course reverification access control

* Automatically create user partitions on course publish for each ICRV checkpoint.
* Disable partitions for ICRV checkpoints that have been deleted.
* Skip partitions that have been disabled when checking access.
* Add verification access control UI to visibility settings.
* Add verification access control UI to sequential and vertical settings.
* Add partition scheme for verification partition groups.
* Cache information used by verification partition scheme and invalidate the cache on update.
* Add location parameter to UserPartition so the partition scheme can find the associated checkpoint.
* Refactor GroupConfiguration to allow multiple user partitions.
* Add special messaging to ICRV for students in the honor track.

Authors: Zubair Arbi, Awais Qureshi, Aamir Khan, Will Daly
parent 63a49d6d
...@@ -199,6 +199,8 @@ class GroupConfiguration(object): ...@@ -199,6 +199,8 @@ class GroupConfiguration(object):
""" """
Returns all units names and their urls. Returns all units names and their urls.
This will return only groups for the cohort user partition.
Returns: Returns:
{'group_id': {'group_id':
[ [
...@@ -214,25 +216,22 @@ class GroupConfiguration(object): ...@@ -214,25 +216,22 @@ class GroupConfiguration(object):
} }
""" """
usage_info = {} usage_info = {}
for item in items: for item, group_id in GroupConfiguration._iterate_items_and_content_group_ids(course, items):
if hasattr(item, 'group_access') and item.group_access: if group_id not in usage_info:
(__, group_ids), = item.group_access.items() usage_info[group_id] = []
for group_id in group_ids:
if group_id not in usage_info: unit = item.get_parent()
usage_info[group_id] = [] if not unit:
log.warning("Unable to find parent for component %s", item.location)
unit = item.get_parent() continue
if not unit:
log.warning("Unable to find parent for component %s", item.location) usage_info = GroupConfiguration._get_usage_info(
continue course,
unit=unit,
usage_info = GroupConfiguration._get_usage_info( item=item,
course, usage_info=usage_info,
unit=unit, group_id=group_id
item=item, )
usage_info=usage_info,
group_id=group_id
)
return usage_info return usage_info
...@@ -250,6 +249,8 @@ class GroupConfiguration(object): ...@@ -250,6 +249,8 @@ class GroupConfiguration(object):
""" """
Returns all items names and their urls. Returns all items names and their urls.
This will return only groups for the cohort user partition.
Returns: Returns:
{'group_id': {'group_id':
[ [
...@@ -265,24 +266,39 @@ class GroupConfiguration(object): ...@@ -265,24 +266,39 @@ class GroupConfiguration(object):
} }
""" """
usage_info = {} usage_info = {}
for item in items: for item, group_id in GroupConfiguration._iterate_items_and_content_group_ids(course, items):
if hasattr(item, 'group_access') and item.group_access: if group_id not in usage_info:
(__, group_ids), = item.group_access.items() usage_info[group_id] = []
for group_id in group_ids:
if group_id not in usage_info: usage_info = GroupConfiguration._get_usage_info(
usage_info[group_id] = [] course,
unit=item,
usage_info = GroupConfiguration._get_usage_info( item=item,
course, usage_info=usage_info,
unit=item, group_id=group_id
item=item, )
usage_info=usage_info,
group_id=group_id
)
return usage_info return usage_info
@staticmethod @staticmethod
def _iterate_items_and_content_group_ids(course, items):
"""
Iterate through items and content group IDs in a course.
This will yield group IDs *only* for cohort user partitions.
Yields: tuple of (item, group_id)
"""
content_group_configuration = get_cohorted_user_partition(course)
if content_group_configuration is not None:
for item in items:
if hasattr(item, 'group_access') and item.group_access:
group_ids = item.group_access.get(content_group_configuration.id, [])
for group_id in group_ids:
yield item, group_id
@staticmethod
def update_usage_info(store, course, configuration): def update_usage_info(store, course, configuration):
""" """
Update usage information for particular Group Configuration. Update usage information for particular Group Configuration.
...@@ -329,7 +345,7 @@ class GroupConfiguration(object): ...@@ -329,7 +345,7 @@ class GroupConfiguration(object):
the client explicitly creates a group within the partition and the client explicitly creates a group within the partition and
POSTs back. POSTs back.
""" """
content_group_configuration = get_cohorted_user_partition(course.id) content_group_configuration = get_cohorted_user_partition(course)
if content_group_configuration is None: if content_group_configuration is None:
content_group_configuration = UserPartition( content_group_configuration = UserPartition(
id=generate_int_id(MINIMUM_GROUP_ID, MYSQL_MAX_INT, GroupConfiguration.get_used_ids(course)), id=generate_int_id(MINIMUM_GROUP_ID, MYSQL_MAX_INT, GroupConfiguration.get_used_ids(course)),
......
...@@ -12,6 +12,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory ...@@ -12,6 +12,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.partitions.partitions import UserPartition, Group
from contentstore import utils from contentstore import utils
from contentstore.tests.utils import CourseTestCase from contentstore.tests.utils import CourseTestCase
...@@ -413,6 +414,43 @@ class GroupVisibilityTest(CourseTestCase): ...@@ -413,6 +414,43 @@ class GroupVisibilityTest(CourseTestCase):
self.html = self.store.get_item(html.location) self.html = self.store.get_item(html.location)
self.problem = self.store.get_item(problem.location) self.problem = self.store.get_item(problem.location)
# Add partitions to the course
self.course.user_partitions = [
UserPartition(
id=0,
name="Partition 0",
description="Partition 0",
scheme=UserPartition.get_scheme("random"),
groups=[
Group(id=0, name="Group A"),
Group(id=1, name="Group B"),
],
),
UserPartition(
id=1,
name="Partition 1",
description="Partition 1",
scheme=UserPartition.get_scheme("random"),
groups=[
Group(id=0, name="Group C"),
Group(id=1, name="Group D"),
],
),
UserPartition(
id=2,
name="Partition 2",
description="Partition 2",
scheme=UserPartition.get_scheme("random"),
groups=[
Group(id=0, name="Group E"),
Group(id=1, name="Group F"),
Group(id=2, name="Group G"),
Group(id=3, name="Group H"),
],
),
]
self.course = self.store.update_item(self.course, ModuleStoreEnum.UserID.test)
def set_group_access(self, xblock, value): def set_group_access(self, xblock, value):
""" Sets group_access to specified value and calls update_item to persist the change. """ """ Sets group_access to specified value and calls update_item to persist the change. """
xblock.group_access = value xblock.group_access = value
...@@ -452,3 +490,173 @@ class GroupVisibilityTest(CourseTestCase): ...@@ -452,3 +490,173 @@ class GroupVisibilityTest(CourseTestCase):
self.assertFalse(utils.is_visible_to_specific_content_groups(self.vertical)) self.assertFalse(utils.is_visible_to_specific_content_groups(self.vertical))
self.assertFalse(utils.is_visible_to_specific_content_groups(self.html)) self.assertFalse(utils.is_visible_to_specific_content_groups(self.html))
self.assertTrue(utils.is_visible_to_specific_content_groups(self.problem)) self.assertTrue(utils.is_visible_to_specific_content_groups(self.problem))
class GetUserPartitionInfoTest(ModuleStoreTestCase):
"""
Tests for utility function that retrieves user partition info
and formats it for consumption by the editing UI.
"""
def setUp(self):
"""Create a dummy course. """
super(GetUserPartitionInfoTest, self).setUp()
self.course = CourseFactory()
self.block = ItemFactory.create(category="problem", parent_location=self.course.location) # pylint: disable=no-member
# Set up some default partitions
self._set_partitions([
UserPartition(
id=0,
name="Cohort user partition",
scheme=UserPartition.get_scheme("cohort"),
description="Cohorted user partition",
groups=[
Group(id=0, name="Group A"),
Group(id=1, name="Group B"),
],
),
UserPartition(
id=1,
name="Random user partition",
scheme=UserPartition.get_scheme("random"),
description="Random user partition",
groups=[
Group(id=0, name="Group C"),
],
),
])
def test_retrieves_partition_info_with_selected_groups(self):
# Initially, no group access is set on the block, so no groups should
# be marked as selected.
expected = [
{
"id": 0,
"name": "Cohort user partition",
"scheme": "cohort",
"groups": [
{
"id": 0,
"name": "Group A",
"selected": False,
"deleted": False,
},
{
"id": 1,
"name": "Group B",
"selected": False,
"deleted": False,
},
]
},
{
"id": 1,
"name": "Random user partition",
"scheme": "random",
"groups": [
{
"id": 0,
"name": "Group C",
"selected": False,
"deleted": False,
},
]
}
]
self.assertEqual(self._get_partition_info(), expected)
# Update group access and expect that now one group is marked as selected.
self._set_group_access({0: [1]})
expected[0]["groups"][1]["selected"] = True
self.assertEqual(self._get_partition_info(), expected)
def test_deleted_groups(self):
# Select a group that is not defined in the partition
self._set_group_access({0: [3]})
# Expect that the group appears as selected but is marked as deleted
partitions = self._get_partition_info()
groups = partitions[0]["groups"]
self.assertEqual(len(groups), 3)
self.assertEqual(groups[2], {
"id": 3,
"name": "Deleted group",
"selected": True,
"deleted": True
})
def test_filter_by_partition_scheme(self):
partitions = self._get_partition_info(schemes=["random"])
self.assertEqual(len(partitions), 1)
self.assertEqual(partitions[0]["scheme"], "random")
def test_exclude_inactive_partitions(self):
# Include an inactive verification scheme
self._set_partitions([
UserPartition(
id=0,
name="Cohort user partition",
scheme=UserPartition.get_scheme("cohort"),
description="Cohorted user partition",
groups=[
Group(id=0, name="Group A"),
Group(id=1, name="Group B"),
],
),
UserPartition(
id=1,
name="Verification user partition",
scheme=UserPartition.get_scheme("verification"),
description="Verification user partition",
groups=[
Group(id=0, name="Group C"),
],
active=False,
),
])
# Expect that the inactive scheme is excluded from the results
partitions = self._get_partition_info()
self.assertEqual(len(partitions), 1)
self.assertEqual(partitions[0]["scheme"], "cohort")
def test_exclude_partitions_with_no_groups(self):
# The cohort partition has no groups defined
self._set_partitions([
UserPartition(
id=0,
name="Cohort user partition",
scheme=UserPartition.get_scheme("cohort"),
description="Cohorted user partition",
groups=[],
),
UserPartition(
id=1,
name="Verification user partition",
scheme=UserPartition.get_scheme("verification"),
description="Verification user partition",
groups=[
Group(id=0, name="Group C"),
],
),
])
# Expect that the partition with no groups is excluded from the results
partitions = self._get_partition_info()
self.assertEqual(len(partitions), 1)
self.assertEqual(partitions[0]["scheme"], "verification")
def _set_partitions(self, partitions):
"""Set the user partitions of the course descriptor. """
self.course.user_partitions = partitions
self.course = self.store.update_item(self.course, ModuleStoreEnum.UserID.test)
def _set_group_access(self, group_access):
"""Set group access of the block. """
self.block.group_access = group_access
self.block = self.store.update_item(self.block, ModuleStoreEnum.UserID.test)
def _get_partition_info(self, schemes=None):
"""Retrieve partition info and selected groups. """
return utils.get_user_partition_info(self.block, schemes=schemes)
...@@ -10,6 +10,7 @@ from pytz import UTC ...@@ -10,6 +10,7 @@ from pytz import UTC
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.translation import ugettext as _
from django_comment_common.models import assign_default_role from django_comment_common.models import assign_default_role
from django_comment_common.utils import seed_permissions_roles from django_comment_common.utils import seed_permissions_roles
...@@ -213,12 +214,11 @@ def is_visible_to_specific_content_groups(xblock): ...@@ -213,12 +214,11 @@ def is_visible_to_specific_content_groups(xblock):
""" """
if not xblock.group_access: if not xblock.group_access:
return False return False
for __, value in xblock.group_access.iteritems():
# value should be a list of group IDs. If it is an empty list or None, the xblock is visible for partition in get_user_partition_info(xblock):
# to all groups in that particular partition. So if value is a truthy value, the xblock is if any(g["selected"] for g in partition["groups"]):
# restricted in some way.
if value:
return True return True
return False return False
...@@ -331,3 +331,158 @@ def has_active_web_certificate(course): ...@@ -331,3 +331,158 @@ def has_active_web_certificate(course):
cert_config = True cert_config = True
break break
return cert_config return cert_config
def get_user_partition_info(xblock, schemes=None, course=None):
"""
Retrieve user partition information for an XBlock for display in editors.
* If a partition has been disabled, it will be excluded from the results.
* If a group within a partition is referenced by the XBlock, but the group has been deleted,
the group will be marked as deleted in the results.
Arguments:
xblock (XBlock): The courseware component being edited.
Keyword Arguments:
schemes (iterable of str): If provided, filter partitions to include only
schemes with the provided names.
course (XBlock): The course descriptor. If provided, uses this to look up the user partitions
instead of loading the course. This is useful if we're calling this function multiple
times for the same course want to minimize queries to the modulestore.
Returns: list
Example Usage:
>>> get_user_partition_info(block, schemes=["cohort", "verification"])
[
{
"id": 12345,
"name": "Cohorts"
"scheme": "cohort",
"groups": [
{
"id": 7890,
"name": "Foo",
"selected": True,
"deleted": False,
}
]
},
{
"id": 7292,
"name": "Midterm A",
"scheme": "verification",
"groups": [
{
"id": 1,
"name": "Completed verification at Midterm A",
"selected": False,
"deleted": False
},
{
"id": 0,
"name": "Did not complete verification at Midterm A",
"selected": False,
"deleted": False,
}
]
}
]
"""
course = course or modulestore().get_course(xblock.location.course_key)
if course is None:
log.warning(
"Could not find course %s to retrieve user partition information",
xblock.location.course_key
)
return []
if schemes is not None:
schemes = set(schemes)
partitions = []
for p in sorted(course.user_partitions, key=lambda p: p.name):
# Exclude disabled partitions, partitions with no groups defined
# Also filter by scheme name if there's a filter defined.
if p.active and p.groups and (schemes is None or p.scheme.name in schemes):
# First, add groups defined by the partition
groups = []
for g in p.groups:
# Falsey group access for a partition mean that all groups
# are selected. In the UI, though, we don't show the particular
# groups selected, since there's a separate option for "all users".
selected_groups = set(xblock.group_access.get(p.id, []) or [])
groups.append({
"id": g.id,
"name": g.name,
"selected": g.id in selected_groups,
"deleted": False,
})
# Next, add any groups set on the XBlock that have been deleted
all_groups = set(g.id for g in p.groups)
missing_group_ids = selected_groups - all_groups
for gid in missing_group_ids:
groups.append({
"id": gid,
"name": _("Deleted group"),
"selected": True,
"deleted": True,
})
# Put together the entire partition dictionary
partitions.append({
"id": p.id,
"name": p.name,
"scheme": p.scheme.name,
"groups": groups,
})
return partitions
def get_visibility_partition_info(xblock):
"""
Retrieve user partition information for the component visibility editor.
This pre-processes partition information to simplify the template.
Arguments:
xblock (XBlock): The component being edited.
Returns: dict
"""
user_partitions = get_user_partition_info(xblock, schemes=["verification", "cohort"])
cohort_partitions = []
verification_partitions = []
has_selected_groups = False
selected_verified_partition_id = None
# Pre-process the partitions to make it easier to display the UI
for p in user_partitions:
has_selected = any(g["selected"] for g in p["groups"])
has_selected_groups = has_selected_groups or has_selected
if p["scheme"] == "cohort":
cohort_partitions.append(p)
elif p["scheme"] == "verification":
verification_partitions.append(p)
if has_selected:
selected_verified_partition_id = p["id"]
return {
"user_partitions": user_partitions,
"cohort_partitions": cohort_partitions,
"verification_partitions": verification_partitions,
"has_selected_groups": has_selected_groups,
"selected_verified_partition_id": selected_verified_partition_id,
}
...@@ -40,8 +40,11 @@ from util.date_utils import get_default_time_display ...@@ -40,8 +40,11 @@ from util.date_utils import get_default_time_display
from util.json_request import expect_json, JsonResponse from util.json_request import expect_json, JsonResponse
from student.auth import has_studio_write_access, has_studio_read_access from student.auth import has_studio_write_access, has_studio_read_access
from contentstore.utils import find_release_date_source, find_staff_lock_source, is_currently_visible_to_students, \ from contentstore.utils import (
ancestor_has_staff_lock, has_children_visible_to_specific_content_groups find_release_date_source, find_staff_lock_source, is_currently_visible_to_students,
ancestor_has_staff_lock, has_children_visible_to_specific_content_groups,
get_user_partition_info,
)
from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \ from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \
xblock_type_display_name, get_parent_xblock, create_xblock, usage_key_with_run xblock_type_display_name, get_parent_xblock, create_xblock, usage_key_with_run
from contentstore.views.preview import get_preview_fragment from contentstore.views.preview import get_preview_fragment
...@@ -756,7 +759,7 @@ def _get_module_info(xblock, rewrite_static_links=True, include_ancestor_info=Fa ...@@ -756,7 +759,7 @@ def _get_module_info(xblock, rewrite_static_links=True, include_ancestor_info=Fa
def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False, def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False,
course_outline=False, include_children_predicate=NEVER, parent_xblock=None, graders=None, course_outline=False, include_children_predicate=NEVER, parent_xblock=None, graders=None,
user=None): user=None, course=None):
""" """
Creates the information needed for client-side XBlockInfo. Creates the information needed for client-side XBlockInfo.
...@@ -788,6 +791,11 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F ...@@ -788,6 +791,11 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
# Filter the graders data as needed # Filter the graders data as needed
graders = _filter_entrance_exam_grader(graders) graders = _filter_entrance_exam_grader(graders)
# We need to load the course in order to retrieve user partition information.
# For this reason, we load the course once and re-use it when recursively loading children.
if course is None:
course = modulestore().get_course(xblock.location.course_key)
# Compute the child info first so it can be included in aggregate information for the parent # Compute the child info first so it can be included in aggregate information for the parent
should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline) should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline)
if should_visit_children and xblock.has_children: if should_visit_children and xblock.has_children:
...@@ -796,7 +804,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F ...@@ -796,7 +804,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
course_outline, course_outline,
graders, graders,
include_children_predicate=include_children_predicate, include_children_predicate=include_children_predicate,
user=user user=user,
course=course
) )
else: else:
child_info = None child_info = None
...@@ -850,6 +859,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F ...@@ -850,6 +859,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
"has_changes": has_changes, "has_changes": has_changes,
"actions": xblock_actions, "actions": xblock_actions,
"explanatory_message": explanatory_message, "explanatory_message": explanatory_message,
"group_access": xblock.group_access,
"user_partitions": get_user_partition_info(xblock, course=course),
} }
# update xblock_info with proctored_exam information if the feature flag is enabled # update xblock_info with proctored_exam information if the feature flag is enabled
...@@ -1023,7 +1034,7 @@ def _create_xblock_ancestor_info(xblock, course_outline): ...@@ -1023,7 +1034,7 @@ def _create_xblock_ancestor_info(xblock, course_outline):
} }
def _create_xblock_child_info(xblock, course_outline, graders, include_children_predicate=NEVER, user=None): def _create_xblock_child_info(xblock, course_outline, graders, include_children_predicate=NEVER, user=None, course=None): # pylint: disable=line-too-long
""" """
Returns information about the children of an xblock, as well as about the primary category Returns information about the children of an xblock, as well as about the primary category
of xblock expected as children. of xblock expected as children.
...@@ -1042,7 +1053,8 @@ def _create_xblock_child_info(xblock, course_outline, graders, include_children_ ...@@ -1042,7 +1053,8 @@ def _create_xblock_child_info(xblock, course_outline, graders, include_children_
include_children_predicate=include_children_predicate, include_children_predicate=include_children_predicate,
parent_xblock=xblock, parent_xblock=xblock,
graders=graders, graders=graders,
user=user user=user,
course=course,
) for child in xblock.get_children() ) for child in xblock.get_children()
] ]
return child_info return child_info
......
...@@ -262,6 +262,8 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations ...@@ -262,6 +262,8 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
{u'name': u'Group A', u'version': 1}, {u'name': u'Group A', u'version': 1},
{u'name': u'Group B', u'version': 1}, {u'name': u'Group B', u'version': 1},
], ],
u'parameters': {},
u'active': True
} }
response = self.client.ajax_post( response = self.client.ajax_post(
self._url(), self._url(),
...@@ -283,6 +285,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations ...@@ -283,6 +285,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
self.assertEqual(len(user_partititons[0].groups), 2) self.assertEqual(len(user_partititons[0].groups), 2)
self.assertEqual(user_partititons[0].groups[0].name, u'Group A') self.assertEqual(user_partititons[0].groups[0].name, u'Group A')
self.assertEqual(user_partititons[0].groups[1].name, u'Group B') self.assertEqual(user_partititons[0].groups[1].name, u'Group B')
self.assertEqual(user_partititons[0].parameters, {})
def test_lazily_creates_cohort_configuration(self): def test_lazily_creates_cohort_configuration(self):
""" """
...@@ -327,6 +330,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -327,6 +330,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
{u'id': 0, u'name': u'Group A', u'version': 1, u'usage': []}, {u'id': 0, u'name': u'Group A', u'version': 1, u'usage': []},
{u'id': 1, u'name': u'Group B', u'version': 1, u'usage': []}, {u'id': 1, u'name': u'Group B', u'version': 1, u'usage': []},
], ],
u'parameters': {},
u'active': True,
} }
response = self.client.put( response = self.client.put(
self._url(cid=666), self._url(cid=666),
...@@ -346,6 +351,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -346,6 +351,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
self.assertEqual(len(user_partitions[0].groups), 2) self.assertEqual(len(user_partitions[0].groups), 2)
self.assertEqual(user_partitions[0].groups[0].name, u'Group A') self.assertEqual(user_partitions[0].groups[0].name, u'Group A')
self.assertEqual(user_partitions[0].groups[1].name, u'Group B') self.assertEqual(user_partitions[0].groups[1].name, u'Group B')
self.assertEqual(user_partitions[0].parameters, {})
def test_can_edit_content_group(self): def test_can_edit_content_group(self):
""" """
...@@ -364,6 +370,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -364,6 +370,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
{u'id': 0, u'name': u'New Group Name', u'version': 1, u'usage': []}, {u'id': 0, u'name': u'New Group Name', u'version': 1, u'usage': []},
{u'id': 2, u'name': u'Group C', u'version': 1, u'usage': []}, {u'id': 2, u'name': u'Group C', u'version': 1, u'usage': []},
], ],
u'parameters': {},
u'active': True,
} }
response = self.client.put( response = self.client.put(
...@@ -385,6 +393,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -385,6 +393,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
self.assertEqual(len(user_partititons[0].groups), 2) self.assertEqual(len(user_partititons[0].groups), 2)
self.assertEqual(user_partititons[0].groups[0].name, u'New Group Name') self.assertEqual(user_partititons[0].groups[0].name, u'New Group Name')
self.assertEqual(user_partititons[0].groups[1].name, u'Group C') self.assertEqual(user_partititons[0].groups[1].name, u'Group C')
self.assertEqual(user_partititons[0].parameters, {})
def test_can_delete_content_group(self): def test_can_delete_content_group(self):
""" """
...@@ -466,6 +475,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -466,6 +475,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
{u'id': 1, u'name': u'Group B', u'version': 1}, {u'id': 1, u'name': u'Group B', u'version': 1},
], ],
u'usage': [], u'usage': [],
u'parameters': {},
u'active': True,
} }
response = self.client.put( response = self.client.put(
...@@ -485,6 +496,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -485,6 +496,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
self.assertEqual(len(user_partitions[0].groups), 2) self.assertEqual(len(user_partitions[0].groups), 2)
self.assertEqual(user_partitions[0].groups[0].name, u'Group A') self.assertEqual(user_partitions[0].groups[0].name, u'Group A')
self.assertEqual(user_partitions[0].groups[1].name, u'Group B') self.assertEqual(user_partitions[0].groups[1].name, u'Group B')
self.assertEqual(user_partitions[0].parameters, {})
def test_can_edit_group_configuration(self): def test_can_edit_group_configuration(self):
""" """
...@@ -504,6 +516,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -504,6 +516,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
{u'id': 2, u'name': u'Group C', u'version': 1}, {u'id': 2, u'name': u'Group C', u'version': 1},
], ],
u'usage': [], u'usage': [],
u'parameters': {},
u'active': True,
} }
response = self.client.put( response = self.client.put(
...@@ -525,6 +539,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio ...@@ -525,6 +539,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
self.assertEqual(len(user_partititons[0].groups), 2) self.assertEqual(len(user_partititons[0].groups), 2)
self.assertEqual(user_partititons[0].groups[0].name, u'New Group Name') self.assertEqual(user_partititons[0].groups[0].name, u'New Group Name')
self.assertEqual(user_partititons[0].groups[1].name, u'Group C') self.assertEqual(user_partititons[0].groups[1].name, u'Group C')
self.assertEqual(user_partititons[0].parameters, {})
def test_can_delete_group_configuration(self): def test_can_delete_group_configuration(self):
""" """
...@@ -609,6 +624,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): ...@@ -609,6 +624,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
{'id': 1, 'name': 'Group B', 'version': 1, 'usage': usage_for_group}, {'id': 1, 'name': 'Group B', 'version': 1, 'usage': usage_for_group},
{'id': 2, 'name': 'Group C', 'version': 1, 'usage': []}, {'id': 2, 'name': 'Group C', 'version': 1, 'usage': []},
], ],
u'parameters': {},
u'active': True,
} }
def test_content_group_not_used(self): def test_content_group_not_used(self):
...@@ -701,6 +718,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): ...@@ -701,6 +718,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
{'id': 2, 'name': 'Group C', 'version': 1}, {'id': 2, 'name': 'Group C', 'version': 1},
], ],
'usage': [], 'usage': [],
'parameters': {},
'active': True,
}] }]
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
...@@ -730,6 +749,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): ...@@ -730,6 +749,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
'label': 'Test Unit 0 / Test Content Experiment 0', 'label': 'Test Unit 0 / Test Content Experiment 0',
'validation': None, 'validation': None,
}], }],
'parameters': {},
'active': True,
}, { }, {
'id': 1, 'id': 1,
'name': 'Name 1', 'name': 'Name 1',
...@@ -742,6 +763,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): ...@@ -742,6 +763,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
{'id': 2, 'name': 'Group C', 'version': 1}, {'id': 2, 'name': 'Group C', 'version': 1},
], ],
'usage': [], 'usage': [],
'parameters': {},
'active': True,
}] }]
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
...@@ -772,6 +795,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): ...@@ -772,6 +795,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
'label': u"Test Unit 0 / Test Content Experiment 0JOSÉ ANDRÉS", 'label': u"Test Unit 0 / Test Content Experiment 0JOSÉ ANDRÉS",
'validation': None, 'validation': None,
}], }],
'parameters': {},
'active': True,
}] }]
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
...@@ -807,6 +832,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): ...@@ -807,6 +832,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
'label': 'Test Unit 1 / Test Content Experiment 1', 'label': 'Test Unit 1 / Test Content Experiment 1',
'validation': None, 'validation': None,
}], }],
'parameters': {},
'active': True,
}] }]
self.assertEqual(actual, expected) self.assertEqual(actual, expected)
...@@ -829,6 +856,48 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods): ...@@ -829,6 +856,48 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
actual = GroupConfiguration.get_content_experiment_usage_info(self.store, self.course) actual = GroupConfiguration.get_content_experiment_usage_info(self.store, self.course)
self.assertEqual(actual, {0: []}) self.assertEqual(actual, {0: []})
def test_can_handle_multiple_partitions(self):
# Create the user partitions
self.course.user_partitions = [
UserPartition(
id=0,
name='Cohort user partition',
scheme=UserPartition.get_scheme('cohort'),
description='Cohorted user partition',
groups=[
Group(id=0, name="Group A"),
Group(id=1, name="Group B"),
],
),
UserPartition(
id=1,
name='Random user partition',
scheme=UserPartition.get_scheme('random'),
description='Random user partition',
groups=[
Group(id=0, name="Group A"),
Group(id=1, name="Group B"),
],
),
]
self.store.update_item(self.course, ModuleStoreEnum.UserID.test)
# Assign group access rules for multiple partitions, one of which is a cohorted partition
__, problem = self._create_problem_with_content_group(0, 1)
problem.group_access = {
0: [0],
1: [1],
}
self.store.update_item(problem, ModuleStoreEnum.UserID.test)
# This used to cause an exception since the code assumed that
# only one partition would be available.
actual = GroupConfiguration.get_content_groups_usage_info(self.store, self.course)
self.assertEqual(actual.keys(), [0])
actual = GroupConfiguration.get_content_groups_items_usage_info(self.store, self.course)
self.assertEqual(actual.keys(), [0])
class GroupConfigurationsValidationTestCase(CourseTestCase, HelperMethods): class GroupConfigurationsValidationTestCase(CourseTestCase, HelperMethods):
""" """
......
...@@ -119,9 +119,9 @@ class GetItemTest(ItemTest): ...@@ -119,9 +119,9 @@ class GetItemTest(ItemTest):
return resp return resp
@ddt.data( @ddt.data(
(1, 16, 14, 15, 11), (1, 17, 15, 16, 12),
(2, 16, 14, 15, 11), (2, 17, 15, 16, 12),
(3, 16, 14, 15, 11), (3, 17, 15, 16, 12),
) )
@ddt.unpack @ddt.unpack
def test_get_query_count(self, branching_factor, chapter_queries, section_queries, unit_queries, problem_queries): def test_get_query_count(self, branching_factor, chapter_queries, section_queries, unit_queries, problem_queries):
...@@ -137,9 +137,9 @@ class GetItemTest(ItemTest): ...@@ -137,9 +137,9 @@ class GetItemTest(ItemTest):
self.client.get(reverse_usage_url('xblock_handler', self.populated_usage_keys['problem'][-1])) self.client.get(reverse_usage_url('xblock_handler', self.populated_usage_keys['problem'][-1]))
@ddt.data( @ddt.data(
(1, 26), (1, 30),
(2, 28), (2, 32),
(3, 30), (3, 34),
) )
@ddt.unpack @ddt.unpack
def test_container_get_query_count(self, branching_factor, unit_queries,): def test_container_get_query_count(self, branching_factor, unit_queries,):
...@@ -310,6 +310,52 @@ class GetItemTest(ItemTest): ...@@ -310,6 +310,52 @@ class GetItemTest(ItemTest):
content_contains="Couldn't parse paging parameters" content_contains="Couldn't parse paging parameters"
) )
def test_get_user_partitions_and_groups(self):
self.course.user_partitions = [
UserPartition(
id=0,
name="Verification user partition",
scheme=UserPartition.get_scheme("verification"),
description="Verification user partition",
groups=[
Group(id=0, name="Group A"),
Group(id=1, name="Group B"),
],
),
]
self.store.update_item(self.course, self.user.id)
# Create an item and retrieve it
resp = self.create_xblock(category='vertical')
usage_key = self.response_usage_key(resp)
resp = self.client.get(reverse_usage_url('xblock_handler', usage_key))
self.assertEqual(resp.status_code, 200)
# Check that the partition and group information was returned
result = json.loads(resp.content)
self.assertEqual(result["user_partitions"], [
{
"id": 0,
"name": "Verification user partition",
"scheme": "verification",
"groups": [
{
"id": 0,
"name": "Group A",
"selected": False,
"deleted": False,
},
{
"id": 1,
"name": "Group B",
"selected": False,
"deleted": False,
},
]
}
])
self.assertEqual(result["group_access"], {})
@ddt.ddt @ddt.ddt
class DeleteItem(ItemTest): class DeleteItem(ItemTest):
...@@ -1414,7 +1460,7 @@ class TestXBlockInfo(ItemTest): ...@@ -1414,7 +1460,7 @@ class TestXBlockInfo(ItemTest):
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.split, 5, 5), (ModuleStoreEnum.Type.split, 5, 5),
(ModuleStoreEnum.Type.mongo, 4, 6), (ModuleStoreEnum.Type.mongo, 5, 7),
) )
@ddt.unpack @ddt.unpack
def test_xblock_outline_handler_mongo_calls(self, store_type, chapter_queries, chapter_queries_1): def test_xblock_outline_handler_mongo_calls(self, store_type, chapter_queries, chapter_queries_1):
......
...@@ -56,6 +56,26 @@ class AuthoringMixinTestCase(ModuleStoreTestCase): ...@@ -56,6 +56,26 @@ class AuthoringMixinTestCase(ModuleStoreTestCase):
self.course.user_partitions = [self.content_partition] self.course.user_partitions = [self.content_partition]
self.store.update_item(self.course, self.user.id) self.store.update_item(self.course, self.user.id)
def create_verification_user_partitions(self, checkpoint_names):
"""
Create user partitions for verification checkpoints.
"""
scheme = UserPartition.get_scheme("verification")
self.course.user_partitions = [
UserPartition(
id=0,
name=checkpoint_name,
description="Verification checkpoint",
scheme=scheme,
groups=[
Group(scheme.ALLOW, "Completed verification at {}".format(checkpoint_name)),
Group(scheme.DENY, "Did not complete verification at {}".format(checkpoint_name)),
],
)
for checkpoint_name in checkpoint_names
]
self.store.update_item(self.course, self.user.id)
def set_staff_only(self, item_location): def set_staff_only(self, item_location):
"""Make an item visible to staff only.""" """Make an item visible to staff only."""
item = self.store.get_item(item_location) item = self.store.get_item(item_location)
...@@ -129,3 +149,14 @@ class AuthoringMixinTestCase(ModuleStoreTestCase): ...@@ -129,3 +149,14 @@ class AuthoringMixinTestCase(ModuleStoreTestCase):
'Content group no longer exists.' 'Content group no longer exists.'
] ]
) )
def test_html_verification_checkpoints(self):
self.create_verification_user_partitions(["Midterm A", "Midterm B"])
self.verify_visibility_view_contains(
self.video_location,
[
"Verification Checkpoint",
"Midterm A",
"Midterm B",
]
)
...@@ -144,8 +144,18 @@ function(Backbone, _, str, ModuleUtils) { ...@@ -144,8 +144,18 @@ function(Backbone, _, str, ModuleUtils) {
/** /**
* Optional explanatory message about the xblock. * Optional explanatory message about the xblock.
*/ */
'explanatory_message': null 'explanatory_message': null,
/**
* The XBlock's group access rules. This is a dictionary keyed to user partition IDs,
* where the values are lists of group IDs.
*/
'group_access': null,
/**
* User partition dictionary. This is pre-processed by Studio, so it contains
* some additional fields that are not stored in the course descriptor
* (for example, which groups are selected for this particular XBlock).
*/
'user_partitions': null,
}, },
initialize: function () { initialize: function () {
...@@ -238,7 +248,20 @@ function(Backbone, _, str, ModuleUtils) { ...@@ -238,7 +248,20 @@ function(Backbone, _, str, ModuleUtils) {
*/ */
isEditableOnCourseOutline: function() { isEditableOnCourseOutline: function() {
return this.isSequential() || this.isChapter() || this.isVertical(); return this.isSequential() || this.isChapter() || this.isVertical();
} },
/*
* Check whether any verification checkpoints are defined in the course.
* Verification checkpoints are defined if there exists a user partition
* that uses the verification partition scheme.
*/
hasVerifiedCheckpoints: function() {
var partitions = this.get("user_partitions") || [];
return Boolean(_.find(partitions, function(p) {
return p.scheme === "verification";
}));
}
}); });
return XBlockInfo; return XBlockInfo;
}); });
...@@ -13,7 +13,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -13,7 +13,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
) { ) {
'use strict'; 'use strict';
var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, AbstractEditor, BaseDateEditor, var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, AbstractEditor, BaseDateEditor,
ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, StaffLockEditor, TimedExaminationPreferenceEditor; ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, StaffLockEditor,
VerificationAccessEditor, TimedExaminationPreferenceEditor;
CourseOutlineXBlockModal = BaseModal.extend({ CourseOutlineXBlockModal = BaseModal.extend({
events : { events : {
...@@ -427,7 +428,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -427,7 +428,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
}, },
hasChanges: function() { hasChanges: function() {
return this.isModelLocked() != this.isLocked(); return this.isModelLocked() !== this.isLocked();
}, },
getRequestData: function() { getRequestData: function() {
...@@ -443,7 +444,110 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -443,7 +444,110 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
return { return {
hasExplicitStaffLock: this.isModelLocked(), hasExplicitStaffLock: this.isModelLocked(),
ancestorLocked: this.isAncestorLocked() ancestorLocked: this.isAncestorLocked()
};
}
});
VerificationAccessEditor = AbstractEditor.extend({
templateName: 'verification-access-editor',
className: 'edit-verification-access',
// This constant MUST match the group ID
// defined by VerificationPartitionScheme on the backend!
ALLOW_GROUP_ID: 1,
getSelectedPartition: function() {
var hasRestrictions = $("#verification-access-checkbox").is(":checked"),
selectedPartitionID = null;
if (hasRestrictions) {
selectedPartitionID = $("#verification-partition-select").val();
} }
return parseInt(selectedPartitionID, 10);
},
getGroupAccess: function() {
var groupAccess = _.clone(this.model.get('group_access')) || [],
userPartitions = this.model.get('user_partitions') || [],
selectedPartition = this.getSelectedPartition(),
that = this;
// We display a simplified UI to course authors.
// On the backend, each verification checkpoint is associated
// with a user partition that has two groups. For example,
// if two checkpoints were defined, they might look like:
//
// Midterm A: |-- ALLOW --|-- DENY --|
// Midterm B: |-- ALLOW --|-- DENY --|
//
// To make life easier for course authors, we display
// *one* option for each checkpoint:
//
// [X] Must complete verification checkpoint
// Dropdown:
// * Midterm A
// * Midterm B
//
// This is where we map the simplified UI to
// the underlying user partition. If the user checked
// the box, that means there *is* a restriction,
// so only the "ALLOW" group for the selected partition has access.
// Otherwise, all groups in the partition have access.
//
_.each(userPartitions, function(partition) {
if (partition.scheme === "verification") {
if (selectedPartition === partition.id) {
groupAccess[partition.id] = [that.ALLOW_GROUP_ID];
} else {
delete groupAccess[partition.id];
}
}
});
return groupAccess;
},
getRequestData: function() {
var groupAccess = this.getGroupAccess(),
hasChanges = !_.isEqual(groupAccess, this.model.get('group_access'));
return hasChanges ? {
publish: 'republish',
metadata: {
group_access: groupAccess,
}
} : {};
},
getContext: function() {
var partitions = this.model.get("user_partitions"),
hasRestrictions = false,
verificationPartitions = [],
isSelected = false;
// Display a simplified version of verified partition schemes.
// Although there are two groups defined (ALLOW and DENY),
// we show only the ALLOW group.
// To avoid searching all the groups, we're assuming that the editor
// either sets the ALLOW group or doesn't set any groups (implicitly allow all).
_.each(partitions, function(item) {
if (item.scheme === "verification") {
isSelected = _.any(_.pluck(item.groups, "selected"));
hasRestrictions = hasRestrictions || isSelected;
verificationPartitions.push({
"id": item.id,
"name": item.name,
"selected": isSelected,
});
}
});
return {
"hasVerificationRestrictions": hasRestrictions,
"verificationPartitions": verificationPartitions,
};
} }
}); });
...@@ -472,8 +576,13 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview', ...@@ -472,8 +576,13 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
} }
editors.push(StaffLockEditor); editors.push(StaffLockEditor);
} else if (xblockInfo.isVertical()) { } else if (xblockInfo.isVertical()) {
editors = [StaffLockEditor]; editors = [StaffLockEditor];
if (xblockInfo.hasVerifiedCheckpoints()) {
editors.push(VerificationAccessEditor);
}
} }
return new SettingsXBlockModal($.extend({ return new SettingsXBlockModal($.extend({
editors: editors, editors: editors,
......
...@@ -6,29 +6,56 @@ ...@@ -6,29 +6,56 @@
function VisibilityEditorView(runtime, element) { function VisibilityEditorView(runtime, element) {
this.getGroupAccess = function() { this.getGroupAccess = function() {
var groupAccess, userPartitionId, selectedGroupIds; var groupAccess = {},
checkboxValues,
partitionId,
groupId,
// This constant MUST match the group ID
// defined by VerificationPartitionScheme on the backend!
ALLOW_GROUP_ID = 1;
if (element.find('.visibility-level-all').prop('checked')) { if (element.find('.visibility-level-all').prop('checked')) {
return {}; return {};
} }
userPartitionId = element.find('.wrapper-visibility-specific').data('user-partition-id').toString();
selectedGroupIds = []; // Cohort partitions (user is allowed to select more than one)
element.find('.field-visibility-content-group input:checked').each(function(index, input) { element.find('.field-visibility-content-group input:checked').each(function(index, input) {
selectedGroupIds.push(parseInt($(input).val())); checkboxValues = $(input).val().split("-");
partitionId = parseInt(checkboxValues[0], 10);
groupId = parseInt(checkboxValues[1], 10);
if (groupAccess.hasOwnProperty(partitionId)) {
groupAccess[partitionId].push(groupId);
} else {
groupAccess[partitionId] = [groupId];
}
}); });
groupAccess = {};
groupAccess[userPartitionId] = selectedGroupIds; // Verification partitions (user can select exactly one)
if (element.find('#verification-access-checkbox').prop('checked')) {
partitionId = parseInt($('#verification-access-dropdown').val(), 10);
groupAccess[partitionId] = [ALLOW_GROUP_ID];
}
return groupAccess; return groupAccess;
}; };
// When selecting "all students and staff", uncheck the specific groups
element.find('.field-visibility-level input').change(function(event) { element.find('.field-visibility-level input').change(function(event) {
if ($(event.target).hasClass('visibility-level-all')) { if ($(event.target).hasClass('visibility-level-all')) {
element.find('.field-visibility-content-group input').prop('checked', false); element.find('.field-visibility-content-group input, .field-visibility-verification input')
.prop('checked', false);
} }
}); });
element.find('.field-visibility-content-group input').change(function(event) {
element.find('.visibility-level-all').prop('checked', false); // When selecting a specific group, deselect "all students and staff" and
element.find('.visibility-level-specific').prop('checked', true); // select "specific content groups" instead.`
}); element.find('.field-visibility-content-group input, .field-visibility-verification input')
.change(function() {
element.find('.visibility-level-all').prop('checked', false);
element.find('.visibility-level-specific').prop('checked', true);
});
} }
VisibilityEditorView.prototype.collectFieldData = function collectFieldData() { VisibilityEditorView.prototype.collectFieldData = function collectFieldData() {
......
...@@ -465,6 +465,15 @@ ...@@ -465,6 +465,15 @@
} }
} }
.field-visibility-verification {
.note {
@extend %t-copy-sub2;
@extend %t-regular;
margin: 14px 0 0 24px;
display: block;
}
}
// CASE: content group has been removed // CASE: content group has been removed
.field-visibility-content-group.was-removed { .field-visibility-content-group.was-removed {
...@@ -629,8 +638,12 @@ ...@@ -629,8 +638,12 @@
} }
} }
.edit-staff-lock {
margin-bottom: $baseline;
}
// UI: staff lock section // UI: staff lock section
.edit-staff-lock, .edit-settings-timed-examination { .edit-staff-lock, .edit-settings-timed-examination, .edit-verification-access {
.checkbox-cosmetic .input-checkbox { .checkbox-cosmetic .input-checkbox {
@extend %cont-text-sr; @extend %cont-text-sr;
...@@ -653,6 +666,20 @@ ...@@ -653,6 +666,20 @@
.checkbox-cosmetic .label { .checkbox-cosmetic .label {
margin-bottom: 0; margin-bottom: 0;
} }
.note {
@extend %t-copy-sub2;
@extend %t-regular;
margin: 14px 0 0 21px;
display: block;
}
}
.verification-access {
.checkbox-cosmetic .label {
@include float(left);
margin: 2px 6px 0 0;
}
} }
// UI: timed and proctored exam section // UI: timed and proctored exam section
......
...@@ -21,7 +21,7 @@ from microsite_configuration import microsite ...@@ -21,7 +21,7 @@ from microsite_configuration import microsite
<%block name="header_extras"> <%block name="header_extras">
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" /> <link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'timed-examination-preference-editor']: % for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'verification-access-editor', 'timed-examination-preference-editor']:
<script type="text/template" id="${template_name}-tpl"> <script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" /> <%static:include path="js/${template_name}.underscore" />
</script> </script>
......
<form>
<div role="group" aria-labelledby="verification-checkpoint-title">
<h3 id="verification-checkpoint-title" class="modal-section-title"><%= gettext('Verification Checkpoint') %></h3>
<div class="modal-section-content verification-access">
<div class="list-fields list-input">
<div class="field field-checkbox checkbox-cosmetic">
<input
type="checkbox"
id="verification-access-checkbox"
class="input input-checkbox"
name="verification-access"
value=""
<% if (hasVerificationRestrictions) { %> checked="checked" <% } %>
aria-describedby="verification-help-text"
>
<label class="label" for="verification-access-checkbox">
<i class="icon fa fa-check-square-o input-checkbox-checked" aria-hidden="true"></i>
<i class="icon fa fa-square-o input-checkbox-unchecked" aria-hidden="true"></i>
<span class="sr"><%- gettext("Must complete verification checkpoint") %></span>
</label>
<label class="sr" for="verification-partition-select">
<%= gettext('Verification checkpoint to be completed') %>
</label>
<select id="verification-partition-select">
<% for (var i = 0; i < verificationPartitions.length; i++) {
partition=verificationPartitions[i];
%>
<option
value="<%- partition.id %>"
<% if (partition.selected) { %> selected <% } %>
><%- partition.name %></option>
<% } %>
</select>
<div id="verification-help-text" class="note">
<%= gettext("Learners who require verification must pass the selected checkpoint to see the content in this unit. Learners who do not require verification see this content by default.") %>
</div>
</div>
</div>
</div>
</div>
</form>
...@@ -35,6 +35,7 @@ from django.db.models.signals import pre_save, post_save ...@@ -35,6 +35,7 @@ from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver, Signal from django.dispatch import receiver, Signal
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import ugettext_noop from django.utils.translation import ugettext_noop
from django.core.cache import cache
from django_countries.fields import CountryField from django_countries.fields import CountryField
import dogstats_wrapper as dog_stats_api import dogstats_wrapper as dog_stats_api
from eventtracking import tracker from eventtracking import tracker
...@@ -846,6 +847,9 @@ class CourseEnrollment(models.Model): ...@@ -846,6 +847,9 @@ class CourseEnrollment(models.Model):
# Maintain a history of requirement status updates for auditing purposes # Maintain a history of requirement status updates for auditing purposes
history = HistoricalRecords() history = HistoricalRecords()
# cache key format e.g enrollment.<username>.<course_key>.mode = 'honor'
COURSE_ENROLLMENT_CACHE_KEY = u"enrollment.{}.{}.mode"
class Meta(object): # pylint: disable=missing-docstring class Meta(object): # pylint: disable=missing-docstring
unique_together = (('user', 'course_id'),) unique_together = (('user', 'course_id'),)
ordering = ('user', 'course_id') ordering = ('user', 'course_id')
...@@ -1338,6 +1342,49 @@ class CourseEnrollment(models.Model): ...@@ -1338,6 +1342,49 @@ class CourseEnrollment(models.Model):
""" """
return CourseMode.is_verified_slug(self.mode) return CourseMode.is_verified_slug(self.mode)
@classmethod
def is_enrolled_as_verified(cls, user, course_key):
"""
Check whether the course enrollment is for a verified mode.
Arguments:
user (User): The user object.
course_key (CourseKey): The identifier for the course.
Returns: bool
"""
enrollment = cls.get_enrollment(user, course_key)
return (
enrollment is not None and
enrollment.is_active and
enrollment.is_verified_enrollment()
)
@classmethod
def cache_key_name(cls, user_id, course_key):
"""Return cache key name to be used to cache current configuration.
Args:
user_id(int): Id of user.
course_key(unicode): Unicode of course key
Returns:
Unicode cache key
"""
return cls.COURSE_ENROLLMENT_CACHE_KEY.format(user_id, unicode(course_key))
@receiver(models.signals.post_save, sender=CourseEnrollment)
@receiver(models.signals.post_delete, sender=CourseEnrollment)
def invalidate_enrollment_mode_cache(sender, instance, **kwargs): # pylint: disable=unused-argument, invalid-name
"""Invalidate the cache of CourseEnrollment model. """
cache_key = CourseEnrollment.cache_key_name(
instance.user.id,
unicode(instance.course_id)
)
cache.delete(cache_key)
class ManualEnrollmentAudit(models.Model): class ManualEnrollmentAudit(models.Model):
""" """
......
...@@ -1533,3 +1533,39 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin): ...@@ -1533,3 +1533,39 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
Returns the topics that have been configured for teams for this course, else None. Returns the topics that have been configured for teams for this course, else None.
""" """
return self.teams_configuration.get('topics', None) return self.teams_configuration.get('topics', None)
def get_user_partitions_for_scheme(self, scheme):
"""
Retrieve all user partitions defined in the course for a particular
partition scheme.
Arguments:
scheme (object): The user partition scheme.
Returns:
list of `UserPartition`
"""
return [
p for p in self.user_partitions
if p.scheme == scheme
]
def set_user_partitions_for_scheme(self, partitions, scheme):
"""
Set the user partitions for a particular scheme.
Preserves partitions associated with other schemes.
Arguments:
scheme (object): The user partition scheme.
Returns:
list of `UserPartition`
"""
other_partitions = [
p for p in self.user_partitions # pylint: disable=access-member-before-definition
if p.scheme != scheme
]
self.user_partitions = other_partitions + partitions # pylint: disable=attribute-defined-outside-init
...@@ -101,6 +101,9 @@ class ModuleStoreEnum(object): ...@@ -101,6 +101,9 @@ class ModuleStoreEnum(object):
# user ID to use for tests that do not have a django user available # user ID to use for tests that do not have a django user available
test = -3 test = -3
# user ID for automatic update by the system
system = -4
class SortOrder(object): class SortOrder(object):
""" """
Values for sorting asset metadata. Values for sorting asset metadata.
...@@ -264,6 +267,12 @@ class BulkOperationsMixin(object): ...@@ -264,6 +267,12 @@ class BulkOperationsMixin(object):
if not bulk_ops_record.active: if not bulk_ops_record.active:
return return
# Send the pre-publish signal within the context of the bulk operation.
# Writes performed by signal handlers will be persisted when the bulk
# operation ends.
if emit_signals and bulk_ops_record.is_root:
self.send_pre_publish_signal(bulk_ops_record, structure_key)
bulk_ops_record.unnest() bulk_ops_record.unnest()
# If this wasn't the outermost context, then don't close out the # If this wasn't the outermost context, then don't close out the
...@@ -293,6 +302,14 @@ class BulkOperationsMixin(object): ...@@ -293,6 +302,14 @@ class BulkOperationsMixin(object):
""" """
return self._get_bulk_ops_record(course_key, ignore_case).active return self._get_bulk_ops_record(course_key, ignore_case).active
def send_pre_publish_signal(self, bulk_ops_record, course_id):
"""
Send a signal just before items are published in the course.
"""
signal_handler = getattr(self, "signal_handler", None)
if signal_handler and bulk_ops_record.has_publish_item:
signal_handler.send("pre_publish", course_key=course_id)
def send_bulk_published_signal(self, bulk_ops_record, course_id): def send_bulk_published_signal(self, bulk_ops_record, course_id):
""" """
Sends out the signal that items have been published from within this course. Sends out the signal that items have been published from within this course.
......
...@@ -86,11 +86,13 @@ class SignalHandler(object): ...@@ -86,11 +86,13 @@ class SignalHandler(object):
almost no work. Its main job is to kick off the celery task that will almost no work. Its main job is to kick off the celery task that will
do the actual work. do the actual work.
""" """
pre_publish = django.dispatch.Signal(providing_args=["course_key"])
course_published = django.dispatch.Signal(providing_args=["course_key"]) course_published = django.dispatch.Signal(providing_args=["course_key"])
course_deleted = django.dispatch.Signal(providing_args=["course_key"]) course_deleted = django.dispatch.Signal(providing_args=["course_key"])
library_updated = django.dispatch.Signal(providing_args=["library_key"]) library_updated = django.dispatch.Signal(providing_args=["library_key"])
_mapping = { _mapping = {
"pre_publish": pre_publish,
"course_published": course_published, "course_published": course_published,
"course_deleted": course_deleted, "course_deleted": course_deleted,
"library_updated": library_updated, "library_updated": library_updated,
......
...@@ -84,18 +84,23 @@ class Group(namedtuple("Group", "id name")): ...@@ -84,18 +84,23 @@ class Group(namedtuple("Group", "id name")):
USER_PARTITION_SCHEME_NAMESPACE = 'openedx.user_partition_scheme' USER_PARTITION_SCHEME_NAMESPACE = 'openedx.user_partition_scheme'
class UserPartition(namedtuple("UserPartition", "id name description groups scheme")): class UserPartition(namedtuple("UserPartition", "id name description groups scheme parameters active")):
"""A named way to partition users into groups, primarily intended for
running experiments. It is expected that each user will be in at most one
group in a partition.
A Partition has an id, name, scheme, description, parameters, and a list
of groups. The id is intended to be unique within the context where these
are used. (e.g., for partitions of users within a course, the ids should
be unique per-course). The scheme is used to assign users into groups.
The parameters field is used to save extra parameters e.g., location of
the block in case of VerificationPartitionScheme.
Partitions can be marked as inactive by setting the "active" flag to False.
Any group access rule referencing inactive partitions will be ignored
when performing access checks.
""" """
A named way to partition users into groups, primarily intended for running VERSION = 3
experiments. It is expected that each user will be in at most one group in a
partition.
A Partition has an id, name, scheme, description, and a list of groups.
The id is intended to be unique within the context where these are used. (e.g. for
partitions of users within a course, the ids should be unique per-course).
The scheme is used to assign users into groups.
"""
VERSION = 2
# The collection of user partition scheme extensions. # The collection of user partition scheme extensions.
scheme_extensions = None scheme_extensions = None
...@@ -103,11 +108,14 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche ...@@ -103,11 +108,14 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
# The default scheme to be used when upgrading version 1 partitions. # The default scheme to be used when upgrading version 1 partitions.
VERSION_1_SCHEME = "random" VERSION_1_SCHEME = "random"
def __new__(cls, id, name, description, groups, scheme=None, scheme_id=VERSION_1_SCHEME): def __new__(cls, id, name, description, groups, scheme=None, parameters=None, active=True, scheme_id=VERSION_1_SCHEME): # pylint: disable=line-too-long
# pylint: disable=super-on-old-class # pylint: disable=super-on-old-class
if not scheme: if not scheme:
scheme = UserPartition.get_scheme(scheme_id) scheme = UserPartition.get_scheme(scheme_id)
return super(UserPartition, cls).__new__(cls, int(id), name, description, groups, scheme) if parameters is None:
parameters = {}
return super(UserPartition, cls).__new__(cls, int(id), name, description, groups, scheme, parameters, active)
@staticmethod @staticmethod
def get_scheme(name): def get_scheme(name):
...@@ -137,7 +145,9 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche ...@@ -137,7 +145,9 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
"name": self.name, "name": self.name,
"scheme": self.scheme.name, "scheme": self.scheme.name,
"description": self.description, "description": self.description,
"parameters": self.parameters,
"groups": [g.to_json() for g in self.groups], "groups": [g.to_json() for g in self.groups],
"active": bool(self.active),
"version": UserPartition.VERSION "version": UserPartition.VERSION
} }
...@@ -165,13 +175,16 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche ...@@ -165,13 +175,16 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
# Version changes should be backwards compatible in case the code # Version changes should be backwards compatible in case the code
# gets rolled back. If we see a version number greater than the current # gets rolled back. If we see a version number greater than the current
# version, we should try to read it rather than raising an exception. # version, we should try to read it rather than raising an exception.
elif value["version"] >= UserPartition.VERSION: elif value["version"] >= 2:
if "scheme" not in value: if "scheme" not in value:
raise TypeError("UserPartition dict {0} missing value key 'scheme'".format(value)) raise TypeError("UserPartition dict {0} missing value key 'scheme'".format(value))
scheme_id = value["scheme"] scheme_id = value["scheme"]
else: else:
raise TypeError("UserPartition dict {0} has unexpected version".format(value)) raise TypeError("UserPartition dict {0} has unexpected version".format(value))
parameters = value.get("parameters", {})
active = value.get("active", True)
groups = [Group.from_json(g) for g in value["groups"]] groups = [Group.from_json(g) for g in value["groups"]]
scheme = UserPartition.get_scheme(scheme_id) scheme = UserPartition.get_scheme(scheme_id)
if not scheme: if not scheme:
...@@ -183,12 +196,22 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche ...@@ -183,12 +196,22 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
value["description"], value["description"],
groups, groups,
scheme, scheme,
parameters,
active,
) )
def get_group(self, group_id): def get_group(self, group_id):
""" """
Returns the group with the specified id. Raises NoSuchUserPartitionGroupError if not found. Returns the group with the specified id.
Arguments:
group_id (int): ID of the partition group.
Raises:
NoSuchUserPartitionGroupError: The specified group could not be found.
""" """
# pylint: disable=no-member
for group in self.groups: for group in self.groups:
if group.id == group_id: if group.id == group_id:
return group return group
......
...@@ -110,6 +110,7 @@ class PartitionTestCase(TestCase): ...@@ -110,6 +110,7 @@ class PartitionTestCase(TestCase):
TEST_ID = 0 TEST_ID = 0
TEST_NAME = "Mock Partition" TEST_NAME = "Mock Partition"
TEST_DESCRIPTION = "for testing purposes" TEST_DESCRIPTION = "for testing purposes"
TEST_PARAMETERS = {"location": "block-v1:edX+DemoX+Demo+type@block@uuid"}
TEST_GROUPS = [Group(0, 'Group 1'), Group(1, 'Group 2')] TEST_GROUPS = [Group(0, 'Group 1'), Group(1, 'Group 2')]
TEST_SCHEME_NAME = "mock" TEST_SCHEME_NAME = "mock"
...@@ -136,7 +137,8 @@ class PartitionTestCase(TestCase): ...@@ -136,7 +137,8 @@ class PartitionTestCase(TestCase):
self.TEST_NAME, self.TEST_NAME,
self.TEST_DESCRIPTION, self.TEST_DESCRIPTION,
self.TEST_GROUPS, self.TEST_GROUPS,
extensions[0].plugin extensions[0].plugin,
self.TEST_PARAMETERS,
) )
# Make sure the names are set on the schemes (which happens normally in code, but may not happen in tests). # Make sure the names are set on the schemes (which happens normally in code, but may not happen in tests).
...@@ -149,17 +151,28 @@ class TestUserPartition(PartitionTestCase): ...@@ -149,17 +151,28 @@ class TestUserPartition(PartitionTestCase):
def test_construct(self): def test_construct(self):
user_partition = UserPartition( user_partition = UserPartition(
self.TEST_ID, self.TEST_NAME, self.TEST_DESCRIPTION, self.TEST_GROUPS, MockUserPartitionScheme() self.TEST_ID,
self.TEST_NAME,
self.TEST_DESCRIPTION,
self.TEST_GROUPS,
MockUserPartitionScheme(),
self.TEST_PARAMETERS,
) )
self.assertEqual(user_partition.id, self.TEST_ID) self.assertEqual(user_partition.id, self.TEST_ID)
self.assertEqual(user_partition.name, self.TEST_NAME) self.assertEqual(user_partition.name, self.TEST_NAME)
self.assertEqual(user_partition.description, self.TEST_DESCRIPTION) self.assertEqual(user_partition.description, self.TEST_DESCRIPTION) # pylint: disable=no-member
self.assertEqual(user_partition.groups, self.TEST_GROUPS) self.assertEqual(user_partition.groups, self.TEST_GROUPS) # pylint: disable=no-member
self.assertEquals(user_partition.scheme.name, self.TEST_SCHEME_NAME) self.assertEquals(user_partition.scheme.name, self.TEST_SCHEME_NAME) # pylint: disable=no-member
self.assertEquals(user_partition.parameters, self.TEST_PARAMETERS) # pylint: disable=no-member
def test_string_id(self): def test_string_id(self):
user_partition = UserPartition( user_partition = UserPartition(
"70", self.TEST_NAME, self.TEST_DESCRIPTION, self.TEST_GROUPS "70",
self.TEST_NAME,
self.TEST_DESCRIPTION,
self.TEST_GROUPS,
MockUserPartitionScheme(),
self.TEST_PARAMETERS,
) )
self.assertEqual(user_partition.id, 70) self.assertEqual(user_partition.id, 70)
...@@ -169,9 +182,11 @@ class TestUserPartition(PartitionTestCase): ...@@ -169,9 +182,11 @@ class TestUserPartition(PartitionTestCase):
"id": self.TEST_ID, "id": self.TEST_ID,
"name": self.TEST_NAME, "name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION, "description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS], "groups": [group.to_json() for group in self.TEST_GROUPS],
"version": self.user_partition.VERSION, "version": self.user_partition.VERSION,
"scheme": self.TEST_SCHEME_NAME "scheme": self.TEST_SCHEME_NAME,
"active": True,
} }
self.assertEqual(jsonified, act_jsonified) self.assertEqual(jsonified, act_jsonified)
...@@ -180,22 +195,26 @@ class TestUserPartition(PartitionTestCase): ...@@ -180,22 +195,26 @@ class TestUserPartition(PartitionTestCase):
"id": self.TEST_ID, "id": self.TEST_ID,
"name": self.TEST_NAME, "name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION, "description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS], "groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION, "version": UserPartition.VERSION,
"scheme": "mock", "scheme": "mock",
} }
user_partition = UserPartition.from_json(jsonified) user_partition = UserPartition.from_json(jsonified)
self.assertEqual(user_partition.id, self.TEST_ID) self.assertEqual(user_partition.id, self.TEST_ID) # pylint: disable=no-member
self.assertEqual(user_partition.name, self.TEST_NAME) self.assertEqual(user_partition.name, self.TEST_NAME) # pylint: disable=no-member
self.assertEqual(user_partition.description, self.TEST_DESCRIPTION) self.assertEqual(user_partition.description, self.TEST_DESCRIPTION) # pylint: disable=no-member
for act_group in user_partition.groups: self.assertEqual(user_partition.parameters, self.TEST_PARAMETERS) # pylint: disable=no-member
for act_group in user_partition.groups: # pylint: disable=no-member
self.assertIn(act_group.id, [0, 1]) self.assertIn(act_group.id, [0, 1])
exp_group = self.TEST_GROUPS[act_group.id] exp_group = self.TEST_GROUPS[act_group.id]
self.assertEqual(exp_group.id, act_group.id) self.assertEqual(exp_group.id, act_group.id)
self.assertEqual(exp_group.name, act_group.name) self.assertEqual(exp_group.name, act_group.name)
def test_version_upgrade(self): def test_version_upgrade(self):
# Version 1 partitions did not have a scheme specified # Test that version 1 partitions did not have a scheme specified
# and have empty parameters
jsonified = { jsonified = {
"id": self.TEST_ID, "id": self.TEST_ID,
"name": self.TEST_NAME, "name": self.TEST_NAME,
...@@ -204,13 +223,61 @@ class TestUserPartition(PartitionTestCase): ...@@ -204,13 +223,61 @@ class TestUserPartition(PartitionTestCase):
"version": 1, "version": 1,
} }
user_partition = UserPartition.from_json(jsonified) user_partition = UserPartition.from_json(jsonified)
self.assertEqual(user_partition.scheme.name, "random") self.assertEqual(user_partition.scheme.name, "random") # pylint: disable=no-member
self.assertEqual(user_partition.parameters, {})
self.assertTrue(user_partition.active)
def test_version_upgrade_2_to_3(self):
# Test that version 3 user partition raises error if 'scheme' field is
# not provided (same behavior as version 2)
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": 2,
}
with self.assertRaisesRegexp(TypeError, "missing value key 'scheme'"):
UserPartition.from_json(jsonified)
# Test that version 3 partitions have a scheme specified
# and a field 'parameters' (optional while setting user partition but
# always present in response)
jsonified = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": 2,
"scheme": self.TEST_SCHEME_NAME,
}
user_partition = UserPartition.from_json(jsonified)
self.assertEqual(user_partition.scheme.name, self.TEST_SCHEME_NAME)
self.assertEqual(user_partition.parameters, {})
self.assertTrue(user_partition.active)
# now test that parameters dict is present in response with same value
# as provided
jsonified = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"parameters": self.TEST_PARAMETERS,
"version": 3,
"scheme": self.TEST_SCHEME_NAME,
}
user_partition = UserPartition.from_json(jsonified)
self.assertEqual(user_partition.parameters, self.TEST_PARAMETERS)
self.assertTrue(user_partition.active)
def test_from_json_broken(self): def test_from_json_broken(self):
# Missing field # Missing field
jsonified = { jsonified = {
"name": self.TEST_NAME, "name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION, "description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS], "groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION, "version": UserPartition.VERSION,
"scheme": self.TEST_SCHEME_NAME, "scheme": self.TEST_SCHEME_NAME,
...@@ -223,6 +290,7 @@ class TestUserPartition(PartitionTestCase): ...@@ -223,6 +290,7 @@ class TestUserPartition(PartitionTestCase):
'id': self.TEST_ID, 'id': self.TEST_ID,
"name": self.TEST_NAME, "name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION, "description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS], "groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION, "version": UserPartition.VERSION,
} }
...@@ -234,6 +302,7 @@ class TestUserPartition(PartitionTestCase): ...@@ -234,6 +302,7 @@ class TestUserPartition(PartitionTestCase):
'id': self.TEST_ID, 'id': self.TEST_ID,
"name": self.TEST_NAME, "name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION, "description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS], "groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION, "version": UserPartition.VERSION,
"scheme": "no_such_scheme", "scheme": "no_such_scheme",
...@@ -246,6 +315,7 @@ class TestUserPartition(PartitionTestCase): ...@@ -246,6 +315,7 @@ class TestUserPartition(PartitionTestCase):
'id': self.TEST_ID, 'id': self.TEST_ID,
"name": self.TEST_NAME, "name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION, "description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS], "groups": [group.to_json() for group in self.TEST_GROUPS],
"version": -1, "version": -1,
"scheme": self.TEST_SCHEME_NAME, "scheme": self.TEST_SCHEME_NAME,
...@@ -258,6 +328,7 @@ class TestUserPartition(PartitionTestCase): ...@@ -258,6 +328,7 @@ class TestUserPartition(PartitionTestCase):
'id': self.TEST_ID, 'id': self.TEST_ID,
"name": self.TEST_NAME, "name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION, "description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS], "groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION, "version": UserPartition.VERSION,
"scheme": "mock", "scheme": "mock",
...@@ -266,6 +337,18 @@ class TestUserPartition(PartitionTestCase): ...@@ -266,6 +337,18 @@ class TestUserPartition(PartitionTestCase):
user_partition = UserPartition.from_json(jsonified) user_partition = UserPartition.from_json(jsonified)
self.assertNotIn("programmer", user_partition.to_json()) self.assertNotIn("programmer", user_partition.to_json())
# No error on missing parameters key (which is optional)
jsonified = {
'id': self.TEST_ID,
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
"scheme": "mock",
}
user_partition = UserPartition.from_json(jsonified)
self.assertEqual(user_partition.parameters, {})
def test_get_group(self): def test_get_group(self):
""" """
UserPartition.get_group correctly returns the group referenced by the UserPartition.get_group correctly returns the group referenced by the
......
...@@ -108,19 +108,19 @@ class ComponentVisibilityEditorView(BaseComponentEditorView): ...@@ -108,19 +108,19 @@ class ComponentVisibilityEditorView(BaseComponentEditorView):
""" """
A :class:`.PageObject` representing the rendered view of a component visibility editor. A :class:`.PageObject` representing the rendered view of a component visibility editor.
""" """
OPTION_SELECTOR = '.modal-section-content li.field' OPTION_SELECTOR = '.modal-section-content .field'
@property @property
def all_options(self): def all_options(self):
""" """
Return all visibility 'li' options. Return all visibility options.
""" """
return self.q(css=self._bounded_selector(self.OPTION_SELECTOR)).results return self.q(css=self._bounded_selector(self.OPTION_SELECTOR)).results
@property @property
def selected_options(self): def selected_options(self):
""" """
Return all selected visibility 'li' options. Return all selected visibility options.
""" """
results = [] results = []
for option in self.all_options: for option in self.all_options:
...@@ -131,7 +131,7 @@ class ComponentVisibilityEditorView(BaseComponentEditorView): ...@@ -131,7 +131,7 @@ class ComponentVisibilityEditorView(BaseComponentEditorView):
def select_option(self, label_text, save=True): def select_option(self, label_text, save=True):
""" """
Click the first li which has a label matching `label_text`. Click the first option which has a label matching `label_text`.
Arguments: Arguments:
label_text (str): Text of a label accompanying the input label_text (str): Text of a label accompanying the input
......
...@@ -24,7 +24,7 @@ from xblock.core import XBlock ...@@ -24,7 +24,7 @@ from xblock.core import XBlock
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, \
TEST_DATA_SPLIT_MODULESTORE, TEST_DATA_MONGO_MODULESTORE TEST_DATA_SPLIT_MODULESTORE, TEST_DATA_MONGO_MODULESTORE
from xmodule.modulestore.tests.factories import check_mongo_calls, CourseFactory, check_sum_of_calls from xmodule.modulestore.tests.factories import check_mongo_calls_range, CourseFactory, check_sum_of_calls
from xmodule.modulestore.tests.utils import ProceduralCourseTestMixin from xmodule.modulestore.tests.utils import ProceduralCourseTestMixin
from ccx_keys.locator import CCXLocator from ccx_keys.locator import CCXLocator
from ccx.tests.factories import CcxFactory from ccx.tests.factories import CcxFactory
...@@ -142,7 +142,7 @@ class FieldOverridePerformanceTestCase(ProceduralCourseTestMixin, ...@@ -142,7 +142,7 @@ class FieldOverridePerformanceTestCase(ProceduralCourseTestMixin,
Assert that mongodb is queried ``calls`` times in the surrounded Assert that mongodb is queried ``calls`` times in the surrounded
context. context.
""" """
return check_mongo_calls(calls) return check_mongo_calls_range(max_finds=calls)
def assertXBlockInstantiations(self, instantiations): def assertXBlockInstantiations(self, instantiations):
""" """
...@@ -214,24 +214,24 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase): ...@@ -214,24 +214,24 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase):
TEST_DATA = { TEST_DATA = {
# (providers, course_width, enable_ccx, view_as_ccx): # of sql queries, # of mongo queries, # of xblocks # (providers, course_width, enable_ccx, view_as_ccx): # of sql queries, # of mongo queries, # of xblocks
('no_overrides', 1, True, False): (24, 7, 14), ('no_overrides', 1, True, False): (24, 6, 13),
('no_overrides', 2, True, False): (69, 7, 85), ('no_overrides', 2, True, False): (69, 6, 84),
('no_overrides', 3, True, False): (264, 7, 336), ('no_overrides', 3, True, False): (264, 6, 335),
('ccx', 1, True, False): (24, 7, 14), ('ccx', 1, True, False): (24, 6, 13),
('ccx', 2, True, False): (69, 7, 85), ('ccx', 2, True, False): (69, 6, 84),
('ccx', 3, True, False): (264, 7, 336), ('ccx', 3, True, False): (264, 6, 335),
('ccx', 1, True, True): (24, 7, 14), ('ccx', 1, True, True): (24, 6, 13),
('ccx', 2, True, True): (69, 7, 85), ('ccx', 2, True, True): (69, 6, 84),
('ccx', 3, True, True): (264, 7, 336), ('ccx', 3, True, True): (264, 6, 335),
('no_overrides', 1, False, False): (24, 7, 14), ('no_overrides', 1, False, False): (24, 6, 13),
('no_overrides', 2, False, False): (69, 7, 85), ('no_overrides', 2, False, False): (69, 6, 84),
('no_overrides', 3, False, False): (264, 7, 336), ('no_overrides', 3, False, False): (264, 6, 335),
('ccx', 1, False, False): (24, 7, 14), ('ccx', 1, False, False): (24, 6, 13),
('ccx', 2, False, False): (69, 7, 85), ('ccx', 2, False, False): (69, 6, 84),
('ccx', 3, False, False): (264, 7, 336), ('ccx', 3, False, False): (264, 6, 335),
('ccx', 1, False, True): (24, 7, 14), ('ccx', 1, False, True): (24, 6, 13),
('ccx', 2, False, True): (69, 7, 85), ('ccx', 2, False, True): (69, 6, 84),
('ccx', 3, False, True): (264, 7, 336), ('ccx', 3, False, True): (264, 6, 335),
} }
......
...@@ -487,16 +487,24 @@ def _has_group_access(descriptor, user, course_key): ...@@ -487,16 +487,24 @@ def _has_group_access(descriptor, user, course_key):
# resolve the partition IDs in group_access to actual # resolve the partition IDs in group_access to actual
# partition objects, skipping those which contain empty group directives. # partition objects, skipping those which contain empty group directives.
# if a referenced partition could not be found, access will be denied. # If a referenced partition could not be found, it will be denied
try: # If the partition is found but is no longer active (meaning it's been disabled)
partitions = [ # then skip the access check for that partition.
descriptor._get_user_partition(partition_id) # pylint: disable=protected-access partitions = []
for partition_id, group_ids in merged_access.items() for partition_id, group_ids in merged_access.items():
if group_ids is not None try:
] partition = descriptor._get_user_partition(partition_id) # pylint: disable=protected-access
except NoSuchUserPartitionError: if partition.active:
log.warning("Error looking up user partition, access will be denied.", exc_info=True) if group_ids is not None:
return ACCESS_DENIED partitions.append(partition)
else:
log.debug(
"Skipping partition with ID %s in course %s because it is no longer active",
partition.id, course_key
)
except NoSuchUserPartitionError:
log.warning("Error looking up user partition, access will be denied.", exc_info=True)
return ACCESS_DENIED
# next resolve the group IDs specified within each partition # next resolve the group IDs specified within each partition
partition_groups = [] partition_groups = []
......
...@@ -93,7 +93,7 @@ class RenderXBlockTestMixin(object): ...@@ -93,7 +93,7 @@ class RenderXBlockTestMixin(object):
return response return response
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 8), (ModuleStoreEnum.Type.mongo, 7),
(ModuleStoreEnum.Type.split, 5), (ModuleStoreEnum.Type.split, 5),
) )
@ddt.unpack @ddt.unpack
......
...@@ -151,11 +151,13 @@ class LmsBlockMixin(XBlockMixin): ...@@ -151,11 +151,13 @@ class LmsBlockMixin(XBlockMixin):
except NoSuchUserPartitionError: except NoSuchUserPartitionError:
has_invalid_user_partitions = True has_invalid_user_partitions = True
else: else:
for group_id in group_ids: # Skip the validation check if the partition has been disabled
try: if user_partition.active:
user_partition.get_group(group_id) for group_id in group_ids:
except NoSuchUserPartitionGroupError: try:
has_invalid_groups = True user_partition.get_group(group_id)
except NoSuchUserPartitionGroupError:
has_invalid_groups = True
if has_invalid_user_partitions: if has_invalid_user_partitions:
validation.add( validation.add(
......
...@@ -1126,11 +1126,16 @@ class VerificationStatus(models.Model): ...@@ -1126,11 +1126,16 @@ class VerificationStatus(models.Model):
A verification status represents a user’s progress through the verification A verification status represents a user’s progress through the verification
process for a particular checkpoint. process for a particular checkpoint.
""" """
SUBMITTED_STATUS = "submitted"
APPROVED_STATUS = "approved"
DENIED_STATUS = "denied"
ERROR_STATUS = "error"
VERIFICATION_STATUS_CHOICES = ( VERIFICATION_STATUS_CHOICES = (
("submitted", "submitted"), (SUBMITTED_STATUS, SUBMITTED_STATUS),
("approved", "approved"), (APPROVED_STATUS, APPROVED_STATUS),
("denied", "denied"), (DENIED_STATUS, DENIED_STATUS),
("error", "error") (ERROR_STATUS, ERROR_STATUS)
) )
checkpoint = models.ForeignKey(VerificationCheckpoint, related_name="checkpoint_status") checkpoint = models.ForeignKey(VerificationCheckpoint, related_name="checkpoint_status")
...@@ -1199,15 +1204,15 @@ class VerificationStatus(models.Model): ...@@ -1199,15 +1204,15 @@ class VerificationStatus(models.Model):
return None return None
@classmethod @classmethod
def get_user_attempts(cls, user_id, course_key, related_assessment_location): def get_user_attempts(cls, user_id, course_key, checkpoint_location):
""" """
Get re-verification attempts against a user for a given 'checkpoint' Get re-verification attempts against a user for a given 'checkpoint'
and 'course_id'. and 'course_id'.
Arguments: Arguments:
user_id(str): User Id string user_id (str): User Id string
course_key(str): A CourseKey of a course course_key (str): A CourseKey of a course
related_assessment_location(str): Verification checkpoint location checkpoint_location (str): Verification checkpoint location
Returns: Returns:
Count of re-verification attempts Count of re-verification attempts
...@@ -1216,8 +1221,8 @@ class VerificationStatus(models.Model): ...@@ -1216,8 +1221,8 @@ class VerificationStatus(models.Model):
return cls.objects.filter( return cls.objects.filter(
user_id=user_id, user_id=user_id,
checkpoint__course_id=course_key, checkpoint__course_id=course_key,
checkpoint__checkpoint_location=related_assessment_location, checkpoint__checkpoint_location=checkpoint_location,
status="submitted" status=cls.SUBMITTED_STATUS
).count() ).count()
@classmethod @classmethod
...@@ -1236,6 +1241,49 @@ class VerificationStatus(models.Model): ...@@ -1236,6 +1241,49 @@ class VerificationStatus(models.Model):
except cls.DoesNotExist: except cls.DoesNotExist:
return "" return ""
@classmethod
def get_all_checkpoints(cls, user_id, course_key):
"""Return dict of all the checkpoints with their status.
Args:
user_id(int): Id of user.
course_key(unicode): Unicode of course key
Returns:
dict: {checkpoint:status}
"""
all_checks_points = cls.objects.filter(
user_id=user_id, checkpoint__course_id=course_key
)
check_points = {}
for check in all_checks_points:
check_points[check.checkpoint.checkpoint_location] = check.status
return check_points
@classmethod
def cache_key_name(cls, user_id, course_key):
"""Return the name of the key to use to cache the current configuration
Args:
user_id(int): Id of user.
course_key(unicode): Unicode of course key
Returns:
Unicode cache key
"""
return u"verification.{}.{}".format(user_id, unicode(course_key))
@receiver(models.signals.post_save, sender=VerificationStatus)
@receiver(models.signals.post_delete, sender=VerificationStatus)
def invalidate_verification_status_cache(sender, instance, **kwargs): # pylint: disable=unused-argument, invalid-name
"""Invalidate the cache of VerificationStatus model. """
cache_key = VerificationStatus.cache_key_name(
instance.user.id,
unicode(instance.checkpoint.course_id)
)
cache.delete(cache_key)
# DEPRECATED: this feature has been permanently enabled. # DEPRECATED: this feature has been permanently enabled.
# Once the application code has been updated in production, # Once the application code has been updated in production,
...@@ -1283,15 +1331,40 @@ class SkippedReverification(models.Model): ...@@ -1283,15 +1331,40 @@ class SkippedReverification(models.Model):
cls.objects.create(checkpoint=checkpoint, user_id=user_id, course_id=course_id) cls.objects.create(checkpoint=checkpoint, user_id=user_id, course_id=course_id)
@classmethod @classmethod
def check_user_skipped_reverification_exists(cls, user, course_id): def check_user_skipped_reverification_exists(cls, user_id, course_id):
"""Check existence of a user's skipped re-verification attempt for a """Check existence of a user's skipped re-verification attempt for a
specific course. specific course.
Arguments: Arguments:
user(User): user object user_id(str): user id
course_id(CourseKey): CourseKey course_id(CourseKey): CourseKey
Returns: Returns:
Boolean Boolean
""" """
return cls.objects.filter(user=user, course_id=course_id).exists() has_skipped = cls.objects.filter(user_id=user_id, course_id=course_id).exists()
return has_skipped
@classmethod
def cache_key_name(cls, user_id, course_key):
"""Return the name of the key to use to cache the current configuration
Arguments:
user(User): user object
course_key(CourseKey): CourseKey
Returns:
string: cache key name
"""
return u"skipped_reverification.{}.{}".format(user_id, unicode(course_key))
@receiver(models.signals.post_save, sender=SkippedReverification)
@receiver(models.signals.post_delete, sender=SkippedReverification)
def invalidate_skipped_verification_cache(sender, instance, **kwargs): # pylint: disable=unused-argument, invalid-name
"""Invalidate the cache of skipped verification model. """
cache_key = SkippedReverification.cache_key_name(
instance.user.id,
unicode(instance.course_id)
)
cache.delete(cache_key)
...@@ -10,6 +10,7 @@ from django.db import IntegrityError ...@@ -10,6 +10,7 @@ from django.db import IntegrityError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from student.models import User, CourseEnrollment
from verify_student.models import VerificationCheckpoint, VerificationStatus, SkippedReverification from verify_student.models import VerificationCheckpoint, VerificationStatus, SkippedReverification
...@@ -21,24 +22,28 @@ class ReverificationService(object): ...@@ -21,24 +22,28 @@ class ReverificationService(object):
Reverification XBlock service Reverification XBlock service
""" """
SKIPPED_STATUS = "skipped"
NON_VERIFIED_TRACK = "not-verified"
def get_status(self, user_id, course_id, related_assessment_location): def get_status(self, user_id, course_id, related_assessment_location):
"""Get verification attempt status against a user for a given """Get verification attempt status against a user for a given
'checkpoint' and 'course_id'. 'checkpoint' and 'course_id'.
Args: Args:
user_id(str): User Id string user_id (str): User Id string
course_id(str): A string of course id course_id (str): A string of course id
related_assessment_location(str): Location of Reverification XBlock related_assessment_location (str): Location of Reverification XBlock
Returns: Returns: str or None
"skipped" if the user has skipped the re-verification or
Verification Status string if the user has submitted photo
verification attempt else None
""" """
user = User.objects.get(id=user_id)
course_key = CourseKey.from_string(course_id) course_key = CourseKey.from_string(course_id)
has_skipped = SkippedReverification.check_user_skipped_reverification_exists(user_id, course_key)
if has_skipped: if not CourseEnrollment.is_enrolled_as_verified(user, course_key):
return "skipped" return self.NON_VERIFIED_TRACK
elif SkippedReverification.check_user_skipped_reverification_exists(user_id, course_key):
return self.SKIPPED_STATUS
try: try:
checkpoint_status = VerificationStatus.objects.filter( checkpoint_status = VerificationStatus.objects.filter(
user_id=user_id, user_id=user_id,
......
...@@ -688,14 +688,12 @@ class VerificationStatusTest(ModuleStoreTestCase): ...@@ -688,14 +688,12 @@ class VerificationStatusTest(ModuleStoreTestCase):
status='submitted' status='submitted'
) )
self.assertEqual( actual_attempts = VerificationStatus.get_user_attempts(
VerificationStatus.get_user_attempts( self.user.id,
user_id=self.user.id, self.course.id,
course_key=self.course.id, self.first_checkpoint_location
related_assessment_location=self.first_checkpoint_location
),
1
) )
self.assertEqual(actual_attempts, 1)
class SkippedReverificationTest(ModuleStoreTestCase): class SkippedReverificationTest(ModuleStoreTestCase):
...@@ -763,12 +761,18 @@ class SkippedReverificationTest(ModuleStoreTestCase): ...@@ -763,12 +761,18 @@ class SkippedReverificationTest(ModuleStoreTestCase):
checkpoint=self.checkpoint, user_id=self.user.id, course_id=unicode(self.course.id) checkpoint=self.checkpoint, user_id=self.user.id, course_id=unicode(self.course.id)
) )
self.assertTrue( self.assertTrue(
SkippedReverification.check_user_skipped_reverification_exists(course_id=self.course.id, user=self.user) SkippedReverification.check_user_skipped_reverification_exists(
user_id=self.user.id,
course_id=self.course.id
)
) )
user2 = UserFactory.create() user2 = UserFactory.create()
self.assertFalse( self.assertFalse(
SkippedReverification.check_user_skipped_reverification_exists(course_id=self.course.id, user=user2) SkippedReverification.check_user_skipped_reverification_exists(
user_id=user2.id,
course_id=self.course.id
)
) )
......
...@@ -4,7 +4,9 @@ Tests of re-verification service. ...@@ -4,7 +4,9 @@ Tests of re-verification service.
import ddt import ddt
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory from course_modes.tests.factories import CourseModeFactory
from student.models import CourseEnrollment
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from verify_student.models import VerificationCheckpoint, VerificationStatus, SkippedReverification from verify_student.models import VerificationCheckpoint, VerificationStatus, SkippedReverification
from verify_student.services import ReverificationService from verify_student.services import ReverificationService
...@@ -35,6 +37,9 @@ class TestReverificationService(ModuleStoreTestCase): ...@@ -35,6 +37,9 @@ class TestReverificationService(ModuleStoreTestCase):
org=self.course_key.org, course=self.course_key.course org=self.course_key.org, course=self.course_key.course
) )
# Enroll in a verified mode
self.enrollment = CourseEnrollment.enroll(self.user, self.course_key, mode=CourseMode.VERIFIED)
@ddt.data('final', 'midterm') @ddt.data('final', 'midterm')
def test_start_verification(self, checkpoint_name): def test_start_verification(self, checkpoint_name):
"""Test the 'start_verification' service method. """Test the 'start_verification' service method.
...@@ -107,6 +112,12 @@ class TestReverificationService(ModuleStoreTestCase): ...@@ -107,6 +112,12 @@ class TestReverificationService(ModuleStoreTestCase):
1 1
) )
# testing service for skipped attempt.
self.assertEqual(
reverification_service.get_status(self.user.id, unicode(self.course_key), self.final_checkpoint_location),
'skipped'
)
def test_get_attempts(self): def test_get_attempts(self):
"""Check verification attempts count against a user for a given """Check verification attempts count against a user for a given
'checkpoint' and 'course_id'. 'checkpoint' and 'course_id'.
...@@ -129,3 +140,12 @@ class TestReverificationService(ModuleStoreTestCase): ...@@ -129,3 +140,12 @@ class TestReverificationService(ModuleStoreTestCase):
reverification_service.get_attempts(self.user.id, course_id, self.final_checkpoint_location), reverification_service.get_attempts(self.user.id, course_id, self.final_checkpoint_location),
1 1
) )
def test_not_in_verified_track(self):
# No longer enrolled in a verified track
self.enrollment.update_enrollment(mode=CourseMode.HONOR)
# Should be marked as "skipped" (opted out)
service = ReverificationService()
status = service.get_status(self.user.id, unicode(self.course_key), self.final_checkpoint_location)
self.assertEqual(status, service.NON_VERIFIED_TRACK)
...@@ -25,7 +25,7 @@ def selected(is_selected): ...@@ -25,7 +25,7 @@ def selected(is_selected):
return "selected" if is_selected else "" return "selected" if is_selected else ""
show_preview_menu = not disable_preview_menu and staff_access and active_page in ["courseware", "info"] show_preview_menu = not disable_preview_menu and staff_access and active_page in ["courseware", "info"]
cohorted_user_partition = get_cohorted_user_partition(course.id) cohorted_user_partition = get_cohorted_user_partition(course)
masquerade_user_name = masquerade.user_name if masquerade else None masquerade_user_name = masquerade.user_name if masquerade else None
masquerade_group_id = masquerade.group_id if masquerade else None masquerade_group_id = masquerade.group_id if masquerade else None
staff_selected = selected(not masquerade or masquerade.role != "student") staff_selected = selected(not masquerade or masquerade.role != "student")
......
...@@ -20,7 +20,7 @@ from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_ ...@@ -20,7 +20,7 @@ from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_
<%block name="js_extra"> <%block name="js_extra">
<%static:require_module module_name="js/groups/views/cohorts_dashboard_factory" class_name="CohortsFactory"> <%static:require_module module_name="js/groups/views/cohorts_dashboard_factory" class_name="CohortsFactory">
<% <%
cohorted_user_partition = get_cohorted_user_partition(course.id) cohorted_user_partition = get_cohorted_user_partition(course)
content_groups = cohorted_user_partition.groups if cohorted_user_partition else [] content_groups = cohorted_user_partition.groups if cohorted_user_partition else []
%> %>
var cohortUserPartitionId = ${cohorted_user_partition.id if cohorted_user_partition else 'null'}, var cohortUserPartitionId = ${cohorted_user_partition.id if cohorted_user_partition else 'null'},
......
...@@ -3,7 +3,6 @@ Provides a UserPartition driver for cohorts. ...@@ -3,7 +3,6 @@ Provides a UserPartition driver for cohorts.
""" """
import logging import logging
from courseware import courses
from courseware.masquerade import ( # pylint: disable=import-error from courseware.masquerade import ( # pylint: disable=import-error
get_course_masquerade, get_course_masquerade,
get_masquerading_group_info, get_masquerading_group_info,
...@@ -100,13 +99,12 @@ class CohortPartitionScheme(object): ...@@ -100,13 +99,12 @@ class CohortPartitionScheme(object):
return None return None
def get_cohorted_user_partition(course_key): def get_cohorted_user_partition(course):
""" """
Returns the first user partition from the specified course which uses the CohortPartitionScheme, Returns the first user partition from the specified course which uses the CohortPartitionScheme,
or None if one is not found. Note that it is currently recommended that each course have only or None if one is not found. Note that it is currently recommended that each course have only
one cohorted user partition. one cohorted user partition.
""" """
course = courses.get_course_by_id(course_key)
for user_partition in course.user_partitions: for user_partition in course.user_partitions:
if user_partition.scheme == CohortPartitionScheme: if user_partition.scheme == CohortPartitionScheme:
return user_partition return user_partition
......
...@@ -317,14 +317,14 @@ class TestGetCohortedUserPartition(ModuleStoreTestCase): ...@@ -317,14 +317,14 @@ class TestGetCohortedUserPartition(ModuleStoreTestCase):
self.course.user_partitions.append(self.random_user_partition) self.course.user_partitions.append(self.random_user_partition)
self.course.user_partitions.append(self.cohort_user_partition) self.course.user_partitions.append(self.cohort_user_partition)
self.course.user_partitions.append(self.second_cohort_user_partition) self.course.user_partitions.append(self.second_cohort_user_partition)
self.assertEqual(self.cohort_user_partition, get_cohorted_user_partition(self.course_key)) self.assertEqual(self.cohort_user_partition, get_cohorted_user_partition(self.course))
def test_no_cohort_user_partitions(self): def test_no_cohort_user_partitions(self):
""" """
Test get_cohorted_user_partition returns None when there are no cohorted user partitions. Test get_cohorted_user_partition returns None when there are no cohorted user partitions.
""" """
self.course.user_partitions.append(self.random_user_partition) self.course.user_partitions.append(self.random_user_partition)
self.assertIsNone(get_cohorted_user_partition(self.course_key)) self.assertIsNone(get_cohorted_user_partition(self.course))
class TestMasqueradedGroup(StaffMasqueradeTestCase): class TestMasqueradedGroup(StaffMasqueradeTestCase):
......
"""
Partition scheme for in-course reverification.
This is responsible for placing users into one of two groups,
ALLOW or DENY, for a partition associated with a particular
in-course reverification checkpoint.
NOTE: This really should be defined in the verify_student app,
which owns the verification and reverification process.
It isn't defined there now because (a) we need access to this in both Studio
and the LMS, but verify_student is specific to the LMS, and
(b) in-course reverification checkpoints currently have messaging that's
specific to credit requirements.
"""
import logging
from django.core.cache import cache
from lms.djangoapps.verify_student.models import SkippedReverification, VerificationStatus
from student.models import CourseEnrollment
from xmodule.partitions.partitions import NoSuchUserPartitionGroupError
log = logging.getLogger(__name__)
class VerificationPartitionScheme(object):
"""
Assign users to groups for a particular verification checkpoint.
Users in the ALLOW group can see gated content;
users in the DENY group cannot.
"""
DENY = 0
ALLOW = 1
@classmethod
def get_group_for_user(cls, course_key, user, user_partition, **kwargs): # pylint: disable=unused-argument
"""
Return the user's group depending their enrollment and verification
status.
Args:
course_key (CourseKey): CourseKey
user (User): user object
user_partition (UserPartition): The user partition object.
Returns:
string of allowed access group
"""
checkpoint = user_partition.parameters['location']
# Retrieve all information we need to determine the user's group
# as a multi-get from the cache.
is_verified, has_skipped, has_completed = _get_user_statuses(user, course_key, checkpoint)
# Decide whether the user should have access to content gated by this checkpoint.
# Intuitively, we allow access if the user doesn't need to do anything at the checkpoint,
# either because the user is in a non-verified track or the user has already submitted.
#
# Note that we do NOT wait the user's reverification attempt to be approved,
# since this can take some time and the user might miss an assignment deadline.
partition_group = cls.DENY
if not is_verified or has_skipped or has_completed:
partition_group = cls.ALLOW
# Return matching user partition group if it exists
try:
return user_partition.get_group(partition_group)
except NoSuchUserPartitionGroupError:
log.error(
(
u"Could not find group with ID %s for verified partition "
"with ID %s in course %s. The user will not be assigned a group."
),
partition_group,
user_partition.id,
course_key
)
return None
def _get_user_statuses(user, course_key, checkpoint):
"""
Retrieve all the information we need to determine the user's group.
This will retrieve the information as a multi-get from the cache.
Args:
user (User): User object
course_key (CourseKey): Identifier for the course.
checkpoint (unicode): Location of the checkpoint in the course (serialized usage key)
Returns:
tuple of booleans of the form (is_verified, has_skipped, has_completed)
"""
enrollment_cache_key = CourseEnrollment.cache_key_name(user.id, unicode(course_key))
has_skipped_cache_key = SkippedReverification.cache_key_name(user.id, unicode(course_key))
verification_status_cache_key = VerificationStatus.cache_key_name(user.id, unicode(course_key))
# Try a multi-get from the cache
cache_values = cache.get_many([
enrollment_cache_key,
has_skipped_cache_key,
verification_status_cache_key
])
# Retrieve whether the user is enrolled in a verified mode.
is_verified = cache_values.get(enrollment_cache_key)
if is_verified is None:
is_verified = CourseEnrollment.is_enrolled_as_verified(user, course_key)
cache.set(enrollment_cache_key, is_verified)
# Retrieve whether the user has skipped any checkpoints in this course
has_skipped = cache_values.get(has_skipped_cache_key)
if has_skipped is None:
has_skipped = SkippedReverification.check_user_skipped_reverification_exists(user, course_key)
cache.set(has_skipped_cache_key, has_skipped)
# Retrieve the user's verification status for each checkpoint in the course.
verification_statuses = cache_values.get(verification_status_cache_key)
if verification_statuses is None:
verification_statuses = VerificationStatus.get_all_checkpoints(user.id, course_key)
cache.set(verification_status_cache_key, verification_statuses)
# Check whether the user has completed this checkpoint
# "Completion" here means *any* submission, regardless of its status
# since we want to show the user the content if they've submitted
# photos.
checkpoint = verification_statuses.get(checkpoint)
has_completed_check = bool(checkpoint)
return (is_verified, has_skipped, has_completed_check)
...@@ -8,14 +8,15 @@ from django.dispatch import receiver ...@@ -8,14 +8,15 @@ from django.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import SignalHandler
from openedx.core.djangoapps.signals.signals import GRADES_UPDATED from openedx.core.djangoapps.signals.signals import GRADES_UPDATED
from openedx.core.djangoapps.credit.verification_access import update_verification_partitions
from xmodule.modulestore.django import SignalHandler
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def on_course_publish(course_key): # pylint: disable=unused-argument def on_course_publish(course_key):
""" """
Will receive a delegated 'course_published' signal from cms/djangoapps/contentstore/signals.py Will receive a delegated 'course_published' signal from cms/djangoapps/contentstore/signals.py
and kick off a celery task to update the credit course requirements. and kick off a celery task to update the credit course requirements.
...@@ -33,6 +34,25 @@ def on_course_publish(course_key): # pylint: disable=unused-argument ...@@ -33,6 +34,25 @@ def on_course_publish(course_key): # pylint: disable=unused-argument
log.info(u'Added task to update credit requirements for course "%s" to the task queue', course_key) log.info(u'Added task to update credit requirements for course "%s" to the task queue', course_key)
@receiver(SignalHandler.pre_publish)
def on_pre_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
"""
Create user partitions for verification checkpoints.
This is a pre-publish step since we need to write to the course descriptor.
"""
from openedx.core.djangoapps.credit import api
if api.is_credit_course(course_key):
# For now, we are tagging content with in-course-reverification access groups
# only in credit courses on publish. In the long run, this is not where we want to put this.
# This really should be a transformation on the course structure performed as a pre-processing
# step by the LMS, and the transformation should be owned by the verify_student app.
# Since none of that infrastructure currently exists, we're doing it this way instead.
log.info(u"Starting to update in-course reverification access rules")
update_verification_partitions(course_key)
log.info(u"Finished updating in-course reverification access rules")
@receiver(GRADES_UPDATED) @receiver(GRADES_UPDATED)
def listen_for_grade_calculation(sender, username, grade_summary, course_key, deadline, **kwargs): # pylint: disable=unused-argument def listen_for_grade_calculation(sender, username, grade_summary, course_key, deadline, **kwargs): # pylint: disable=unused-argument
"""Receive 'MIN_GRADE_REQUIREMENT_STATUS' signal and update minimum grade """Receive 'MIN_GRADE_REQUIREMENT_STATUS' signal and update minimum grade
......
...@@ -15,8 +15,9 @@ from opaque_keys.edx.keys import CourseKey ...@@ -15,8 +15,9 @@ from opaque_keys.edx.keys import CourseKey
from .api import set_credit_requirements from .api import set_credit_requirements
from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements from openedx.core.djangoapps.credit.exceptions import InvalidCreditRequirements
from openedx.core.djangoapps.credit.models import CreditCourse from openedx.core.djangoapps.credit.models import CreditCourse
from xmodule.modulestore.django import modulestore from openedx.core.djangoapps.credit.utils import get_course_blocks
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
LOGGER = get_task_logger(__name__) LOGGER = get_task_logger(__name__)
...@@ -139,34 +140,13 @@ def _get_credit_course_requirement_xblocks(course_key): # pylint: disable=inval ...@@ -139,34 +140,13 @@ def _get_credit_course_requirement_xblocks(course_key): # pylint: disable=inval
return requirements return requirements
def _is_in_course_tree(block):
"""
Check that the XBlock is in the course tree.
It's possible that the XBlock is not in the course tree
if its parent has been deleted and is now an orphan.
"""
ancestor = block.get_parent()
while ancestor is not None and ancestor.location.category != "course":
ancestor = ancestor.get_parent()
return ancestor is not None
def _get_xblocks(course_key, category): def _get_xblocks(course_key, category):
""" """
Retrieve all XBlocks in the course for a particular category. Retrieve all XBlocks in the course for a particular category.
Returns only XBlocks that are published and haven't been deleted. Returns only XBlocks that are published and haven't been deleted.
""" """
xblocks = [ xblocks = get_course_blocks(course_key, category)
block for block in modulestore().get_items(
course_key,
qualifiers={"category": category},
revision=ModuleStoreEnum.RevisionOption.published_only,
)
if _is_in_course_tree(block)
]
# Secondary sort on credit requirement name # Secondary sort on credit requirement name
xblocks = sorted(xblocks, key=lambda block: block.get_credit_requirement_display_name()) xblocks = sorted(xblocks, key=lambda block: block.get_credit_requirement_display_name())
......
# -*- coding: utf-8 -*-
"""
Tests for In-Course Reverification Access Control Partition scheme
"""
import ddt
import unittest
from django.conf import settings
from lms.djangoapps.verify_student.models import (
VerificationCheckpoint,
VerificationStatus,
SkippedReverification,
)
from openedx.core.djangoapps.credit.partition_schemes import VerificationPartitionScheme
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from xmodule.partitions.partitions import UserPartition, Group
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@ddt.ddt
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class ReverificationPartitionTest(ModuleStoreTestCase):
"""Tests for the Reverification Partition Scheme. """
SUBMITTED = "submitted"
APPROVED = "approved"
DENIED = "denied"
def setUp(self):
super(ReverificationPartitionTest, self).setUp()
# creating course, checkpoint location and user partition mock object.
self.course = CourseFactory.create()
self.checkpoint_location = u'i4x://{org}/{course}/edx-reverification-block/first_uuid'.format(
org=self.course.id.org, course=self.course.id.course
)
scheme = UserPartition.get_scheme("verification")
self.user_partition = UserPartition(
id=0,
name=u"Verification Checkpoint",
description=u"Verification Checkpoint",
scheme=scheme,
parameters={"location": self.checkpoint_location},
groups=[
Group(scheme.ALLOW, "Allow access to content"),
Group(scheme.DENY, "Deny access to content"),
]
)
self.first_checkpoint = VerificationCheckpoint.objects.create(
course_id=self.course.id,
checkpoint_location=self.checkpoint_location
)
def create_user_and_enroll(self, enrollment_type):
"""Create and enroll users with provided enrollment type."""
user = UserFactory.create()
CourseEnrollment.objects.create(
user=user,
course_id=self.course.id,
mode=enrollment_type,
is_active=True
)
return user
def add_verification_status(self, user, status):
"""Adding the verification status for a user."""
VerificationStatus.add_status_from_checkpoints(
checkpoints=[self.first_checkpoint],
user=user,
status=status
)
@ddt.data(
("verified", SUBMITTED, VerificationPartitionScheme.ALLOW),
("verified", APPROVED, VerificationPartitionScheme.ALLOW),
("verified", DENIED, VerificationPartitionScheme.ALLOW),
("verified", None, VerificationPartitionScheme.DENY),
("honor", None, VerificationPartitionScheme.ALLOW),
)
@ddt.unpack
def test_get_group_for_user(self, enrollment_type, verification_status, expected_group):
# creating user and enroll them.
user = self.create_user_and_enroll(enrollment_type)
if verification_status:
self.add_verification_status(user, verification_status)
self._assert_group_assignment(user, expected_group)
def test_get_group_for_user_with_skipped(self):
# Check that a user is in verified allow group if that user has skipped
# any ICRV block.
user = self.create_user_and_enroll('verified')
SkippedReverification.add_skipped_reverification_attempt(
checkpoint=self.first_checkpoint,
user_id=user.id,
course_id=self.course.id
)
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
def test_cache_with_skipped_icrv(self):
# Check that a user is in verified allow group if that user has skipped
# any ICRV block.
user = self.create_user_and_enroll('verified')
SkippedReverification.add_skipped_reverification_attempt(
checkpoint=self.first_checkpoint,
user_id=user.id,
course_id=self.course.id
)
# this will warm the cache.
with self.assertNumQueries(3):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
# no db queries this time.
with self.assertNumQueries(0):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
def test_cache_with_submitted_status(self):
# Check that a user is in verified allow group if that user has approved status at
# any ICRV block.
user = self.create_user_and_enroll('verified')
self.add_verification_status(user, VerificationStatus.APPROVED_STATUS)
# this will warm the cache.
with self.assertNumQueries(4):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
# no db queries this time.
with self.assertNumQueries(0):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
def test_cache_with_denied_status(self):
# Check that a user is in verified allow group if that user has denied at
# any ICRV block.
user = self.create_user_and_enroll('verified')
self.add_verification_status(user, VerificationStatus.DENIED_STATUS)
# this will warm the cache.
with self.assertNumQueries(4):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
# no db queries this time.
with self.assertNumQueries(0):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
def test_cache_with_honor(self):
# Check that a user is in honor mode.
# any ICRV block.
user = self.create_user_and_enroll('honor')
# this will warm the cache.
with self.assertNumQueries(3):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
# no db queries this time.
with self.assertNumQueries(0):
self._assert_group_assignment(user, VerificationPartitionScheme.ALLOW)
def test_cache_with_verified_deny_group(self):
# Check that a user is in verified mode. But not perform any action
user = self.create_user_and_enroll('verified')
# this will warm the cache.
with self.assertNumQueries(3):
self._assert_group_assignment(user, VerificationPartitionScheme.DENY)
# no db queries this time.
with self.assertNumQueries(0):
self._assert_group_assignment(user, VerificationPartitionScheme.DENY)
def _assert_group_assignment(self, user, expected_group_id):
"""Check that the user was assigned to a group. """
actual_group = VerificationPartitionScheme.get_group_for_user(self.course.id, user, self.user_partition)
self.assertEqual(actual_group.id, expected_group_id)
...@@ -208,7 +208,7 @@ class TestTaskExecution(ModuleStoreTestCase): ...@@ -208,7 +208,7 @@ class TestTaskExecution(ModuleStoreTestCase):
self.add_credit_course(self.course.id) self.add_credit_course(self.course.id)
self.add_icrv_xblock() self.add_icrv_xblock()
with check_mongo_calls_range(max_finds=7): with check_mongo_calls_range(max_finds=11):
on_course_publish(self.course.id) on_course_publish(self.course.id)
def test_remove_icrv_requirement(self): def test_remove_icrv_requirement(self):
......
"""
Utilities for the credit app.
"""
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
def get_course_blocks(course_key, category):
"""
Retrieve all XBlocks in the course for a particular category.
Returns only XBlocks that are published and haven't been deleted.
"""
# Note: we need to check if found components have been orphaned
# due to a bug in split modulestore (PLAT-799). Once that bug
# is resolved, we can skip the `_is_in_course_tree()` check entirely.
return [
block for block in modulestore().get_items(
course_key,
qualifiers={"category": category},
revision=ModuleStoreEnum.RevisionOption.published_only,
)
if _is_in_course_tree(block)
]
def _is_in_course_tree(block):
"""
Check that the XBlock is in the course tree.
It's possible that the XBlock is not in the course tree
if its parent has been deleted and is now an orphan.
"""
ancestor = block.get_parent()
while ancestor is not None and ancestor.location.category != "course":
ancestor = ancestor.get_parent()
return ancestor is not None
"""
Create in-course reverification access groups in a course.
We model the rules as a set of user partitions, one for each
verification checkpoint in a course.
For example, suppose that a course has two verification checkpoints,
one at midterm A and one at the midterm B.
Then the user partitions would look like this:
Midterm A: |-- ALLOW --|-- DENY --|
Midterm B: |-- ALLOW --|-- DENY --|
where the groups are defined as:
* ALLOW: The user has access to content gated by the checkpoint.
* DENY: The user does not have access to content gated by the checkpoint.
"""
import logging
from util.db import generate_int_id
from openedx.core.djangoapps.credit.utils import get_course_blocks
from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum
from xmodule.partitions.partitions import Group, UserPartition
log = logging.getLogger(__name__)
VERIFICATION_SCHEME_NAME = "verification"
VERIFICATION_BLOCK_CATEGORY = "edx-reverification-block"
def update_verification_partitions(course_key):
"""
Create a user partition for each verification checkpoint in the course.
This will modify the published version of the course descriptor.
It ensures that any in-course reverification XBlocks in the course
have an associated user partition. Other user partitions (e.g. cohorts)
will be preserved. Partitions associated with deleted reverification checkpoints
will be marked as inactive and will not be used to restrict access.
Arguments:
course_key (CourseKey): identifier for the course.
Returns:
None
"""
# Batch all the queries we're about to do and suppress
# the "publish" signal to avoid an infinite call loop.
with modulestore().bulk_operations(course_key, emit_signals=False):
# Retrieve all in-course reverification blocks in the course
icrv_blocks = get_course_blocks(course_key, VERIFICATION_BLOCK_CATEGORY)
# Update the verification definitions in the course descriptor
# This will also clean out old verification partitions if checkpoints
# have been deleted.
_set_verification_partitions(course_key, icrv_blocks)
def _unique_partition_id(course):
"""Return a unique user partition ID for the course. """
# Exclude all previously used IDs, even for partitions that have been disabled
# (e.g. if the course author deleted an in-course reverifification block but
# there are courseware components that reference the disabled partition).
used_ids = set(p.id for p in course.user_partitions)
return generate_int_id(used_ids=used_ids)
def _other_partitions(verified_partitions, exclude_partitions, course_key):
"""
Retrieve all partitions NOT associated with the current set of ICRV blocks.
Any partition associated with a deleted ICRV block will be marked as inactive
so its access rules will no longer be enforced.
Arguments:
all_partitions (list of UserPartition): All verified partitions defined in the course.
exclude_partitions (list of UserPartition): Partitions to exclude (e.g. the ICRV partitions already added)
course_key (CourseKey): Identifier for the course (used for logging).
Returns: list of `UserPartition`s
"""
results = []
partition_by_id = {
p.id: p for p in verified_partitions
}
other_partition_ids = set(p.id for p in verified_partitions) - set(p.id for p in exclude_partitions)
for pid in other_partition_ids:
partition = partition_by_id[pid]
results.append(
UserPartition(
id=partition.id,
name=partition.name,
description=partition.description,
scheme=partition.scheme,
parameters=partition.parameters,
groups=partition.groups,
active=False,
)
)
log.info(
(
"Disabled partition %s in course %s because the "
"associated in-course-reverification checkpoint does not exist."
),
partition.id, course_key
)
return results
def _set_verification_partitions(course_key, icrv_blocks):
"""
Create or update user partitions in the course.
Ensures that each ICRV block in the course has an associated user partition
with the groups ALLOW and DENY.
Arguments:
course_key (CourseKey): Identifier for the course.
icrv_blocks (list of XBlock): In-course reverification blocks, e.g. reverification checkpoints.
Returns:
list of UserPartition
"""
scheme = UserPartition.get_scheme(VERIFICATION_SCHEME_NAME)
if scheme is None:
log.error("Could not retrieve user partition scheme with ID %s", VERIFICATION_SCHEME_NAME)
return []
course = modulestore().get_course(course_key)
if course is None:
log.error("Could not find course %s", course_key)
return []
verified_partitions = course.get_user_partitions_for_scheme(scheme)
partition_id_for_location = {
p.parameters["location"]: p.id
for p in verified_partitions
if "location" in p.parameters
}
partitions = []
for block in icrv_blocks:
partition = UserPartition(
id=partition_id_for_location.get(
unicode(block.location),
_unique_partition_id(course)
),
name=block.related_assessment,
description=u"Verification checkpoint at {}".format(block.related_assessment),
scheme=scheme,
parameters={"location": unicode(block.location)},
groups=[
Group(scheme.ALLOW, "Completed verification at {}".format(block.related_assessment)),
Group(scheme.DENY, "Did not complete verification at {}".format(block.related_assessment)),
]
)
partitions.append(partition)
log.info(
(
"Configured partition %s for course %s using a verified partition scheme "
"for the in-course-reverification checkpoint at location %s"
),
partition.id,
course_key,
partition.parameters["location"]
)
# Preserve existing, non-verified partitions from the course
# Mark partitions for deleted in-course reverification as disabled.
partitions += _other_partitions(verified_partitions, partitions, course_key)
course.set_user_partitions_for_scheme(partitions, scheme)
modulestore().update_item(course, ModuleStoreEnum.UserID.system)
log.info("Saved updated partitions for the course %s", course_key)
return partitions
...@@ -53,7 +53,7 @@ git+https://github.com/edx/edx-oauth2-provider.git@0.5.6#egg=oauth2-provider==0. ...@@ -53,7 +53,7 @@ git+https://github.com/edx/edx-oauth2-provider.git@0.5.6#egg=oauth2-provider==0.
git+https://github.com/edx/edx-lint.git@ed8c8d2a0267d4d42f43642d193e25f8bd575d9b#egg=edx_lint==0.2.3 git+https://github.com/edx/edx-lint.git@ed8c8d2a0267d4d42f43642d193e25f8bd575d9b#egg=edx_lint==0.2.3
-e git+https://github.com/edx/xblock-utils.git@213a97a50276d6a2504d8133650b2930ead357a0#egg=xblock-utils -e git+https://github.com/edx/xblock-utils.git@213a97a50276d6a2504d8133650b2930ead357a0#egg=xblock-utils
-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@1e8f5a7fd589951a90bd31a0824a2c01ac9598ce#egg=edx-reverification-block -e git+https://github.com/edx/edx-reverification-block.git@30fcf2fea305ed6649adcee9c831afaefba635c5#egg=edx-reverification-block
git+https://github.com/edx/ecommerce-api-client.git@1.1.0#egg=ecommerce-api-client==1.1.0 git+https://github.com/edx/ecommerce-api-client.git@1.1.0#egg=ecommerce-api-client==1.1.0
-e git+https://github.com/edx/edx-user-state-client.git@30c0ad4b9f57f8d48d6943eb585ec8a9205f4469#egg=edx-user-state-client -e git+https://github.com/edx/edx-user-state-client.git@30c0ad4b9f57f8d48d6943eb585ec8a9205f4469#egg=edx-user-state-client
-e git+https://github.com/edx/edx-organizations.git@release-2015-08-03#egg=edx-organizations -e git+https://github.com/edx/edx-organizations.git@release-2015-08-03#egg=edx-organizations
......
...@@ -6,13 +6,14 @@ from setuptools import setup ...@@ -6,13 +6,14 @@ from setuptools import setup
setup( setup(
name="Open edX", name="Open edX",
version="0.4", version="0.5",
install_requires=["setuptools"], install_requires=["setuptools"],
requires=[], requires=[],
# NOTE: These are not the names we should be installing. This tree should # NOTE: These are not the names we should be installing. This tree should
# be reorganized to be a more conventional Python tree. # be reorganized to be a more conventional Python tree.
packages=[ packages=[
"openedx.core.djangoapps.course_groups", "openedx.core.djangoapps.course_groups",
"openedx.core.djangoapps.credit",
"openedx.core.djangoapps.user_api", "openedx.core.djangoapps.user_api",
"lms", "lms",
"cms", "cms",
...@@ -45,6 +46,7 @@ setup( ...@@ -45,6 +46,7 @@ setup(
"openedx.user_partition_scheme": [ "openedx.user_partition_scheme": [
"random = openedx.core.djangoapps.user_api.partition_schemes:RandomUserPartitionScheme", "random = openedx.core.djangoapps.user_api.partition_schemes:RandomUserPartitionScheme",
"cohort = openedx.core.djangoapps.course_groups.partition_scheme:CohortPartitionScheme", "cohort = openedx.core.djangoapps.course_groups.partition_scheme:CohortPartitionScheme",
"verification = openedx.core.djangoapps.credit.partition_schemes:VerificationPartitionScheme",
], ],
} }
) )
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