Commit 02cc9ca8 by David Ormsbee

Merge pull request #9436 from edx/rc/2015-08-25

2015-08-25 Release
parents b0247f17 4bc62274
...@@ -232,3 +232,4 @@ William Ono <william.ono@ubc.ca> ...@@ -232,3 +232,4 @@ William Ono <william.ono@ubc.ca>
Dongwook Yoon <dy252@cornell.edu> Dongwook Yoon <dy252@cornell.edu>
Awais Qureshi <awais.qureshi@arbisoft.com> Awais Qureshi <awais.qureshi@arbisoft.com>
Eric Fischer <efischer@edx.org> Eric Fischer <efischer@edx.org>
Brian Beggs <macdiesel@gmail.com>
...@@ -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
......
...@@ -67,10 +67,15 @@ CMS.User.isGlobalStaff = '${is_global_staff}'=='True' ? true : false; ...@@ -67,10 +67,15 @@ CMS.User.isGlobalStaff = '${is_global_staff}'=='True' ? true : false;
<div class="bit"> <div class="bit">
<div class="certificates-doc"> <div class="certificates-doc">
<h2 class="title-3">${_("Certificates")}</h2> <h2 class="title-3">${_("Certificates")}</h2>
<p>${_("Upon successful completion of your course, learners receive a certificate to acknowledge their accomplishment. If you are a course team member with the Admin role in Studio, you can configure your course certificate.")}</p> <h3 class="title-3">${_("Working with Certificates")}</h3>
<p>${_("Click {em_start}Add your first certificate{em_end} to add a certificate configuration. Upload the organization logo to be used on the certificate, and specify at least one signatory. You can include up to four signatories for a certificate. You can also upload a signature image file for each signatory. {em_start}Note:{em_end} Signature images are used only for verified certificates. Optionally, specify a different course title to use on your course certificate. You might want to use a different title if, for example, the official course name is too long to display well on a certificate.") .format(em_start='<strong>', em_end="</strong>")}</p> <p>${_("Specify a course title to use on the certificate if the course's official title is too long to be displayed well.")}</p>
<p>${_("Select a course mode and click {em_start}Preview Certificate{em_end} to preview the certificate that a learner in the selected enrollment track would receive. When the certificate is ready for issuing, click {em_start}Activate.{em_end} To stop issuing an active certificate, click {em_start}Deactivate{em_end}.").format(em_start='<strong>', em_end="</strong>")}</p> <p>${_("For verified certificates, specify between one and four signatories and upload the associated images.")}</p>
<p>${_(" To edit the certificate configuration, hover over the top right corner of the form and click {em_start}Edit{em_end}. To delete a certificate, hover over the top right corner of the form and click the delete icon. In general, do not delete certificates after a course has started, because some certificates might already have been issued to learners.").format(em_start="<strong>", em_end="</strong>")}</p> <p>${_("To edit or delete a certificate before it is activated, hover over the top right corner of the form and select {em_start}Edit{em_end} or the delete icon.").format(em_start="<strong>", em_end="</strong>")}</p>
<p>${_("To view a sample certificate, choose a course mode and select {em_start}Preview Certificate{em_end}.").format(em_start='<strong>', em_end="</strong>")}</p>
<h3 class="title-3">${_("Issuing Certificates to Learners")}</h3>
<p>${_("To begin issuing certificates, a course team member with the Admin role selects {em_start}Activate{em_end}. Course team members without the Admin role cannot edit or delete an activated certificate.").format(em_start='<strong>', em_end="</strong>")}</p>
<p>${_("{em_start}Do not{em_end} delete certificates after a course has started; learners who have already earned certificates will no longer be able to access them.").format(em_start="<strong>", em_end="</strong>")}</p>
<p><a href="${get_online_help_info(online_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn more about certificates")}</a></p> <p><a href="${get_online_help_info(online_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn more about certificates")}</a></p>
</div> </div>
</div> </div>
......
...@@ -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>
...@@ -95,6 +95,7 @@ from django.core.urlresolvers import reverse ...@@ -95,6 +95,7 @@ from django.core.urlresolvers import reverse
<div class="bit"> <div class="bit">
<h3 class="title-3">${_("Course Team Roles")}</h3> <h3 class="title-3">${_("Course Team Roles")}</h3>
<p>${_("Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.")}</p> <p>${_("Course team members with the Staff role are course co-authors. They have full writing and editing privileges on all course content.")}</p>
## Note that the "Admin" role below is identified as "Instructor" in the Django admin panel.
<p>${_("Admins are course team members who can add and remove other course team members.")}</p> <p>${_("Admins are course team members who can add and remove other course team members.")}</p>
<p>${_("All course team members are automatically enrolled in the course and can access content in Studio, the LMS, and Insights.")}</p> <p>${_("All course team members are automatically enrolled in the course and can access content in Studio, the LMS, and Insights.")}</p>
</div> </div>
......
...@@ -10,6 +10,8 @@ from django.test.client import Client ...@@ -10,6 +10,8 @@ from django.test.client import Client
from django.test.utils import override_settings from django.test.utils import override_settings
import unittest import unittest
from student.tests.factories import UserFactory
# NOTE: We set SESSION_SAVE_EVERY_REQUEST to True in order to make sure # NOTE: We set SESSION_SAVE_EVERY_REQUEST to True in order to make sure
# Sessions are always started on every request # Sessions are always started on every request
...@@ -22,8 +24,13 @@ class MicroSiteSessionCookieTests(TestCase): ...@@ -22,8 +24,13 @@ class MicroSiteSessionCookieTests(TestCase):
def setUp(self): def setUp(self):
super(MicroSiteSessionCookieTests, self).setUp() super(MicroSiteSessionCookieTests, self).setUp()
# create a test client # Create a test client, and log it in so that it will save some session
# data.
self.user = UserFactory.create()
self.user.set_password('password')
self.user.save()
self.client = Client() self.client = Client()
self.client.login(username=self.user.username, password="password")
def test_session_cookie_domain_no_microsite(self): def test_session_cookie_domain_no_microsite(self):
""" """
......
...@@ -95,6 +95,11 @@ class CourseAccessRoleForm(forms.ModelForm): ...@@ -95,6 +95,11 @@ class CourseAccessRoleForm(forms.ModelForm):
return cleaned_data return cleaned_data
def __init__(self, *args, **kwargs):
super(CourseAccessRoleForm, self).__init__(*args, **kwargs)
if self.instance.user_id:
self.fields['email'].initial = self.instance.user.email
class CourseAccessRoleAdmin(admin.ModelAdmin): class CourseAccessRoleAdmin(admin.ModelAdmin):
"""Admin panel for the Course Access Role. """ """Admin panel for the Course Access Role. """
......
...@@ -108,7 +108,7 @@ class AccountCreationForm(forms.Form): ...@@ -108,7 +108,7 @@ class AccountCreationForm(forms.Form):
max_length=30, max_length=30,
error_messages={ error_messages={
"required": _USERNAME_TOO_SHORT_MSG, "required": _USERNAME_TOO_SHORT_MSG,
"invalid": _("Username should only consist of A-Z and 0-9, with no spaces."), "invalid": _("Usernames must contain only letters, numbers, underscores (_), and hyphens (-)."),
"min_length": _USERNAME_TOO_SHORT_MSG, "min_length": _USERNAME_TOO_SHORT_MSG,
"max_length": _("Username cannot be more than %(limit_value)s characters long"), "max_length": _("Username cannot be more than %(limit_value)s characters long"),
} }
......
...@@ -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):
""" """
......
...@@ -310,7 +310,7 @@ class TestCreateAccountValidation(TestCase): ...@@ -310,7 +310,7 @@ class TestCreateAccountValidation(TestCase):
# Invalid # Invalid
params["username"] = "invalid username" params["username"] = "invalid username"
assert_username_error("Username should only consist of A-Z and 0-9, with no spaces.") assert_username_error("Usernames must contain only letters, numbers, underscores (_), and hyphens (-).")
def test_email(self): def test_email(self):
params = dict(self.minimal_params) params = dict(self.minimal_params)
......
...@@ -9,7 +9,17 @@ from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModel ...@@ -9,7 +9,17 @@ from config_models.admin import ConfigurationModelAdmin, KeyedConfigurationModel
from .models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, SAMLProviderData from .models import OAuth2ProviderConfig, SAMLProviderConfig, SAMLConfiguration, SAMLProviderData
from .tasks import fetch_saml_metadata from .tasks import fetch_saml_metadata
admin.site.register(OAuth2ProviderConfig, KeyedConfigurationModelAdmin)
class OAuth2ProviderConfigAdmin(KeyedConfigurationModelAdmin):
""" Django Admin class for OAuth2ProviderConfig """
def get_list_display(self, request):
""" Don't show every single field in the admin change list """
return (
'name', 'enabled', 'backend_name', 'secondary', 'skip_registration_form',
'skip_email_verification', 'change_date', 'changed_by', 'edit_link',
)
admin.site.register(OAuth2ProviderConfig, OAuth2ProviderConfigAdmin)
class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin): class SAMLProviderConfigAdmin(KeyedConfigurationModelAdmin):
...@@ -55,10 +65,12 @@ class SAMLConfigurationAdmin(ConfigurationModelAdmin): ...@@ -55,10 +65,12 @@ class SAMLConfigurationAdmin(ConfigurationModelAdmin):
def key_summary(self, inst): def key_summary(self, inst):
""" Short summary of the key pairs configured """ """ Short summary of the key pairs configured """
if not inst.public_key or not inst.private_key: public_key = inst.get_setting('SP_PUBLIC_CERT')
private_key = inst.get_setting('SP_PRIVATE_KEY')
if not public_key or not private_key:
return u'<em>Key pair incomplete/missing</em>' return u'<em>Key pair incomplete/missing</em>'
pub1, pub2 = inst.public_key[0:10], inst.public_key[-10:] pub1, pub2 = public_key[0:10], public_key[-10:]
priv1, priv2 = inst.private_key[0:10], inst.private_key[-10:] priv1, priv2 = private_key[0:10], private_key[-10:]
return u'Public: {}…{}<br>Private: {}…{}'.format(pub1, pub2, priv1, priv2) return u'Public: {}…{}<br>Private: {}…{}'.format(pub1, pub2, priv1, priv2)
key_summary.allow_tags = True key_summary.allow_tags = True
......
...@@ -178,7 +178,16 @@ class OAuth2ProviderConfig(ProviderConfig): ...@@ -178,7 +178,16 @@ class OAuth2ProviderConfig(ProviderConfig):
) )
) )
key = models.TextField(blank=True, verbose_name="Client ID") key = models.TextField(blank=True, verbose_name="Client ID")
secret = models.TextField(blank=True, verbose_name="Client Secret") secret = models.TextField(
blank=True,
verbose_name="Client Secret",
help_text=(
'For increased security, you can avoid storing this in your database by leaving '
' this field blank and setting '
'SOCIAL_AUTH_OAUTH_SECRETS = {"(backend name)": "secret", ...} '
'in your instance\'s Django settings (or lms.auth.json)'
)
)
other_settings = models.TextField(blank=True, help_text="Optional JSON object with advanced settings, if any.") other_settings = models.TextField(blank=True, help_text="Optional JSON object with advanced settings, if any.")
class Meta(object): # pylint: disable=missing-docstring class Meta(object): # pylint: disable=missing-docstring
...@@ -192,8 +201,13 @@ class OAuth2ProviderConfig(ProviderConfig): ...@@ -192,8 +201,13 @@ class OAuth2ProviderConfig(ProviderConfig):
def get_setting(self, name): def get_setting(self, name):
""" Get the value of a setting, or raise KeyError """ """ Get the value of a setting, or raise KeyError """
if name in ("KEY", "SECRET"): if name == "KEY":
return getattr(self, name.lower()) return self.key
if name == "SECRET":
if self.secret:
return self.secret
# To allow instances to avoid storing secrets in the DB, the secret can also be set via Django:
return getattr(settings, 'SOCIAL_AUTH_OAUTH_SECRETS', {}).get(self.backend_name, '')
if self.other_settings: if self.other_settings:
other_settings = json.loads(self.other_settings) other_settings = json.loads(self.other_settings)
assert isinstance(other_settings, dict), "other_settings should be a JSON object (dictionary)" assert isinstance(other_settings, dict), "other_settings should be a JSON object (dictionary)"
...@@ -310,10 +324,22 @@ class SAMLConfiguration(ConfigurationModel): ...@@ -310,10 +324,22 @@ class SAMLConfiguration(ConfigurationModel):
help_text=( help_text=(
'To generate a key pair as two files, run ' 'To generate a key pair as two files, run '
'"openssl req -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.key". ' '"openssl req -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.key". '
'Paste the contents of saml.key here.' 'Paste the contents of saml.key here. '
) 'For increased security, you can avoid storing this in your database by leaving '
'this field blank and setting it via the SOCIAL_AUTH_SAML_SP_PRIVATE_KEY setting '
'in your instance\'s Django settings (or lms.auth.json).'
),
blank=True,
)
public_key = models.TextField(
help_text=(
'Public key certificate. '
'For increased security, you can avoid storing this in your database by leaving '
'this field blank and setting it via the SOCIAL_AUTH_SAML_SP_PUBLIC_CERT setting '
'in your instance\'s Django settings (or lms.auth.json).'
),
blank=True,
) )
public_key = models.TextField(help_text="Public key certificate.")
entity_id = models.CharField(max_length=255, default="http://saml.example.com", verbose_name="Entity ID") entity_id = models.CharField(max_length=255, default="http://saml.example.com", verbose_name="Entity ID")
org_info_str = models.TextField( org_info_str = models.TextField(
verbose_name="Organization Info", verbose_name="Organization Info",
...@@ -360,9 +386,15 @@ class SAMLConfiguration(ConfigurationModel): ...@@ -360,9 +386,15 @@ class SAMLConfiguration(ConfigurationModel):
if name == "SP_ENTITY_ID": if name == "SP_ENTITY_ID":
return self.entity_id return self.entity_id
if name == "SP_PUBLIC_CERT": if name == "SP_PUBLIC_CERT":
return self.public_key if self.public_key:
return self.public_key
# To allow instances to avoid storing keys in the DB, the key pair can also be set via Django:
return getattr(settings, 'SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', '')
if name == "SP_PRIVATE_KEY": if name == "SP_PRIVATE_KEY":
return self.private_key if self.private_key:
return self.private_key
# To allow instances to avoid storing keys in the DB, the private key can also be set via Django:
return getattr(settings, 'SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', '')
other_config = json.loads(self.other_config_str) other_config = json.loads(self.other_config_str)
if name in ("TECHNICAL_CONTACT", "SUPPORT_CONTACT"): if name in ("TECHNICAL_CONTACT", "SUPPORT_CONTACT"):
contact = { contact = {
......
...@@ -23,7 +23,7 @@ class RegistryTest(testutil.TestCase): ...@@ -23,7 +23,7 @@ class RegistryTest(testutil.TestCase):
enabled_providers = provider.Registry.enabled() enabled_providers = provider.Registry.enabled()
self.assertEqual(len(enabled_providers), 1) self.assertEqual(len(enabled_providers), 1)
self.assertEqual(enabled_providers[0].name, "Google") self.assertEqual(enabled_providers[0].name, "Google")
self.assertEqual(enabled_providers[0].secret, "opensesame") self.assertEqual(enabled_providers[0].get_setting("SECRET"), "opensesame")
self.configure_google_provider(enabled=False) self.configure_google_provider(enabled=False)
enabled_providers = provider.Registry.enabled() enabled_providers = provider.Registry.enabled()
...@@ -32,7 +32,17 @@ class RegistryTest(testutil.TestCase): ...@@ -32,7 +32,17 @@ class RegistryTest(testutil.TestCase):
self.configure_google_provider(enabled=True, secret="alohomora") self.configure_google_provider(enabled=True, secret="alohomora")
enabled_providers = provider.Registry.enabled() enabled_providers = provider.Registry.enabled()
self.assertEqual(len(enabled_providers), 1) self.assertEqual(len(enabled_providers), 1)
self.assertEqual(enabled_providers[0].secret, "alohomora") self.assertEqual(enabled_providers[0].get_setting("SECRET"), "alohomora")
def test_secure_configuration(self):
""" Test that some sensitive values can be configured via Django settings """
self.configure_google_provider(enabled=True, secret="")
enabled_providers = provider.Registry.enabled()
self.assertEqual(len(enabled_providers), 1)
self.assertEqual(enabled_providers[0].name, "Google")
self.assertEqual(enabled_providers[0].get_setting("SECRET"), "")
with self.settings(SOCIAL_AUTH_OAUTH_SECRETS={'google-oauth2': 'secret42'}):
self.assertEqual(enabled_providers[0].get_setting("SECRET"), "secret42")
def test_cannot_load_arbitrary_backends(self): def test_cannot_load_arbitrary_backends(self):
""" Test that only backend_names listed in settings.AUTHENTICATION_BACKENDS can be used """ """ Test that only backend_names listed in settings.AUTHENTICATION_BACKENDS can be used """
......
...@@ -4,6 +4,7 @@ Test the views served by third_party_auth. ...@@ -4,6 +4,7 @@ Test the views served by third_party_auth.
# pylint: disable=no-member # pylint: disable=no-member
import ddt import ddt
from lxml import etree from lxml import etree
from onelogin.saml2.errors import OneLogin_Saml2_Error
import unittest import unittest
from .testutil import AUTH_FEATURE_ENABLED, SAMLTestCase from .testutil import AUTH_FEATURE_ENABLED, SAMLTestCase
...@@ -26,8 +27,7 @@ class SAMLMetadataTest(SAMLTestCase): ...@@ -26,8 +27,7 @@ class SAMLMetadataTest(SAMLTestCase):
response = self.client.get(self.METADATA_URL) response = self.client.get(self.METADATA_URL)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@ddt.data('saml_key', 'saml_key_alt') # Test two slightly different key pair export formats def test_metadata(self):
def test_metadata(self, key_name):
self.enable_saml() self.enable_saml()
doc = self._fetch_metadata() doc = self._fetch_metadata()
# Check the ACS URL: # Check the ACS URL:
...@@ -62,13 +62,44 @@ class SAMLMetadataTest(SAMLTestCase): ...@@ -62,13 +62,44 @@ class SAMLMetadataTest(SAMLTestCase):
support_email="joe@example.com" support_email="joe@example.com"
) )
def test_signed_metadata(self): @ddt.data(
# Test two slightly different key pair export formats
('saml_key', 'MIICsDCCAhmgAw'),
('saml_key_alt', 'MIICWDCCAcGgAw'),
)
@ddt.unpack
def test_signed_metadata(self, key_name, pub_key_starts_with):
self.enable_saml( self.enable_saml(
private_key=self._get_private_key(key_name),
public_key=self._get_public_key(key_name),
other_config_str='{"SECURITY_CONFIG": {"signMetadata": true} }', other_config_str='{"SECURITY_CONFIG": {"signMetadata": true} }',
) )
self._validate_signed_metadata(pub_key_starts_with=pub_key_starts_with)
def test_secure_key_configuration(self):
""" Test that the SAML private key can be stored in Django settings and not the DB """
self.enable_saml(
public_key='',
private_key='',
other_config_str='{"SECURITY_CONFIG": {"signMetadata": true} }',
)
with self.assertRaises(OneLogin_Saml2_Error):
self._fetch_metadata() # OneLogin_Saml2_Error: Cannot sign metadata: missing SP private key.
with self.settings(
SOCIAL_AUTH_SAML_SP_PRIVATE_KEY=self._get_private_key('saml_key'),
SOCIAL_AUTH_SAML_SP_PUBLIC_CERT=self._get_public_key('saml_key'),
):
self._validate_signed_metadata()
def _validate_signed_metadata(self, pub_key_starts_with='MIICsDCCAhmgAw'):
""" Fetch the SAML metadata and do some validation """
doc = self._fetch_metadata() doc = self._fetch_metadata()
sig_node = doc.find(".//{}".format(etree.QName(XMLDSIG_XML_NS, 'SignatureValue'))) sig_node = doc.find(".//{}".format(etree.QName(XMLDSIG_XML_NS, 'SignatureValue')))
self.assertIsNotNone(sig_node) self.assertIsNotNone(sig_node)
# Check that the right public key was used:
pub_key_node = doc.find(".//{}".format(etree.QName(XMLDSIG_XML_NS, 'X509Certificate')))
self.assertIsNotNone(pub_key_node)
self.assertIn(pub_key_starts_with, pub_key_node.text)
def _fetch_metadata(self): def _fetch_metadata(self):
""" Fetch and parse the metadata XML at self.METADATA_URL """ """ Fetch and parse the metadata XML at self.METADATA_URL """
......
...@@ -83,8 +83,8 @@ class MongoBackend(BaseBackend): ...@@ -83,8 +83,8 @@ class MongoBackend(BaseBackend):
# TODO: The creation of indexes can be moved to a Django # TODO: The creation of indexes can be moved to a Django
# management command or equivalent. There is also an option to # management command or equivalent. There is also an option to
# run the indexing on the background, without locking. # run the indexing on the background, without locking.
self.collection.ensure_index([('time', pymongo.DESCENDING)]) self.collection.ensure_index([('time', pymongo.DESCENDING)], background=True)
self.collection.ensure_index('event_type') self.collection.ensure_index('event_type', background=True)
def send(self, event): def send(self, event):
"""Insert the event in to the Mongo collection""" """Insert the event in to the Mongo collection"""
......
...@@ -4,12 +4,10 @@ import gzip ...@@ -4,12 +4,10 @@ import gzip
import logging import logging
from django.db import models from django.db import models
from django.db.models.signals import post_init
from django.utils.text import compress_string from django.utils.text import compress_string
from config_models.models import ConfigurationModel from config_models.models import ConfigurationModel
logger = logging.getLogger(__name__) # pylint: disable=invalid-name logger = logging.getLogger(__name__) # pylint: disable=invalid-name
...@@ -25,27 +23,31 @@ class RateLimitConfiguration(ConfigurationModel): ...@@ -25,27 +23,31 @@ class RateLimitConfiguration(ConfigurationModel):
pass pass
def uncompress_string(s): def decompress_string(value):
""" """
Helper function to reverse CompressedTextField.get_prep_value. Helper function to reverse CompressedTextField.get_prep_value.
""" """
try: try:
val = s.encode('utf').decode('base64') val = value.encode('utf').decode('base64')
zbuf = cStringIO.StringIO(val) zbuf = cStringIO.StringIO(val)
zfile = gzip.GzipFile(fileobj=zbuf) zfile = gzip.GzipFile(fileobj=zbuf)
ret = zfile.read() ret = zfile.read()
zfile.close() zfile.close()
except Exception as e: except Exception as e:
logger.error('String decompression failed. There may be corrupted data in the database: %s', e) logger.error('String decompression failed. There may be corrupted data in the database: %s', e)
ret = s ret = value
return ret return ret
class CompressedTextField(models.TextField): class CompressedTextField(models.TextField):
"""transparently compress data before hitting the db and uncompress after fetching""" """ TextField that transparently compresses data when saving to the database, and decompresses the data
when retrieving it from the database. """
__metaclass__ = models.SubfieldBase
def get_prep_value(self, value): def get_prep_value(self, value):
""" Compress the text data. """
if value is not None: if value is not None:
if isinstance(value, unicode): if isinstance(value, unicode):
value = value.encode('utf8') value = value.encode('utf8')
...@@ -53,28 +55,12 @@ class CompressedTextField(models.TextField): ...@@ -53,28 +55,12 @@ class CompressedTextField(models.TextField):
value = value.encode('base64').decode('utf8') value = value.encode('base64').decode('utf8')
return value return value
def post_init(self, instance=None, **kwargs): # pylint: disable=unused-argument def to_python(self, value):
value = self._get_val_from_obj(instance) """ Decompresses the value from the database. """
if value: if isinstance(value, unicode):
setattr(instance, self.attname, value) value = decompress_string(value)
def contribute_to_class(self, cls, name): return value
super(CompressedTextField, self).contribute_to_class(cls, name)
post_init.connect(self.post_init, sender=cls)
def _get_val_from_obj(self, obj):
if obj:
value = uncompress_string(getattr(obj, self.attname))
if value is not None:
try:
value = value.decode('utf8')
except UnicodeDecodeError:
pass
return value
else:
return self.get_default()
else:
return self.get_default()
def south_field_triple(self): def south_field_triple(self):
"""Returns a suitable description of this field for South.""" """Returns a suitable description of this field for South."""
......
"""
Creates Indexes on contentstore and modulestore databases.
"""
from django.core.management.base import BaseCommand
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore
class Command(BaseCommand):
"""
This command will create indexes on the stores used for both contentstore and modulestore.
"""
args = ''
help = 'Creates the indexes for ContentStore and ModuleStore databases'
def handle(self, *args, **options):
contentstore().ensure_indexes()
modulestore().ensure_indexes()
print 'contentstore and modulestore indexes created!'
...@@ -10,7 +10,7 @@ class CorrectMap(object): ...@@ -10,7 +10,7 @@ class CorrectMap(object):
in a capa problem. The response evaluation result for each answer_id includes in a capa problem. The response evaluation result for each answer_id includes
(correctness, npoints, msg, hint, hintmode). (correctness, npoints, msg, hint, hintmode).
- correctness : either 'correct' or 'incorrect' - correctness : 'correct', 'incorrect', or 'partially-correct'
- npoints : None, or integer specifying number of points awarded for this answer_id - npoints : None, or integer specifying number of points awarded for this answer_id
- msg : string (may have HTML) giving extra message response - msg : string (may have HTML) giving extra message response
(displayed below textline or textbox) (displayed below textline or textbox)
...@@ -101,10 +101,23 @@ class CorrectMap(object): ...@@ -101,10 +101,23 @@ class CorrectMap(object):
self.set(k, **correct_map[k]) self.set(k, **correct_map[k])
def is_correct(self, answer_id): def is_correct(self, answer_id):
"""
Takes an answer_id
Returns true if the problem is correct OR partially correct.
"""
if answer_id in self.cmap: if answer_id in self.cmap:
return self.cmap[answer_id]['correctness'] in ['correct', 'partially-correct'] return self.cmap[answer_id]['correctness'] in ['correct', 'partially-correct']
return None return None
def is_partially_correct(self, answer_id):
"""
Takes an answer_id
Returns true if the problem is partially correct.
"""
if answer_id in self.cmap:
return self.cmap[answer_id]['correctness'] == 'partially-correct'
return None
def is_queued(self, answer_id): def is_queued(self, answer_id):
return answer_id in self.cmap and self.cmap[answer_id]['queuestate'] is not None return answer_id in self.cmap and self.cmap[answer_id]['queuestate'] is not None
......
...@@ -85,6 +85,7 @@ class Status(object): ...@@ -85,6 +85,7 @@ class Status(object):
names = { names = {
'correct': _('correct'), 'correct': _('correct'),
'incorrect': _('incorrect'), 'incorrect': _('incorrect'),
'partially-correct': _('partially correct'),
'incomplete': _('incomplete'), 'incomplete': _('incomplete'),
'unanswered': _('unanswered'), 'unanswered': _('unanswered'),
'unsubmitted': _('unanswered'), 'unsubmitted': _('unanswered'),
...@@ -94,6 +95,7 @@ class Status(object): ...@@ -94,6 +95,7 @@ class Status(object):
# Translators: these are tooltips that indicate the state of an assessment question # Translators: these are tooltips that indicate the state of an assessment question
'correct': _('This is correct.'), 'correct': _('This is correct.'),
'incorrect': _('This is incorrect.'), 'incorrect': _('This is incorrect.'),
'partially-correct': _('This is partially correct.'),
'unanswered': _('This is unanswered.'), 'unanswered': _('This is unanswered.'),
'unsubmitted': _('This is unanswered.'), 'unsubmitted': _('This is unanswered.'),
'queued': _('This is being processed.'), 'queued': _('This is being processed.'),
...@@ -896,7 +898,7 @@ class MatlabInput(CodeInput): ...@@ -896,7 +898,7 @@ class MatlabInput(CodeInput):
Right now, we only want this button to show up when a problem has not been Right now, we only want this button to show up when a problem has not been
checked. checked.
""" """
if self.status in ['correct', 'incorrect']: if self.status in ['correct', 'incorrect', 'partially-correct']:
return False return False
else: else:
return True return True
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
<div id="input_${id}_preview" class="equation"></div> <div id="input_${id}_preview" class="equation"></div>
<p id="answer_${id}" class="answer"></p> <p id="answer_${id}" class="answer"></p>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div> </div>
% endif % endif
</div> </div>
...@@ -7,6 +7,8 @@ ...@@ -7,6 +7,8 @@
<% <%
if status == 'correct': if status == 'correct':
correctness = 'correct' correctness = 'correct'
elif status == 'partially-correct':
correctness = 'partially-correct'
elif status == 'incorrect': elif status == 'incorrect':
correctness = 'incorrect' correctness = 'incorrect'
else: else:
...@@ -31,7 +33,7 @@ ...@@ -31,7 +33,7 @@
/> ${choice_description} /> ${choice_description}
% if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ): % if input_type == 'radio' and ( (isinstance(value, basestring) and (choice_id == value)) or (not isinstance(value, basestring) and choice_id in value) ):
% if status in ('correct', 'incorrect') and not show_correctness=='never': % if status in ('correct', 'partially-correct', 'incorrect') and not show_correctness=='never':
<span class="sr status">${choice_description|h} - ${status.display_name}</span> <span class="sr status">${choice_description|h} - ${status.display_name}</span>
% endif % endif
% endif % endif
...@@ -60,4 +62,4 @@ ...@@ -60,4 +62,4 @@
% if msg: % if msg:
<span class="message">${msg|n}</span> <span class="message">${msg|n}</span>
% endif % endif
</form> </form>
\ No newline at end of file
...@@ -20,6 +20,8 @@ ...@@ -20,6 +20,8 @@
correctness = 'correct' correctness = 'correct'
elif status == 'incorrect': elif status == 'incorrect':
correctness = 'incorrect' correctness = 'incorrect'
elif status == 'partially-correct':
correctness = 'partially-correct'
else: else:
correctness = None correctness = None
%> %>
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
<div class="script_placeholder" data-src="/static/js/sylvester.js"></div> <div class="script_placeholder" data-src="/static/js/sylvester.js"></div>
<div class="script_placeholder" data-src="/static/js/crystallography.js"></div> <div class="script_placeholder" data-src="/static/js/crystallography.js"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="status ${status.classname}" id="status_${id}"> <div class="status ${status.classname}" id="status_${id}">
% endif % endif
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
<span class="message">${msg|n}</span> <span class="message">${msg|n}</span>
% endif % endif
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div> </div>
% endif % endif
</section> </section>
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div class="script_placeholder" data-src="/static/js/capa/protex/protex.nocache.js?raw"/> <div class="script_placeholder" data-src="/static/js/capa/protex/protex.nocache.js?raw"/>
<div class="script_placeholder" data-src="${applet_loader}"/> <div class="script_placeholder" data-src="${applet_loader}"/>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}"> <div class="${status.classname}" id="status_${id}">
% endif % endif
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
</p> </p>
<p id="answer_${id}" class="answer"></p> <p id="answer_${id}" class="answer"></p>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div> </div>
% endif % endif
</section> </section>
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
<div class="script_placeholder" data-src="${STATIC_URL}js/capa/drag_and_drop.js"></div> <div class="script_placeholder" data-src="${STATIC_URL}js/capa/drag_and_drop.js"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}"> <div class="${status.classname}" id="status_${id}">
% endif % endif
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
<span class="message">${msg|n}</span> <span class="message">${msg|n}</span>
% endif % endif
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div> </div>
% endif % endif
</div> </div>
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
<div class="script_placeholder" data-src="/static/js/capa/genex/genex.nocache.js?raw"/> <div class="script_placeholder" data-src="/static/js/capa/genex/genex.nocache.js?raw"/>
<div class="script_placeholder" data-src="${applet_loader}"/> <div class="script_placeholder" data-src="${applet_loader}"/>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}"> <div class="${status.classname}" id="status_${id}">
% endif % endif
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
</p> </p>
<p id="answer_${id}" class="answer"></p> <p id="answer_${id}" class="answer"></p>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div> </div>
% endif % endif
</section> </section>
......
<section id="editamoleculeinput_${id}" class="editamoleculeinput"> <section id="editamoleculeinput_${id}" class="editamoleculeinput">
<div class="script_placeholder" data-src="${applet_loader}"/> <div class="script_placeholder" data-src="${applet_loader}"/>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}"> <div class="${status.classname}" id="status_${id}">
% endif % endif
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
<div class="error_message" style="padding: 5px 5px 5px 5px; background-color:#FA6666; height:60px;width:400px; display: none"></div> <div class="error_message" style="padding: 5px 5px 5px 5px; background-color:#FA6666; height:60px;width:400px; display: none"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div> </div>
% endif % endif
</section> </section>
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
<div class="script_placeholder" data-src="${jschannel_loader}"/> <div class="script_placeholder" data-src="${jschannel_loader}"/>
<div class="script_placeholder" data-src="${jsinput_loader}"/> <div class="script_placeholder" data-src="${jsinput_loader}"/>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}"> <div class="${status.classname}" id="status_${id}">
% endif % endif
...@@ -47,7 +47,7 @@ ...@@ -47,7 +47,7 @@
<div class="error_message" style="padding: 5px 5px 5px 5px; background-color:#FA6666; height:60px;width:400px; display: none"></div> <div class="error_message" style="padding: 5px 5px 5px 5px; background-color:#FA6666; height:60px;width:400px; display: none"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div> </div>
% endif % endif
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
<div class="script_placeholder" data-src="${preprocessor['script_src']}"/> <div class="script_placeholder" data-src="${preprocessor['script_src']}"/>
% endif % endif
% if status in ('unsubmitted', 'correct', 'incorrect', 'incomplete'): % if status in ('unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete'):
<div class="${status.classname} ${doinline}" id="status_${id}"> <div class="${status.classname} ${doinline}" id="status_${id}">
% endif % endif
% if hidden: % if hidden:
...@@ -50,7 +50,7 @@ ...@@ -50,7 +50,7 @@
% endif % endif
% if status in ('unsubmitted', 'correct', 'incorrect', 'incomplete'): % if status in ('unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete'):
</div> </div>
% endif % endif
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
<div class="script_placeholder" data-src="/static/js/vsepr/vsepr.js"></div> <div class="script_placeholder" data-src="/static/js/vsepr/vsepr.js"></div>
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
<div class="${status.classname}" id="status_${id}"> <div class="${status.classname}" id="status_${id}">
% endif % endif
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
% if msg: % if msg:
<span class="message">${msg|n}</span> <span class="message">${msg|n}</span>
% endif % endif
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']: % if status in ['unsubmitted', 'correct', 'incorrect', 'partially-correct', 'incomplete']:
</div> </div>
% endif % endif
</section> </section>
...@@ -49,6 +49,9 @@ class ResponseXMLFactory(object): ...@@ -49,6 +49,9 @@ class ResponseXMLFactory(object):
*num_inputs*: The number of input elements *num_inputs*: The number of input elements
to create [DEFAULT: 1] to create [DEFAULT: 1]
*credit_type*: String of comma-separated words specifying the
partial credit grading scheme.
Returns a string representation of the XML tree. Returns a string representation of the XML tree.
""" """
...@@ -58,6 +61,7 @@ class ResponseXMLFactory(object): ...@@ -58,6 +61,7 @@ class ResponseXMLFactory(object):
script = kwargs.get('script', None) script = kwargs.get('script', None)
num_responses = kwargs.get('num_responses', 1) num_responses = kwargs.get('num_responses', 1)
num_inputs = kwargs.get('num_inputs', 1) num_inputs = kwargs.get('num_inputs', 1)
credit_type = kwargs.get('credit_type', None)
# The root is <problem> # The root is <problem>
root = etree.Element("problem") root = etree.Element("problem")
...@@ -75,6 +79,11 @@ class ResponseXMLFactory(object): ...@@ -75,6 +79,11 @@ class ResponseXMLFactory(object):
# Add the response(s) # Add the response(s)
for __ in range(int(num_responses)): for __ in range(int(num_responses)):
response_element = self.create_response_element(**kwargs) response_element = self.create_response_element(**kwargs)
# Set partial credit
if credit_type is not None:
response_element.set('partial_credit', str(credit_type))
root.append(response_element) root.append(response_element)
# Add input elements # Add input elements
...@@ -132,6 +141,10 @@ class ResponseXMLFactory(object): ...@@ -132,6 +141,10 @@ class ResponseXMLFactory(object):
*choice_names": List of strings identifying the choices. *choice_names": List of strings identifying the choices.
If specified, you must ensure that If specified, you must ensure that
len(choice_names) == len(choices) len(choice_names) == len(choices)
*points*: List of strings giving partial credit values (0-1)
for each choice. Interpreted as floats in problem.
If specified, ensure len(points) == len(choices)
""" """
# Names of group elements # Names of group elements
group_element_names = { group_element_names = {
...@@ -144,15 +157,23 @@ class ResponseXMLFactory(object): ...@@ -144,15 +157,23 @@ class ResponseXMLFactory(object):
choices = kwargs.get('choices', [True]) choices = kwargs.get('choices', [True])
choice_type = kwargs.get('choice_type', 'multiple') choice_type = kwargs.get('choice_type', 'multiple')
choice_names = kwargs.get('choice_names', [None] * len(choices)) choice_names = kwargs.get('choice_names', [None] * len(choices))
points = kwargs.get('points', [None] * len(choices))
# Create the <choicegroup>, <checkboxgroup>, or <radiogroup> element # Create the <choicegroup>, <checkboxgroup>, or <radiogroup> element
assert choice_type in group_element_names assert choice_type in group_element_names
group_element = etree.Element(group_element_names[choice_type]) group_element = etree.Element(group_element_names[choice_type])
# Create the <choice> elements # Create the <choice> elements
for (correct_val, name) in zip(choices, choice_names): for (correct_val, name, pointval) in zip(choices, choice_names, points):
choice_element = etree.SubElement(group_element, "choice") choice_element = etree.SubElement(group_element, "choice")
choice_element.set("correct", "true" if correct_val else "false") if correct_val is True:
correctness = 'true'
elif correct_val is False:
correctness = 'false'
elif 'partial' in correct_val:
correctness = 'partial'
choice_element.set('correct', correctness)
# Add a name identifying the choice, if one exists # Add a name identifying the choice, if one exists
# For simplicity, we use the same string as both the # For simplicity, we use the same string as both the
...@@ -161,6 +182,10 @@ class ResponseXMLFactory(object): ...@@ -161,6 +182,10 @@ class ResponseXMLFactory(object):
choice_element.text = str(name) choice_element.text = str(name)
choice_element.set("name", str(name)) choice_element.set("name", str(name))
# Add point values for partially-correct choices.
if pointval:
choice_element.set("point_value", str(pointval))
return group_element return group_element
...@@ -176,10 +201,22 @@ class NumericalResponseXMLFactory(ResponseXMLFactory): ...@@ -176,10 +201,22 @@ class NumericalResponseXMLFactory(ResponseXMLFactory):
*tolerance*: The tolerance within which a response *tolerance*: The tolerance within which a response
is considered correct. Can be a decimal (e.g. "0.01") is considered correct. Can be a decimal (e.g. "0.01")
or percentage (e.g. "2%") or percentage (e.g. "2%")
*credit_type*: String of comma-separated words specifying the
partial credit grading scheme.
*partial_range*: The multiplier for the tolerance that will
still provide partial credit in the "close" grading style
*partial_answers*: A string of comma-separated alternate
answers that will receive partial credit in the "list" style
""" """
answer = kwargs.get('answer', None) answer = kwargs.get('answer', None)
tolerance = kwargs.get('tolerance', None) tolerance = kwargs.get('tolerance', None)
credit_type = kwargs.get('credit_type', None)
partial_range = kwargs.get('partial_range', None)
partial_answers = kwargs.get('partial_answers', None)
response_element = etree.Element('numericalresponse') response_element = etree.Element('numericalresponse')
...@@ -193,6 +230,13 @@ class NumericalResponseXMLFactory(ResponseXMLFactory): ...@@ -193,6 +230,13 @@ class NumericalResponseXMLFactory(ResponseXMLFactory):
responseparam_element = etree.SubElement(response_element, 'responseparam') responseparam_element = etree.SubElement(response_element, 'responseparam')
responseparam_element.set('type', 'tolerance') responseparam_element.set('type', 'tolerance')
responseparam_element.set('default', str(tolerance)) responseparam_element.set('default', str(tolerance))
if partial_range is not None and 'close' in credit_type:
responseparam_element.set('partial_range', str(partial_range))
if partial_answers is not None and 'list' in credit_type:
# The line below throws a false positive pylint violation, so it's excepted.
responseparam_element = etree.SubElement(response_element, 'responseparam') # pylint: disable=E1101
responseparam_element.set('partial_answers', partial_answers)
return response_element return response_element
...@@ -629,15 +673,25 @@ class OptionResponseXMLFactory(ResponseXMLFactory): ...@@ -629,15 +673,25 @@ class OptionResponseXMLFactory(ResponseXMLFactory):
*options*: a list of possible options the user can choose from [REQUIRED] *options*: a list of possible options the user can choose from [REQUIRED]
You must specify at least 2 options. You must specify at least 2 options.
*correct_option*: the correct choice from the list of options [REQUIRED] *correct_option*: a string with comma-separated correct choices [REQUIRED]
*partial_option*: a string with comma-separated partially-correct choices
*point_values*: a string with comma-separated values (0-1) that give the
partial credit values in the "points" grading scheme.
Must have one per partial option.
*credit_type*: String of comma-separated words specifying the
partial credit grading scheme.
""" """
options_list = kwargs.get('options', None) options_list = kwargs.get('options', None)
correct_option = kwargs.get('correct_option', None) correct_option = kwargs.get('correct_option', None)
partial_option = kwargs.get('partial_option', None)
point_values = kwargs.get('point_values', None)
credit_type = kwargs.get('credit_type', None)
assert options_list and correct_option assert options_list and correct_option
assert len(options_list) > 1 assert len(options_list) > 1
assert correct_option in options_list for option in correct_option.split(','):
assert option.strip() in options_list
# Create the <optioninput> element # Create the <optioninput> element
optioninput_element = etree.Element("optioninput") optioninput_element = etree.Element("optioninput")
...@@ -651,6 +705,15 @@ class OptionResponseXMLFactory(ResponseXMLFactory): ...@@ -651,6 +705,15 @@ class OptionResponseXMLFactory(ResponseXMLFactory):
# Set the "correct" attribute # Set the "correct" attribute
optioninput_element.set('correct', str(correct_option)) optioninput_element.set('correct', str(correct_option))
# If we have 'points'-style partial credit...
if 'points' in str(credit_type):
# Set the "partial" attribute
optioninput_element.set('partial', str(partial_option))
# Set the "point_values" attribute, if it's specified.
if point_values is not None:
optioninput_element.set('point_values', str(point_values))
return optioninput_element return optioninput_element
......
...@@ -17,7 +17,7 @@ class CorrectMapTest(unittest.TestCase): ...@@ -17,7 +17,7 @@ class CorrectMapTest(unittest.TestCase):
self.cmap = CorrectMap() self.cmap = CorrectMap()
def test_set_input_properties(self): def test_set_input_properties(self):
# Set the correctmap properties for two inputs # Set the correctmap properties for three inputs
self.cmap.set( self.cmap.set(
answer_id='1_2_1', answer_id='1_2_1',
correctness='correct', correctness='correct',
...@@ -41,15 +41,34 @@ class CorrectMapTest(unittest.TestCase): ...@@ -41,15 +41,34 @@ class CorrectMapTest(unittest.TestCase):
queuestate=None queuestate=None
) )
self.cmap.set(
answer_id='3_2_1',
correctness='partially-correct',
npoints=3,
msg=None,
hint=None,
hintmode=None,
queuestate=None
)
# Assert that each input has the expected properties # Assert that each input has the expected properties
self.assertTrue(self.cmap.is_correct('1_2_1')) self.assertTrue(self.cmap.is_correct('1_2_1'))
self.assertFalse(self.cmap.is_correct('2_2_1')) self.assertFalse(self.cmap.is_correct('2_2_1'))
self.assertTrue(self.cmap.is_correct('3_2_1'))
self.assertTrue(self.cmap.is_partially_correct('3_2_1'))
self.assertFalse(self.cmap.is_partially_correct('2_2_1'))
# Intentionally testing an item that's not in cmap.
self.assertFalse(self.cmap.is_partially_correct('9_2_1'))
self.assertEqual(self.cmap.get_correctness('1_2_1'), 'correct') self.assertEqual(self.cmap.get_correctness('1_2_1'), 'correct')
self.assertEqual(self.cmap.get_correctness('2_2_1'), 'incorrect') self.assertEqual(self.cmap.get_correctness('2_2_1'), 'incorrect')
self.assertEqual(self.cmap.get_correctness('3_2_1'), 'partially-correct')
self.assertEqual(self.cmap.get_npoints('1_2_1'), 5) self.assertEqual(self.cmap.get_npoints('1_2_1'), 5)
self.assertEqual(self.cmap.get_npoints('2_2_1'), 0) self.assertEqual(self.cmap.get_npoints('2_2_1'), 0)
self.assertEqual(self.cmap.get_npoints('3_2_1'), 3)
self.assertEqual(self.cmap.get_msg('1_2_1'), 'Test message') self.assertEqual(self.cmap.get_msg('1_2_1'), 'Test message')
self.assertEqual(self.cmap.get_msg('2_2_1'), None) self.assertEqual(self.cmap.get_msg('2_2_1'), None)
...@@ -83,6 +102,8 @@ class CorrectMapTest(unittest.TestCase): ...@@ -83,6 +102,8 @@ class CorrectMapTest(unittest.TestCase):
# 3) incorrect, 5 points # 3) incorrect, 5 points
# 4) incorrect, None points # 4) incorrect, None points
# 5) correct, 0 points # 5) correct, 0 points
# 4) partially correct, 2.5 points
# 5) partially correct, None points
self.cmap.set( self.cmap.set(
answer_id='1_2_1', answer_id='1_2_1',
correctness='correct', correctness='correct',
...@@ -113,15 +134,30 @@ class CorrectMapTest(unittest.TestCase): ...@@ -113,15 +134,30 @@ class CorrectMapTest(unittest.TestCase):
npoints=0 npoints=0
) )
self.cmap.set(
answer_id='6_2_1',
correctness='partially-correct',
npoints=2.5
)
self.cmap.set(
answer_id='7_2_1',
correctness='partially-correct',
npoints=None
)
# Assert that we get the expected points # Assert that we get the expected points
# If points assigned --> npoints # If points assigned --> npoints
# If no points assigned and correct --> 1 point # If no points assigned and correct --> 1 point
# If no points assigned and partially correct --> 1 point
# If no points assigned and incorrect --> 0 points # If no points assigned and incorrect --> 0 points
self.assertEqual(self.cmap.get_npoints('1_2_1'), 5.3) self.assertEqual(self.cmap.get_npoints('1_2_1'), 5.3)
self.assertEqual(self.cmap.get_npoints('2_2_1'), 1) self.assertEqual(self.cmap.get_npoints('2_2_1'), 1)
self.assertEqual(self.cmap.get_npoints('3_2_1'), 5) self.assertEqual(self.cmap.get_npoints('3_2_1'), 5)
self.assertEqual(self.cmap.get_npoints('4_2_1'), 0) self.assertEqual(self.cmap.get_npoints('4_2_1'), 0)
self.assertEqual(self.cmap.get_npoints('5_2_1'), 0) self.assertEqual(self.cmap.get_npoints('5_2_1'), 0)
self.assertEqual(self.cmap.get_npoints('6_2_1'), 2.5)
self.assertEqual(self.cmap.get_npoints('7_2_1'), 1)
def test_set_overall_message(self): def test_set_overall_message(self):
......
...@@ -393,36 +393,80 @@ class MongoContentStore(ContentStore): ...@@ -393,36 +393,80 @@ class MongoContentStore(ContentStore):
return dbkey return dbkey
def ensure_indexes(self): def ensure_indexes(self):
# Index needed thru 'category' by `_get_all_content_for_course` and others. That query also takes a sort # Index needed thru 'category' by `_get_all_content_for_course` and others. That query also takes a sort
# which can be `uploadDate`, `display_name`, # which can be `uploadDate`, `display_name`,
self.fs_files.create_index( self.fs_files.create_index(
[('_id.org', pymongo.ASCENDING), ('_id.course', pymongo.ASCENDING), ('_id.name', pymongo.ASCENDING)], [
sparse=True ('_id.tag', pymongo.ASCENDING),
('_id.org', pymongo.ASCENDING),
('_id.course', pymongo.ASCENDING),
('_id.category', pymongo.ASCENDING)
],
sparse=True,
background=True
)
self.fs_files.create_index(
[
('content_son.org', pymongo.ASCENDING),
('content_son.course', pymongo.ASCENDING),
('uploadDate', pymongo.DESCENDING)
],
sparse=True,
background=True
)
self.fs_files.create_index(
[
('_id.org', pymongo.ASCENDING),
('_id.course', pymongo.ASCENDING),
('_id.name', pymongo.ASCENDING)
],
sparse=True,
background=True
) )
self.fs_files.create_index( self.fs_files.create_index(
[('content_son.org', pymongo.ASCENDING), ('content_son.course', pymongo.ASCENDING), [
('content_son.name', pymongo.ASCENDING)], ('content_son.org', pymongo.ASCENDING),
sparse=True ('content_son.course', pymongo.ASCENDING),
('content_son.name', pymongo.ASCENDING)
],
sparse=True,
background=True
) )
self.fs_files.create_index( self.fs_files.create_index(
[('_id.org', pymongo.ASCENDING), ('_id.course', pymongo.ASCENDING), ('uploadDate', pymongo.ASCENDING)], [
sparse=True ('_id.org', pymongo.ASCENDING),
('_id.course', pymongo.ASCENDING),
('uploadDate', pymongo.ASCENDING)
],
sparse=True,
background=True
) )
self.fs_files.create_index( self.fs_files.create_index(
[('_id.org', pymongo.ASCENDING), ('_id.course', pymongo.ASCENDING), ('display_name', pymongo.ASCENDING)], [
sparse=True ('_id.org', pymongo.ASCENDING),
('_id.course', pymongo.ASCENDING),
('display_name', pymongo.ASCENDING)
],
sparse=True,
background=True
) )
self.fs_files.create_index( self.fs_files.create_index(
[('content_son.org', pymongo.ASCENDING), ('content_son.course', pymongo.ASCENDING), [
('uploadDate', pymongo.ASCENDING)], ('content_son.org', pymongo.ASCENDING),
sparse=True ('content_son.course', pymongo.ASCENDING),
('uploadDate', pymongo.ASCENDING)
],
sparse=True,
background=True
) )
self.fs_files.create_index( self.fs_files.create_index(
[('content_son.org', pymongo.ASCENDING), ('content_son.course', pymongo.ASCENDING), [
('display_name', pymongo.ASCENDING)], ('content_son.org', pymongo.ASCENDING),
sparse=True ('content_son.course', pymongo.ASCENDING),
('display_name', pymongo.ASCENDING)
],
sparse=True,
background=True
) )
......
...@@ -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
...@@ -24,6 +24,7 @@ ...@@ -24,6 +24,7 @@
$annotation-yellow: rgba(255,255,10,0.3); $annotation-yellow: rgba(255,255,10,0.3);
$color-copy-tip: rgb(100,100,100); $color-copy-tip: rgb(100,100,100);
$correct: $green-d1; $correct: $green-d1;
$partiallycorrect: $green-d1;
$incorrect: $red; $incorrect: $red;
// +Extends - Capa // +Extends - Capa
...@@ -75,6 +76,11 @@ h2 { ...@@ -75,6 +76,11 @@ h2 {
color: $correct; color: $correct;
} }
.feedback-hint-partially-correct {
margin-top: ($baseline/2);
color: $partiallycorrect;
}
.feedback-hint-incorrect { .feedback-hint-incorrect {
margin-top: ($baseline/2); margin-top: ($baseline/2);
color: $incorrect; color: $incorrect;
...@@ -174,6 +180,16 @@ div.problem { ...@@ -174,6 +180,16 @@ div.problem {
} }
} }
&.choicegroup_partially-correct {
@include status-icon($partiallycorrect, "\f00c");
border: 2px solid $partiallycorrect;
// keep green for correct answers on hover.
&:hover {
border-color: $partiallycorrect;
}
}
&.choicegroup_incorrect { &.choicegroup_incorrect {
@include status-icon($incorrect, "\f00d"); @include status-icon($incorrect, "\f00d");
border: 2px solid $incorrect; border: 2px solid $incorrect;
...@@ -227,6 +243,11 @@ div.problem { ...@@ -227,6 +243,11 @@ div.problem {
@include status-icon($correct, "\f00c"); @include status-icon($correct, "\f00c");
} }
// CASE: partially correct answer
&.partially-correct {
@include status-icon($partiallycorrect, "\f00c");
}
// CASE: incorrect answer // CASE: incorrect answer
&.incorrect { &.incorrect {
@include status-icon($incorrect, "\f00d"); @include status-icon($incorrect, "\f00d");
...@@ -338,6 +359,19 @@ div.problem { ...@@ -338,6 +359,19 @@ div.problem {
} }
} }
&.partially-correct, &.ui-icon-check {
p.status {
display: inline-block;
width: 25px;
height: 20px;
background: url('../images/partially-correct-icon.png') center center no-repeat;
}
input {
border-color: $partiallycorrect;
}
}
&.processing { &.processing {
p.status { p.status {
display: inline-block; display: inline-block;
...@@ -713,7 +747,7 @@ div.problem { ...@@ -713,7 +747,7 @@ div.problem {
height: 46px; height: 46px;
} }
> .incorrect, .correct, .unanswered { > .incorrect, .partially-correct, .correct, .unanswered {
.status { .status {
display: inline-block; display: inline-block;
...@@ -734,6 +768,18 @@ div.problem { ...@@ -734,6 +768,18 @@ div.problem {
} }
} }
// CASE: partially correct answer
> .partially-correct {
input {
border: 2px solid $partiallycorrect;
}
.status {
@include status-icon($partiallycorrect, "\f00c");
}
}
// CASE: correct answer // CASE: correct answer
> .correct { > .correct {
...@@ -775,7 +821,7 @@ div.problem { ...@@ -775,7 +821,7 @@ div.problem {
.indicator-container { .indicator-container {
display: inline-block; display: inline-block;
.status.correct:after, .status.incorrect:after, .status.unanswered:after { .status.correct:after, .status.partially-correct:after, .status.incorrect:after, .status.unanswered:after {
@include margin-left(0); @include margin-left(0);
} }
} }
...@@ -941,6 +987,20 @@ div.problem { ...@@ -941,6 +987,20 @@ div.problem {
} }
} }
.detailed-targeted-feedback-partially-correct {
> p:first-child {
@extend %t-strong;
color: $partiallycorrect;
text-transform: uppercase;
font-style: normal;
font-size: 0.9em;
}
p:last-child {
margin-bottom: 0;
}
}
.detailed-targeted-feedback-correct { .detailed-targeted-feedback-correct {
> p:first-child { > p:first-child {
@extend %t-strong; @extend %t-strong;
...@@ -1135,6 +1195,14 @@ div.problem { ...@@ -1135,6 +1195,14 @@ div.problem {
} }
} }
.result-partially-correct {
background: url('../images/partially-correct-icon.png') left 20px no-repeat;
.result-actual-output {
color: #090;
}
}
.result-incorrect { .result-incorrect {
background: url('../images/incorrect-icon.png') left 20px no-repeat; background: url('../images/incorrect-icon.png') left 20px no-repeat;
...@@ -1340,6 +1408,14 @@ div.problem { ...@@ -1340,6 +1408,14 @@ div.problem {
} }
} }
label.choicetextgroup_partially-correct, section.choicetextgroup_partially-correct {
@extend label.choicegroup_partially-correct;
input[type="text"] {
border-color: $partiallycorrect;
}
}
label.choicetextgroup_incorrect, section.choicetextgroup_incorrect { label.choicetextgroup_incorrect, section.choicetextgroup_incorrect {
@extend label.choicegroup_incorrect; @extend label.choicegroup_incorrect;
} }
......
...@@ -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,
......
...@@ -1914,21 +1914,25 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo ...@@ -1914,21 +1914,25 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
""" """
# Because we often query for some subset of the id, we define this index: # Because we often query for some subset of the id, we define this index:
self.collection.create_index([ self.collection.create_index(
('_id.org', pymongo.ASCENDING), [
('_id.course', pymongo.ASCENDING), ('_id.tag', pymongo.ASCENDING),
('_id.category', pymongo.ASCENDING), ('_id.org', pymongo.ASCENDING),
('_id.name', pymongo.ASCENDING), ('_id.course', pymongo.ASCENDING),
]) ('_id.category', pymongo.ASCENDING),
('_id.name', pymongo.ASCENDING),
('_id.revision', pymongo.ASCENDING),
],
background=True)
# Because we often scan for all category='course' regardless of the value of the other fields: # Because we often scan for all category='course' regardless of the value of the other fields:
self.collection.create_index('_id.category') self.collection.create_index('_id.category', background=True)
# Because lms calls get_parent_locations frequently (for path generation): # Because lms calls get_parent_locations frequently (for path generation):
self.collection.create_index('definition.children', sparse=True) self.collection.create_index('definition.children', sparse=True, background=True)
# To allow prioritizing draft vs published material # To allow prioritizing draft vs published material
self.collection.create_index('_id.revision') self.collection.create_index('_id.revision', background=True)
# Some overrides that still need to be implemented by subclasses # Some overrides that still need to be implemented by subclasses
def convert_to_draft(self, location, user_id): def convert_to_draft(self, location, user_id):
......
...@@ -532,5 +532,6 @@ class MongoConnection(object): ...@@ -532,5 +532,6 @@ class MongoConnection(object):
('course', pymongo.ASCENDING), ('course', pymongo.ASCENDING),
('run', pymongo.ASCENDING) ('run', pymongo.ASCENDING)
], ],
unique=True unique=True,
background=True
) )
...@@ -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
......
...@@ -83,6 +83,7 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) { ...@@ -83,6 +83,7 @@ define(['sinon', 'underscore', 'URI'], function(sinon, _, URI) {
requestIndex = requests.length - 1; requestIndex = requests.length - 1;
} }
request = requests[requestIndex]; request = requests[requestIndex];
expect(new URI(request.url).path()).toEqual(expectedUrl);
parameters = new URI(request.url).query(true); parameters = new URI(request.url).query(true);
delete parameters._; // Ignore the cache-busting argument delete parameters._; // Ignore the cache-busting argument
expect(parameters).toEqual(expectedParameters); expect(parameters).toEqual(expectedParameters);
......
...@@ -91,7 +91,7 @@ from django.core.urlresolvers import reverse ...@@ -91,7 +91,7 @@ from django.core.urlresolvers import reverse
<ul class="list-actions"> <ul class="list-actions">
<li class="action action-select"> <li class="action action-select">
<input type="hidden" name="contribution" value="${min_price}" /> <input type="hidden" name="contribution" value="${min_price}" />
<input type="submit" name="verified_mode" value="${_('Pursue a Verified Certificate')} ($${min_price})" /> <input type="submit" name="verified_mode" value="${_('Pursue a Verified Certificate')} ($${min_price} USD)" />
</li> </li>
</ul> </ul>
</div> </div>
...@@ -117,7 +117,7 @@ from django.core.urlresolvers import reverse ...@@ -117,7 +117,7 @@ from django.core.urlresolvers import reverse
<ul class="list-actions"> <ul class="list-actions">
<li class="action action-select"> <li class="action action-select">
<input type="hidden" name="contribution" value="${min_price}" /> <input type="hidden" name="contribution" value="${min_price}" />
<input type="submit" name="verified_mode" value="${_('Pursue a Verified Certificate')} ($${min_price})" /> <input type="submit" name="verified_mode" value="${_('Pursue a Verified Certificate')} ($${min_price} USD)" />
</li> </li>
</ul> </ul>
</div> </div>
......
...@@ -402,6 +402,7 @@ class InlineDiscussionPage(PageObject): ...@@ -402,6 +402,7 @@ class InlineDiscussionPage(PageObject):
return self.q(css=self._discussion_selector + " " + selector) return self.q(css=self._discussion_selector + " " + selector)
def is_browser_on_page(self): def is_browser_on_page(self):
self.wait_for_ajax()
return self.q(css=self._discussion_selector).present return self.q(css=self._discussion_selector).present
def is_discussion_expanded(self): def is_discussion_expanded(self):
......
...@@ -64,6 +64,12 @@ class ProblemPage(PageObject): ...@@ -64,6 +64,12 @@ class ProblemPage(PageObject):
""" """
self.q(css='div.problem div.capa_inputtype.textline input').fill(text) self.q(css='div.problem div.capa_inputtype.textline input').fill(text)
def fill_answer_numerical(self, text):
"""
Fill in the answer to a numerical problem.
"""
self.q(css='div.problem section.inputtype input').fill(text)
def click_check(self): def click_check(self):
""" """
Click the Check button! Click the Check button!
...@@ -84,6 +90,24 @@ class ProblemPage(PageObject): ...@@ -84,6 +90,24 @@ class ProblemPage(PageObject):
""" """
return self.q(css="div.problem div.capa_inputtype.textline div.correct span.status").is_present() return self.q(css="div.problem div.capa_inputtype.textline div.correct span.status").is_present()
def simpleprob_is_correct(self):
"""
Is there a "correct" status showing? Works with simple problem types.
"""
return self.q(css="div.problem section.inputtype div.correct span.status").is_present()
def simpleprob_is_partially_correct(self):
"""
Is there a "partially correct" status showing? Works with simple problem types.
"""
return self.q(css="div.problem section.inputtype div.partially-correct span.status").is_present()
def simpleprob_is_incorrect(self):
"""
Is there an "incorrect" status showing? Works with simple problem types.
"""
return self.q(css="div.problem section.inputtype div.incorrect span.status").is_present()
def click_clarification(self, index=0): def click_clarification(self, index=0):
""" """
Click on an inline icon that can be included in problem text using an HTML <clarification> element: Click on an inline icon that can be included in problem text using an HTML <clarification> element:
......
...@@ -261,6 +261,7 @@ class TeamPage(CoursePage, PaginatedUIMixin): ...@@ -261,6 +261,7 @@ class TeamPage(CoursePage, PaginatedUIMixin):
def is_browser_on_page(self): def is_browser_on_page(self):
"""Check if we're on the teams list page for a particular team.""" """Check if we're on the teams list page for a particular team."""
self.wait_for_ajax()
if self.team: if self.team:
if not self.url.endswith(self.url_path): if not self.url.endswith(self.url_path):
return False return False
......
...@@ -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
......
...@@ -213,3 +213,35 @@ class ProblemWithMathjax(ProblemsTest): ...@@ -213,3 +213,35 @@ class ProblemWithMathjax(ProblemsTest):
self.assertIn("Hint (2 of 2): mathjax should work2", problem_page.hint_text) self.assertIn("Hint (2 of 2): mathjax should work2", problem_page.hint_text)
self.assertTrue(problem_page.mathjax_rendered_in_hint, "MathJax did not rendered in problem hint") self.assertTrue(problem_page.mathjax_rendered_in_hint, "MathJax did not rendered in problem hint")
class ProblemPartialCredit(ProblemsTest):
"""
Makes sure that the partial credit is appearing properly.
"""
def get_problem(self):
"""
Create a problem with partial credit.
"""
xml = dedent("""
<problem>
<p>The answer is 1. Partial credit for -1.</p>
<numericalresponse answer="1" partial_credit="list">
<formulaequationinput label="How many miles away from Earth is the sun? Use scientific notation to answer." />
<responseparam type="tolerance" default="0.01" />
<responseparam partial_answers="-1" />
</numericalresponse>
</problem>
""")
return XBlockFixtureDesc('problem', 'PARTIAL CREDIT TEST PROBLEM', data=xml)
def test_partial_credit(self):
"""
Test that we can see the partial credit value and feedback.
"""
self.courseware_page.visit()
problem_page = ProblemPage(self.browser)
self.assertEqual(problem_page.problem_name, 'PARTIAL CREDIT TEST PROBLEM')
problem_page.fill_answer_numerical('-1')
problem_page.click_check()
self.assertTrue(problem_page.simpleprob_is_partially_correct())
...@@ -4,6 +4,7 @@ Acceptance tests for the teams feature. ...@@ -4,6 +4,7 @@ Acceptance tests for the teams feature.
import json import json
import ddt import ddt
from flaky import flaky
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from uuid import uuid4 from uuid import uuid4
...@@ -952,9 +953,11 @@ class TeamPageTest(TeamsTabBase): ...@@ -952,9 +953,11 @@ class TeamPageTest(TeamsTabBase):
self._set_team_configuration_and_membership() self._set_team_configuration_and_membership()
self.team_page.visit() self.team_page.visit()
learner_name = self.team_page.first_member_username
self.team_page.click_first_profile_image() self.team_page.click_first_profile_image()
learner_profile_page = LearnerProfilePage(self.browser, self.team_page.first_member_username) learner_profile_page = LearnerProfilePage(self.browser, learner_name)
learner_profile_page.wait_for_page() learner_profile_page.wait_for_page()
learner_profile_page.wait_for_field('username') learner_profile_page.wait_for_field('username')
self.assertTrue(learner_profile_page.field_is_visible('username')) self.assertTrue(learner_profile_page.field_is_visible('username'))
......
...@@ -80,8 +80,8 @@ msgid "" ...@@ -80,8 +80,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: edx-platform\n" "Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2015-08-14 13:42+0000\n" "POT-Creation-Date: 2015-08-21 14:17+0000\n"
"PO-Revision-Date: 2015-08-14 13:44+0000\n" "PO-Revision-Date: 2015-08-21 02:41+0000\n"
"Last-Translator: Sarina Canelake <sarina@edx.org>\n" "Last-Translator: Sarina Canelake <sarina@edx.org>\n"
"Language-Team: Arabic (http://www.transifex.com/open-edx/edx-platform/language/ar/)\n" "Language-Team: Arabic (http://www.transifex.com/open-edx/edx-platform/language/ar/)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
...@@ -2193,7 +2193,7 @@ msgstr "جرى الإسقاط على الهدف" ...@@ -2193,7 +2193,7 @@ msgstr "جرى الإسقاط على الهدف"
#: common/static/js/src/jquery.timeago.locale.js #: common/static/js/src/jquery.timeago.locale.js
#, c-format #, c-format
msgid "%s ago" msgid "%s ago"
msgstr "منذ %s ثوانٍ " msgstr "منذ %s "
#. Translators: %s will be a time quantity, such as "4 minutes" or "1 day" #. Translators: %s will be a time quantity, such as "4 minutes" or "1 day"
#: common/static/js/src/jquery.timeago.locale.js #: common/static/js/src/jquery.timeago.locale.js
...@@ -2517,7 +2517,7 @@ msgid "Team description cannot have more than 300 characters." ...@@ -2517,7 +2517,7 @@ msgid "Team description cannot have more than 300 characters."
msgstr "" msgstr ""
#: lms/djangoapps/teams/static/teams/js/views/my_teams.js #: lms/djangoapps/teams/static/teams/js/views/my_teams.js
msgid "You are not currently a member of any teams." msgid "You are not currently a member of any team."
msgstr "" msgstr ""
#: lms/djangoapps/teams/static/teams/js/views/team_card.js #: lms/djangoapps/teams/static/teams/js/views/team_card.js
...@@ -5985,7 +5985,7 @@ msgstr "هل أنت جديد على %(platformName)s؟" ...@@ -5985,7 +5985,7 @@ msgstr "هل أنت جديد على %(platformName)s؟"
#: lms/templates/student_account/login.underscore #: lms/templates/student_account/login.underscore
msgid "Create an account" msgid "Create an account"
msgstr "إنشاء حساب " msgstr " أنشئ حساباً جديداً اﻵن."
#: lms/templates/student_account/password_reset.underscore #: lms/templates/student_account/password_reset.underscore
#: lms/templates/student_account/register.underscore #: lms/templates/student_account/register.underscore
...@@ -6028,12 +6028,12 @@ msgstr "" ...@@ -6028,12 +6028,12 @@ msgstr ""
#: lms/templates/student_account/register.underscore #: lms/templates/student_account/register.underscore
msgid "Create an account using" msgid "Create an account using"
msgstr "إنشاء حساب باستخدام" msgstr "أنشئ حساباً باستخدام"
#: lms/templates/student_account/register.underscore #: lms/templates/student_account/register.underscore
#, python-format #, python-format
msgid "Create account using %(providerName)s." msgid "Create account using %(providerName)s."
msgstr "إنشاء حساب باستخدام %(providerName)s." msgstr "أنشئ حساباً باستخدام %(providerName)s."
#: lms/templates/student_account/register.underscore #: lms/templates/student_account/register.underscore
msgid "or create a new one here" msgid "or create a new one here"
...@@ -6568,7 +6568,7 @@ msgstr "الحالة" ...@@ -6568,7 +6568,7 @@ msgstr "الحالة"
#: cms/templates/js/add-xblock-component-menu-problem.underscore #: cms/templates/js/add-xblock-component-menu-problem.underscore
msgid "Common Problem Types" msgid "Common Problem Types"
msgstr "أنواع المسائل الشائعة" msgstr "المسائل الشائعة"
#: cms/templates/js/add-xblock-component.underscore #: cms/templates/js/add-xblock-component.underscore
msgid "Add New Component" msgid "Add New Component"
...@@ -7442,6 +7442,25 @@ msgstr "" ...@@ -7442,6 +7442,25 @@ msgstr ""
"يُرجى التحقّق من الملاحظات والتعليقات التالية بشأن التحقّق، وتطبيقها على " "يُرجى التحقّق من الملاحظات والتعليقات التالية بشأن التحقّق، وتطبيقها على "
"الإعدادات الخاصة بمساقك:" "الإعدادات الخاصة بمساقك:"
#: cms/templates/js/verification-access-editor.underscore
msgid "Verification Checkpoint"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid "Must complete verification checkpoint"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid "Verification checkpoint to be completed"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid ""
"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."
msgstr ""
#: cms/templates/js/xblock-string-field-editor.underscore #: cms/templates/js/xblock-string-field-editor.underscore
msgid "Edit the name" msgid "Edit the name"
msgstr "تعديل الاسم" msgstr "تعديل الاسم"
......
...@@ -26,8 +26,8 @@ msgid "" ...@@ -26,8 +26,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: 0.1a\n" "Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2015-08-14 13:51+0000\n" "POT-Creation-Date: 2015-08-21 14:38+0000\n"
"PO-Revision-Date: 2015-08-14 13:52:03.060189\n" "PO-Revision-Date: 2015-08-21 14:39:20.002663\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: openedx-translation <openedx-translation@googlegroups.com>\n" "Language-Team: openedx-translation <openedx-translation@googlegroups.com>\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
...@@ -2177,13 +2177,13 @@ msgstr "dröppéd ön tärgét Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмє ...@@ -2177,13 +2177,13 @@ msgstr "dröppéd ön tärgét Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмє
#: common/static/js/src/jquery.timeago.locale.js #: common/static/js/src/jquery.timeago.locale.js
#, c-format #, c-format
msgid "%s ago" msgid "%s ago"
msgstr "%s ägö Ⱡ'σяєм ιρѕυ#" msgstr "%s ägö Ⱡ'σяєм ιρѕυм #"
#. Translators: %s will be a time quantity, such as "4 minutes" or "1 day" #. Translators: %s will be a time quantity, such as "4 minutes" or "1 day"
#: common/static/js/src/jquery.timeago.locale.js #: common/static/js/src/jquery.timeago.locale.js
#, c-format #, c-format
msgid "%s from now" msgid "%s from now"
msgstr "%s fröm nöw Ⱡ'σяєм ιρѕυм ∂σłσя #" msgstr "%s fröm nöw Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#"
#: common/static/js/src/jquery.timeago.locale.js #: common/static/js/src/jquery.timeago.locale.js
msgid "less than a minute" msgid "less than a minute"
...@@ -2197,8 +2197,8 @@ msgstr "äßöüt ä mïnüté Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#" ...@@ -2197,8 +2197,8 @@ msgstr "äßöüt ä mïnüté Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#"
#, c-format #, c-format
msgid "%d minute" msgid "%d minute"
msgid_plural "%d minutes" msgid_plural "%d minutes"
msgstr[0] "%d mïnüté Ⱡ'σяєм ιρѕυм ∂σł#" msgstr[0] "%d mïnüté Ⱡ'σяєм ιρѕυм ∂σłσ#"
msgstr[1] "%d mïnütés Ⱡ'σяєм ιρѕυм ∂σłσ#" msgstr[1] "%d mïnütés Ⱡ'σяєм ιρѕυм ∂σłσя #"
#: common/static/js/src/jquery.timeago.locale.js #: common/static/js/src/jquery.timeago.locale.js
msgid "about an hour" msgid "about an hour"
...@@ -2208,8 +2208,8 @@ msgstr "äßöüt än höür Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" ...@@ -2208,8 +2208,8 @@ msgstr "äßöüt än höür Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#, c-format #, c-format
msgid "about %d hour" msgid "about %d hour"
msgid_plural "about %d hours" msgid_plural "about %d hours"
msgstr[0] "äßöüt %d höür Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" msgstr[0] "äßöüt %d höür Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#"
msgstr[1] "äßöüt %d höürs Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#" msgstr[1] "äßöüt %d höürs Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#"
#: common/static/js/src/jquery.timeago.locale.js #: common/static/js/src/jquery.timeago.locale.js
msgid "a day" msgid "a day"
...@@ -2219,8 +2219,8 @@ msgstr "ä däý Ⱡ'σяєм ιρѕ#" ...@@ -2219,8 +2219,8 @@ msgstr "ä däý Ⱡ'σяєм ιρѕ#"
#, c-format #, c-format
msgid "%d day" msgid "%d day"
msgid_plural "%d days" msgid_plural "%d days"
msgstr[0] "%d däý Ⱡ'σяєм ιρѕυ#" msgstr[0] "%d däý Ⱡ'σяєм ιρѕυм #"
msgstr[1] "%d däýs Ⱡ'σяєм ιρѕυм #" msgstr[1] "%d däýs Ⱡ'σяєм ιρѕυм #"
#: common/static/js/src/jquery.timeago.locale.js #: common/static/js/src/jquery.timeago.locale.js
msgid "about a month" msgid "about a month"
...@@ -2230,8 +2230,8 @@ msgstr "äßöüt ä mönth Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" ...@@ -2230,8 +2230,8 @@ msgstr "äßöüt ä mönth Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
#, c-format #, c-format
msgid "%d month" msgid "%d month"
msgid_plural "%d months" msgid_plural "%d months"
msgstr[0] "%d mönth Ⱡ'σяєм ιρѕυм ∂#" msgstr[0] "%d mönth Ⱡ'σяєм ιρѕυм ∂σł#"
msgstr[1] "%d mönths Ⱡ'σяєм ιρѕυм ∂σł#" msgstr[1] "%d mönths Ⱡ'σяєм ιρѕυм ∂σłσ#"
#: common/static/js/src/jquery.timeago.locale.js #: common/static/js/src/jquery.timeago.locale.js
msgid "about a year" msgid "about a year"
...@@ -2241,8 +2241,8 @@ msgstr "äßöüt ä ýéär Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#" ...@@ -2241,8 +2241,8 @@ msgstr "äßöüt ä ýéär Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#"
#, c-format #, c-format
msgid "%d year" msgid "%d year"
msgid_plural "%d years" msgid_plural "%d years"
msgstr[0] "%d ýéär Ⱡ'σяєм ιρѕυм #" msgstr[0] "%d ýéär Ⱡ'σяєм ιρѕυм #"
msgstr[1] "%d ýéärs Ⱡ'σяєм ιρѕυм ∂#" msgstr[1] "%d ýéärs Ⱡ'σяєм ιρѕυм ∂σł#"
#. Translators: please note that this is not a literal flag, but rather a #. Translators: please note that this is not a literal flag, but rather a
#. report #. report
...@@ -2502,9 +2502,9 @@ msgstr "" ...@@ -2502,9 +2502,9 @@ msgstr ""
"ѕιт αмєт, ¢σηѕє¢тєтυя α#" "ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: lms/djangoapps/teams/static/teams/js/views/my_teams.js #: lms/djangoapps/teams/static/teams/js/views/my_teams.js
msgid "You are not currently a member of any teams." msgid "You are not currently a member of any team."
msgstr "" msgstr ""
"Ýöü äré nöt çürréntlý ä mémßér öf äný téäms. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " "Ýöü äré nöt çürréntlý ä mémßér öf äný téäm. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, "
"¢σηѕє¢тєтυя #" "¢σηѕє¢тєтυя #"
#: lms/djangoapps/teams/static/teams/js/views/team_card.js #: lms/djangoapps/teams/static/teams/js/views/team_card.js
...@@ -3691,8 +3691,8 @@ msgstr "" ...@@ -3691,8 +3691,8 @@ msgstr ""
#, c-format #, c-format
msgid "Viewing %s course" msgid "Viewing %s course"
msgid_plural "Viewing %s courses" msgid_plural "Viewing %s courses"
msgstr[0] "Vïéwïng %s çöürsé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмє#" msgstr[0] "Vïéwïng %s çöürsé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт#"
msgstr[1] "Vïéwïng %s çöürsés Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт#" msgstr[1] "Vïéwïng %s çöürsés Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт,#"
#: lms/static/js/discovery/views/search_form.js #: lms/static/js/discovery/views/search_form.js
#, c-format #, c-format
...@@ -3976,8 +3976,8 @@ msgstr "Märk énröllmént çödé äs ünüséd Ⱡ'σяєм ιρѕυм ∂σ ...@@ -3976,8 +3976,8 @@ msgstr "Märk énröllmént çödé äs ünüséd Ⱡ'σяєм ιρѕυм ∂σ
#, c-format #, c-format
msgid "%s result" msgid "%s result"
msgid_plural "%s results" msgid_plural "%s results"
msgstr[0] "%s résült Ⱡ'σяєм ιρѕυм ∂σł#" msgstr[0] "%s résült Ⱡ'σяєм ιρѕυм ∂σłσ#"
msgstr[1] "%s résülts Ⱡ'σяєм ιρѕυм ∂σłσ#" msgstr[1] "%s résülts Ⱡ'σяєм ιρѕυм ∂σłσя #"
#: lms/static/js/student_account/account.js #: lms/static/js/student_account/account.js
msgid "The data could not be saved." msgid "The data could not be saved."
...@@ -4647,7 +4647,7 @@ msgstr "" ...@@ -4647,7 +4647,7 @@ msgstr ""
#: cms/static/js/collections/group.js #: cms/static/js/collections/group.js
#, c-format #, c-format
msgid "Group %s" msgid "Group %s"
msgstr "Gröüp %s Ⱡ'σяєм ιρѕυм ∂#" msgstr "Gröüp %s Ⱡ'σяєм ιρѕυм ∂σł#"
#. Translators: Dictionary used for creation ids that are used in #. Translators: Dictionary used for creation ids that are used in
#. default group names. For example: A, B, AA in Group A, #. default group names. For example: A, B, AA in Group A,
...@@ -5882,7 +5882,7 @@ msgid "" ...@@ -5882,7 +5882,7 @@ msgid ""
msgstr "" msgstr ""
"<button data-provider=\"%s\" data-course-key=\"%s\" data-username=\"%s\" " "<button data-provider=\"%s\" data-course-key=\"%s\" data-username=\"%s\" "
"class=\"complete-course\" onClick=completeOrder(this)>%s</button> Ⱡ'σяєм " "class=\"complete-course\" onClick=completeOrder(this)>%s</button> Ⱡ'σяєм "
"ιρѕυм ∂#" "ιρѕυм ∂σł#"
#: lms/templates/commerce/receipt.underscore #: lms/templates/commerce/receipt.underscore
#: lms/templates/verify_student/payment_confirmation_step.underscore #: lms/templates/verify_student/payment_confirmation_step.underscore
...@@ -7518,7 +7518,7 @@ msgstr "Çhäptér Nämé Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#" ...@@ -7518,7 +7518,7 @@ msgstr "Çhäptér Nämé Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#"
#: cms/templates/js/edit-chapter.underscore #: cms/templates/js/edit-chapter.underscore
#, python-format #, python-format
msgid "Chapter %s" msgid "Chapter %s"
msgstr "Çhäptér %s Ⱡ'σяєм ιρѕυм ∂σłσ#" msgstr "Çhäptér %s Ⱡ'σяєм ιρѕυм ∂σłσя #"
#: cms/templates/js/edit-chapter.underscore #: cms/templates/js/edit-chapter.underscore
msgid "provide the title/name of the chapter that will be used in navigating" msgid "provide the title/name of the chapter that will be used in navigating"
...@@ -8107,6 +8107,37 @@ msgstr "" ...@@ -8107,6 +8107,37 @@ msgstr ""
"Pléäsé çhéçk thé föllöwïng välïdätïön féédßäçks änd réfléçt thém ïn ýöür " "Pléäsé çhéçk thé föllöwïng välïdätïön féédßäçks änd réfléçt thém ïn ýöür "
"çöürsé séttïngs: Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" "çöürsé séttïngs: Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
#: cms/templates/js/verification-access-editor.underscore
msgid "Verification Checkpoint"
msgstr "Vérïfïçätïön Çhéçkpöïnt Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#"
#: cms/templates/js/verification-access-editor.underscore
msgid "Must complete verification checkpoint"
msgstr ""
"Müst çömplété vérïfïçätïön çhéçkpöïnt Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, "
"¢σηѕє¢тєтυ#"
#: cms/templates/js/verification-access-editor.underscore
msgid "Verification checkpoint to be completed"
msgstr ""
"Vérïfïçätïön çhéçkpöïnt tö ßé çömplétéd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, "
"¢σηѕє¢тєтυя#"
#: cms/templates/js/verification-access-editor.underscore
msgid ""
"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."
msgstr ""
"Léärnérs whö réqüïré vérïfïçätïön müst päss thé séléçtéd çhéçkpöïnt tö séé "
"thé çöntént ïn thïs ünït. Léärnérs whö dö nöt réqüïré vérïfïçätïön séé thïs "
"çöntént ßý défäült. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg "
"єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт "
"єηιм α∂ мιηιм νєηιαм, qυιѕ ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт "
"αłιqυιρ єχ єα ¢σммσ∂σ ¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη "
"νσłυρтαтє νєłιт єѕѕє ¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт "
"σ¢¢αє¢αт ¢υρι∂αтαт ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σ#"
#: cms/templates/js/xblock-string-field-editor.underscore #: cms/templates/js/xblock-string-field-editor.underscore
msgid "Edit the name" msgid "Edit the name"
msgstr "Édït thé nämé Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" msgstr "Édït thé nämé Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
......
...@@ -110,8 +110,8 @@ msgid "" ...@@ -110,8 +110,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: edx-platform\n" "Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2015-08-14 13:42+0000\n" "POT-Creation-Date: 2015-08-21 14:17+0000\n"
"PO-Revision-Date: 2015-08-14 13:44+0000\n" "PO-Revision-Date: 2015-08-21 02:41+0000\n"
"Last-Translator: Sarina Canelake <sarina@edx.org>\n" "Last-Translator: Sarina Canelake <sarina@edx.org>\n"
"Language-Team: French (http://www.transifex.com/open-edx/edx-platform/language/fr/)\n" "Language-Team: French (http://www.transifex.com/open-edx/edx-platform/language/fr/)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
...@@ -2509,7 +2509,7 @@ msgid "Team description cannot have more than 300 characters." ...@@ -2509,7 +2509,7 @@ msgid "Team description cannot have more than 300 characters."
msgstr "" msgstr ""
#: lms/djangoapps/teams/static/teams/js/views/my_teams.js #: lms/djangoapps/teams/static/teams/js/views/my_teams.js
msgid "You are not currently a member of any teams." msgid "You are not currently a member of any team."
msgstr "" msgstr ""
#: lms/djangoapps/teams/static/teams/js/views/team_card.js #: lms/djangoapps/teams/static/teams/js/views/team_card.js
...@@ -7357,6 +7357,25 @@ msgstr "" ...@@ -7357,6 +7357,25 @@ msgstr ""
"Veuillez vérifier les commentaires de validation suivants et en tenir compte" "Veuillez vérifier les commentaires de validation suivants et en tenir compte"
" dans les paramètres de votre cours:" " dans les paramètres de votre cours:"
#: cms/templates/js/verification-access-editor.underscore
msgid "Verification Checkpoint"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid "Must complete verification checkpoint"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid "Verification checkpoint to be completed"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid ""
"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."
msgstr ""
#: cms/templates/js/xblock-string-field-editor.underscore #: cms/templates/js/xblock-string-field-editor.underscore
msgid "Edit the name" msgid "Edit the name"
msgstr "Modifier le nom" msgstr "Modifier le nom"
......
...@@ -61,7 +61,7 @@ msgid "" ...@@ -61,7 +61,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: edx-platform\n" "Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2015-08-14 13:42+0000\n" "POT-Creation-Date: 2015-08-21 14:18+0000\n"
"PO-Revision-Date: 2015-05-28 20:00+0000\n" "PO-Revision-Date: 2015-05-28 20:00+0000\n"
"Last-Translator: Nadav Stark <nadav@yeda.org.il>\n" "Last-Translator: Nadav Stark <nadav@yeda.org.il>\n"
"Language-Team: Hebrew (http://www.transifex.com/open-edx/edx-platform/language/he/)\n" "Language-Team: Hebrew (http://www.transifex.com/open-edx/edx-platform/language/he/)\n"
...@@ -415,7 +415,9 @@ msgid "Your legal name must be a minimum of two characters long" ...@@ -415,7 +415,9 @@ msgid "Your legal name must be a minimum of two characters long"
msgstr "" msgstr ""
#: common/djangoapps/student/forms.py #: common/djangoapps/student/forms.py
msgid "Username should only consist of A-Z and 0-9, with no spaces." msgid ""
"Usernames must contain only letters, numbers, underscores (_), and hyphens "
"(-)."
msgstr "" msgstr ""
#: common/djangoapps/student/forms.py #: common/djangoapps/student/forms.py
...@@ -4914,27 +4916,6 @@ msgstr "" ...@@ -4914,27 +4916,6 @@ msgstr ""
msgid "Free" msgid "Free"
msgstr "" msgstr ""
#. Translators: The join of three or more institution names (e.g., Harvard,
#. MIT, and Dartmouth).
#: lms/djangoapps/courseware/views.py
msgid "{first_institutions}, and {last_institution}"
msgstr ""
#. Translators: The join of two institution names (e.g., Harvard and MIT).
#: lms/djangoapps/courseware/views.py
msgid "{first_institution} and {second_institution}"
msgstr ""
#: lms/djangoapps/courseware/views.py
msgid ""
"I would like to receive email from {institution_series} and learn about its "
"other programs."
msgid_plural ""
"I would like to receive email from {institution_series} and learn about "
"their other programs."
msgstr[0] ""
msgstr[1] ""
#: lms/djangoapps/courseware/views.py #: lms/djangoapps/courseware/views.py
msgid "Invalid location." msgid "Invalid location."
msgstr "" msgstr ""
...@@ -7430,10 +7411,6 @@ msgstr "" ...@@ -7430,10 +7411,6 @@ msgstr ""
msgid "text_search is not yet supported." msgid "text_search is not yet supported."
msgstr "" msgstr ""
#: lms/djangoapps/teams/views.py
msgid "last_activity is not yet supported"
msgstr ""
#. Translators: 'ordering' is a string describing a way #. Translators: 'ordering' is a string describing a way
#. of ordering a list. For example, {ordering} may be #. of ordering a list. For example, {ordering} may be
#. 'name', indicating that the user wants to sort the #. 'name', indicating that the user wants to sort the
...@@ -7447,6 +7424,10 @@ msgid "The supplied course_id {course_id} is not valid." ...@@ -7447,6 +7424,10 @@ msgid "The supplied course_id {course_id} is not valid."
msgstr "" msgstr ""
#: lms/djangoapps/teams/views.py #: lms/djangoapps/teams/views.py
msgid "You are already in a team in this course."
msgstr ""
#: lms/djangoapps/teams/views.py
msgid "username or team_id must be specified." msgid "username or team_id must be specified."
msgstr "" msgstr ""
...@@ -7459,6 +7440,10 @@ msgid "Team id is required." ...@@ -7459,6 +7440,10 @@ msgid "Team id is required."
msgstr "" msgstr ""
#: lms/djangoapps/teams/views.py #: lms/djangoapps/teams/views.py
msgid "This team is already full."
msgstr ""
#: lms/djangoapps/teams/views.py
msgid "The user {username} is already a member of a team in this course." msgid "The user {username} is already a member of a team in this course."
msgstr "" msgstr ""
...@@ -8634,6 +8619,10 @@ msgstr "" ...@@ -8634,6 +8619,10 @@ msgstr ""
msgid "Missing branch on fresh clone" msgid "Missing branch on fresh clone"
msgstr "" msgstr ""
#: cms/djangoapps/contentstore/utils.py
msgid "Deleted group"
msgstr ""
#: cms/djangoapps/contentstore/management/commands/git_export.py #: cms/djangoapps/contentstore/management/commands/git_export.py
msgid "" msgid ""
"Take the specified course and attempt to export it to a git repository\n" "Take the specified course and attempt to export it to a git repository\n"
...@@ -8998,6 +8987,11 @@ msgstr "" ...@@ -8998,6 +8987,11 @@ msgstr ""
msgid "Course Number" msgid "Course Number"
msgstr "מספר הקורס" msgstr "מספר הקורס"
#: cms/templates/course_outline.html
#: lms/templates/instructor/instructor_dashboard_2/course_info.html
msgid "Course Start Date:"
msgstr ""
#: cms/templates/html_error.html lms/templates/module-error.html #: cms/templates/html_error.html lms/templates/module-error.html
msgid "Error:" msgid "Error:"
msgstr "" msgstr ""
...@@ -10757,20 +10751,20 @@ msgstr "" ...@@ -10757,20 +10751,20 @@ msgstr ""
#: lms/templates/ccx/enrollment.html #: lms/templates/ccx/enrollment.html
#: lms/templates/instructor/instructor_dashboard_2/membership.html #: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "" msgid "Email Addresses/Usernames"
"Enter email addresses and/or usernames separated by new lines or commas."
msgstr "" msgstr ""
#: lms/templates/ccx/enrollment.html #: lms/templates/ccx/enrollment.html
#: lms/templates/instructor/instructor_dashboard_2/membership.html #: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "" msgid ""
"You will not get notification for emails that bounce, so please double-check" "Enter email addresses and/or usernames separated by new lines or commas."
" spelling."
msgstr "" msgstr ""
#: lms/templates/ccx/enrollment.html #: lms/templates/ccx/enrollment.html
#: lms/templates/instructor/instructor_dashboard_2/membership.html #: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Email Addresses/Usernames" msgid ""
"You will not get notification for emails that bounce, so please double-check"
" spelling."
msgstr "" msgstr ""
#: lms/templates/ccx/enrollment.html #: lms/templates/ccx/enrollment.html
...@@ -10812,7 +10806,6 @@ msgstr "" ...@@ -10812,7 +10806,6 @@ msgstr ""
#: lms/templates/ccx/enrollment.html #: lms/templates/ccx/enrollment.html
#: lms/templates/courseware/course_about.html #: lms/templates/courseware/course_about.html
#: lms/templates/courseware/mktg_course_about.html
#: lms/templates/instructor/instructor_dashboard_2/membership.html #: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Enroll" msgid "Enroll"
msgstr "" msgstr ""
...@@ -10821,6 +10814,10 @@ msgstr "" ...@@ -10821,6 +10814,10 @@ msgstr ""
msgid "Student List Management" msgid "Student List Management"
msgstr "" msgstr ""
#: lms/templates/ccx/enrollment.html
msgid "Enter username or email"
msgstr ""
#: lms/templates/ccx/grading_policy.html #: lms/templates/ccx/grading_policy.html
msgid "WARNING" msgid "WARNING"
msgstr "" msgstr ""
...@@ -11195,7 +11192,6 @@ msgid "This section is graded." ...@@ -11195,7 +11192,6 @@ msgid "This section is graded."
msgstr "" msgstr ""
#: lms/templates/courseware/course_about.html #: lms/templates/courseware/course_about.html
#: lms/templates/courseware/mktg_course_about.html
msgid "An error occurred. Please try again later." msgid "An error occurred. Please try again later."
msgstr "" msgstr ""
...@@ -11232,7 +11228,7 @@ msgid "Enrollment is Closed" ...@@ -11232,7 +11228,7 @@ msgid "Enrollment is Closed"
msgstr "" msgstr ""
#: lms/templates/courseware/course_about.html #: lms/templates/courseware/course_about.html
msgid "Add {course_name} to Cart ({price})" msgid "Add {course_name} to Cart <span>({price} USD)</span>"
msgstr "" msgstr ""
#: lms/templates/courseware/course_about.html #: lms/templates/courseware/course_about.html
...@@ -11309,7 +11305,6 @@ msgid "Additional Resources" ...@@ -11309,7 +11305,6 @@ msgid "Additional Resources"
msgstr "" msgstr ""
#: lms/templates/courseware/course_about.html #: lms/templates/courseware/course_about.html
#: lms/templates/courseware/mktg_course_about.html
msgid "enroll" msgid "enroll"
msgstr "" msgstr ""
...@@ -11681,54 +11676,6 @@ msgstr "" ...@@ -11681,54 +11676,6 @@ msgstr ""
msgid "Course errors" msgid "Course errors"
msgstr "" msgstr ""
#: lms/templates/courseware/mktg_coming_soon.html
msgid "About {course_id}"
msgstr ""
#: lms/templates/courseware/mktg_coming_soon.html
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Coming Soon"
msgstr ""
#: lms/templates/courseware/mktg_course_about.html
msgid "About {course_number}"
msgstr ""
#: lms/templates/courseware/mktg_course_about.html
msgid "Access Courseware"
msgstr ""
#: lms/templates/courseware/mktg_course_about.html
msgid "You Are Enrolled"
msgstr ""
#: lms/templates/courseware/mktg_course_about.html
msgid "Enroll in"
msgstr ""
#. Translators: This is the second line on a button users can click. The
#. first
#. line is "Enroll in COURSE_NAME"
#. The "choose your student track" means users can select between taking the
#. course as an auditor, as a verified student, etc
#: lms/templates/courseware/mktg_course_about.html
msgid "and choose your student track"
msgstr ""
#. Translators: This is the second line on a button users can click. The
#. first
#. line is "Enroll in COURSE_NAME"
#. 'Verification' here refers to verifying one's identity in order to receive
#. a
#. verified certificate.
#: lms/templates/courseware/mktg_course_about.html
msgid "and proceed to verification"
msgstr ""
#: lms/templates/courseware/mktg_course_about.html
msgid "Enrollment Is Closed"
msgstr ""
#: lms/templates/courseware/news.html #: lms/templates/courseware/news.html
msgid "News - MITx 6.002x" msgid "News - MITx 6.002x"
msgstr "" msgstr ""
...@@ -12019,6 +11966,10 @@ msgid "Started - {start_date}" ...@@ -12019,6 +11966,10 @@ msgid "Started - {start_date}"
msgstr "" msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html #: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Coming Soon"
msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Starts - {start_date}" msgid "Starts - {start_date}"
msgstr "" msgstr ""
...@@ -12207,8 +12158,8 @@ msgstr "" ...@@ -12207,8 +12158,8 @@ msgstr ""
#. credit provider, such as 'State University' or 'Happy Fun Company' #. credit provider, such as 'State University' or 'Happy Fun Company'
#: lms/templates/dashboard/_dashboard_credit_info.html #: lms/templates/dashboard/_dashboard_credit_info.html
msgid "" msgid ""
"Your credit has been processed and approved. <b>Congratulations</b>. Please " "Your credit has been processed and approved. <b>Congratulations!</b>. Please"
"see {link_to_provider_site} for more information." " see {link_to_provider_site} for more information."
msgstr "" msgstr ""
#: lms/templates/dashboard/_dashboard_credit_info.html #: lms/templates/dashboard/_dashboard_credit_info.html
...@@ -12399,10 +12350,6 @@ msgid "by most votes" ...@@ -12399,10 +12350,6 @@ msgid "by most votes"
msgstr "" msgstr ""
#: lms/templates/discussion/_user_profile.html #: lms/templates/discussion/_user_profile.html
msgid ", "
msgstr ""
#: lms/templates/discussion/_user_profile.html
#, python-format #, python-format
msgid "%s discussion started" msgid "%s discussion started"
msgid_plural "%s discussions started" msgid_plural "%s discussions started"
...@@ -13323,6 +13270,10 @@ msgid "Course Display Name:" ...@@ -13323,6 +13270,10 @@ msgid "Course Display Name:"
msgstr "" msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/course_info.html #: lms/templates/instructor/instructor_dashboard_2/course_info.html
msgid "Course End Date:"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/course_info.html
msgid "Has the course started?" msgid "Has the course started?"
msgstr "" msgstr ""
...@@ -13339,6 +13290,10 @@ msgid "Has the course ended?" ...@@ -13339,6 +13290,10 @@ msgid "Has the course ended?"
msgstr "" msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/course_info.html #: lms/templates/instructor/instructor_dashboard_2/course_info.html
msgid "Number of sections:"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/course_info.html
msgid "Grade Cutoffs:" msgid "Grade Cutoffs:"
msgstr "" msgstr ""
...@@ -16154,10 +16109,6 @@ msgid "View Live" ...@@ -16154,10 +16109,6 @@ msgid "View Live"
msgstr "" msgstr ""
#: cms/templates/course_outline.html #: cms/templates/course_outline.html
msgid "Course Start Date:"
msgstr "תאריך תחילת קורס:"
#: cms/templates/course_outline.html
msgid "Edit Start Date" msgid "Edit Start Date"
msgstr "" msgstr ""
...@@ -18286,6 +18237,21 @@ msgid "" ...@@ -18286,6 +18237,21 @@ msgid ""
" Students and staff" " Students and staff"
msgstr "" msgstr ""
#: cms/templates/visibility_editor.html
msgid "Verification Checkpoint"
msgstr ""
#: cms/templates/visibility_editor.html
msgid "Verification checkpoint to complete"
msgstr ""
#: cms/templates/visibility_editor.html
msgid ""
"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."
msgstr ""
#: cms/templates/emails/activation_email.txt #: cms/templates/emails/activation_email.txt
msgid "" msgid ""
"Thank you for signing up for {studio_name}! To activate your account, please" "Thank you for signing up for {studio_name}! To activate your account, please"
......
...@@ -44,8 +44,8 @@ msgid "" ...@@ -44,8 +44,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: edx-platform\n" "Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2015-08-14 13:42+0000\n" "POT-Creation-Date: 2015-08-21 14:17+0000\n"
"PO-Revision-Date: 2015-08-14 13:44+0000\n" "PO-Revision-Date: 2015-08-21 02:41+0000\n"
"Last-Translator: Sarina Canelake <sarina@edx.org>\n" "Last-Translator: Sarina Canelake <sarina@edx.org>\n"
"Language-Team: Hebrew (http://www.transifex.com/open-edx/edx-platform/language/he/)\n" "Language-Team: Hebrew (http://www.transifex.com/open-edx/edx-platform/language/he/)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
...@@ -2351,7 +2351,7 @@ msgid "Team description cannot have more than 300 characters." ...@@ -2351,7 +2351,7 @@ msgid "Team description cannot have more than 300 characters."
msgstr "" msgstr ""
#: lms/djangoapps/teams/static/teams/js/views/my_teams.js #: lms/djangoapps/teams/static/teams/js/views/my_teams.js
msgid "You are not currently a member of any teams." msgid "You are not currently a member of any team."
msgstr "" msgstr ""
#: lms/djangoapps/teams/static/teams/js/views/team_card.js #: lms/djangoapps/teams/static/teams/js/views/team_card.js
...@@ -6896,6 +6896,25 @@ msgid "" ...@@ -6896,6 +6896,25 @@ msgid ""
"course settings:" "course settings:"
msgstr "" msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid "Verification Checkpoint"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid "Must complete verification checkpoint"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid "Verification checkpoint to be completed"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid ""
"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."
msgstr ""
#: cms/templates/js/xblock-string-field-editor.underscore #: cms/templates/js/xblock-string-field-editor.underscore
msgid "Edit the name" msgid "Edit the name"
msgstr "" msgstr ""
......
...@@ -46,8 +46,8 @@ msgid "" ...@@ -46,8 +46,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: edx-platform\n" "Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2015-08-14 13:42+0000\n" "POT-Creation-Date: 2015-08-21 14:17+0000\n"
"PO-Revision-Date: 2015-08-14 13:44+0000\n" "PO-Revision-Date: 2015-08-21 02:41+0000\n"
"Last-Translator: Sarina Canelake <sarina@edx.org>\n" "Last-Translator: Sarina Canelake <sarina@edx.org>\n"
"Language-Team: Hindi (http://www.transifex.com/open-edx/edx-platform/language/hi/)\n" "Language-Team: Hindi (http://www.transifex.com/open-edx/edx-platform/language/hi/)\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
...@@ -2370,7 +2370,7 @@ msgid "Team description cannot have more than 300 characters." ...@@ -2370,7 +2370,7 @@ msgid "Team description cannot have more than 300 characters."
msgstr "" msgstr ""
#: lms/djangoapps/teams/static/teams/js/views/my_teams.js #: lms/djangoapps/teams/static/teams/js/views/my_teams.js
msgid "You are not currently a member of any teams." msgid "You are not currently a member of any team."
msgstr "" msgstr ""
#: lms/djangoapps/teams/static/teams/js/views/team_card.js #: lms/djangoapps/teams/static/teams/js/views/team_card.js
...@@ -6962,6 +6962,25 @@ msgid "" ...@@ -6962,6 +6962,25 @@ msgid ""
"course settings:" "course settings:"
msgstr "" msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid "Verification Checkpoint"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid "Must complete verification checkpoint"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid "Verification checkpoint to be completed"
msgstr ""
#: cms/templates/js/verification-access-editor.underscore
msgid ""
"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."
msgstr ""
#: cms/templates/js/xblock-string-field-editor.underscore #: cms/templates/js/xblock-string-field-editor.underscore
msgid "Edit the name" msgid "Edit the name"
msgstr "" msgstr ""
......
...@@ -66,6 +66,7 @@ ...@@ -66,6 +66,7 @@
# #
# Translators: # Translators:
# Álex Filipe <alexfilipe@outlook.com>, 2014 # Álex Filipe <alexfilipe@outlook.com>, 2014
# Antonio Henrique Dianin <antoniohdianin@hotmail.com>, 2015
# brk0_0, 2013 # brk0_0, 2013
# Cleomir Waiczyk <w.cleomir@gmail.com>, 2015 # Cleomir Waiczyk <w.cleomir@gmail.com>, 2015
# Edgar Aparecido Pereira de Melo <edgarapmelo@gmail.com>, 2014 # Edgar Aparecido Pereira de Melo <edgarapmelo@gmail.com>, 2014
...@@ -222,7 +223,7 @@ msgid "" ...@@ -222,7 +223,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: edx-platform\n" "Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
"POT-Creation-Date: 2015-08-14 13:42+0000\n" "POT-Creation-Date: 2015-08-21 14:18+0000\n"
"PO-Revision-Date: 2015-07-20 00:15+0000\n" "PO-Revision-Date: 2015-07-20 00:15+0000\n"
"Last-Translator: javiercencig <javier@jecnet.com.br>\n" "Last-Translator: javiercencig <javier@jecnet.com.br>\n"
"Language-Team: Portuguese (Brazil) (http://www.transifex.com/open-edx/edx-platform/language/pt_BR/)\n" "Language-Team: Portuguese (Brazil) (http://www.transifex.com/open-edx/edx-platform/language/pt_BR/)\n"
...@@ -569,9 +570,10 @@ msgid "Your legal name must be a minimum of two characters long" ...@@ -569,9 +570,10 @@ msgid "Your legal name must be a minimum of two characters long"
msgstr "O seu nome legal deve conter no mínimo dois caracteres" msgstr "O seu nome legal deve conter no mínimo dois caracteres"
#: common/djangoapps/student/forms.py #: common/djangoapps/student/forms.py
msgid "Username should only consist of A-Z and 0-9, with no spaces." msgid ""
"Usernames must contain only letters, numbers, underscores (_), and hyphens "
"(-)."
msgstr "" msgstr ""
"O nome de usuário deve conter apenas letras e números, sem espaços. 0-9"
#: common/djangoapps/student/forms.py #: common/djangoapps/student/forms.py
#, python-format #, python-format
...@@ -5319,27 +5321,6 @@ msgstr "" ...@@ -5319,27 +5321,6 @@ msgstr ""
msgid "Free" msgid "Free"
msgstr "" msgstr ""
#. Translators: The join of three or more institution names (e.g., Harvard,
#. MIT, and Dartmouth).
#: lms/djangoapps/courseware/views.py
msgid "{first_institutions}, and {last_institution}"
msgstr ""
#. Translators: The join of two institution names (e.g., Harvard and MIT).
#: lms/djangoapps/courseware/views.py
msgid "{first_institution} and {second_institution}"
msgstr ""
#: lms/djangoapps/courseware/views.py
msgid ""
"I would like to receive email from {institution_series} and learn about its "
"other programs."
msgid_plural ""
"I would like to receive email from {institution_series} and learn about "
"their other programs."
msgstr[0] ""
msgstr[1] ""
#: lms/djangoapps/courseware/views.py #: lms/djangoapps/courseware/views.py
msgid "Invalid location." msgid "Invalid location."
msgstr "Localização inválida." msgstr "Localização inválida."
...@@ -7946,10 +7927,6 @@ msgstr "" ...@@ -7946,10 +7927,6 @@ msgstr ""
msgid "text_search is not yet supported." msgid "text_search is not yet supported."
msgstr "" msgstr ""
#: lms/djangoapps/teams/views.py
msgid "last_activity is not yet supported"
msgstr ""
#. Translators: 'ordering' is a string describing a way #. Translators: 'ordering' is a string describing a way
#. of ordering a list. For example, {ordering} may be #. of ordering a list. For example, {ordering} may be
#. 'name', indicating that the user wants to sort the #. 'name', indicating that the user wants to sort the
...@@ -7963,6 +7940,10 @@ msgid "The supplied course_id {course_id} is not valid." ...@@ -7963,6 +7940,10 @@ msgid "The supplied course_id {course_id} is not valid."
msgstr "" msgstr ""
#: lms/djangoapps/teams/views.py #: lms/djangoapps/teams/views.py
msgid "You are already in a team in this course."
msgstr ""
#: lms/djangoapps/teams/views.py
msgid "username or team_id must be specified." msgid "username or team_id must be specified."
msgstr "" msgstr ""
...@@ -7975,6 +7956,10 @@ msgid "Team id is required." ...@@ -7975,6 +7956,10 @@ msgid "Team id is required."
msgstr "" msgstr ""
#: lms/djangoapps/teams/views.py #: lms/djangoapps/teams/views.py
msgid "This team is already full."
msgstr ""
#: lms/djangoapps/teams/views.py
msgid "The user {username} is already a member of a team in this course." msgid "The user {username} is already a member of a team in this course."
msgstr "" msgstr ""
...@@ -9195,6 +9180,10 @@ msgstr "A localização de curso fornecida está incorreta" ...@@ -9195,6 +9180,10 @@ msgstr "A localização de curso fornecida está incorreta"
msgid "Missing branch on fresh clone" msgid "Missing branch on fresh clone"
msgstr "Esta faltando um branch na clonagem recente" msgstr "Esta faltando um branch na clonagem recente"
#: cms/djangoapps/contentstore/utils.py
msgid "Deleted group"
msgstr ""
#: cms/djangoapps/contentstore/management/commands/git_export.py #: cms/djangoapps/contentstore/management/commands/git_export.py
msgid "" msgid ""
"Take the specified course and attempt to export it to a git repository\n" "Take the specified course and attempt to export it to a git repository\n"
...@@ -9565,6 +9554,11 @@ msgstr "Cancelar" ...@@ -9565,6 +9554,11 @@ msgstr "Cancelar"
msgid "Course Number" msgid "Course Number"
msgstr "Número do curso" msgstr "Número do curso"
#: cms/templates/course_outline.html
#: lms/templates/instructor/instructor_dashboard_2/course_info.html
msgid "Course Start Date:"
msgstr ""
#: cms/templates/html_error.html lms/templates/module-error.html #: cms/templates/html_error.html lms/templates/module-error.html
msgid "Error:" msgid "Error:"
msgstr "Erro:" msgstr "Erro:"
...@@ -11397,6 +11391,11 @@ msgstr "Inscrição em lote" ...@@ -11397,6 +11391,11 @@ msgstr "Inscrição em lote"
#: lms/templates/ccx/enrollment.html #: lms/templates/ccx/enrollment.html
#: lms/templates/instructor/instructor_dashboard_2/membership.html #: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Email Addresses/Usernames"
msgstr "Endereços de e-mail/Nomes de usuário"
#: lms/templates/ccx/enrollment.html
#: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "" msgid ""
"Enter email addresses and/or usernames separated by new lines or commas." "Enter email addresses and/or usernames separated by new lines or commas."
msgstr "" msgstr ""
...@@ -11414,11 +11413,6 @@ msgstr "" ...@@ -11414,11 +11413,6 @@ msgstr ""
#: lms/templates/ccx/enrollment.html #: lms/templates/ccx/enrollment.html
#: lms/templates/instructor/instructor_dashboard_2/membership.html #: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Email Addresses/Usernames"
msgstr "Endereços de e-mail/Nomes de usuário"
#: lms/templates/ccx/enrollment.html
#: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Auto Enroll" msgid "Auto Enroll"
msgstr "Inscrever-se automaticamente" msgstr "Inscrever-se automaticamente"
...@@ -11465,7 +11459,6 @@ msgstr "" ...@@ -11465,7 +11459,6 @@ msgstr ""
#: lms/templates/ccx/enrollment.html #: lms/templates/ccx/enrollment.html
#: lms/templates/courseware/course_about.html #: lms/templates/courseware/course_about.html
#: lms/templates/courseware/mktg_course_about.html
#: lms/templates/instructor/instructor_dashboard_2/membership.html #: lms/templates/instructor/instructor_dashboard_2/membership.html
msgid "Enroll" msgid "Enroll"
msgstr "Inscrever-se" msgstr "Inscrever-se"
...@@ -11474,6 +11467,10 @@ msgstr "Inscrever-se" ...@@ -11474,6 +11467,10 @@ msgstr "Inscrever-se"
msgid "Student List Management" msgid "Student List Management"
msgstr "" msgstr ""
#: lms/templates/ccx/enrollment.html
msgid "Enter username or email"
msgstr ""
#: lms/templates/ccx/grading_policy.html #: lms/templates/ccx/grading_policy.html
msgid "WARNING" msgid "WARNING"
msgstr "" msgstr ""
...@@ -11864,7 +11861,6 @@ msgid "This section is graded." ...@@ -11864,7 +11861,6 @@ msgid "This section is graded."
msgstr "" msgstr ""
#: lms/templates/courseware/course_about.html #: lms/templates/courseware/course_about.html
#: lms/templates/courseware/mktg_course_about.html
msgid "An error occurred. Please try again later." msgid "An error occurred. Please try again later."
msgstr "Ocorreu um erro. Por favor, tente novamente mais tarde." msgstr "Ocorreu um erro. Por favor, tente novamente mais tarde."
...@@ -11905,7 +11901,7 @@ msgid "Enrollment is Closed" ...@@ -11905,7 +11901,7 @@ msgid "Enrollment is Closed"
msgstr "" msgstr ""
#: lms/templates/courseware/course_about.html #: lms/templates/courseware/course_about.html
msgid "Add {course_name} to Cart ({price})" msgid "Add {course_name} to Cart <span>({price} USD)</span>"
msgstr "" msgstr ""
#: lms/templates/courseware/course_about.html #: lms/templates/courseware/course_about.html
...@@ -11985,7 +11981,6 @@ msgid "Additional Resources" ...@@ -11985,7 +11981,6 @@ msgid "Additional Resources"
msgstr "Recursos adicionais " msgstr "Recursos adicionais "
#: lms/templates/courseware/course_about.html #: lms/templates/courseware/course_about.html
#: lms/templates/courseware/mktg_course_about.html
msgid "enroll" msgid "enroll"
msgstr "" msgstr ""
...@@ -12371,54 +12366,6 @@ msgstr "" ...@@ -12371,54 +12366,6 @@ msgstr ""
msgid "Course errors" msgid "Course errors"
msgstr "Erros do curso" msgstr "Erros do curso"
#: lms/templates/courseware/mktg_coming_soon.html
msgid "About {course_id}"
msgstr "Sobre {course_id}"
#: lms/templates/courseware/mktg_coming_soon.html
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Coming Soon"
msgstr "Em breve"
#: lms/templates/courseware/mktg_course_about.html
msgid "About {course_number}"
msgstr "Sobre {course_number}"
#: lms/templates/courseware/mktg_course_about.html
msgid "Access Courseware"
msgstr "Acesso ao material didático"
#: lms/templates/courseware/mktg_course_about.html
msgid "You Are Enrolled"
msgstr ""
#: lms/templates/courseware/mktg_course_about.html
msgid "Enroll in"
msgstr ""
#. Translators: This is the second line on a button users can click. The
#. first
#. line is "Enroll in COURSE_NAME"
#. The "choose your student track" means users can select between taking the
#. course as an auditor, as a verified student, etc
#: lms/templates/courseware/mktg_course_about.html
msgid "and choose your student track"
msgstr ""
#. Translators: This is the second line on a button users can click. The
#. first
#. line is "Enroll in COURSE_NAME"
#. 'Verification' here refers to verifying one's identity in order to receive
#. a
#. verified certificate.
#: lms/templates/courseware/mktg_course_about.html
msgid "and proceed to verification"
msgstr ""
#: lms/templates/courseware/mktg_course_about.html
msgid "Enrollment Is Closed"
msgstr ""
#: lms/templates/courseware/news.html #: lms/templates/courseware/news.html
msgid "News - MITx 6.002x" msgid "News - MITx 6.002x"
msgstr "Notícias - MITx 6.002x" msgstr "Notícias - MITx 6.002x"
...@@ -12732,6 +12679,10 @@ msgid "Started - {start_date}" ...@@ -12732,6 +12679,10 @@ msgid "Started - {start_date}"
msgstr "" msgstr ""
#: lms/templates/dashboard/_dashboard_course_listing.html #: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Coming Soon"
msgstr "Em breve"
#: lms/templates/dashboard/_dashboard_course_listing.html
msgid "Starts - {start_date}" msgid "Starts - {start_date}"
msgstr "" msgstr ""
...@@ -12924,8 +12875,8 @@ msgstr "" ...@@ -12924,8 +12875,8 @@ msgstr ""
#. credit provider, such as 'State University' or 'Happy Fun Company' #. credit provider, such as 'State University' or 'Happy Fun Company'
#: lms/templates/dashboard/_dashboard_credit_info.html #: lms/templates/dashboard/_dashboard_credit_info.html
msgid "" msgid ""
"Your credit has been processed and approved. <b>Congratulations</b>. Please " "Your credit has been processed and approved. <b>Congratulations!</b>. Please"
"see {link_to_provider_site} for more information." " see {link_to_provider_site} for more information."
msgstr "" msgstr ""
#: lms/templates/dashboard/_dashboard_credit_info.html #: lms/templates/dashboard/_dashboard_credit_info.html
...@@ -13121,10 +13072,6 @@ msgid "by most votes" ...@@ -13121,10 +13072,6 @@ msgid "by most votes"
msgstr "" msgstr ""
#: lms/templates/discussion/_user_profile.html #: lms/templates/discussion/_user_profile.html
msgid ", "
msgstr ", "
#: lms/templates/discussion/_user_profile.html
#, python-format #, python-format
msgid "%s discussion started" msgid "%s discussion started"
msgid_plural "%s discussions started" msgid_plural "%s discussions started"
...@@ -14125,6 +14072,10 @@ msgid "Course Display Name:" ...@@ -14125,6 +14072,10 @@ msgid "Course Display Name:"
msgstr "Nome de exibição do curso:" msgstr "Nome de exibição do curso:"
#: lms/templates/instructor/instructor_dashboard_2/course_info.html #: lms/templates/instructor/instructor_dashboard_2/course_info.html
msgid "Course End Date:"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/course_info.html
msgid "Has the course started?" msgid "Has the course started?"
msgstr "O curso já começou?" msgstr "O curso já começou?"
...@@ -14141,6 +14092,10 @@ msgid "Has the course ended?" ...@@ -14141,6 +14092,10 @@ msgid "Has the course ended?"
msgstr "O curso terminou?" msgstr "O curso terminou?"
#: lms/templates/instructor/instructor_dashboard_2/course_info.html #: lms/templates/instructor/instructor_dashboard_2/course_info.html
msgid "Number of sections:"
msgstr ""
#: lms/templates/instructor/instructor_dashboard_2/course_info.html
msgid "Grade Cutoffs:" msgid "Grade Cutoffs:"
msgstr "Notas de corte:" msgstr "Notas de corte:"
...@@ -17084,10 +17039,6 @@ msgid "View Live" ...@@ -17084,10 +17039,6 @@ msgid "View Live"
msgstr "" msgstr ""
#: cms/templates/course_outline.html #: cms/templates/course_outline.html
msgid "Course Start Date:"
msgstr ""
#: cms/templates/course_outline.html
msgid "Edit Start Date" msgid "Edit Start Date"
msgstr "" msgstr ""
...@@ -19237,6 +19188,21 @@ msgid "" ...@@ -19237,6 +19188,21 @@ msgid ""
" Students and staff" " Students and staff"
msgstr "" msgstr ""
#: cms/templates/visibility_editor.html
msgid "Verification Checkpoint"
msgstr ""
#: cms/templates/visibility_editor.html
msgid "Verification checkpoint to complete"
msgstr ""
#: cms/templates/visibility_editor.html
msgid ""
"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."
msgstr ""
#: cms/templates/emails/activation_email.txt #: cms/templates/emails/activation_email.txt
msgid "" msgid ""
"Thank you for signing up for {studio_name}! To activate your account, please" "Thank you for signing up for {studio_name}! To activate your account, please"
......
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