Merge pull request #15289 from edx/jlajoie/EDUCATOR-434

EDUCATOR-434: Unit Group Access
This commit is contained in:
Jeff LaJoie
2017-07-19 15:32:02 -04:00
committed by GitHub
50 changed files with 1334 additions and 246 deletions

View File

@@ -7,6 +7,7 @@ import logging
from django.utils.translation import ugettext as _
from contentstore.utils import reverse_usage_url
from lms.lib.utils import get_parent_unit
from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
from util.db import MYSQL_MAX_INT, generate_int_id
from xmodule.partitions.partitions import MINIMUM_STATIC_PARTITION_ID, UserPartition
@@ -111,9 +112,30 @@ class GroupConfiguration(object):
"""
Get usage info for unit/module.
"""
parent_unit = get_parent_unit(item)
if unit == parent_unit and not item.has_children:
# Display the topmost unit page if
# the item is a child of the topmost unit and doesn't have its own children.
unit_for_url = unit
elif (not parent_unit and unit.get_parent()) or (unit == parent_unit and item.has_children):
# Display the item's page rather than the unit page if
# the item is one level below the topmost unit and has children, or
# the item itself *is* the topmost unit (and thus does not have a parent unit, but is not an orphan).
unit_for_url = item
else:
# If the item is nested deeper than two levels (the topmost unit > vertical > ... > item)
# display the page for the nested vertical element.
parent = item.get_parent()
nested_vertical = item
while parent != parent_unit:
nested_vertical = parent
parent = parent.get_parent()
unit_for_url = nested_vertical
unit_url = reverse_usage_url(
'container_handler',
course.location.course_key.make_usage_key(unit.location.block_type, unit.location.name)
course.location.course_key.make_usage_key(unit_for_url.location.block_type, unit_for_url.location.name)
)
usage_dict = {'label': u"{} / {}".format(unit.display_name, item.display_name), 'url': unit_url}

View File

@@ -431,7 +431,7 @@ def get_user_partition_info(xblock, schemes=None, course=None):
return partitions
def get_visibility_partition_info(xblock):
def get_visibility_partition_info(xblock, course=None):
"""
Retrieve user partition information for the component visibility editor.
@@ -440,12 +440,16 @@ def get_visibility_partition_info(xblock):
Arguments:
xblock (XBlock): The component being edited.
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: dict
"""
selectable_partitions = []
# We wish to display enrollment partitions before cohort partitions.
enrollment_user_partitions = get_user_partition_info(xblock, schemes=["enrollment_track"])
enrollment_user_partitions = get_user_partition_info(xblock, schemes=["enrollment_track"], course=course)
# For enrollment partitions, we only show them if there is a selected group or
# or if the number of groups > 1.
@@ -454,7 +458,7 @@ def get_visibility_partition_info(xblock):
selectable_partitions.append(partition)
# Now add the cohort user partitions.
selectable_partitions = selectable_partitions + get_user_partition_info(xblock, schemes=["cohort"])
selectable_partitions = selectable_partitions + get_user_partition_info(xblock, schemes=["cohort"], course=course)
# Find the first partition with a selected group. That will be the one initially enabled in the dialog
# (if the course has only been added in Studio, only one partition should have a selected group).

View File

@@ -46,8 +46,8 @@ CONTAINER_TEMPLATES = [
"editor-mode-button", "upload-dialog",
"add-xblock-component", "add-xblock-component-button", "add-xblock-component-menu",
"add-xblock-component-support-legend", "add-xblock-component-support-level", "add-xblock-component-menu-problem",
"xblock-string-field-editor", "publish-xblock", "publish-history",
"unit-outline", "container-message", "license-selector",
"xblock-string-field-editor", "xblock-access-editor", "publish-xblock", "publish-history",
"unit-outline", "container-message", "container-access", "license-selector",
]

View File

@@ -30,6 +30,7 @@ from contentstore.utils import (
find_staff_lock_source,
get_split_group_display_name,
get_user_partition_info,
get_visibility_partition_info,
has_children_visible_to_specific_partition_groups,
is_currently_visible_to_students,
is_self_paced
@@ -1231,9 +1232,11 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
else:
xblock_info['staff_only_message'] = False
xblock_info["has_partition_group_components"] = has_children_visible_to_specific_partition_groups(
xblock_info['has_partition_group_components'] = has_children_visible_to_specific_partition_groups(
xblock
)
xblock_info['user_partition_info'] = get_visibility_partition_info(xblock, course=course)
return xblock_info

View File

@@ -38,15 +38,22 @@ class HelperMethods(object):
"""
Mixin that provides useful methods for Group Configuration tests.
"""
def _create_content_experiment(self, cid=-1, name_suffix='', special_characters=''):
def _create_content_experiment(self, cid=-1, group_id=None, cid_for_problem=None,
name_suffix='', special_characters=''):
"""
Create content experiment.
Assign Group Configuration to the experiment if cid is provided.
Assigns a problem to the first group in the split test if group_id and cid_for_problem is provided.
"""
sequential = ItemFactory.create(
category='sequential',
parent_location=self.course.location,
display_name='Test Subsection {}'.format(name_suffix)
)
vertical = ItemFactory.create(
category='vertical',
parent_location=self.course.location,
parent_location=sequential.location,
display_name='Test Unit {}'.format(name_suffix)
)
c0_url = self.course.id.make_usage_key("vertical", "split_test_cond0")
@@ -65,7 +72,7 @@ class HelperMethods(object):
display_name="Condition 0 vertical",
location=c0_url,
)
ItemFactory.create(
c1_vertical = ItemFactory.create(
parent_location=split_test.location,
category="vertical",
display_name="Condition 1 vertical",
@@ -78,6 +85,19 @@ class HelperMethods(object):
location=c2_url,
)
problem = None
if group_id and cid_for_problem:
problem = ItemFactory.create(
category='problem',
parent_location=c1_vertical.location,
display_name=u"Test Problem"
)
self.client.ajax_post(
reverse_usage_url("xblock_handler", problem.location),
data={'metadata': {'group_access': {cid_for_problem: [group_id]}}}
)
c1_vertical.children.append(problem.location)
partitions_json = [p.to_json() for p in self.course.user_partitions]
self.client.ajax_post(
@@ -86,16 +106,25 @@ class HelperMethods(object):
)
self.save_course()
return (vertical, split_test)
return vertical, split_test, problem
def _create_problem_with_content_group(self, cid, group_id, name_suffix='', special_characters='', orphan=False):
"""
Create a problem
Assign content group to the problem.
"""
vertical_parent_location = self.course.location
if not orphan:
subsection = ItemFactory.create(
category='sequential',
parent_location=self.course.location,
display_name="Test Subsection {}".format(name_suffix)
)
vertical_parent_location = subsection.location
vertical = ItemFactory.create(
category='vertical',
parent_location=self.course.location,
parent_location=vertical_parent_location,
display_name="Test Unit {}".format(name_suffix)
)
@@ -113,7 +142,7 @@ class HelperMethods(object):
)
if not orphan:
self.course.children.append(vertical.location)
self.course.children.append(subsection.location)
self.save_course()
return vertical, problem
@@ -757,12 +786,108 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
}]
self.assertEqual(actual, expected)
def test_can_get_correct_usage_info_for_split_test(self):
"""
When a split test is created and content group access is set for a problem within a group,
the usage info should return a url to the split test, not to the group.
"""
# Create user partition for groups in the split test,
# and another partition to set group access for the problem within the split test.
self._add_user_partitions(count=1)
self.course.user_partitions += [
UserPartition(
id=1,
name='Cohort User Partition',
scheme=UserPartition.get_scheme('cohort'),
description='Cohort User Partition',
groups=[
Group(id=3, name="Problem Group")
],
),
]
self.store.update_item(self.course, ModuleStoreEnum.UserID.test)
__, split_test, problem = self._create_content_experiment(cid=0, name_suffix='0', group_id=3, cid_for_problem=1)
expected = {
'id': 1,
'name': 'Cohort User Partition',
'scheme': 'cohort',
'description': 'Cohort User Partition',
'version': UserPartition.VERSION,
'groups': [
{'id': 3, 'name': 'Problem Group', 'version': 1, 'usage': [
{
'url': '/container/{}'.format(split_test.location),
'label': 'Condition 1 vertical / Test Problem'
}
]},
],
u'parameters': {},
u'active': True,
}
actual = self._get_user_partition('cohort')
self.assertEqual(actual, expected)
def test_can_get_correct_usage_info_for_unit(self):
"""
When group access is set on the unit level, the usage info should return a url to the unit, not
the sequential parent of the unit.
"""
self.course.user_partitions = [
UserPartition(
id=0,
name='User Partition',
scheme=UserPartition.get_scheme('cohort'),
description='User Partition',
groups=[
Group(id=0, name="Group")
],
),
]
vertical, __ = self._create_problem_with_content_group(
cid=0, group_id=0, name_suffix='0'
)
self.client.ajax_post(
reverse_usage_url("xblock_handler", vertical.location),
data={'metadata': {'group_access': {0: [0]}}}
)
actual = self._get_user_partition('cohort')
expected = {
'id': 0,
'name': 'User Partition',
'scheme': 'cohort',
'description': 'User Partition',
'version': UserPartition.VERSION,
'groups': [
{'id': 0, 'name': 'Group', 'version': 1, 'usage': [
{
'url': u"/container/{}".format(vertical.location),
'label': u"Test Subsection 0 / Test Unit 0"
},
{
'url': u"/container/{}".format(vertical.location),
'label': u"Test Unit 0 / Test Problem 0"
}
]},
],
u'parameters': {},
u'active': True,
}
self.maxDiff = None
self.assertEqual(actual, expected)
def test_can_get_correct_usage_info(self):
"""
Test if group configurations json updated successfully with usage information.
"""
self._add_user_partitions(count=2)
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0')
__, split_test, __ = self._create_content_experiment(cid=0, name_suffix='0')
self._create_content_experiment(name_suffix='1')
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.store, self.course)
@@ -779,7 +904,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
{'id': 2, 'name': 'Group C', 'version': 1},
],
'usage': [{
'url': '/container/{}'.format(vertical.location),
'url': '/container/{}'.format(split_test.location),
'label': 'Test Unit 0 / Test Content Experiment 0',
'validation': None,
}],
@@ -809,7 +934,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
characters are being used in content experiment
"""
self._add_user_partitions(count=1)
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0', special_characters=u"JOSÉ ANDRÉS")
__, split_test, __ = self._create_content_experiment(cid=0, name_suffix='0', special_characters=u"JOSÉ ANDRÉS")
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.store, self.course, )
@@ -825,7 +950,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
{'id': 2, 'name': 'Group C', 'version': 1},
],
'usage': [{
'url': '/container/{}'.format(vertical.location),
'url': reverse_usage_url("container_handler", split_test.location),
'label': u"Test Unit 0 / Test Content Experiment 0JOSÉ ANDRÉS",
'validation': None,
}],
@@ -841,8 +966,8 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
group configuration.
"""
self._add_user_partitions()
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0')
vertical1, __ = self._create_content_experiment(cid=0, name_suffix='1')
__, split_test, __ = self._create_content_experiment(cid=0, name_suffix='0')
__, split_test1, __ = self._create_content_experiment(cid=0, name_suffix='1')
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.store, self.course)
@@ -858,11 +983,11 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
{'id': 2, 'name': 'Group C', 'version': 1},
],
'usage': [{
'url': '/container/{}'.format(vertical.location),
'url': '/container/{}'.format(split_test.location),
'label': 'Test Unit 0 / Test Content Experiment 0',
'validation': None,
}, {
'url': '/container/{}'.format(vertical1.location),
'url': '/container/{}'.format(split_test1.location),
'label': 'Test Unit 1 / Test Content Experiment 1',
'validation': None,
}],

View File

@@ -1,53 +1,60 @@
"""Tests for items views."""
import json
from datetime import datetime, timedelta
import ddt
from mock import patch, Mock, PropertyMock
from pytz import UTC
from pyquery import PyQuery
from webob import Response
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import Http404
from django.test import TestCase
from django.test.client import RequestFactory
from django.core.urlresolvers import reverse
from contentstore.utils import reverse_usage_url, reverse_course_url
from mock import Mock, PropertyMock, patch
from opaque_keys import InvalidKeyError
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from contentstore.views.component import (
component_handler, get_component_templates
)
from contentstore.views.item import (
create_xblock_info, _get_source_index, _get_module_info, ALWAYS, VisibilityState, _xblock_type_and_display_name,
add_container_page_publishing_info
)
from contentstore.tests.utils import CourseTestCase
from student.tests.factories import UserFactory
from xblock_django.models import XBlockConfiguration, XBlockStudioConfiguration, XBlockStudioConfigurationFlag
from xmodule.capa_module import CapaDescriptor
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE
from xmodule.modulestore.tests.factories import ItemFactory, LibraryFactory, check_mongo_calls, CourseFactory
from xmodule.x_module import STUDIO_VIEW, STUDENT_VIEW
from xmodule.course_module import DEFAULT_START_DATE
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locations import Location
from pyquery import PyQuery
from pytz import UTC
from webob import Response
from xblock.core import XBlockAside
from xblock.fields import Scope, String, ScopeIds
from xblock.exceptions import NoSuchHandlerError
from xblock.fields import Scope, ScopeIds, String
from xblock.fragment import Fragment
from xblock.runtime import DictKeyValueStore, KvsFieldData
from xblock.test.tools import TestRuntime
from xblock.exceptions import NoSuchHandlerError
from xblock_django.user_service import DjangoXBlockUserService
from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.locations import Location
from xmodule.partitions.partitions import (
Group, UserPartition, ENROLLMENT_TRACK_PARTITION_ID, MINIMUM_STATIC_PARTITION_ID
from xblock.validation import ValidationMessage
from contentstore.tests.utils import CourseTestCase
from contentstore.utils import reverse_course_url, reverse_usage_url
from contentstore.views.component import component_handler, get_component_templates
from contentstore.views.item import (
ALWAYS,
VisibilityState,
_get_module_info,
_get_source_index,
_xblock_type_and_display_name,
add_container_page_publishing_info,
create_xblock_info
)
from lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from student.tests.factories import UserFactory
from xblock_django.models import XBlockConfiguration, XBlockStudioConfiguration, XBlockStudioConfigurationFlag
from xblock_django.user_service import DjangoXBlockUserService
from xmodule.capa_module import CapaDescriptor
from xmodule.course_module import DEFAULT_START_DATE
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, LibraryFactory, check_mongo_calls
from xmodule.partitions.partitions import (
ENROLLMENT_TRACK_PARTITION_ID,
MINIMUM_STATIC_PARTITION_ID,
Group,
UserPartition
)
from xmodule.partitions.tests.test_partitions import MockPartitionService
from xmodule.x_module import STUDENT_VIEW, STUDIO_VIEW
class AsideTest(XBlockAside):
@@ -1155,6 +1162,64 @@ class TestMoveItem(ItemTest):
response = json.loads(response.content)
self.assertEqual(response['error'], 'Patch request did not recognise any parameters to handle.')
def _verify_validation_message(self, message, expected_message, expected_message_type):
"""
Verify that the validation message has the expected validation message and type.
"""
self.assertEqual(message.text, expected_message)
self.assertEqual(message.type, expected_message_type)
def test_move_component_nonsensical_access_restriction_validation(self):
"""
Test that moving a component with non-contradicting access
restrictions into a unit that has contradicting access
restrictions brings up the nonsensical access validation
message and that the message does not show up when moved
into a unit where the component's access settings do not
contradict the unit's access settings.
"""
group1 = self.course.user_partitions[0].groups[0]
group2 = self.course.user_partitions[0].groups[1]
vert2 = self.store.get_item(self.vert2_usage_key)
html = self.store.get_item(self.html_usage_key)
# Inject mock partition service as obtaining the course from the draft modulestore
# (which is the default for these tests) does not work.
partitions_service = MockPartitionService(
self.course,
course_id=self.course.id,
)
html.runtime._services['partitions'] = partitions_service
# Set access settings so html will contradict vert2 when moved into that unit
vert2.group_access = {self.course.user_partitions[0].id: [group1.id]}
html.group_access = {self.course.user_partitions[0].id: [group2.id]}
self.store.update_item(html, self.user.id)
self.store.update_item(vert2, self.user.id)
# Verify that there is no warning when html is in a non contradicting unit
validation = html.validate()
self.assertEqual(len(validation.messages), 0)
# Now move it and confirm that the html component has been moved into vertical 2
self.assert_move_item(self.html_usage_key, self.vert2_usage_key)
html.parent = self.vert2_usage_key
self.store.update_item(html, self.user.id)
validation = html.validate()
self.assertEqual(len(validation.messages), 1)
self._verify_validation_message(
validation.messages[0],
NONSENSICAL_ACCESS_RESTRICTION,
ValidationMessage.ERROR,
)
# Move the html component back and confirm that the warning is gone again
self.assert_move_item(self.html_usage_key, self.vert_usage_key)
html.parent = self.vert_usage_key
self.store.update_item(html, self.user.id)
validation = html.validate()
self.assertEqual(len(validation.messages), 0)
@patch('contentstore.views.item.log')
def test_move_logging(self, mock_logger):
"""