Skip to content
Snippets Groups Projects
Commit ec28a75f authored by zubair-arbi's avatar zubair-arbi Committed by Will Daly
Browse files

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
No related merge requests found
Showing
with 1147 additions and 137 deletions
......@@ -199,6 +199,8 @@ class GroupConfiguration(object):
"""
Returns all units names and their urls.
This will return only groups for the cohort user partition.
Returns:
{'group_id':
[
......@@ -214,25 +216,22 @@ class GroupConfiguration(object):
}
"""
usage_info = {}
for item in items:
if hasattr(item, 'group_access') and item.group_access:
(__, group_ids), = item.group_access.items()
for group_id in group_ids:
if group_id not in usage_info:
usage_info[group_id] = []
unit = item.get_parent()
if not unit:
log.warning("Unable to find parent for component %s", item.location)
continue
usage_info = GroupConfiguration._get_usage_info(
course,
unit=unit,
item=item,
usage_info=usage_info,
group_id=group_id
)
for item, group_id in GroupConfiguration._iterate_items_and_content_group_ids(course, items):
if group_id not in usage_info:
usage_info[group_id] = []
unit = item.get_parent()
if not unit:
log.warning("Unable to find parent for component %s", item.location)
continue
usage_info = GroupConfiguration._get_usage_info(
course,
unit=unit,
item=item,
usage_info=usage_info,
group_id=group_id
)
return usage_info
......@@ -250,6 +249,8 @@ class GroupConfiguration(object):
"""
Returns all items names and their urls.
This will return only groups for the cohort user partition.
Returns:
{'group_id':
[
......@@ -265,23 +266,38 @@ class GroupConfiguration(object):
}
"""
usage_info = {}
for item in items:
if hasattr(item, 'group_access') and item.group_access:
(__, group_ids), = item.group_access.items()
for group_id in group_ids:
if group_id not in usage_info:
usage_info[group_id] = []
usage_info = GroupConfiguration._get_usage_info(
course,
unit=item,
item=item,
usage_info=usage_info,
group_id=group_id
)
for item, group_id in GroupConfiguration._iterate_items_and_content_group_ids(course, items):
if group_id not in usage_info:
usage_info[group_id] = []
usage_info = GroupConfiguration._get_usage_info(
course,
unit=item,
item=item,
usage_info=usage_info,
group_id=group_id
)
return usage_info
@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):
"""
......@@ -329,7 +345,7 @@ class GroupConfiguration(object):
the client explicitly creates a group within the partition and
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:
content_group_configuration = UserPartition(
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
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.modulestore.django import modulestore
from xmodule.partitions.partitions import UserPartition, Group
from contentstore import utils
from contentstore.tests.utils import CourseTestCase
......@@ -413,6 +414,43 @@ class GroupVisibilityTest(CourseTestCase):
self.html = self.store.get_item(html.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):
""" Sets group_access to specified value and calls update_item to persist the change. """
xblock.group_access = value
......@@ -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.html))
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
from django.conf import settings
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.utils import seed_permissions_roles
......@@ -213,12 +214,11 @@ def is_visible_to_specific_content_groups(xblock):
"""
if not xblock.group_access:
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
# to all groups in that particular partition. So if value is a truthy value, the xblock is
# restricted in some way.
if value:
for partition in get_user_partition_info(xblock):
if any(g["selected"] for g in partition["groups"]):
return True
return False
......@@ -331,3 +331,158 @@ def has_active_web_certificate(course):
cert_config = True
break
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
from util.json_request import expect_json, JsonResponse
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, \
ancestor_has_staff_lock, has_children_visible_to_specific_content_groups
from contentstore.utils import (
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, \
xblock_type_display_name, get_parent_xblock, create_xblock, usage_key_with_run
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
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,
user=None):
user=None, course=None):
"""
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
# Filter the graders data as needed
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
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:
......@@ -796,7 +804,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
course_outline,
graders,
include_children_predicate=include_children_predicate,
user=user
user=user,
course=course
)
else:
child_info = None
......@@ -850,6 +859,8 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
"has_changes": has_changes,
"actions": xblock_actions,
"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
......@@ -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
of xblock expected as children.
......@@ -1042,7 +1053,8 @@ def _create_xblock_child_info(xblock, course_outline, graders, include_children_
include_children_predicate=include_children_predicate,
parent_xblock=xblock,
graders=graders,
user=user
user=user,
course=course,
) for child in xblock.get_children()
]
return child_info
......
......@@ -262,6 +262,8 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
{u'name': u'Group A', u'version': 1},
{u'name': u'Group B', u'version': 1},
],
u'parameters': {},
u'active': True
}
response = self.client.ajax_post(
self._url(),
......@@ -283,6 +285,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
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[1].name, u'Group B')
self.assertEqual(user_partititons[0].parameters, {})
def test_lazily_creates_cohort_configuration(self):
"""
......@@ -327,6 +330,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
{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'parameters': {},
u'active': True,
}
response = self.client.put(
self._url(cid=666),
......@@ -346,6 +351,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
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[1].name, u'Group B')
self.assertEqual(user_partitions[0].parameters, {})
def test_can_edit_content_group(self):
"""
......@@ -364,6 +370,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
{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'parameters': {},
u'active': True,
}
response = self.client.put(
......@@ -385,6 +393,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
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[1].name, u'Group C')
self.assertEqual(user_partititons[0].parameters, {})
def test_can_delete_content_group(self):
"""
......@@ -466,6 +475,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
{u'id': 1, u'name': u'Group B', u'version': 1},
],
u'usage': [],
u'parameters': {},
u'active': True,
}
response = self.client.put(
......@@ -485,6 +496,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
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[1].name, u'Group B')
self.assertEqual(user_partitions[0].parameters, {})
def test_can_edit_group_configuration(self):
"""
......@@ -504,6 +516,8 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
{u'id': 2, u'name': u'Group C', u'version': 1},
],
u'usage': [],
u'parameters': {},
u'active': True,
}
response = self.client.put(
......@@ -525,6 +539,7 @@ class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfiguratio
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[1].name, u'Group C')
self.assertEqual(user_partititons[0].parameters, {})
def test_can_delete_group_configuration(self):
"""
......@@ -609,6 +624,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
{'id': 1, 'name': 'Group B', 'version': 1, 'usage': usage_for_group},
{'id': 2, 'name': 'Group C', 'version': 1, 'usage': []},
],
u'parameters': {},
u'active': True,
}
def test_content_group_not_used(self):
......@@ -701,6 +718,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
{'id': 2, 'name': 'Group C', 'version': 1},
],
'usage': [],
'parameters': {},
'active': True,
}]
self.assertEqual(actual, expected)
......@@ -730,6 +749,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
'label': 'Test Unit 0 / Test Content Experiment 0',
'validation': None,
}],
'parameters': {},
'active': True,
}, {
'id': 1,
'name': 'Name 1',
......@@ -742,6 +763,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
{'id': 2, 'name': 'Group C', 'version': 1},
],
'usage': [],
'parameters': {},
'active': True,
}]
self.assertEqual(actual, expected)
......@@ -772,6 +795,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
'label': u"Test Unit 0 / Test Content Experiment 0JOSÉ ANDRÉS",
'validation': None,
}],
'parameters': {},
'active': True,
}]
self.assertEqual(actual, expected)
......@@ -807,6 +832,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
'label': 'Test Unit 1 / Test Content Experiment 1',
'validation': None,
}],
'parameters': {},
'active': True,
}]
self.assertEqual(actual, expected)
......@@ -829,6 +856,48 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
actual = GroupConfiguration.get_content_experiment_usage_info(self.store, self.course)
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):
"""
......
......@@ -119,9 +119,9 @@ class GetItemTest(ItemTest):
return resp
@ddt.data(
(1, 16, 14, 15, 11),
(2, 16, 14, 15, 11),
(3, 16, 14, 15, 11),
(1, 17, 15, 16, 12),
(2, 17, 15, 16, 12),
(3, 17, 15, 16, 12),
)
@ddt.unpack
def test_get_query_count(self, branching_factor, chapter_queries, section_queries, unit_queries, problem_queries):
......@@ -137,9 +137,9 @@ class GetItemTest(ItemTest):
self.client.get(reverse_usage_url('xblock_handler', self.populated_usage_keys['problem'][-1]))
@ddt.data(
(1, 26),
(2, 28),
(3, 30),
(1, 30),
(2, 32),
(3, 34),
)
@ddt.unpack
def test_container_get_query_count(self, branching_factor, unit_queries,):
......@@ -310,6 +310,52 @@ class GetItemTest(ItemTest):
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
class DeleteItem(ItemTest):
......@@ -1414,7 +1460,7 @@ class TestXBlockInfo(ItemTest):
@ddt.data(
(ModuleStoreEnum.Type.split, 5, 5),
(ModuleStoreEnum.Type.mongo, 4, 6),
(ModuleStoreEnum.Type.mongo, 5, 7),
)
@ddt.unpack
def test_xblock_outline_handler_mongo_calls(self, store_type, chapter_queries, chapter_queries_1):
......
......@@ -56,6 +56,26 @@ class AuthoringMixinTestCase(ModuleStoreTestCase):
self.course.user_partitions = [self.content_partition]
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):
"""Make an item visible to staff only."""
item = self.store.get_item(item_location)
......@@ -129,3 +149,14 @@ class AuthoringMixinTestCase(ModuleStoreTestCase):
'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) {
/**
* 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 () {
......@@ -238,7 +248,20 @@ function(Backbone, _, str, ModuleUtils) {
*/
isEditableOnCourseOutline: function() {
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;
});
......@@ -13,7 +13,8 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
) {
'use strict';
var CourseOutlineXBlockModal, SettingsXBlockModal, PublishXBlockModal, AbstractEditor, BaseDateEditor,
ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, StaffLockEditor, TimedExaminationPreferenceEditor;
ReleaseDateEditor, DueDateEditor, GradingEditor, PublishEditor, StaffLockEditor,
VerificationAccessEditor, TimedExaminationPreferenceEditor;
CourseOutlineXBlockModal = BaseModal.extend({
events : {
......@@ -427,7 +428,7 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
},
hasChanges: function() {
return this.isModelLocked() != this.isLocked();
return this.isModelLocked() !== this.isLocked();
},
getRequestData: function() {
......@@ -443,7 +444,110 @@ define(['jquery', 'backbone', 'underscore', 'gettext', 'js/views/baseview',
return {
hasExplicitStaffLock: this.isModelLocked(),
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',
}
editors.push(StaffLockEditor);
} else if (xblockInfo.isVertical()) {
editors = [StaffLockEditor];
if (xblockInfo.hasVerifiedCheckpoints()) {
editors.push(VerificationAccessEditor);
}
}
return new SettingsXBlockModal($.extend({
editors: editors,
......
......@@ -6,29 +6,56 @@
function VisibilityEditorView(runtime, element) {
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')) {
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) {
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;
};
// When selecting "all students and staff", uncheck the specific groups
element.find('.field-visibility-level input').change(function(event) {
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);
element.find('.visibility-level-specific').prop('checked', true);
});
// When selecting a specific group, deselect "all students and staff" and
// 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() {
......
......@@ -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
.field-visibility-content-group.was-removed {
......@@ -629,8 +638,12 @@
}
}
.edit-staff-lock {
margin-bottom: $baseline;
}
// 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 {
@extend %cont-text-sr;
......@@ -653,6 +666,20 @@
.checkbox-cosmetic .label {
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
......
......@@ -21,7 +21,7 @@ from microsite_configuration import microsite
<%block name="header_extras">
<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">
<%static:include path="js/${template_name}.underscore" />
</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>
<%
from django.utils.translation import ugettext as _
from openedx.core.djangoapps.credit.partition_schemes import VerificationPartitionScheme
from contentstore.utils import ancestor_has_staff_lock, get_visibility_partition_info
from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
from contentstore.utils import ancestor_has_staff_lock
partition_info = get_visibility_partition_info(xblock)
user_partitions = partition_info["user_partitions"]
cohort_partitions = partition_info["cohort_partitions"]
verification_partitions = partition_info["verification_partitions"]
has_selected_groups = partition_info["has_selected_groups"]
selected_verified_partition_id = partition_info["selected_verified_partition_id"]
cohorted_user_partition = get_cohorted_user_partition(xblock.location.course_key)
unsorted_groups = cohorted_user_partition.groups if cohorted_user_partition else []
groups = sorted(unsorted_groups, key=lambda group: group.name)
selected_group_ids = xblock.group_access.get(cohorted_user_partition.id, []) if cohorted_user_partition else []
has_selected_groups = len(selected_group_ids) > 0
is_staff_locked = ancestor_has_staff_lock(xblock)
%>
<div class="modal-section visibility-summary">
% if len(groups) == 0:
% if len(user_partitions) == 0:
<div class="is-not-configured has-actions">
<h4 class="title">${_('No content groups exist')}</h4>
......@@ -42,7 +43,7 @@ is_staff_locked = ancestor_has_staff_lock(xblock)
% endif
</div>
% if len(groups) > 0:
% if len(user_partitions) > 0:
<form class="visibility-controls-form" method="post" action="">
<div class="modal-section visibility-controls">
......@@ -51,49 +52,83 @@ is_staff_locked = ancestor_has_staff_lock(xblock)
<div class="modal-section-content">
<section class="visibility-controls-primary">
<ul class="list-fields list-radio">
<li class="field field-radio field-visibility-level">
<div class="list-fields list-radio">
<div class="field field-radio field-visibility-level">
<input type="radio" id="visibility-level-all" name="visibility-level" value="" class="input input-radio visibility-level-all" ${'checked="checked"' if not has_selected_groups else ''} />
<label for="visibility-level-all" class="label">${_('All Students and Staff')}</label>
</li>
</div>
<li class="field field-radio field-visibility-level">
<div class="field field-radio field-visibility-level">
<input type="radio" id="visibility-level-specific" name="visibility-level" value="" class="input input-radio visibility-level-specific" ${'checked="checked"' if has_selected_groups else ''} />
<label for="visibility-level-specific" class="label">${_('Specific Content Groups')}</label>
</li>
</ul>
</div>
</div>
</section>
<div class="wrapper-visibility-specific" data-user-partition-id="${cohorted_user_partition.id}">
<div class="wrapper-visibility-specific">
<section class="visibility-controls-secondary">
<div class="visibility-controls-group">
<h4 class="visibility-controls-title modal-subsection-title sr">${_('Content Groups')}</h4>
<ul class="list-fields list-checkbox">
<%
missing_group_ids = set(selected_group_ids)
%>
% for group in groups:
<%
is_group_selected = group.id in selected_group_ids
if is_group_selected:
missing_group_ids.remove(group.id)
%>
<li class="field field-checkbox field-visibility-content-group">
<input type="checkbox" id="visibility-content-group-${group.id}" name="visibility-content-group" value="${group.id}" class="input input-checkbox" ${'checked="checked"' if group.id in selected_group_ids else ''}/>
<label for="visibility-content-group-${group.id}" class="label">${group.name | h}</label>
</li>
<div class="list-fields list-checkbox">
% for partition in cohort_partitions:
% for group in partition["groups"]:
<div class="field field-checkbox field-visibility-content-group ${'was-removed' if group["deleted"] else ''}">
<input type="checkbox"
id="visibility-content-group-${partition["id"]}-${group["id"]}"
name="visibility-content-group"
value="${partition["id"]}-${group["id"]}"
class="input input-checkbox"
${'checked="checked"' if group["selected"] else ''}
/>
% if group["deleted"]:
<label for="visibility-content-group-${partition["id"]}-${group["id"]}" class="label">
${_('Deleted Content Group')}
<span class="note">${_('Content group no longer exists. Please choose another or allow access to All Students and staff')}</span>
</label>
% else:
<label for="visibility-content-group-${partition["id"]}-${group["id"]}" class="label">${group["name"] | h}</label>
% endif
</div>
% endfor
% endfor
% for group_id in missing_group_ids:
<li class="field field-checkbox field-visibility-content-group was-removed">
<input type="checkbox" id="visibility-content-group-${group_id}" name="visibility-content-group" value="${group_id}" class="input input-checkbox" checked="checked" />
<label for="visibility-content-group-${group_id}" class="label">
${_('Deleted Content Group')}
<span class="note">${_('Content group no longer exists. Please choose another or allow access to All Students and staff')}</span>
## Allow only one verification checkpoint to be selected at a time.
% if verification_partitions:
<div role="group" aria-labelledby="verification-access-title">
<div id="verification-access-title" class="sr">${_('Verification Checkpoint')}</div>
<div class="field field-checkbox field-visibility-verification">
<input type="checkbox"
id="verification-access-checkbox"
name="verification-access-checkbox"
class="input input-checkbox"
value=""
aria-describedby="verification-help-text"
${'checked="checked"' if selected_verified_partition_id is not None else ''}
/>
<label for="verification-access-checkbox" class="label">
${_('Verification Checkpoint')}:
</label>
</li>
% endfor
</ul>
<label class="sr" for="verification-access-dropdown">
${_('Verification checkpoint to complete')}
</label>
<select id="verification-access-dropdown">
% for partition in verification_partitions:
<option
value="${partition["id"]}"
${ "selected" if partition["id"] == selected_verified_partition_id else ""}
>${partition["name"]}</option>
% endfor
</select>
<div class="note" id="verification-help-text">
${_("Learners who require verification must pass the selected checkpoint to see the content in this component. Learners who do not require verification see this content by default.")}
</div>
</div>
</div>
% endif
</div>
</div>
</section>
</div>
......
......@@ -35,6 +35,7 @@ from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver, Signal
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import ugettext_noop
from django.core.cache import cache
from django_countries.fields import CountryField
import dogstats_wrapper as dog_stats_api
from eventtracking import tracker
......@@ -846,6 +847,9 @@ class CourseEnrollment(models.Model):
# Maintain a history of requirement status updates for auditing purposes
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
unique_together = (('user', 'course_id'),)
ordering = ('user', 'course_id')
......@@ -1338,6 +1342,49 @@ class CourseEnrollment(models.Model):
"""
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):
"""
......
......@@ -1533,3 +1533,39 @@ class CourseDescriptor(CourseFields, SequenceDescriptor, LicenseMixin):
Returns the topics that have been configured for teams for this course, else 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):
# user ID to use for tests that do not have a django user available
test = -3
# user ID for automatic update by the system
system = -4
class SortOrder(object):
"""
Values for sorting asset metadata.
......@@ -264,6 +267,12 @@ class BulkOperationsMixin(object):
if not bulk_ops_record.active:
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()
# If this wasn't the outermost context, then don't close out the
......@@ -293,6 +302,14 @@ class BulkOperationsMixin(object):
"""
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):
"""
Sends out the signal that items have been published from within this course.
......
......@@ -86,11 +86,13 @@ class SignalHandler(object):
almost no work. Its main job is to kick off the celery task that will
do the actual work.
"""
pre_publish = 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"])
library_updated = django.dispatch.Signal(providing_args=["library_key"])
_mapping = {
"pre_publish": pre_publish,
"course_published": course_published,
"course_deleted": course_deleted,
"library_updated": library_updated,
......
......@@ -84,18 +84,23 @@ class Group(namedtuple("Group", "id name")):
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
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
VERSION = 3
# The collection of user partition scheme extensions.
scheme_extensions = None
......@@ -103,11 +108,14 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
# The default scheme to be used when upgrading version 1 partitions.
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
if not scheme:
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
def get_scheme(name):
......@@ -137,7 +145,9 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
"name": self.name,
"scheme": self.scheme.name,
"description": self.description,
"parameters": self.parameters,
"groups": [g.to_json() for g in self.groups],
"active": bool(self.active),
"version": UserPartition.VERSION
}
......@@ -165,13 +175,16 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
# Version changes should be backwards compatible in case the code
# 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.
elif value["version"] >= UserPartition.VERSION:
elif value["version"] >= 2:
if "scheme" not in value:
raise TypeError("UserPartition dict {0} missing value key 'scheme'".format(value))
scheme_id = value["scheme"]
else:
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"]]
scheme = UserPartition.get_scheme(scheme_id)
if not scheme:
......@@ -183,12 +196,22 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
value["description"],
groups,
scheme,
parameters,
active,
)
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:
if group.id == group_id:
return group
......
......@@ -110,6 +110,7 @@ class PartitionTestCase(TestCase):
TEST_ID = 0
TEST_NAME = "Mock Partition"
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_SCHEME_NAME = "mock"
......@@ -136,7 +137,8 @@ class PartitionTestCase(TestCase):
self.TEST_NAME,
self.TEST_DESCRIPTION,
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).
......@@ -149,17 +151,28 @@ class TestUserPartition(PartitionTestCase):
def test_construct(self):
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.name, self.TEST_NAME)
self.assertEqual(user_partition.description, self.TEST_DESCRIPTION)
self.assertEqual(user_partition.groups, self.TEST_GROUPS)
self.assertEquals(user_partition.scheme.name, self.TEST_SCHEME_NAME)
self.assertEqual(user_partition.description, self.TEST_DESCRIPTION) # pylint: disable=no-member
self.assertEqual(user_partition.groups, self.TEST_GROUPS) # pylint: disable=no-member
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):
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)
......@@ -169,9 +182,11 @@ class TestUserPartition(PartitionTestCase):
"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": self.user_partition.VERSION,
"scheme": self.TEST_SCHEME_NAME
"scheme": self.TEST_SCHEME_NAME,
"active": True,
}
self.assertEqual(jsonified, act_jsonified)
......@@ -180,22 +195,26 @@ class TestUserPartition(PartitionTestCase):
"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": UserPartition.VERSION,
"scheme": "mock",
}
user_partition = UserPartition.from_json(jsonified)
self.assertEqual(user_partition.id, self.TEST_ID)
self.assertEqual(user_partition.name, self.TEST_NAME)
self.assertEqual(user_partition.description, self.TEST_DESCRIPTION)
for act_group in user_partition.groups:
self.assertEqual(user_partition.id, self.TEST_ID) # pylint: disable=no-member
self.assertEqual(user_partition.name, self.TEST_NAME) # pylint: disable=no-member
self.assertEqual(user_partition.description, self.TEST_DESCRIPTION) # pylint: disable=no-member
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])
exp_group = self.TEST_GROUPS[act_group.id]
self.assertEqual(exp_group.id, act_group.id)
self.assertEqual(exp_group.name, act_group.name)
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 = {
"id": self.TEST_ID,
"name": self.TEST_NAME,
......@@ -204,13 +223,61 @@ class TestUserPartition(PartitionTestCase):
"version": 1,
}
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):
# Missing field
jsonified = {
"name": self.TEST_NAME,
"description": self.TEST_DESCRIPTION,
"parameters": self.TEST_PARAMETERS,
"groups": [group.to_json() for group in self.TEST_GROUPS],
"version": UserPartition.VERSION,
"scheme": self.TEST_SCHEME_NAME,
......@@ -223,6 +290,7 @@ class TestUserPartition(PartitionTestCase):
'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": UserPartition.VERSION,
}
......@@ -234,6 +302,7 @@ class TestUserPartition(PartitionTestCase):
'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": UserPartition.VERSION,
"scheme": "no_such_scheme",
......@@ -246,6 +315,7 @@ class TestUserPartition(PartitionTestCase):
'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": -1,
"scheme": self.TEST_SCHEME_NAME,
......@@ -258,6 +328,7 @@ class TestUserPartition(PartitionTestCase):
'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": UserPartition.VERSION,
"scheme": "mock",
......@@ -266,6 +337,18 @@ class TestUserPartition(PartitionTestCase):
user_partition = UserPartition.from_json(jsonified)
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):
"""
UserPartition.get_group correctly returns the group referenced by the
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment