Merge pull request #6299 from edx/cohorted-courseware
Cohorted courseware
This commit is contained in:
@@ -5,6 +5,8 @@ These are notable changes in edx-platform. This is a rolling list of changes,
|
||||
in roughly chronological order, most recent first. Add your entries at or near
|
||||
the top. Include a label indicating the component affected.
|
||||
|
||||
Studio/LMS: Implement cohorted courseware. TNL-648
|
||||
|
||||
LMS: Student Notes: Eventing for Student Notes. TNL-931
|
||||
|
||||
LMS: Student Notes: Add course structure view. TNL-762
|
||||
|
||||
@@ -423,3 +423,63 @@ class InheritedStaffLockTest(StaffLockTest):
|
||||
def test_no_inheritance_for_orphan(self):
|
||||
"""Tests that an orphaned xblock does not inherit staff lock"""
|
||||
self.assertFalse(utils.ancestor_has_staff_lock(self.orphan))
|
||||
|
||||
|
||||
class GroupVisibilityTest(CourseTestCase):
|
||||
"""
|
||||
Test content group access rules.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(GroupVisibilityTest, self).setUp()
|
||||
|
||||
chapter = ItemFactory.create(category='chapter', parent_location=self.course.location)
|
||||
sequential = ItemFactory.create(category='sequential', parent_location=chapter.location)
|
||||
vertical = ItemFactory.create(category='vertical', parent_location=sequential.location)
|
||||
html = ItemFactory.create(category='html', parent_location=vertical.location)
|
||||
problem = ItemFactory.create(
|
||||
category='problem', parent_location=vertical.location, data="<problem></problem>"
|
||||
)
|
||||
self.sequential = self.store.get_item(sequential.location)
|
||||
self.vertical = self.store.get_item(vertical.location)
|
||||
self.html = self.store.get_item(html.location)
|
||||
self.problem = self.store.get_item(problem.location)
|
||||
|
||||
def set_group_access(self, xblock, value):
|
||||
""" Sets group_access to specified value and calls update_item to persist the change. """
|
||||
xblock.group_access = value
|
||||
self.store.update_item(xblock, self.user.id)
|
||||
|
||||
def test_no_visibility_set(self):
|
||||
""" Tests when group_access has not been set on anything. """
|
||||
|
||||
def verify_all_components_visible_to_all(): # pylint: disable=invalid-name
|
||||
""" Verifies when group_access has not been set on anything. """
|
||||
for item in (self.sequential, self.vertical, self.html, self.problem):
|
||||
self.assertFalse(utils.has_children_visible_to_specific_content_groups(item))
|
||||
self.assertFalse(utils.is_visible_to_specific_content_groups(item))
|
||||
|
||||
verify_all_components_visible_to_all()
|
||||
|
||||
# Test with group_access set to Falsey values.
|
||||
self.set_group_access(self.vertical, {1: []})
|
||||
self.set_group_access(self.html, {2: None})
|
||||
|
||||
verify_all_components_visible_to_all()
|
||||
|
||||
def test_sequential_and_problem_have_group_access(self):
|
||||
""" Tests when group_access is set on a few different components. """
|
||||
self.set_group_access(self.sequential, {1: [0]})
|
||||
# This is a no-op.
|
||||
self.set_group_access(self.vertical, {1: []})
|
||||
self.set_group_access(self.problem, {2: [3, 4]})
|
||||
|
||||
# Note that "has_children_visible_to_specific_content_groups" only checks immediate children.
|
||||
self.assertFalse(utils.has_children_visible_to_specific_content_groups(self.sequential))
|
||||
self.assertTrue(utils.has_children_visible_to_specific_content_groups(self.vertical))
|
||||
self.assertFalse(utils.has_children_visible_to_specific_content_groups(self.html))
|
||||
self.assertFalse(utils.has_children_visible_to_specific_content_groups(self.problem))
|
||||
|
||||
self.assertTrue(utils.is_visible_to_specific_content_groups(self.sequential))
|
||||
self.assertFalse(utils.is_visible_to_specific_content_groups(self.vertical))
|
||||
self.assertFalse(utils.is_visible_to_specific_content_groups(self.html))
|
||||
self.assertTrue(utils.is_visible_to_specific_content_groups(self.problem))
|
||||
|
||||
@@ -179,6 +179,36 @@ def is_currently_visible_to_students(xblock):
|
||||
return True
|
||||
|
||||
|
||||
def has_children_visible_to_specific_content_groups(xblock):
|
||||
"""
|
||||
Returns True if this xblock has children that are limited to specific content groups.
|
||||
Note that this method is not recursive (it does not check grandchildren).
|
||||
"""
|
||||
if not xblock.has_children:
|
||||
return False
|
||||
|
||||
for child in xblock.get_children():
|
||||
if is_visible_to_specific_content_groups(child):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def is_visible_to_specific_content_groups(xblock):
|
||||
"""
|
||||
Returns True if this xblock has visibility limited to specific content groups.
|
||||
"""
|
||||
if not xblock.group_access:
|
||||
return False
|
||||
for __, value in xblock.group_access.iteritems():
|
||||
# value should be a list of group IDs. If it is an empty list or None, the xblock is visible
|
||||
# to all groups in that particular partition. So if value is a truthy value, the xblock is
|
||||
# restricted in some way.
|
||||
if value:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_release_date_source(xblock):
|
||||
"""
|
||||
Finds the ancestor of xblock that set its release date.
|
||||
|
||||
@@ -21,7 +21,7 @@ from xblock.runtime import Mixologist
|
||||
|
||||
from contentstore.utils import get_lms_link_for_item
|
||||
from contentstore.views.helpers import get_parent_xblock, is_unit, xblock_type_display_name
|
||||
from contentstore.views.item import create_xblock_info
|
||||
from contentstore.views.item import create_xblock_info, add_container_page_publishing_info
|
||||
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
|
||||
@@ -186,8 +186,9 @@ def container_handler(request, usage_key_string):
|
||||
# about the block's ancestors and siblings for use by the Unit Outline.
|
||||
xblock_info = create_xblock_info(xblock, include_ancestor_info=is_unit_page)
|
||||
|
||||
# Create the link for preview.
|
||||
preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE')
|
||||
if is_unit_page:
|
||||
add_container_page_publishing_info(xblock, xblock_info)
|
||||
|
||||
# need to figure out where this item is in the list of children as the
|
||||
# preview will need this
|
||||
index = 1
|
||||
|
||||
@@ -16,6 +16,7 @@ from django.core.urlresolvers import reverse
|
||||
from django.http import HttpResponseBadRequest, HttpResponseNotFound, HttpResponse, Http404
|
||||
from util.json_request import JsonResponse, JsonResponseBadRequest
|
||||
from util.date_utils import get_default_time_display
|
||||
from util.db import generate_int_id, MYSQL_MAX_INT
|
||||
from edxmako.shortcuts import render_to_response
|
||||
|
||||
from xmodule.course_module import DEFAULT_START_DATE
|
||||
@@ -29,6 +30,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseErr
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.locations import Location
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
|
||||
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from contentstore.course_info_model import get_course_updates, update_course_updates, delete_course_update
|
||||
@@ -70,7 +72,15 @@ from course_action_state.models import CourseRerunState, CourseRerunUIStateManag
|
||||
from course_action_state.managers import CourseActionStateItemNotFoundError
|
||||
from microsite_configuration import microsite
|
||||
from xmodule.course_module import CourseFields
|
||||
from xmodule.split_test_module import get_split_user_partitions
|
||||
|
||||
MINIMUM_GROUP_ID = 100
|
||||
|
||||
# Note: the following content group configuration strings are not
|
||||
# translated since they are not visible to users.
|
||||
CONTENT_GROUP_CONFIGURATION_DESCRIPTION = 'The groups in this configuration can be mapped to cohort groups in the LMS.'
|
||||
|
||||
CONTENT_GROUP_CONFIGURATION_NAME = 'Content Group Configuration'
|
||||
|
||||
__all__ = ['course_info_handler', 'course_handler', 'course_listing',
|
||||
'course_info_update_handler',
|
||||
@@ -1252,23 +1262,16 @@ class GroupConfiguration(object):
|
||||
if len(self.configuration.get('groups', [])) < 1:
|
||||
raise GroupConfigurationsValidationError(_("must have at least one group"))
|
||||
|
||||
def generate_id(self, used_ids):
|
||||
"""
|
||||
Generate unique id for the group configuration.
|
||||
If this id is already used, we generate new one.
|
||||
"""
|
||||
cid = random.randint(100, 10 ** 12)
|
||||
|
||||
while cid in used_ids:
|
||||
cid = random.randint(100, 10 ** 12)
|
||||
|
||||
return cid
|
||||
|
||||
def assign_id(self, configuration_id=None):
|
||||
"""
|
||||
Assign id for the json representation of group configuration.
|
||||
"""
|
||||
self.configuration['id'] = int(configuration_id) if configuration_id else self.generate_id(self.get_used_ids())
|
||||
if configuration_id:
|
||||
self.configuration['id'] = int(configuration_id)
|
||||
else:
|
||||
self.configuration['id'] = generate_int_id(
|
||||
MINIMUM_GROUP_ID, MYSQL_MAX_INT, GroupConfiguration.get_used_ids(self.course)
|
||||
)
|
||||
|
||||
def assign_group_ids(self):
|
||||
"""
|
||||
@@ -1278,14 +1281,15 @@ class GroupConfiguration(object):
|
||||
# Assign ids to every group in configuration.
|
||||
for group in self.configuration.get('groups', []):
|
||||
if group.get('id') is None:
|
||||
group["id"] = self.generate_id(used_ids)
|
||||
group["id"] = generate_int_id(MINIMUM_GROUP_ID, MYSQL_MAX_INT, used_ids)
|
||||
used_ids.append(group["id"])
|
||||
|
||||
def get_used_ids(self):
|
||||
@staticmethod
|
||||
def get_used_ids(course):
|
||||
"""
|
||||
Return a list of IDs that already in use.
|
||||
"""
|
||||
return set([p.id for p in self.course.user_partitions])
|
||||
return set([p.id for p in course.user_partitions])
|
||||
|
||||
def get_user_partition(self):
|
||||
"""
|
||||
@@ -1296,21 +1300,19 @@ class GroupConfiguration(object):
|
||||
@staticmethod
|
||||
def get_usage_info(course, store):
|
||||
"""
|
||||
Get usage information for all Group Configurations.
|
||||
Get usage information for all Group Configurations currently referenced by a split_test instance.
|
||||
"""
|
||||
split_tests = store.get_items(course.id, qualifiers={'category': 'split_test'})
|
||||
return GroupConfiguration._get_usage_info(store, course, split_tests)
|
||||
|
||||
@staticmethod
|
||||
def add_usage_info(course, store):
|
||||
def get_split_test_partitions_with_usage(course, store):
|
||||
"""
|
||||
Add usage information to group configurations jsons in course.
|
||||
|
||||
Returns json of group configurations updated with usage information.
|
||||
Returns json split_test group configurations updated with usage information.
|
||||
"""
|
||||
usage_info = GroupConfiguration.get_usage_info(course, store)
|
||||
configurations = []
|
||||
for partition in course.user_partitions:
|
||||
for partition in get_split_user_partitions(course.user_partitions):
|
||||
configuration = partition.to_json()
|
||||
configuration['usage'] = usage_info.get(partition.id, [])
|
||||
configurations.append(configuration)
|
||||
@@ -1384,6 +1386,26 @@ class GroupConfiguration(object):
|
||||
configuration_json['usage'] = usage_information.get(configuration.id, [])
|
||||
return configuration_json
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_content_group_configuration(course):
|
||||
"""
|
||||
Returns the first user partition from the course which uses the
|
||||
CohortPartitionScheme, or generates one if no such partition is
|
||||
found. The created partition is not saved to the course until
|
||||
the client explicitly creates a group within the partition and
|
||||
POSTs back.
|
||||
"""
|
||||
content_group_configuration = get_cohorted_user_partition(course.id)
|
||||
if content_group_configuration is None:
|
||||
content_group_configuration = UserPartition(
|
||||
id=generate_int_id(MINIMUM_GROUP_ID, MYSQL_MAX_INT, GroupConfiguration.get_used_ids(course)),
|
||||
name=CONTENT_GROUP_CONFIGURATION_NAME,
|
||||
description=CONTENT_GROUP_CONFIGURATION_DESCRIPTION,
|
||||
groups=[],
|
||||
scheme_id='cohort'
|
||||
)
|
||||
return content_group_configuration
|
||||
|
||||
|
||||
@require_http_methods(("GET", "POST"))
|
||||
@login_required
|
||||
@@ -1405,12 +1427,21 @@ def group_configurations_list_handler(request, course_key_string):
|
||||
if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'):
|
||||
group_configuration_url = reverse_course_url('group_configurations_list_handler', course_key)
|
||||
course_outline_url = reverse_course_url('course_handler', course_key)
|
||||
configurations = GroupConfiguration.add_usage_info(course, store)
|
||||
should_show_experiment_groups = are_content_experiments_enabled(course)
|
||||
if should_show_experiment_groups:
|
||||
experiment_group_configurations = GroupConfiguration.get_split_test_partitions_with_usage(course, store)
|
||||
else:
|
||||
experiment_group_configurations = None
|
||||
content_group_configuration = GroupConfiguration.get_or_create_content_group_configuration(
|
||||
course
|
||||
).to_json()
|
||||
return render_to_response('group_configurations.html', {
|
||||
'context_course': course,
|
||||
'group_configuration_url': group_configuration_url,
|
||||
'course_outline_url': course_outline_url,
|
||||
'configurations': configurations if should_show_group_configurations_page(course) else None,
|
||||
'experiment_group_configurations': experiment_group_configurations,
|
||||
'should_show_experiment_groups': should_show_experiment_groups,
|
||||
'content_group_configuration': content_group_configuration
|
||||
})
|
||||
elif "application/json" in request.META.get('HTTP_ACCEPT'):
|
||||
if request.method == 'POST':
|
||||
@@ -1489,9 +1520,9 @@ def group_configurations_detail_handler(request, course_key_string, group_config
|
||||
return JsonResponse(status=204)
|
||||
|
||||
|
||||
def should_show_group_configurations_page(course):
|
||||
def are_content_experiments_enabled(course):
|
||||
"""
|
||||
Returns true if Studio should show the "Group Configurations" page for the specified course.
|
||||
Returns True if content experiments have been enabled for the course.
|
||||
"""
|
||||
return (
|
||||
SPLIT_TEST_COMPONENT_TYPE in ADVANCED_COMPONENT_TYPES and
|
||||
|
||||
@@ -39,7 +39,7 @@ from util.json_request import expect_json, JsonResponse
|
||||
|
||||
from student.auth import has_studio_write_access, has_studio_read_access
|
||||
from contentstore.utils import find_release_date_source, find_staff_lock_source, is_currently_visible_to_students, \
|
||||
ancestor_has_staff_lock
|
||||
ancestor_has_staff_lock, has_children_visible_to_specific_content_groups
|
||||
from contentstore.views.helpers import is_unit, xblock_studio_url, xblock_primary_child_category, \
|
||||
xblock_type_display_name, get_parent_xblock
|
||||
from contentstore.views.preview import get_preview_fragment
|
||||
@@ -48,8 +48,11 @@ from models.settings.course_grading import CourseGradingModel
|
||||
from cms.lib.xblock.runtime import handler_url, local_resource_url
|
||||
from opaque_keys.edx.keys import UsageKey, CourseKey
|
||||
from opaque_keys.edx.locator import LibraryUsageLocator
|
||||
from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW
|
||||
|
||||
__all__ = ['orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler']
|
||||
__all__ = [
|
||||
'orphan_handler', 'xblock_handler', 'xblock_view_handler', 'xblock_outline_handler', 'xblock_container_handler'
|
||||
]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,7 +62,6 @@ CREATE_IF_NOT_FOUND = ['course_info']
|
||||
NEVER = lambda x: False
|
||||
ALWAYS = lambda x: True
|
||||
|
||||
|
||||
# In order to allow descriptors to use a handler url, we need to
|
||||
# monkey-patch the x_module library.
|
||||
# TODO: Remove this code when Runtimes are no longer created by modulestores
|
||||
@@ -143,8 +145,9 @@ def xblock_handler(request, usage_key_string):
|
||||
# right now can't combine output of this w/ output of _get_module_info, but worthy goal
|
||||
return JsonResponse(CourseGradingModel.get_section_grader_type(usage_key))
|
||||
# TODO: pass fields to _get_module_info and only return those
|
||||
rsp = _get_module_info(_get_xblock(usage_key, request.user))
|
||||
return JsonResponse(rsp)
|
||||
with modulestore().bulk_operations(usage_key.course_key):
|
||||
response = _get_module_info(_get_xblock(usage_key, request.user))
|
||||
return JsonResponse(response)
|
||||
else:
|
||||
return HttpResponse(status=406)
|
||||
|
||||
@@ -225,14 +228,14 @@ def xblock_view_handler(request, usage_key_string, view_name):
|
||||
request_token=request_token(request),
|
||||
))
|
||||
|
||||
if view_name == STUDIO_VIEW:
|
||||
if view_name in (STUDIO_VIEW, VISIBILITY_VIEW):
|
||||
try:
|
||||
fragment = xblock.render(STUDIO_VIEW)
|
||||
fragment = xblock.render(view_name)
|
||||
# catch exceptions indiscriminately, since after this point they escape the
|
||||
# dungeon and surface as uneditable, unsaveable, and undeletable
|
||||
# component-goblins.
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
log.debug("unable to render studio_view for %r", xblock, exc_info=True)
|
||||
log.debug("Unable to render %s for %r", view_name, xblock, exc_info=True)
|
||||
fragment = Fragment(render_to_string('html_error.html', {'message': str(exc)}))
|
||||
|
||||
elif view_name in (PREVIEW_VIEWS + container_views):
|
||||
@@ -333,6 +336,31 @@ def xblock_outline_handler(request, usage_key_string):
|
||||
return Http404
|
||||
|
||||
|
||||
@require_http_methods(("GET"))
|
||||
@login_required
|
||||
@expect_json
|
||||
def xblock_container_handler(request, usage_key_string):
|
||||
"""
|
||||
The restful handler for requests for XBlock information about the block and its children.
|
||||
This is used by the container page in particular to get additional information about publish state
|
||||
and ancestor state.
|
||||
"""
|
||||
usage_key = usage_key_with_run(usage_key_string)
|
||||
|
||||
if not has_studio_read_access(request.user, usage_key.course_key):
|
||||
raise PermissionDenied()
|
||||
|
||||
response_format = request.REQUEST.get('format', 'html')
|
||||
if response_format == 'json' or 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'):
|
||||
with modulestore().bulk_operations(usage_key.course_key):
|
||||
response = _get_module_info(
|
||||
_get_xblock(usage_key, request.user), include_ancestor_info=True, include_publishing_info=True
|
||||
)
|
||||
return JsonResponse(response)
|
||||
else:
|
||||
return Http404
|
||||
|
||||
|
||||
def _update_with_callback(xblock, user, old_metadata=None, old_content=None):
|
||||
"""
|
||||
Updates the xblock in the modulestore.
|
||||
@@ -695,7 +723,7 @@ def _get_xblock(usage_key, user):
|
||||
return JsonResponse({"error": "Can't find item by location: " + unicode(usage_key)}, 404)
|
||||
|
||||
|
||||
def _get_module_info(xblock, rewrite_static_links=True):
|
||||
def _get_module_info(xblock, rewrite_static_links=True, include_ancestor_info=False, include_publishing_info=False):
|
||||
"""
|
||||
metadata, data, id representation of a leaf module fetcher.
|
||||
:param usage_key: A UsageKey
|
||||
@@ -715,7 +743,12 @@ def _get_module_info(xblock, rewrite_static_links=True):
|
||||
modulestore().has_changes(modulestore().get_course(xblock.location.course_key, depth=None))
|
||||
|
||||
# Note that children aren't being returned until we have a use case.
|
||||
return create_xblock_info(xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=True)
|
||||
xblock_info = create_xblock_info(
|
||||
xblock, data=data, metadata=own_metadata(xblock), include_ancestor_info=include_ancestor_info
|
||||
)
|
||||
if include_publishing_info:
|
||||
add_container_page_publishing_info(xblock, xblock_info)
|
||||
return xblock_info
|
||||
|
||||
|
||||
def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=False, include_child_info=False,
|
||||
@@ -735,24 +768,6 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
|
||||
In addition, an optional include_children_predicate argument can be provided to define whether or
|
||||
not a particular xblock should have its children included.
|
||||
"""
|
||||
|
||||
def safe_get_username(user_id):
|
||||
"""
|
||||
Guard against bad user_ids, like the infamous "**replace_user**".
|
||||
Note that this will ignore our special known IDs (ModuleStoreEnum.UserID).
|
||||
We should consider adding special handling for those values.
|
||||
|
||||
:param user_id: the user id to get the username of
|
||||
:return: username, or None if the user does not exist or user_id is None
|
||||
"""
|
||||
if user_id:
|
||||
try:
|
||||
return User.objects.get(id=user_id).username
|
||||
except: # pylint: disable=bare-except
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
is_library_block = isinstance(xblock.location, LibraryUsageLocator)
|
||||
is_xblock_unit = is_unit(xblock, parent_xblock)
|
||||
# this should not be calculated for Sections and Subsections on Unit page or for library blocks
|
||||
@@ -778,8 +793,6 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
|
||||
else:
|
||||
child_info = None
|
||||
|
||||
# Treat DEFAULT_START_DATE as a magic number that means the release date has not been set
|
||||
release_date = get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None
|
||||
if xblock.category != 'course':
|
||||
visibility_state = _compute_visibility_state(xblock, child_info, is_xblock_unit and has_changes)
|
||||
else:
|
||||
@@ -795,7 +808,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
|
||||
"published_on": get_default_time_display(xblock.published_on) if published and xblock.published_on else None,
|
||||
"studio_url": xblock_studio_url(xblock, parent_xblock),
|
||||
"released_to_students": datetime.now(UTC) > xblock.start,
|
||||
"release_date": release_date,
|
||||
"release_date": _get_release_date(xblock),
|
||||
"visibility_state": visibility_state,
|
||||
"has_explicit_staff_lock": xblock.fields['visible_to_staff_only'].is_set_on(xblock),
|
||||
"start": xblock.fields['start'].to_json(xblock.start),
|
||||
@@ -819,19 +832,6 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
|
||||
else:
|
||||
xblock_info["ancestor_has_staff_lock"] = False
|
||||
|
||||
# Currently, 'edited_by', 'published_by', and 'release_date_from' are only used by the
|
||||
# container page when rendering a unit. Since they are expensive to compute, only include them for units
|
||||
# that are not being rendered on the course outline.
|
||||
if is_xblock_unit and not course_outline:
|
||||
xblock_info["edited_by"] = safe_get_username(xblock.subtree_edited_by)
|
||||
xblock_info["published_by"] = safe_get_username(xblock.published_by)
|
||||
xblock_info["currently_visible_to_students"] = is_currently_visible_to_students(xblock)
|
||||
if release_date:
|
||||
xblock_info["release_date_from"] = _get_release_date_from(xblock)
|
||||
if visibility_state == VisibilityState.staff_only:
|
||||
xblock_info["staff_lock_from"] = _get_staff_lock_from(xblock)
|
||||
else:
|
||||
xblock_info["staff_lock_from"] = None
|
||||
if course_outline:
|
||||
if xblock_info["has_explicit_staff_lock"]:
|
||||
xblock_info["staff_only_message"] = True
|
||||
@@ -843,6 +843,40 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
|
||||
return xblock_info
|
||||
|
||||
|
||||
def add_container_page_publishing_info(xblock, xblock_info): # pylint: disable=invalid-name
|
||||
"""
|
||||
Adds information about the xblock's publish state to the supplied
|
||||
xblock_info for the container page.
|
||||
"""
|
||||
def safe_get_username(user_id):
|
||||
"""
|
||||
Guard against bad user_ids, like the infamous "**replace_user**".
|
||||
Note that this will ignore our special known IDs (ModuleStoreEnum.UserID).
|
||||
We should consider adding special handling for those values.
|
||||
|
||||
:param user_id: the user id to get the username of
|
||||
:return: username, or None if the user does not exist or user_id is None
|
||||
"""
|
||||
if user_id:
|
||||
try:
|
||||
return User.objects.get(id=user_id).username
|
||||
except: # pylint: disable=bare-except
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
xblock_info["edited_by"] = safe_get_username(xblock.subtree_edited_by)
|
||||
xblock_info["published_by"] = safe_get_username(xblock.published_by)
|
||||
xblock_info["currently_visible_to_students"] = is_currently_visible_to_students(xblock)
|
||||
xblock_info["has_content_group_components"] = has_children_visible_to_specific_content_groups(xblock)
|
||||
if xblock_info["release_date"]:
|
||||
xblock_info["release_date_from"] = _get_release_date_from(xblock)
|
||||
if xblock_info["visibility_state"] == VisibilityState.staff_only:
|
||||
xblock_info["staff_lock_from"] = _get_staff_lock_from(xblock)
|
||||
else:
|
||||
xblock_info["staff_lock_from"] = None
|
||||
|
||||
|
||||
class VisibilityState(object):
|
||||
"""
|
||||
Represents the possible visibility states for an xblock:
|
||||
@@ -962,6 +996,14 @@ def _create_xblock_child_info(xblock, course_outline, graders, include_children_
|
||||
return child_info
|
||||
|
||||
|
||||
def _get_release_date(xblock):
|
||||
"""
|
||||
Returns the release date for the xblock, or None if the release date has never been set.
|
||||
"""
|
||||
# Treat DEFAULT_START_DATE as a magic number that means the release date has not been set
|
||||
return get_default_time_display(xblock.start) if xblock.start != DEFAULT_START_DATE else None
|
||||
|
||||
|
||||
def _get_release_date_from(xblock):
|
||||
"""
|
||||
Returns a string representation of the section or subsection that sets the xblock's release date
|
||||
|
||||
@@ -17,6 +17,7 @@ from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
from xmodule.library_tools import LibraryToolsService
|
||||
from xmodule.modulestore.django import modulestore, ModuleI18nService
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from opaque_keys.edx.locator import LibraryUsageLocator
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xblock.runtime import KvsFieldData
|
||||
from xblock.django.request import webob_to_django_response, django_to_webob_request
|
||||
@@ -242,6 +243,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
# Only add the Studio wrapper when on the container page. The "Pages" page will remain as is for now.
|
||||
if not context.get('is_pages_view', None) and view in PREVIEW_VIEWS:
|
||||
root_xblock = context.get('root_xblock')
|
||||
can_edit_visibility = not isinstance(xblock.location, LibraryUsageLocator)
|
||||
is_root = root_xblock and xblock.location == root_xblock.location
|
||||
is_reorderable = _is_xblock_reorderable(xblock, context)
|
||||
template_context = {
|
||||
@@ -251,6 +253,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
'is_root': is_root,
|
||||
'is_reorderable': is_reorderable,
|
||||
'can_edit': context.get('can_edit', True),
|
||||
'can_edit_visibility': can_edit_visibility,
|
||||
}
|
||||
html = render_to_string('studio_xblock_wrapper.html', template_context)
|
||||
frag = wrap_fragment(frag, html)
|
||||
|
||||
@@ -207,17 +207,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'First name')
|
||||
self.assertContains(response, 'Group C')
|
||||
|
||||
def test_view_index_disabled(self):
|
||||
"""
|
||||
Check that group configuration page is not displayed when turned off.
|
||||
"""
|
||||
if SPLIT_TEST_COMPONENT_TYPE in self.course.advanced_modules:
|
||||
self.course.advanced_modules.remove(SPLIT_TEST_COMPONENT_TYPE)
|
||||
self.store.update_item(self.course, self.user.id)
|
||||
|
||||
resp = self.client.get(self._url())
|
||||
self.assertContains(resp, "module is disabled")
|
||||
self.assertContains(response, 'Content Group Configuration')
|
||||
|
||||
def test_unsupported_http_accept_header(self):
|
||||
"""
|
||||
@@ -243,12 +233,9 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
|
||||
{u'name': u'Group B', u'version': 1},
|
||||
],
|
||||
}
|
||||
response = self.client.post(
|
||||
response = self.client.ajax_post(
|
||||
self._url(),
|
||||
data=json.dumps(GROUP_CONFIGURATION_JSON),
|
||||
content_type="application/json",
|
||||
HTTP_ACCEPT="application/json",
|
||||
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||
data=GROUP_CONFIGURATION_JSON
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertIn("Location", response)
|
||||
@@ -267,6 +254,16 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations
|
||||
self.assertEqual(user_partititons[0].groups[0].name, u'Group A')
|
||||
self.assertEqual(user_partititons[0].groups[1].name, u'Group B')
|
||||
|
||||
def test_lazily_creates_cohort_configuration(self):
|
||||
"""
|
||||
Test that a cohort schemed user partition is NOT created by
|
||||
default for the user.
|
||||
"""
|
||||
self.assertEqual(len(self.course.user_partitions), 0)
|
||||
self.client.get(self._url())
|
||||
self.reload_course()
|
||||
self.assertEqual(len(self.course.user_partitions), 0)
|
||||
|
||||
|
||||
# pylint: disable=no-member
|
||||
class GroupConfigurationsDetailHandlerTestCase(CourseTestCase, GroupConfigurationsBaseTestCase, HelperMethods):
|
||||
@@ -436,7 +433,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
Test that right data structure will be created if group configuration is not used.
|
||||
"""
|
||||
self._add_user_partitions()
|
||||
actual = GroupConfiguration.add_usage_info(self.course, self.store)
|
||||
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)
|
||||
expected = [{
|
||||
'id': 0,
|
||||
'name': 'Name 0',
|
||||
@@ -460,7 +457,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0')
|
||||
self._create_content_experiment(name_suffix='1')
|
||||
|
||||
actual = GroupConfiguration.add_usage_info(self.course, self.store)
|
||||
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)
|
||||
|
||||
expected = [{
|
||||
'id': 0,
|
||||
@@ -503,7 +500,7 @@ class GroupConfigurationsUsageInfoTestCase(CourseTestCase, HelperMethods):
|
||||
vertical, __ = self._create_content_experiment(cid=0, name_suffix='0')
|
||||
vertical1, __ = self._create_content_experiment(cid=0, name_suffix='1')
|
||||
|
||||
actual = GroupConfiguration.add_usage_info(self.course, self.store)
|
||||
actual = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)
|
||||
|
||||
expected = [{
|
||||
'id': 0,
|
||||
@@ -567,7 +564,7 @@ class GroupConfigurationsValidationTestCase(CourseTestCase, HelperMethods):
|
||||
validation.add(mocked_message)
|
||||
mocked_validation_messages.return_value = validation
|
||||
|
||||
group_configuration = GroupConfiguration.add_usage_info(self.course, self.store)[0]
|
||||
group_configuration = GroupConfiguration.get_split_test_partitions_with_usage(self.course, self.store)[0]
|
||||
self.assertEqual(expected_result.to_json(), group_configuration['usage'][0]['validation'])
|
||||
|
||||
def test_error_message_present(self):
|
||||
|
||||
@@ -18,8 +18,9 @@ from contentstore.views.component import (
|
||||
component_handler, get_component_templates
|
||||
)
|
||||
|
||||
|
||||
from contentstore.views.item import create_xblock_info, ALWAYS, VisibilityState, _xblock_type_and_display_name
|
||||
from contentstore.views.item import (
|
||||
create_xblock_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 xmodule.capa_module import CapaDescriptor
|
||||
@@ -116,9 +117,9 @@ class GetItemTest(ItemTest):
|
||||
return resp
|
||||
|
||||
@ddt.data(
|
||||
(1, 21, 23, 35, 37),
|
||||
(2, 22, 24, 38, 39),
|
||||
(3, 23, 25, 41, 41),
|
||||
(1, 16, 14, 15, 11),
|
||||
(2, 16, 14, 15, 11),
|
||||
(3, 16, 14, 15, 11),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_get_query_count(self, branching_factor, chapter_queries, section_queries, unit_queries, problem_queries):
|
||||
@@ -133,6 +134,17 @@ class GetItemTest(ItemTest):
|
||||
with check_mongo_calls(problem_queries):
|
||||
self.client.get(reverse_usage_url('xblock_handler', self.populated_usage_keys['problem'][-1]))
|
||||
|
||||
@ddt.data(
|
||||
(1, 26),
|
||||
(2, 28),
|
||||
(3, 30),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_container_get_query_count(self, branching_factor, unit_queries,):
|
||||
self.populate_course(branching_factor)
|
||||
with check_mongo_calls(unit_queries):
|
||||
self.client.get(reverse_usage_url('xblock_container_handler', self.populated_usage_keys['vertical'][-1]))
|
||||
|
||||
def test_get_vertical(self):
|
||||
# Add a vertical
|
||||
resp = self.create_xblock(category='vertical')
|
||||
@@ -411,21 +423,46 @@ class TestDuplicateItem(ItemTest):
|
||||
except for location and display name.
|
||||
"""
|
||||
def duplicate_and_verify(source_usage_key, parent_usage_key):
|
||||
""" Duplicates the source, parenting to supplied parent. Then does equality check. """
|
||||
usage_key = self._duplicate_item(parent_usage_key, source_usage_key)
|
||||
self.assertTrue(check_equality(source_usage_key, usage_key), "Duplicated item differs from original")
|
||||
self.assertTrue(
|
||||
check_equality(source_usage_key, usage_key, parent_usage_key),
|
||||
"Duplicated item differs from original"
|
||||
)
|
||||
|
||||
def check_equality(source_usage_key, duplicate_usage_key):
|
||||
def check_equality(source_usage_key, duplicate_usage_key, parent_usage_key=None):
|
||||
"""
|
||||
Gets source and duplicated items from the modulestore using supplied usage keys.
|
||||
Then verifies that they represent equivalent items (modulo parents and other
|
||||
known things that may differ).
|
||||
"""
|
||||
original_item = self.get_item_from_modulestore(source_usage_key)
|
||||
duplicated_item = self.get_item_from_modulestore(duplicate_usage_key)
|
||||
|
||||
self.assertNotEqual(
|
||||
original_item.location,
|
||||
duplicated_item.location,
|
||||
unicode(original_item.location),
|
||||
unicode(duplicated_item.location),
|
||||
"Location of duplicate should be different from original"
|
||||
)
|
||||
# Set the location and display name to be the same so we can make sure the rest of the duplicate is equal.
|
||||
|
||||
# Parent will only be equal for root of duplicated structure, in the case
|
||||
# where an item is duplicated in-place.
|
||||
if parent_usage_key and unicode(original_item.parent) == unicode(parent_usage_key):
|
||||
self.assertEqual(
|
||||
unicode(parent_usage_key), unicode(duplicated_item.parent),
|
||||
"Parent of duplicate should equal parent of source for root xblock when duplicated in-place"
|
||||
)
|
||||
else:
|
||||
self.assertNotEqual(
|
||||
unicode(original_item.parent), unicode(duplicated_item.parent),
|
||||
"Parent duplicate should be different from source"
|
||||
)
|
||||
|
||||
# Set the location, display name, and parent to be the same so we can make sure the rest of the
|
||||
# duplicate is equal.
|
||||
duplicated_item.location = original_item.location
|
||||
duplicated_item.display_name = original_item.display_name
|
||||
duplicated_item.parent = original_item.parent
|
||||
|
||||
# Children will also be duplicated, so for the purposes of testing equality, we will set
|
||||
# the children to the original after recursively checking the children.
|
||||
@@ -1367,6 +1404,7 @@ class TestXBlockInfo(ItemTest):
|
||||
include_children_predicate=ALWAYS,
|
||||
include_ancestor_info=True
|
||||
)
|
||||
add_container_page_publishing_info(vertical, xblock_info)
|
||||
self.validate_vertical_xblock_info(xblock_info)
|
||||
|
||||
def test_component_xblock_info(self):
|
||||
@@ -1487,10 +1525,6 @@ class TestXBlockInfo(ItemTest):
|
||||
)
|
||||
else:
|
||||
self.assertIsNone(xblock_info.get('child_info', None))
|
||||
if xblock_info['category'] == 'vertical' and not course_outline:
|
||||
self.assertEqual(xblock_info['edited_by'], 'testuser')
|
||||
else:
|
||||
self.assertIsNone(xblock_info.get('edited_by', None))
|
||||
|
||||
|
||||
class TestLibraryXBlockInfo(ModuleStoreTestCase):
|
||||
@@ -1595,7 +1629,8 @@ class TestXBlockPublishingInfo(ItemTest):
|
||||
)
|
||||
if staff_only:
|
||||
self._enable_staff_only(child.location)
|
||||
return child
|
||||
# In case the staff_only state was set, return the updated xblock.
|
||||
return modulestore().get_item(child.location)
|
||||
|
||||
def _get_child_xblock_info(self, xblock_info, index):
|
||||
"""
|
||||
@@ -1684,12 +1719,6 @@ class TestXBlockPublishingInfo(ItemTest):
|
||||
"""
|
||||
self._verify_xblock_info_state(xblock_info, 'has_explicit_staff_lock', expected_state, path, should_equal)
|
||||
|
||||
def _verify_staff_lock_from_state(self, xblock_info, expected_state, path=None, should_equal=True):
|
||||
"""
|
||||
Verify the staff_lock_from state of an item in the xblock_info.
|
||||
"""
|
||||
self._verify_xblock_info_state(xblock_info, 'staff_lock_from', expected_state, path, should_equal)
|
||||
|
||||
def test_empty_chapter(self):
|
||||
empty_chapter = self._create_child(self.course, 'chapter', "Empty Chapter")
|
||||
xblock_info = self._get_xblock_info(empty_chapter.location)
|
||||
@@ -1779,7 +1808,7 @@ class TestXBlockPublishingInfo(ItemTest):
|
||||
"""
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter", staff_only=True)
|
||||
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
|
||||
self._create_child(sequential, 'vertical', "Unit")
|
||||
vertical = self._create_child(sequential, 'vertical', "Unit")
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH)
|
||||
@@ -1789,7 +1818,9 @@ class TestXBlockPublishingInfo(ItemTest):
|
||||
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_SUBSECTION_PATH)
|
||||
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_UNIT_PATH)
|
||||
|
||||
self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(chapter), path=self.FIRST_UNIT_PATH)
|
||||
vertical_info = self._get_xblock_info(vertical.location)
|
||||
add_container_page_publishing_info(vertical, vertical_info)
|
||||
self.assertEqual(_xblock_type_and_display_name(chapter), vertical_info["staff_lock_from"])
|
||||
|
||||
def test_no_staff_only_section(self):
|
||||
"""
|
||||
@@ -1810,7 +1841,7 @@ class TestXBlockPublishingInfo(ItemTest):
|
||||
"""
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
|
||||
sequential = self._create_child(chapter, 'sequential', "Test Sequential", staff_only=True)
|
||||
self._create_child(sequential, 'vertical', "Unit")
|
||||
vertical = self._create_child(sequential, 'vertical', "Unit")
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH)
|
||||
@@ -1820,7 +1851,9 @@ class TestXBlockPublishingInfo(ItemTest):
|
||||
self._verify_explicit_staff_lock_state(xblock_info, True, path=self.FIRST_SUBSECTION_PATH)
|
||||
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_UNIT_PATH)
|
||||
|
||||
self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(sequential), path=self.FIRST_UNIT_PATH)
|
||||
vertical_info = self._get_xblock_info(vertical.location)
|
||||
add_container_page_publishing_info(vertical, vertical_info)
|
||||
self.assertEqual(_xblock_type_and_display_name(sequential), vertical_info["staff_lock_from"])
|
||||
|
||||
def test_no_staff_only_subsection(self):
|
||||
"""
|
||||
@@ -1838,7 +1871,7 @@ class TestXBlockPublishingInfo(ItemTest):
|
||||
def test_staff_only_unit(self):
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
|
||||
sequential = self._create_child(chapter, 'sequential', "Test Sequential")
|
||||
unit = self._create_child(sequential, 'vertical', "Unit", staff_only=True)
|
||||
vertical = self._create_child(sequential, 'vertical', "Unit", staff_only=True)
|
||||
xblock_info = self._get_xblock_info(chapter.location)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only)
|
||||
self._verify_visibility_state(xblock_info, VisibilityState.staff_only, path=self.FIRST_SUBSECTION_PATH)
|
||||
@@ -1848,7 +1881,9 @@ class TestXBlockPublishingInfo(ItemTest):
|
||||
self._verify_explicit_staff_lock_state(xblock_info, False, path=self.FIRST_SUBSECTION_PATH)
|
||||
self._verify_explicit_staff_lock_state(xblock_info, True, path=self.FIRST_UNIT_PATH)
|
||||
|
||||
self._verify_staff_lock_from_state(xblock_info, _xblock_type_and_display_name(unit), path=self.FIRST_UNIT_PATH)
|
||||
vertical_info = self._get_xblock_info(vertical.location)
|
||||
add_container_page_publishing_info(vertical, vertical_info)
|
||||
self.assertEqual(_xblock_type_and_display_name(vertical), vertical_info["staff_lock_from"])
|
||||
|
||||
def test_unscheduled_section_with_live_subsection(self):
|
||||
chapter = self._create_child(self.course, 'chapter', "Test Chapter")
|
||||
|
||||
@@ -37,6 +37,7 @@ from path import path
|
||||
from warnings import simplefilter
|
||||
|
||||
from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin
|
||||
from cms.lib.xblock.authoring_mixin import AuthoringMixin
|
||||
import dealer.git
|
||||
from xmodule.modulestore.edit_info import EditInfoMixin
|
||||
|
||||
@@ -121,6 +122,12 @@ FEATURES = {
|
||||
# for consistency in user-experience, keep the value of this feature flag
|
||||
# in sync with the one in lms/envs/common.py
|
||||
'ENABLE_EDXNOTES': False,
|
||||
|
||||
# Enable support for content libraries. Note that content libraries are
|
||||
# only supported in courses using split mongo. Change the setting
|
||||
# DEFAULT_STORE_FOR_NEW_COURSE to be 'split' to have future courses
|
||||
# and libraries created with split.
|
||||
'ENABLE_CONTENT_LIBRARIES': False,
|
||||
}
|
||||
ENABLE_JASMINE = False
|
||||
|
||||
@@ -269,7 +276,13 @@ from xmodule.x_module import XModuleMixin
|
||||
|
||||
# This should be moved into an XBlock Runtime/Application object
|
||||
# once the responsibility of XBlock creation is moved out of modulestore - cpennington
|
||||
XBLOCK_MIXINS = (LmsBlockMixin, InheritanceMixin, XModuleMixin, EditInfoMixin)
|
||||
XBLOCK_MIXINS = (
|
||||
LmsBlockMixin,
|
||||
InheritanceMixin,
|
||||
XModuleMixin,
|
||||
EditInfoMixin,
|
||||
AuthoringMixin,
|
||||
)
|
||||
|
||||
# Allow any XBlock in Studio
|
||||
# You should also enable the ALLOW_ALL_ADVANCED_COMPONENTS feature flag, so that
|
||||
|
||||
49
cms/lib/xblock/authoring_mixin.py
Normal file
49
cms/lib/xblock/authoring_mixin.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Mixin class that provides authoring capabilities for XBlocks.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import XBlockMixin
|
||||
from xblock.fragment import Fragment
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
VISIBILITY_VIEW = 'visibility_view'
|
||||
|
||||
|
||||
@XBlock.needs("i18n")
|
||||
class AuthoringMixin(XBlockMixin):
|
||||
"""
|
||||
Mixin class that provides authoring capabilities for XBlocks.
|
||||
"""
|
||||
_services_requested = {
|
||||
'i18n': 'need',
|
||||
}
|
||||
|
||||
def _get_studio_resource_url(self, relative_url):
|
||||
"""
|
||||
Returns the Studio URL to a static resource.
|
||||
"""
|
||||
# TODO: is there a cleaner way to do this?
|
||||
from cms.envs.common import STATIC_URL
|
||||
return STATIC_URL + relative_url
|
||||
|
||||
def visibility_view(self, _context=None):
|
||||
"""
|
||||
Render the view to manage an xblock's visibility settings in Studio.
|
||||
Args:
|
||||
_context: Not actively used for this view.
|
||||
Returns:
|
||||
(Fragment): An HTML fragment for editing the visibility of this XBlock.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
from contentstore.utils import reverse_course_url
|
||||
fragment.add_content(self.system.render_template('visibility_editor.html', {
|
||||
'xblock': self,
|
||||
'manage_groups_url': reverse_course_url('group_configurations_list_handler', self.location.course_key),
|
||||
}))
|
||||
fragment.add_javascript_url(self._get_studio_resource_url('/js/xblock/authoring.js'))
|
||||
fragment.initialize_js('VisibilityEditorInit')
|
||||
return fragment
|
||||
125
cms/lib/xblock/test/test_authoring_mixin.py
Normal file
125
cms/lib/xblock/test/test_authoring_mixin.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Tests for the Studio authoring XBlock mixin.
|
||||
"""
|
||||
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
|
||||
|
||||
class AuthoringMixinTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the studio authoring XBlock mixin.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a simple course with a video component.
|
||||
"""
|
||||
super(AuthoringMixinTestCase, self).setUp()
|
||||
self.course = CourseFactory.create()
|
||||
chapter = ItemFactory.create(
|
||||
category='chapter',
|
||||
parent_location=self.course.location,
|
||||
display_name='Test Chapter'
|
||||
)
|
||||
sequential = ItemFactory.create(
|
||||
category='sequential',
|
||||
parent_location=chapter.location,
|
||||
display_name='Test Sequential'
|
||||
)
|
||||
self.vertical = ItemFactory.create(
|
||||
category='vertical',
|
||||
parent_location=sequential.location,
|
||||
display_name='Test Vertical'
|
||||
)
|
||||
self.video = ItemFactory.create(
|
||||
category='video',
|
||||
parent_location=self.vertical.location,
|
||||
display_name='Test Vertical'
|
||||
)
|
||||
self.pet_groups = [Group(1, 'Cat Lovers'), Group(2, 'Dog Lovers')]
|
||||
|
||||
def create_content_groups(self, content_groups):
|
||||
"""
|
||||
Create a cohorted user partition with the specified content groups.
|
||||
"""
|
||||
# pylint: disable=attribute-defined-outside-init
|
||||
self.content_partition = UserPartition(
|
||||
1,
|
||||
'Content Groups',
|
||||
'Contains Groups for Cohorted Courseware',
|
||||
content_groups,
|
||||
scheme_id='cohort'
|
||||
)
|
||||
self.course.user_partitions = [self.content_partition]
|
||||
self.store.update_item(self.course, self.user.id)
|
||||
|
||||
def set_staff_only(self, item):
|
||||
"""Make an item visible to staff only."""
|
||||
item.visible_to_staff_only = True
|
||||
self.store.update_item(item, self.user.id)
|
||||
|
||||
def set_group_access(self, item, group_ids):
|
||||
"""
|
||||
Set group_access for the specified item to the specified group
|
||||
ids within the content partition.
|
||||
"""
|
||||
item.group_access[self.content_partition.id] = group_ids # pylint: disable=no-member
|
||||
self.store.update_item(item, self.user.id)
|
||||
|
||||
def verify_visibility_view_contains(self, item, substrings):
|
||||
"""
|
||||
Verify that an item's visibility view returns an html string
|
||||
containing all the expected substrings.
|
||||
"""
|
||||
html = item.visibility_view().body_html()
|
||||
for string in substrings:
|
||||
self.assertIn(string, html)
|
||||
|
||||
def test_html_no_partition(self):
|
||||
self.verify_visibility_view_contains(self.video, 'No content groups exist')
|
||||
|
||||
def test_html_empty_partition(self):
|
||||
self.create_content_groups([])
|
||||
self.verify_visibility_view_contains(self.video, 'No content groups exist')
|
||||
|
||||
def test_html_populated_partition(self):
|
||||
self.create_content_groups(self.pet_groups)
|
||||
self.verify_visibility_view_contains(self.video, ['Cat Lovers', 'Dog Lovers'])
|
||||
|
||||
def test_html_no_partition_staff_locked(self):
|
||||
self.set_staff_only(self.vertical)
|
||||
self.verify_visibility_view_contains(self.video, ['No content groups exist'])
|
||||
|
||||
def test_html_empty_partition_staff_locked(self):
|
||||
self.create_content_groups([])
|
||||
self.set_staff_only(self.vertical)
|
||||
self.verify_visibility_view_contains(self.video, 'No content groups exist')
|
||||
|
||||
def test_html_populated_partition_staff_locked(self):
|
||||
self.create_content_groups(self.pet_groups)
|
||||
self.set_staff_only(self.vertical)
|
||||
self.verify_visibility_view_contains(
|
||||
self.video, ['The Unit this component is contained in is hidden from students.', 'Cat Lovers', 'Dog Lovers']
|
||||
)
|
||||
|
||||
def test_html_false_content_group(self):
|
||||
self.create_content_groups(self.pet_groups)
|
||||
self.set_group_access(self.video, ['false_group_id'])
|
||||
self.verify_visibility_view_contains(
|
||||
self.video, ['Cat Lovers', 'Dog Lovers', 'Content group no longer exists.']
|
||||
)
|
||||
|
||||
def test_html_false_content_group_staff_locked(self):
|
||||
self.create_content_groups(self.pet_groups)
|
||||
self.set_staff_only(self.vertical)
|
||||
self.set_group_access(self.video, ['false_group_id'])
|
||||
self.verify_visibility_view_contains(
|
||||
self.video,
|
||||
[
|
||||
'Cat Lovers',
|
||||
'Dog Lovers',
|
||||
'The Unit this component is contained in is hidden from students.',
|
||||
'Content group no longer exists.'
|
||||
]
|
||||
)
|
||||
@@ -52,7 +52,5 @@ define ["jquery", "underscore", "gettext", "xblock/runtime.v1",
|
||||
|
||||
clickEditButton: (event) ->
|
||||
event.preventDefault()
|
||||
modal = new EditXBlockModal({
|
||||
view: 'student_view'
|
||||
});
|
||||
modal = new EditXBlockModal();
|
||||
modal.edit(this.$el, self.model, { refresh: _.bind(@render, this) })
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
define([
|
||||
'jquery', 'underscore', 'js/models/xblock_info', 'js/views/pages/container',
|
||||
'jquery', 'underscore', 'js/models/xblock_container_info', 'js/views/pages/container',
|
||||
'js/collections/component_template', 'xmodule', 'coffee/src/main',
|
||||
'xblock/cms.runtime.v1'
|
||||
],
|
||||
function($, _, XBlockInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
|
||||
function($, _, XBlockContainerInfo, ContainerPage, ComponentTemplates, xmoduleLoader) {
|
||||
'use strict';
|
||||
return function (componentTemplates, XBlockInfoJson, action, options) {
|
||||
var main_options = {
|
||||
el: $('#content'),
|
||||
model: new XBlockInfo(XBlockInfoJson, {parse: true}),
|
||||
model: new XBlockContainerInfo(XBlockInfoJson, {parse: true}),
|
||||
action: action,
|
||||
templates: new ComponentTemplates(componentTemplates, {parse: true})
|
||||
};
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
define([
|
||||
'js/collections/group_configuration', 'js/views/pages/group_configurations'
|
||||
], function(GroupConfigurationCollection, GroupConfigurationsPage) {
|
||||
'js/collections/group_configuration', 'js/models/group_configuration', 'js/views/pages/group_configurations'
|
||||
], function(GroupConfigurationCollection, GroupConfigurationModel, GroupConfigurationsPage) {
|
||||
'use strict';
|
||||
return function (configurations, groupConfigurationUrl, courseOutlineUrl) {
|
||||
var collection = new GroupConfigurationCollection(configurations, { parse: true }),
|
||||
configurationsPage;
|
||||
return function (experimentsEnabled, experimentGroupConfigurationsJson, contentGroupConfigurationJson,
|
||||
groupConfigurationUrl, courseOutlineUrl) {
|
||||
var experimentGroupConfigurations = new GroupConfigurationCollection(
|
||||
experimentGroupConfigurationsJson, {parse: true}
|
||||
),
|
||||
contentGroupConfiguration = new GroupConfigurationModel(contentGroupConfigurationJson, {parse: true});
|
||||
|
||||
collection.url = groupConfigurationUrl;
|
||||
collection.outlineUrl = courseOutlineUrl;
|
||||
configurationsPage = new GroupConfigurationsPage({
|
||||
experimentGroupConfigurations.url = groupConfigurationUrl;
|
||||
experimentGroupConfigurations.outlineUrl = courseOutlineUrl;
|
||||
contentGroupConfiguration.urlRoot = groupConfigurationUrl;
|
||||
new GroupConfigurationsPage({
|
||||
el: $('#content'),
|
||||
collection: collection
|
||||
experimentsEnabled: experimentsEnabled,
|
||||
experimentGroupConfigurations: experimentGroupConfigurations,
|
||||
contentGroupConfiguration: contentGroupConfiguration
|
||||
}).render();
|
||||
};
|
||||
});
|
||||
|
||||
10
cms/static/js/models/custom_sync_xblock_info.js
Normal file
10
cms/static/js/models/custom_sync_xblock_info.js
Normal file
@@ -0,0 +1,10 @@
|
||||
define(["js/models/xblock_info"],
|
||||
function(XBlockInfo) {
|
||||
var CustomSyncXBlockInfo = XBlockInfo.extend({
|
||||
sync: function(method, model, options) {
|
||||
options.url = (this.urlRoots[method] || this.urlRoot) + '/' + this.get('id');
|
||||
return XBlockInfo.prototype.sync.call(this, method, model, options);
|
||||
}
|
||||
});
|
||||
return CustomSyncXBlockInfo;
|
||||
});
|
||||
9
cms/static/js/models/xblock_container_info.js
Normal file
9
cms/static/js/models/xblock_container_info.js
Normal file
@@ -0,0 +1,9 @@
|
||||
define(["js/models/custom_sync_xblock_info"],
|
||||
function(CustomSyncXBlockInfo) {
|
||||
var XBlockContainerInfo = CustomSyncXBlockInfo.extend({
|
||||
urlRoots: {
|
||||
'read': '/xblock/container'
|
||||
}
|
||||
});
|
||||
return XBlockContainerInfo;
|
||||
});
|
||||
@@ -32,7 +32,8 @@ function(Backbone, _, str, ModuleUtils) {
|
||||
*/
|
||||
'edited_on':null,
|
||||
/**
|
||||
* User who last edited the xblock or any of its descendants.
|
||||
* User who last edited the xblock or any of its descendants. Will only be present if
|
||||
* publishing info was explicitly requested.
|
||||
*/
|
||||
'edited_by':null,
|
||||
/**
|
||||
@@ -44,7 +45,8 @@ function(Backbone, _, str, ModuleUtils) {
|
||||
*/
|
||||
'published_on': null,
|
||||
/**
|
||||
* User who last published the xblock, or null if never published.
|
||||
* User who last published the xblock, or null if never published. Will only be present if
|
||||
* publishing info was explicitly requested.
|
||||
*/
|
||||
'published_by': null,
|
||||
/**
|
||||
@@ -70,12 +72,14 @@ function(Backbone, _, str, ModuleUtils) {
|
||||
/**
|
||||
* The xblock which is determining the release date. For instance, for a unit,
|
||||
* this will either be the parent subsection or the grandparent section.
|
||||
* This can be null if the release date is unscheduled.
|
||||
* This can be null if the release date is unscheduled. Will only be present if
|
||||
* publishing info was explicitly requested.
|
||||
*/
|
||||
'release_date_from':null,
|
||||
/**
|
||||
* True if this xblock is currently visible to students. This is computed server-side
|
||||
* so that the logic isn't duplicated on the client.
|
||||
* so that the logic isn't duplicated on the client. Will only be present if
|
||||
* publishing info was explicitly requested.
|
||||
*/
|
||||
'currently_visible_to_students': null,
|
||||
/**
|
||||
@@ -114,13 +118,20 @@ function(Backbone, _, str, ModuleUtils) {
|
||||
/**
|
||||
* The xblock which is determining the staff lock value. For instance, for a unit,
|
||||
* this will either be the parent subsection or the grandparent section.
|
||||
* This can be null if the xblock has no inherited staff lock.
|
||||
* This can be null if the xblock has no inherited staff lock. Will only be present if
|
||||
* publishing info was explicitly requested.
|
||||
*/
|
||||
'staff_lock_from': null,
|
||||
/**
|
||||
* True iff this xblock should display a "Contains staff only content" message.
|
||||
*/
|
||||
'staff_only_message': null
|
||||
'staff_only_message': null,
|
||||
/**
|
||||
* True iff this xblock is a unit, and it has children that are only visible to certain
|
||||
* content groups. Note that this is not a recursive property. Will only be present if
|
||||
* publishing info was explicitly requested.
|
||||
*/
|
||||
'has_content_group_components': null
|
||||
},
|
||||
|
||||
initialize: function () {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
define(["js/models/xblock_info"],
|
||||
function(XBlockInfo) {
|
||||
var XBlockOutlineInfo = XBlockInfo.extend({
|
||||
define(["js/models/custom_sync_xblock_info"],
|
||||
function(CustomSyncXBlockInfo) {
|
||||
var XBlockOutlineInfo = CustomSyncXBlockInfo.extend({
|
||||
|
||||
urlRoots: {
|
||||
'read': '/xblock/outline'
|
||||
@@ -8,15 +8,6 @@ define(["js/models/xblock_info"],
|
||||
|
||||
createChild: function(response) {
|
||||
return new XBlockOutlineInfo(response, { parse: true });
|
||||
},
|
||||
|
||||
sync: function(method, model, options) {
|
||||
var urlRoot = this.urlRoots[method];
|
||||
if (!urlRoot) {
|
||||
urlRoot = this.urlRoot;
|
||||
}
|
||||
options.url = urlRoot + '/' + this.get('id');
|
||||
return XBlockInfo.prototype.sync.call(this, method, model, options);
|
||||
}
|
||||
});
|
||||
return XBlockOutlineInfo;
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
define([
|
||||
'underscore', 'js/models/course', 'js/models/group_configuration',
|
||||
'js/collections/group_configuration',
|
||||
'js/views/group_configuration_details',
|
||||
'js/views/group_configurations_list', 'js/views/group_configuration_edit',
|
||||
'js/views/group_configuration_item', 'js/models/group',
|
||||
'js/collections/group', 'js/views/group_edit',
|
||||
'underscore', 'js/models/course', 'js/models/group_configuration', 'js/models/group',
|
||||
'js/collections/group_configuration', 'js/collections/group',
|
||||
'js/views/group_configuration_details', 'js/views/group_configurations_list', 'js/views/group_configuration_editor',
|
||||
'js/views/group_configuration_item', 'js/views/experiment_group_edit', 'js/views/content_group_list',
|
||||
'js/views/feedback_notification', 'js/common_helpers/ajax_helpers', 'js/common_helpers/template_helpers',
|
||||
'js/spec_helpers/view_helpers', 'jasmine-stealth'
|
||||
], function(
|
||||
_, Course, GroupConfigurationModel, GroupConfigurationCollection,
|
||||
GroupConfigurationDetails, GroupConfigurationsList, GroupConfigurationEdit,
|
||||
GroupConfigurationItem, GroupModel, GroupCollection, GroupEdit,
|
||||
Notification, AjaxHelpers, TemplateHelpers, ViewHelpers
|
||||
_, Course, GroupConfigurationModel, GroupModel, GroupConfigurationCollection, GroupCollection,
|
||||
GroupConfigurationDetailsView, GroupConfigurationsListView, GroupConfigurationEditorView,
|
||||
GroupConfigurationItemView, ExperimentGroupEditView, GroupList, Notification, AjaxHelpers, TemplateHelpers,
|
||||
ViewHelpers
|
||||
) {
|
||||
'use strict';
|
||||
var SELECTORS = {
|
||||
@@ -26,7 +24,7 @@ define([
|
||||
groupsAllocation: '.group-allocation',
|
||||
errorMessage: '.group-configuration-edit-error',
|
||||
inputGroupName: '.group-name',
|
||||
inputName: '.group-configuration-name-input',
|
||||
inputName: '.collection-name-input',
|
||||
inputDescription: '.group-configuration-description-input',
|
||||
usageCount: '.group-configuration-usage-count',
|
||||
usage: '.group-configuration-usage',
|
||||
@@ -90,7 +88,7 @@ define([
|
||||
delete window.course;
|
||||
});
|
||||
|
||||
describe('GroupConfigurationDetails', function() {
|
||||
describe('Experiment group configurations details view', function() {
|
||||
beforeEach(function() {
|
||||
TemplateHelpers.installTemplate('group-configuration-details', true);
|
||||
|
||||
@@ -102,7 +100,7 @@ define([
|
||||
|
||||
this.collection = new GroupConfigurationCollection([ this.model ]);
|
||||
this.collection.outlineUrl = '/outline';
|
||||
this.view = new GroupConfigurationDetails({
|
||||
this.view = new GroupConfigurationDetailsView({
|
||||
model: this.model
|
||||
});
|
||||
appendSetFixtures(this.view.render().el);
|
||||
@@ -259,7 +257,7 @@ define([
|
||||
});
|
||||
});
|
||||
|
||||
describe('GroupConfigurationEdit', function() {
|
||||
describe('Experiment group configurations editor view', function() {
|
||||
|
||||
var setValuesToInputs = function (view, values) {
|
||||
_.each(values, function (value, selector) {
|
||||
@@ -272,7 +270,7 @@ define([
|
||||
beforeEach(function() {
|
||||
ViewHelpers.installViewTemplates();
|
||||
TemplateHelpers.installTemplates([
|
||||
'group-configuration-edit', 'group-edit'
|
||||
'group-configuration-editor', 'group-edit'
|
||||
]);
|
||||
|
||||
this.model = new GroupConfigurationModel({
|
||||
@@ -283,7 +281,7 @@ define([
|
||||
});
|
||||
this.collection = new GroupConfigurationCollection([this.model]);
|
||||
this.collection.url = '/group_configurations';
|
||||
this.view = new GroupConfigurationEdit({
|
||||
this.view = new GroupConfigurationEditorView({
|
||||
model: this.model
|
||||
});
|
||||
appendSetFixtures(this.view.render().el);
|
||||
@@ -490,15 +488,17 @@ define([
|
||||
});
|
||||
});
|
||||
|
||||
describe('GroupConfigurationsList', function() {
|
||||
var emptyMessage = 'You haven\'t created any group configurations yet.';
|
||||
describe('Experiment group configurations list view', function() {
|
||||
var emptyMessage = 'You have not created any group configurations yet.';
|
||||
|
||||
beforeEach(function() {
|
||||
TemplateHelpers.installTemplate('no-group-configurations', true);
|
||||
TemplateHelpers.installTemplates(
|
||||
['group-configuration-editor', 'group-edit', 'list']
|
||||
);
|
||||
|
||||
this.model = new GroupConfigurationModel({ id: 0 });
|
||||
this.collection = new GroupConfigurationCollection();
|
||||
this.view = new GroupConfigurationsList({
|
||||
this.view = new GroupConfigurationsListView({
|
||||
collection: this.collection
|
||||
});
|
||||
appendSetFixtures(this.view.render().el);
|
||||
@@ -526,20 +526,25 @@ define([
|
||||
expect(this.view.$el).toContainText(emptyMessage);
|
||||
expect(this.view.$(SELECTORS.itemView)).not.toExist();
|
||||
});
|
||||
|
||||
it('can create a new group configuration', function () {
|
||||
this.view.$('.new-button').click();
|
||||
expect($('.group-configuration-edit').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GroupConfigurationItem', function() {
|
||||
describe('Experiment group configurations controller view', function() {
|
||||
var clickDeleteItem;
|
||||
|
||||
beforeEach(function() {
|
||||
TemplateHelpers.installTemplates([
|
||||
'group-configuration-edit', 'group-configuration-details'
|
||||
'group-configuration-editor', 'group-configuration-details'
|
||||
], true);
|
||||
this.model = new GroupConfigurationModel({ id: 0 });
|
||||
this.collection = new GroupConfigurationCollection([ this.model ]);
|
||||
this.collection.url = '/group_configurations';
|
||||
this.view = new GroupConfigurationItem({
|
||||
this.view = new GroupConfigurationItemView({
|
||||
model: this.model
|
||||
});
|
||||
appendSetFixtures(this.view.render().el);
|
||||
@@ -547,7 +552,7 @@ define([
|
||||
|
||||
clickDeleteItem = function (view, promptSpy) {
|
||||
view.$('.delete').click();
|
||||
ViewHelpers.verifyPromptShowing(promptSpy, /Delete this Group Configuration/);
|
||||
ViewHelpers.verifyPromptShowing(promptSpy, /Delete this group configuration/);
|
||||
ViewHelpers.confirmPrompt(promptSpy);
|
||||
ViewHelpers.verifyPromptHidden(promptSpy);
|
||||
};
|
||||
@@ -598,7 +603,7 @@ define([
|
||||
});
|
||||
});
|
||||
|
||||
describe('GroupEdit', function() {
|
||||
describe('Experiment group configurations group editor view', function() {
|
||||
beforeEach(function() {
|
||||
TemplateHelpers.installTemplate('group-edit', true);
|
||||
|
||||
@@ -608,7 +613,7 @@ define([
|
||||
|
||||
this.collection = new GroupCollection([this.model]);
|
||||
|
||||
this.view = new GroupEdit({
|
||||
this.view = new ExperimentGroupEditView({
|
||||
model: this.model
|
||||
});
|
||||
});
|
||||
@@ -626,4 +631,211 @@ define([
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content groups list view', function() {
|
||||
var newGroupCss = '.new-button',
|
||||
addGroupCss = '.action-add',
|
||||
inputCss = '.collection-name-input',
|
||||
saveButtonCss = '.action-primary',
|
||||
cancelButtonCss = '.action-cancel',
|
||||
validationErrorCss = '.content-group-edit-error',
|
||||
scopedGroupSelector, createGroups, renderView, saveOrCancel, editNewGroup, editExistingGroup,
|
||||
verifyEditingGroup, respondToSave, expectGroupsVisible, correctValidationError;
|
||||
|
||||
scopedGroupSelector = function(groupIndex, additionalSelectors) {
|
||||
var groupSelector = '.content-groups-list-item-' + groupIndex;
|
||||
if (additionalSelectors) {
|
||||
return groupSelector + ' ' + additionalSelectors;
|
||||
} else {
|
||||
return groupSelector;
|
||||
}
|
||||
};
|
||||
|
||||
createGroups = function (groupNames) {
|
||||
var groups = new GroupCollection(_.map(groupNames, function (groupName) {
|
||||
return {name: groupName};
|
||||
})),
|
||||
groupConfiguration = new GroupConfigurationModel({
|
||||
id: 0,
|
||||
name: 'Content Group Configuration',
|
||||
groups: groups
|
||||
});
|
||||
groupConfiguration.urlRoot = '/mock_url';
|
||||
return groups;
|
||||
};
|
||||
|
||||
renderView = function(groupNames) {
|
||||
var view = new GroupList({collection: createGroups(groupNames || [])}).render();
|
||||
appendSetFixtures(view.el);
|
||||
return view;
|
||||
};
|
||||
|
||||
saveOrCancel = function(view, options, groupIndex) {
|
||||
if (options.save) {
|
||||
view.$(scopedGroupSelector(groupIndex, saveButtonCss)).click();
|
||||
} else if (options.cancel) {
|
||||
view.$(scopedGroupSelector(groupIndex, cancelButtonCss)).click();
|
||||
}
|
||||
};
|
||||
|
||||
editNewGroup = function(view, options) {
|
||||
var newGroupIndex;
|
||||
if (view.collection.length === 0) {
|
||||
view.$(newGroupCss).click();
|
||||
} else {
|
||||
view.$(addGroupCss).click();
|
||||
}
|
||||
newGroupIndex = view.collection.length - 1;
|
||||
view.$(inputCss).val(options.newName);
|
||||
verifyEditingGroup(view, true, newGroupIndex);
|
||||
saveOrCancel(view, options, newGroupIndex);
|
||||
};
|
||||
|
||||
editExistingGroup = function(view, options) {
|
||||
var groupIndex = options.groupIndex || 0;
|
||||
view.$(scopedGroupSelector(groupIndex, '.edit')).click();
|
||||
view.$(scopedGroupSelector(groupIndex, inputCss)).val(options.newName);
|
||||
saveOrCancel(view, options, groupIndex);
|
||||
};
|
||||
|
||||
verifyEditingGroup = function(view, expectEditing, index) {
|
||||
// Should prevent the user from opening more than one edit
|
||||
// form at a time by removing the add button(s) when
|
||||
// editing a group.
|
||||
index = index || 0;
|
||||
if (expectEditing) {
|
||||
expect(view.$(scopedGroupSelector(index, '.content-group-edit'))).toExist();
|
||||
expect(view.$(newGroupCss)).not.toExist();
|
||||
expect(view.$(addGroupCss)).toHaveClass('is-hidden');
|
||||
} else {
|
||||
expect(view.$('.content-group-edit')).not.toExist();
|
||||
if (view.collection.length === 0) {
|
||||
expect(view.$(newGroupCss)).toExist();
|
||||
expect(view.$(addGroupCss)).not.toExist();
|
||||
} else {
|
||||
expect(view.$(newGroupCss)).not.toExist();
|
||||
expect(view.$(addGroupCss)).not.toHaveClass('is-hidden');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
respondToSave = function(requests, view) {
|
||||
expect(requests.length).toBe(1);
|
||||
expect(requests[0].method).toBe('POST');
|
||||
expect(requests[0].url).toBe('/mock_url/0');
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
name: 'Content Group Configuration',
|
||||
groups: view.collection.map(function(groupModel, index) {
|
||||
return _.extend(groupModel.toJSON(), {id: index});
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
correctValidationError = function(view, requests, newGroupName) {
|
||||
expect(view.$(validationErrorCss)).toExist();
|
||||
verifyEditingGroup(view, true);
|
||||
view.$(inputCss).val(newGroupName);
|
||||
view.$(saveButtonCss).click();
|
||||
respondToSave(requests, view);
|
||||
expect(view.$(validationErrorCss)).not.toExist();
|
||||
};
|
||||
|
||||
expectGroupsVisible = function(view, groupNames) {
|
||||
_.each(groupNames, function(groupName) {
|
||||
expect(view.$('.content-groups-list-item')).toContainText(groupName);
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
TemplateHelpers.installTemplates(
|
||||
['content-group-editor', 'content-group-details', 'list']
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a message when no groups are present', function() {
|
||||
expect(renderView().$('.no-content'))
|
||||
.toContainText('You have not created any content groups yet.');
|
||||
});
|
||||
|
||||
it('can render groups', function() {
|
||||
var groupNames = ['Group 1', 'Group 2', 'Group 3'];
|
||||
renderView(groupNames).$('.content-group-details').each(function(index) {
|
||||
expect($(this)).toContainText(groupNames[index]);
|
||||
});
|
||||
});
|
||||
|
||||
it('can create an initial group and save', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
newGroupName = 'New Group Name',
|
||||
view = renderView();
|
||||
editNewGroup(view, {newName: newGroupName, save: true});
|
||||
respondToSave(requests, view);
|
||||
verifyEditingGroup(view, false);
|
||||
expectGroupsVisible(view, [newGroupName]);
|
||||
});
|
||||
|
||||
it('can add another group and save', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
oldGroupName = 'Old Group Name',
|
||||
newGroupName = 'New Group Name',
|
||||
view = renderView([oldGroupName]);
|
||||
editNewGroup(view, {newName: newGroupName, save: true});
|
||||
respondToSave(requests, view);
|
||||
verifyEditingGroup(view, false, 1);
|
||||
expectGroupsVisible(view, [oldGroupName, newGroupName]);
|
||||
});
|
||||
|
||||
it('can cancel adding a group', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
newGroupName = 'New Group Name',
|
||||
view = renderView();
|
||||
editNewGroup(view, {newName: newGroupName, cancel: true});
|
||||
expect(requests.length).toBe(0);
|
||||
verifyEditingGroup(view, false);
|
||||
expect(view.$()).not.toContainText(newGroupName);
|
||||
});
|
||||
|
||||
it('can cancel editing a group', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
originalGroupName = 'Original Group Name',
|
||||
view = renderView([originalGroupName]);
|
||||
editExistingGroup(view, {newName: 'New Group Name', cancel: true});
|
||||
verifyEditingGroup(view, false);
|
||||
expect(requests.length).toBe(0);
|
||||
expect(view.collection.at(0).get('name')).toBe(originalGroupName);
|
||||
});
|
||||
|
||||
it('can show and correct a validation error', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
newGroupName = 'New Group Name',
|
||||
view = renderView();
|
||||
editNewGroup(view, {newName: '', save: true});
|
||||
expect(requests.length).toBe(0);
|
||||
correctValidationError(view, requests, newGroupName);
|
||||
});
|
||||
|
||||
it('can not invalidate an existing content group', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
oldGroupName = 'Old Group Name',
|
||||
view = renderView([oldGroupName]);
|
||||
editExistingGroup(view, {newName: '', save: true});
|
||||
expect(requests.length).toBe(0);
|
||||
correctValidationError(view, requests, oldGroupName);
|
||||
});
|
||||
|
||||
it('trims whitespace', function() {
|
||||
var requests = AjaxHelpers.requests(this),
|
||||
newGroupName = 'New Group Name',
|
||||
view = renderView();
|
||||
editNewGroup(view, {newName: ' ' + newGroupName + ' ', save: true});
|
||||
respondToSave(requests, view);
|
||||
expect(view.collection.at(0).get('name')).toBe(newGroupName);
|
||||
});
|
||||
|
||||
it('only edits one form at a time', function() {
|
||||
var view = renderView();
|
||||
view.collection.add({name: 'Editing Group', editing: true});
|
||||
verifyEditingGroup(view, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_helpers",
|
||||
"js/common_helpers/template_helpers", "js/spec_helpers/edit_helpers",
|
||||
"js/views/pages/container", "js/views/pages/paged_container", "js/models/xblock_info"],
|
||||
"js/views/pages/container", "js/views/pages/paged_container", "js/models/xblock_info", "jquery.simulate"],
|
||||
function ($, _, str, AjaxHelpers, TemplateHelpers, EditHelpers, ContainerPage, PagedContainerPage, XBlockInfo) {
|
||||
|
||||
function parameterized_suite(label, global_page_options, fixtures) {
|
||||
@@ -14,7 +14,9 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
|
||||
mockBadXBlockContainerXBlockHtml = readFixtures('mock/mock-bad-xblock-container-xblock.underscore'),
|
||||
mockUpdatedContainerXBlockHtml = readFixtures('mock/mock-updated-container-xblock.underscore'),
|
||||
mockXBlockEditorHtml = readFixtures('mock/mock-xblock-editor.underscore'),
|
||||
PageClass = fixtures.page;
|
||||
mockXBlockVisibilityEditorHtml = readFixtures('mock/mock-xblock-visibility-editor.underscore'),
|
||||
PageClass = fixtures.page,
|
||||
hasVisibilityEditor = fixtures.has_visibility_editor;
|
||||
|
||||
beforeEach(function () {
|
||||
var newDisplayName = 'New Display Name';
|
||||
@@ -219,6 +221,26 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
|
||||
});
|
||||
expect(EditHelpers.isShowingModal()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('can show a visibility modal for a child xblock if supported for the page', function() {
|
||||
var visibilityButtons;
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
visibilityButtons = containerPage.$('.wrapper-xblock .visibility-button');
|
||||
if (hasVisibilityEditor) {
|
||||
expect(visibilityButtons.length).toBe(6);
|
||||
visibilityButtons[0].click();
|
||||
expect(str.startsWith(lastRequest().url, '/xblock/locator-component-A1/visibility_view'))
|
||||
.toBeTruthy();
|
||||
AjaxHelpers.respondWithJson(requests, {
|
||||
html: mockXBlockVisibilityEditorHtml,
|
||||
resources: []
|
||||
});
|
||||
expect(EditHelpers.isShowingModal()).toBeTruthy();
|
||||
}
|
||||
else {
|
||||
expect(visibilityButtons.length).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Editing an xmodule", function () {
|
||||
@@ -572,19 +594,25 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
|
||||
});
|
||||
}
|
||||
|
||||
// Create a suite for a non-paged container that includes 'edit visibility' buttons
|
||||
parameterized_suite("Non paged",
|
||||
{ },
|
||||
{
|
||||
page: ContainerPage,
|
||||
initial: 'mock/mock-container-xblock.underscore',
|
||||
add_response: 'mock/mock-xblock.underscore'
|
||||
add_response: 'mock/mock-xblock.underscore',
|
||||
has_visibility_editor: true
|
||||
}
|
||||
);
|
||||
|
||||
// Create a suite for a paged container that does not include 'edit visibility' buttons
|
||||
parameterized_suite("Paged",
|
||||
{ page_size: 42 },
|
||||
{
|
||||
page: PagedContainerPage,
|
||||
initial: 'mock/mock-container-paged-xblock.underscore',
|
||||
add_response: 'mock/mock-xblock-paged.underscore'
|
||||
});
|
||||
add_response: 'mock/mock-xblock-paged.underscore',
|
||||
has_visibility_editor: false
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,7 +80,8 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
|
||||
|
||||
describe("PreviewActionController", function () {
|
||||
var viewPublishedCss = '.button-view',
|
||||
previewCss = '.button-preview';
|
||||
previewCss = '.button-preview',
|
||||
visibilityNoteCss = '.note-visibility';
|
||||
|
||||
it('renders correctly for unscheduled unit', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
@@ -109,6 +110,18 @@ define(["jquery", "underscore", "underscore.string", "js/common_helpers/ajax_hel
|
||||
fetch({published: false, has_changes: false});
|
||||
expect(containerPage.$(previewCss)).not.toHaveClass(disabledCss);
|
||||
});
|
||||
|
||||
it('updates when has_content_group_components attribute changes', function () {
|
||||
renderContainerPage(this, mockContainerXBlockHtml);
|
||||
fetch({has_content_group_components: false});
|
||||
expect(containerPage.$(visibilityNoteCss).length).toBe(0);
|
||||
|
||||
fetch({has_content_group_components: true});
|
||||
expect(containerPage.$(visibilityNoteCss).length).toBe(1);
|
||||
|
||||
fetch({has_content_group_components: false});
|
||||
expect(containerPage.$(visibilityNoteCss).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Publisher", function () {
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
define([
|
||||
'jquery', 'underscore', 'js/views/pages/group_configurations',
|
||||
'js/collections/group_configuration', 'js/common_helpers/template_helpers'
|
||||
], function ($, _, GroupConfigurationsPage, GroupConfigurationCollection, TemplateHelpers) {
|
||||
'js/models/group_configuration', 'js/collections/group_configuration',
|
||||
'js/common_helpers/template_helpers'
|
||||
], function ($, _, GroupConfigurationsPage, GroupConfigurationModel, GroupConfigurationCollection, TemplateHelpers) {
|
||||
'use strict';
|
||||
describe('GroupConfigurationsPage', function() {
|
||||
var mockGroupConfigurationsPage = readFixtures(
|
||||
'mock/mock-group-configuration-page.underscore'
|
||||
),
|
||||
itemClassName = '.group-configurations-list-item';
|
||||
groupConfigItemClassName = '.group-configurations-list-item';
|
||||
|
||||
var initializePage = function (disableSpy) {
|
||||
var view = new GroupConfigurationsPage({
|
||||
el: $('#content'),
|
||||
collection: new GroupConfigurationCollection({
|
||||
experimentsEnabled: true,
|
||||
experimentGroupConfigurations: new GroupConfigurationCollection({
|
||||
id: 0,
|
||||
name: 'Configuration 1'
|
||||
})
|
||||
}),
|
||||
contentGroupConfiguration: new GroupConfigurationModel({groups: []})
|
||||
});
|
||||
|
||||
if (!disableSpy) {
|
||||
@@ -29,15 +32,11 @@ define([
|
||||
return initializePage().render();
|
||||
};
|
||||
|
||||
var clickNewConfiguration = function (view) {
|
||||
view.$('.nav-actions .new-button').click();
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
setFixtures(mockGroupConfigurationsPage);
|
||||
TemplateHelpers.installTemplates([
|
||||
'no-group-configurations', 'group-configuration-edit',
|
||||
'group-configuration-details'
|
||||
'group-configuration-editor', 'group-configuration-details', 'content-group-details',
|
||||
'content-group-editor', 'group-edit', 'list'
|
||||
]);
|
||||
|
||||
this.addMatchers({
|
||||
@@ -52,69 +51,67 @@ define([
|
||||
var view = initializePage();
|
||||
expect(view.$('.ui-loading')).toBeVisible();
|
||||
view.render();
|
||||
expect(view.$(itemClassName)).toExist();
|
||||
expect(view.$(groupConfigItemClassName)).toExist();
|
||||
expect(view.$('.content-groups .no-content')).toExist();
|
||||
expect(view.$('.ui-loading')).toHaveClass('is-hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('on page close/change', function() {
|
||||
it('I see notification message if the model is changed',
|
||||
function() {
|
||||
var view = initializePage(true),
|
||||
message;
|
||||
|
||||
view.render();
|
||||
message = view.onBeforeUnload();
|
||||
expect(message).toBeUndefined();
|
||||
});
|
||||
|
||||
it('I do not see notification message if the model is not changed',
|
||||
function() {
|
||||
var expectedMessage ='You have unsaved changes. Do you really want to leave this page?',
|
||||
view = renderPage(),
|
||||
message;
|
||||
|
||||
view.collection.at(0).set('name', 'Configuration 2');
|
||||
message = view.onBeforeUnload();
|
||||
expect(message).toBe(expectedMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check that Group Configuration will focus and expand depending on content of url hash', function() {
|
||||
describe('Experiment group configurations', function() {
|
||||
beforeEach(function () {
|
||||
spyOn($.fn, 'focus');
|
||||
TemplateHelpers.installTemplate('group-configuration-details');
|
||||
this.view = initializePage(true);
|
||||
});
|
||||
|
||||
it('should focus and expand group configuration if its id is part of url hash', function() {
|
||||
it('should focus and expand if its id is part of url hash', function() {
|
||||
spyOn(this.view, 'getLocationHash').andReturn('#0');
|
||||
this.view.render();
|
||||
// We cannot use .toBeFocused due to flakiness.
|
||||
expect($.fn.focus).toHaveBeenCalled();
|
||||
expect(this.view.$(itemClassName)).toBeExpanded();
|
||||
expect(this.view.$(groupConfigItemClassName)).toBeExpanded();
|
||||
});
|
||||
|
||||
it('should not focus on any group configuration if url hash is empty', function() {
|
||||
it('should not focus on any experiment configuration if url hash is empty', function() {
|
||||
spyOn(this.view, 'getLocationHash').andReturn('');
|
||||
this.view.render();
|
||||
expect($.fn.focus).not.toHaveBeenCalled();
|
||||
expect(this.view.$(itemClassName)).not.toBeExpanded();
|
||||
expect(this.view.$(groupConfigItemClassName)).not.toBeExpanded();
|
||||
});
|
||||
|
||||
it('should not focus on any group configuration if url hash contains wrong id', function() {
|
||||
it('should not focus on any experiment configuration if url hash contains wrong id', function() {
|
||||
spyOn(this.view, 'getLocationHash').andReturn('#1');
|
||||
this.view.render();
|
||||
expect($.fn.focus).not.toHaveBeenCalled();
|
||||
expect(this.view.$(itemClassName)).not.toBeExpanded();
|
||||
expect(this.view.$(groupConfigItemClassName)).not.toBeExpanded();
|
||||
});
|
||||
|
||||
it('should not show a notification message if an experiment configuration is not changed', function () {
|
||||
this.view.render();
|
||||
expect(this.view.onBeforeUnload()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show a notification message if an experiment configuration is changed', function () {
|
||||
this.view.experimentGroupConfigurations.at(0).set('name', 'Configuration 2');
|
||||
expect(this.view.onBeforeUnload())
|
||||
.toBe('You have unsaved changes. Do you really want to leave this page?');
|
||||
});
|
||||
});
|
||||
|
||||
it('create new group configuration', function () {
|
||||
var view = renderPage();
|
||||
describe('Content groups', function() {
|
||||
beforeEach(function() {
|
||||
this.view = renderPage();
|
||||
});
|
||||
|
||||
clickNewConfiguration(view);
|
||||
expect($('.group-configuration-edit').length).toBeGreaterThan(0);
|
||||
it('should not show a notification message if a content group is not changed', function () {
|
||||
expect(this.view.onBeforeUnload()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should show a notification message if a content group is changed', function () {
|
||||
this.view.contentGroupConfiguration.get('groups').add({name: 'Content Group'});
|
||||
expect(this.view.onBeforeUnload())
|
||||
.toBe('You have unsaved changes. Do you really want to leave this page?');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ define([ "jquery", "underscore", "js/common_helpers/ajax_helpers", "js/spec_help
|
||||
});
|
||||
// Give the mock xblock a save method...
|
||||
editor.xblock.save = window.MockDescriptor.save;
|
||||
editor.model.save(editor.getXModuleData());
|
||||
editor.model.save(editor.getXBlockFieldData());
|
||||
request = requests[requests.length - 1];
|
||||
response = JSON.parse(request.requestBody);
|
||||
expect(response.metadata.display_name).toBe(testDisplayName);
|
||||
|
||||
32
cms/static/js/views/content_group_details.js
Normal file
32
cms/static/js/views/content_group_details.js
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* This class defines a simple display view for a content group.
|
||||
* It is expected to be backed by a Group model.
|
||||
*/
|
||||
define([
|
||||
'js/views/baseview'
|
||||
], function(BaseView) {
|
||||
'use strict';
|
||||
|
||||
var ContentGroupDetailsView = BaseView.extend({
|
||||
tagName: 'div',
|
||||
className: 'content-group-details collection',
|
||||
|
||||
events: {
|
||||
'click .edit': 'editGroup'
|
||||
},
|
||||
|
||||
editGroup: function() {
|
||||
this.model.set({'editing': true});
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
this.template = this.loadTemplate('content-group-details');
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.$el.html(this.template(this.model.toJSON()));
|
||||
}
|
||||
});
|
||||
|
||||
return ContentGroupDetailsView;
|
||||
});
|
||||
44
cms/static/js/views/content_group_editor.js
Normal file
44
cms/static/js/views/content_group_editor.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* This class defines an editing view for content groups.
|
||||
* It is expected to be backed by a Group model.
|
||||
*/
|
||||
define([
|
||||
'js/views/list_item_editor', 'underscore'
|
||||
],
|
||||
function(ListItemEditorView, _) {
|
||||
'use strict';
|
||||
|
||||
var ContentGroupEditorView = ListItemEditorView.extend({
|
||||
tagName: 'div',
|
||||
className: 'content-group-edit collection-edit',
|
||||
events: {
|
||||
'submit': 'setAndClose',
|
||||
'click .action-cancel': 'cancel'
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
ListItemEditorView.prototype.initialize.call(this);
|
||||
this.template = this.loadTemplate('content-group-editor');
|
||||
},
|
||||
|
||||
getTemplateOptions: function() {
|
||||
return {
|
||||
name: this.model.escape('name'),
|
||||
index: this.model.collection.indexOf(this.model),
|
||||
isNew: this.model.isNew(),
|
||||
uniqueId: _.uniqueId()
|
||||
};
|
||||
},
|
||||
|
||||
setValues: function() {
|
||||
this.model.set({name: this.$('input').val().trim()});
|
||||
return this;
|
||||
},
|
||||
|
||||
getSaveableModel: function() {
|
||||
return this.model.collection.parents[0];
|
||||
}
|
||||
});
|
||||
|
||||
return ContentGroupEditorView;
|
||||
});
|
||||
27
cms/static/js/views/content_group_item.js
Normal file
27
cms/static/js/views/content_group_item.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* This class defines an controller view for content groups.
|
||||
* It renders an editor view or a details view depending on the state
|
||||
* of the underlying model.
|
||||
* It is expected to be backed by a Group model.
|
||||
*/
|
||||
define([
|
||||
'js/views/list_item', 'js/views/content_group_editor', 'js/views/content_group_details'
|
||||
], function(ListItemView, ContentGroupEditorView, ContentGroupDetailsView) {
|
||||
'use strict';
|
||||
|
||||
var ContentGroupItemView = ListItemView.extend({
|
||||
tagName: 'section',
|
||||
|
||||
baseClassName: 'content-group',
|
||||
|
||||
createEditView: function() {
|
||||
return new ContentGroupEditorView({model: this.model});
|
||||
},
|
||||
|
||||
createDetailsView: function() {
|
||||
return new ContentGroupDetailsView({model: this.model});
|
||||
}
|
||||
});
|
||||
|
||||
return ContentGroupItemView;
|
||||
});
|
||||
26
cms/static/js/views/content_group_list.js
Normal file
26
cms/static/js/views/content_group_list.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* This class defines a list view for content groups.
|
||||
* It is expected to be backed by a Group collection.
|
||||
*/
|
||||
define([
|
||||
'js/views/list', 'js/views/content_group_item', 'gettext'
|
||||
], function(ListView, ContentGroupItemView, gettext) {
|
||||
'use strict';
|
||||
|
||||
var ContentGroupListView = ListView.extend({
|
||||
tagName: 'div',
|
||||
|
||||
className: 'content-group-list',
|
||||
|
||||
// Translators: This refers to a content group that can be linked to a student cohort.
|
||||
itemCategoryDisplayName: gettext('content group'),
|
||||
|
||||
emptyMessage: gettext('You have not created any content groups yet.'),
|
||||
|
||||
createItemView: function(options) {
|
||||
return new ContentGroupItemView(options);
|
||||
}
|
||||
});
|
||||
|
||||
return ContentGroupListView;
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
/**
|
||||
* This class defines an edit view for groups within content experiment group configurations.
|
||||
* It is expected to be backed by a Group model.
|
||||
*/
|
||||
define([
|
||||
'js/views/baseview', 'underscore', 'underscore.string', 'jquery', 'gettext'
|
||||
'js/views/baseview', 'underscore', 'underscore.string', 'gettext'
|
||||
],
|
||||
function(BaseView, _, str, $, gettext) {
|
||||
function(BaseView, _, str, gettext) {
|
||||
'use strict';
|
||||
_.str = str; // used in template
|
||||
var GroupEdit = BaseView.extend({
|
||||
var ExperimentGroupEditView = BaseView.extend({
|
||||
tagName: 'li',
|
||||
events: {
|
||||
'click .action-close': 'removeGroup',
|
||||
@@ -38,7 +42,7 @@ function(BaseView, _, str, $, gettext) {
|
||||
},
|
||||
|
||||
changeName: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
this.model.set({
|
||||
name: this.$('.group-name').val()
|
||||
}, { silent: true });
|
||||
@@ -47,7 +51,7 @@ function(BaseView, _, str, $, gettext) {
|
||||
},
|
||||
|
||||
removeGroup: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
this.model.collection.remove(this.model);
|
||||
return this.remove();
|
||||
},
|
||||
@@ -65,5 +69,5 @@ function(BaseView, _, str, $, gettext) {
|
||||
}
|
||||
});
|
||||
|
||||
return GroupEdit;
|
||||
return ExperimentGroupEditView;
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* This class defines a details view for content experiment group configurations.
|
||||
* It is expected to be instantiated with a GroupConfiguration model.
|
||||
*/
|
||||
define([
|
||||
'js/views/baseview', 'underscore', 'gettext', 'underscore.string'
|
||||
],
|
||||
function(BaseView, _, gettext, str) {
|
||||
'use strict';
|
||||
var GroupConfigurationDetails = BaseView.extend({
|
||||
var GroupConfigurationDetailsView = BaseView.extend({
|
||||
tagName: 'div',
|
||||
events: {
|
||||
'click .edit': 'editConfiguration',
|
||||
@@ -15,6 +19,7 @@ function(BaseView, _, gettext, str) {
|
||||
var index = this.model.collection.indexOf(this.model);
|
||||
|
||||
return [
|
||||
'collection',
|
||||
'group-configuration-details',
|
||||
'group-configuration-details-' + index
|
||||
].join(' ');
|
||||
@@ -40,17 +45,17 @@ function(BaseView, _, gettext, str) {
|
||||
},
|
||||
|
||||
editConfiguration: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
this.model.set('editing', true);
|
||||
},
|
||||
|
||||
showGroups: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
this.model.set('showGroups', true);
|
||||
},
|
||||
|
||||
hideGroups: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
this.model.set('showGroups', false);
|
||||
},
|
||||
|
||||
@@ -107,5 +112,5 @@ function(BaseView, _, gettext, str) {
|
||||
}
|
||||
});
|
||||
|
||||
return GroupConfigurationDetails;
|
||||
return GroupConfigurationDetailsView;
|
||||
});
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
define([
|
||||
'js/views/baseview', 'underscore', 'jquery', 'gettext',
|
||||
'js/views/group_edit', 'js/views/utils/view_utils'
|
||||
],
|
||||
function(BaseView, _, $, gettext, GroupEdit, ViewUtils) {
|
||||
'use strict';
|
||||
var GroupConfigurationEdit = BaseView.extend({
|
||||
tagName: 'div',
|
||||
events: {
|
||||
'change .group-configuration-name-input': 'setName',
|
||||
'change .group-configuration-description-input': 'setDescription',
|
||||
"click .action-add-group": "createGroup",
|
||||
'focus .input-text': 'onFocus',
|
||||
'blur .input-text': 'onBlur',
|
||||
'submit': 'setAndClose',
|
||||
'click .action-cancel': 'cancel'
|
||||
},
|
||||
|
||||
className: function () {
|
||||
var index = this.model.collection.indexOf(this.model);
|
||||
|
||||
return [
|
||||
'group-configuration-edit',
|
||||
'group-configuration-edit-' + index
|
||||
].join(' ');
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
var groups;
|
||||
|
||||
this.template = this.loadTemplate('group-configuration-edit');
|
||||
this.listenTo(this.model, 'invalid', this.render);
|
||||
groups = this.model.get('groups');
|
||||
this.listenTo(groups, 'add', this.addOne);
|
||||
this.listenTo(groups, 'reset', this.addAll);
|
||||
this.listenTo(groups, 'all', this.render);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.$el.html(this.template({
|
||||
id: this.model.get('id'),
|
||||
uniqueId: _.uniqueId(),
|
||||
name: this.model.escape('name'),
|
||||
description: this.model.escape('description'),
|
||||
usage: this.model.get('usage'),
|
||||
isNew: this.model.isNew(),
|
||||
error: this.model.validationError
|
||||
}));
|
||||
this.addAll();
|
||||
return this;
|
||||
},
|
||||
|
||||
addOne: function(group) {
|
||||
var view = new GroupEdit({ model: group });
|
||||
this.$('ol.groups').append(view.render().el);
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
addAll: function() {
|
||||
this.model.get('groups').each(this.addOne, this);
|
||||
},
|
||||
|
||||
createGroup: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
var collection = this.model.get('groups');
|
||||
collection.add([{
|
||||
name: collection.getNextDefaultGroupName(),
|
||||
order: collection.nextOrder()
|
||||
}]);
|
||||
},
|
||||
|
||||
setName: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
this.model.set(
|
||||
'name', this.$('.group-configuration-name-input').val(),
|
||||
{ silent: true }
|
||||
);
|
||||
},
|
||||
|
||||
setDescription: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
this.model.set(
|
||||
'description',
|
||||
this.$('.group-configuration-description-input').val(),
|
||||
{ silent: true }
|
||||
);
|
||||
},
|
||||
|
||||
setValues: function() {
|
||||
this.setName();
|
||||
this.setDescription();
|
||||
|
||||
_.each(this.$('.groups li'), function(li, i) {
|
||||
var group = this.model.get('groups').at(i);
|
||||
|
||||
if(group) {
|
||||
group.set({
|
||||
'name': $('.group-name', li).val()
|
||||
});
|
||||
}
|
||||
}, this);
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
setAndClose: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
|
||||
this.setValues();
|
||||
if(!this.model.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ViewUtils.runOperationShowingMessage(
|
||||
gettext('Saving'),
|
||||
function () {
|
||||
var dfd = $.Deferred();
|
||||
|
||||
this.model.save({}, {
|
||||
success: function() {
|
||||
this.model.setOriginalAttributes();
|
||||
this.close();
|
||||
dfd.resolve();
|
||||
}.bind(this)
|
||||
});
|
||||
|
||||
return dfd;
|
||||
}.bind(this)
|
||||
);
|
||||
},
|
||||
|
||||
cancel: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
|
||||
this.model.reset();
|
||||
return this.close();
|
||||
},
|
||||
|
||||
close: function() {
|
||||
var groupConfigurations = this.model.collection;
|
||||
|
||||
this.remove();
|
||||
if(this.model.isNew()) {
|
||||
// if the group configuration has never been saved, remove it
|
||||
groupConfigurations.remove(this.model);
|
||||
} else {
|
||||
// tell the model that it's no longer being edited
|
||||
this.model.set('editing', false);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
return GroupConfigurationEdit;
|
||||
});
|
||||
121
cms/static/js/views/group_configuration_editor.js
Normal file
121
cms/static/js/views/group_configuration_editor.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* This class defines an editing view for content experiment group configurations.
|
||||
* It is expected to be backed by a GroupConfiguration model.
|
||||
*/
|
||||
define([
|
||||
'js/views/list_item_editor', 'underscore', 'jquery', 'gettext',
|
||||
'js/views/experiment_group_edit'
|
||||
],
|
||||
function(ListItemEditorView, _, $, gettext, ExperimentGroupEditView) {
|
||||
'use strict';
|
||||
var GroupConfigurationEditorView = ListItemEditorView.extend({
|
||||
tagName: 'div',
|
||||
events: {
|
||||
'change .collection-name-input': 'setName',
|
||||
'change .group-configuration-description-input': 'setDescription',
|
||||
'click .action-add-group': 'createGroup',
|
||||
'focus .input-text': 'onFocus',
|
||||
'blur .input-text': 'onBlur',
|
||||
'submit': 'setAndClose',
|
||||
'click .action-cancel': 'cancel'
|
||||
},
|
||||
|
||||
className: function () {
|
||||
var index = this.model.collection.indexOf(this.model);
|
||||
|
||||
return [
|
||||
'collection-edit',
|
||||
'group-configuration-edit',
|
||||
'group-configuration-edit-' + index
|
||||
].join(' ');
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
var groups = this.model.get('groups');
|
||||
|
||||
ListItemEditorView.prototype.initialize.call(this);
|
||||
|
||||
this.template = this.loadTemplate('group-configuration-editor');
|
||||
this.listenTo(groups, 'add', this.onAddItem);
|
||||
this.listenTo(groups, 'reset', this.addAll);
|
||||
this.listenTo(groups, 'all', this.render);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
ListItemEditorView.prototype.render.call(this);
|
||||
this.addAll();
|
||||
return this;
|
||||
},
|
||||
|
||||
getTemplateOptions: function() {
|
||||
return {
|
||||
id: this.model.get('id'),
|
||||
uniqueId: _.uniqueId(),
|
||||
name: this.model.escape('name'),
|
||||
description: this.model.escape('description'),
|
||||
usage: this.model.get('usage'),
|
||||
isNew: this.model.isNew()
|
||||
};
|
||||
},
|
||||
|
||||
getSaveableModel: function() {
|
||||
return this.model;
|
||||
},
|
||||
|
||||
onAddItem: function(group) {
|
||||
var view = new ExperimentGroupEditView({ model: group });
|
||||
this.$('ol.groups').append(view.render().el);
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
addAll: function() {
|
||||
this.model.get('groups').each(this.onAddItem, this);
|
||||
},
|
||||
|
||||
createGroup: function(event) {
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
var collection = this.model.get('groups');
|
||||
collection.add([{
|
||||
name: collection.getNextDefaultGroupName(),
|
||||
order: collection.nextOrder()
|
||||
}]);
|
||||
},
|
||||
|
||||
setName: function(event) {
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
this.model.set(
|
||||
'name', this.$('.collection-name-input').val(),
|
||||
{ silent: true }
|
||||
);
|
||||
},
|
||||
|
||||
setDescription: function(event) {
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
this.model.set(
|
||||
'description',
|
||||
this.$('.group-configuration-description-input').val(),
|
||||
{ silent: true }
|
||||
);
|
||||
},
|
||||
|
||||
setValues: function() {
|
||||
this.setName();
|
||||
this.setDescription();
|
||||
|
||||
_.each(this.$('.groups li'), function(li, i) {
|
||||
var group = this.model.get('groups').at(i);
|
||||
|
||||
if (group) {
|
||||
group.set({
|
||||
'name': $('.group-name', li).val()
|
||||
});
|
||||
}
|
||||
}, this);
|
||||
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
return GroupConfigurationEditorView;
|
||||
});
|
||||
@@ -1,77 +1,45 @@
|
||||
/**
|
||||
* This class defines an controller view for content experiment group configurations.
|
||||
* It renders an editor view or a details view depending on the state
|
||||
* of the underlying model.
|
||||
* It is expected to be backed by a Group model.
|
||||
*/
|
||||
define([
|
||||
'js/views/baseview', 'jquery', "gettext", 'js/views/group_configuration_details',
|
||||
'js/views/group_configuration_edit', "js/views/utils/view_utils"
|
||||
'js/views/list_item', 'js/views/group_configuration_details', 'js/views/group_configuration_editor', 'gettext'
|
||||
], function(
|
||||
BaseView, $, gettext, GroupConfigurationDetails, GroupConfigurationEdit, ViewUtils
|
||||
ListItemView, GroupConfigurationDetailsView, GroupConfigurationEditorView, gettext
|
||||
) {
|
||||
'use strict';
|
||||
var GroupConfigurationsItem = BaseView.extend({
|
||||
|
||||
var GroupConfigurationItemView = ListItemView.extend({
|
||||
events: {
|
||||
'click .delete': 'deleteItem'
|
||||
},
|
||||
|
||||
tagName: 'section',
|
||||
|
||||
baseClassName: 'group-configuration',
|
||||
|
||||
canDelete: true,
|
||||
|
||||
// Translators: this refers to a collection of groups.
|
||||
itemDisplayName: gettext('group configuration'),
|
||||
|
||||
attributes: function () {
|
||||
return {
|
||||
'id': this.model.get('id'),
|
||||
'tabindex': -1
|
||||
};
|
||||
},
|
||||
events: {
|
||||
'click .delete': 'deleteConfiguration'
|
||||
|
||||
createEditView: function() {
|
||||
return new GroupConfigurationEditorView({model: this.model});
|
||||
},
|
||||
|
||||
className: function () {
|
||||
var index = this.model.collection.indexOf(this.model);
|
||||
|
||||
return [
|
||||
'group-configuration',
|
||||
'group-configurations-list-item',
|
||||
'group-configurations-list-item-' + index
|
||||
].join(' ');
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
this.listenTo(this.model, 'change:editing', this.render);
|
||||
this.listenTo(this.model, 'remove', this.remove);
|
||||
},
|
||||
|
||||
deleteConfiguration: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
var self = this;
|
||||
ViewUtils.confirmThenRunOperation(
|
||||
gettext('Delete this Group Configuration?'),
|
||||
gettext('Deleting this Group Configuration is permanent and cannot be undone.'),
|
||||
gettext('Delete'),
|
||||
function() {
|
||||
return ViewUtils.runOperationShowingMessage(
|
||||
gettext('Deleting'),
|
||||
function () {
|
||||
return self.model.destroy({ wait: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// Removes a view from the DOM, and calls stopListening to remove
|
||||
// any bound events that the view has listened to.
|
||||
if (this.view) {
|
||||
this.view.remove();
|
||||
}
|
||||
|
||||
if (this.model.get('editing')) {
|
||||
this.view = new GroupConfigurationEdit({
|
||||
model: this.model
|
||||
});
|
||||
} else {
|
||||
this.view = new GroupConfigurationDetails({
|
||||
model: this.model
|
||||
});
|
||||
}
|
||||
|
||||
this.$el.html(this.view.render().el);
|
||||
|
||||
return this;
|
||||
createDetailsView: function() {
|
||||
return new GroupConfigurationDetailsView({model: this.model});
|
||||
}
|
||||
});
|
||||
|
||||
return GroupConfigurationsItem;
|
||||
return GroupConfigurationItemView;
|
||||
});
|
||||
|
||||
@@ -1,71 +1,28 @@
|
||||
/**
|
||||
* This class defines a list view for content experiment group configurations.
|
||||
* It is expected to be backed by a GroupConfiguration collection.
|
||||
*/
|
||||
define([
|
||||
'js/views/baseview', 'jquery', 'js/views/group_configuration_item'
|
||||
], function(
|
||||
BaseView, $, GroupConfigurationItemView
|
||||
) {
|
||||
'js/views/list', 'js/views/group_configuration_item', 'gettext'
|
||||
], function(ListView, GroupConfigurationItemView, gettext) {
|
||||
'use strict';
|
||||
var GroupConfigurationsList = BaseView.extend({
|
||||
|
||||
var GroupConfigurationsListView = ListView.extend({
|
||||
tagName: 'div',
|
||||
|
||||
className: 'group-configurations-list',
|
||||
events: {
|
||||
'click .new-button': 'addOne'
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
this.emptyTemplate = this.loadTemplate('no-group-configurations');
|
||||
this.listenTo(this.collection, 'add', this.addNewItemView);
|
||||
this.listenTo(this.collection, 'remove', this.handleDestory);
|
||||
},
|
||||
newModelOptions: {addDefaultGroups: true},
|
||||
|
||||
render: function() {
|
||||
var configurations = this.collection;
|
||||
// Translators: this refers to a collection of groups.
|
||||
itemCategoryDisplayName: gettext('group configuration'),
|
||||
|
||||
if(configurations.length === 0) {
|
||||
this.$el.html(this.emptyTemplate());
|
||||
} else {
|
||||
var frag = document.createDocumentFragment();
|
||||
emptyMessage: gettext('You have not created any group configurations yet.'),
|
||||
|
||||
configurations.each(function(configuration) {
|
||||
var view = new GroupConfigurationItemView({
|
||||
model: configuration
|
||||
});
|
||||
|
||||
frag.appendChild(view.render().el);
|
||||
});
|
||||
|
||||
this.$el.html([frag]);
|
||||
}
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
addNewItemView: function (model) {
|
||||
var view = new GroupConfigurationItemView({
|
||||
model: model
|
||||
});
|
||||
|
||||
// If items already exist, just append one new. Otherwise, overwrite
|
||||
// no-content message.
|
||||
if (this.collection.length > 1) {
|
||||
this.$el.append(view.render().el);
|
||||
} else {
|
||||
this.$el.html(view.render().el);
|
||||
}
|
||||
|
||||
view.$el.focus();
|
||||
},
|
||||
|
||||
addOne: function(event) {
|
||||
if(event && event.preventDefault) { event.preventDefault(); }
|
||||
this.collection.add([{ editing: true }]);
|
||||
},
|
||||
|
||||
handleDestory: function () {
|
||||
if(this.collection.length === 0) {
|
||||
this.$el.html(this.emptyTemplate());
|
||||
}
|
||||
createItemView: function(options) {
|
||||
return new GroupConfigurationItemView(options);
|
||||
}
|
||||
});
|
||||
|
||||
return GroupConfigurationsList;
|
||||
return GroupConfigurationsListView;
|
||||
});
|
||||
|
||||
99
cms/static/js/views/list.js
Normal file
99
cms/static/js/views/list.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* A generic list view class.
|
||||
*
|
||||
* Expects the following properties to be overriden:
|
||||
* render when the collection is empty.
|
||||
* - createItemView (function): Create and return an item view for a
|
||||
* model in the collection.
|
||||
* - newModelOptions (object): Options to pass to models which are
|
||||
* added to the collection.
|
||||
* - itemCategoryDisplayName (string): Display name for the category
|
||||
* of items this list contains. For example, 'Group Configuration'.
|
||||
* Note that it must be translated.
|
||||
* - emptyMessage (string): Text to render when the list is empty.
|
||||
*/
|
||||
define([
|
||||
'js/views/baseview'
|
||||
], function(BaseView) {
|
||||
'use strict';
|
||||
var ListView = BaseView.extend({
|
||||
events: {
|
||||
'click .action-add': 'onAddItem',
|
||||
'click .new-button': 'onAddItem'
|
||||
},
|
||||
|
||||
listContainerCss: '.list-items',
|
||||
|
||||
initialize: function() {
|
||||
this.listenTo(this.collection, 'add', this.addNewItemView);
|
||||
this.listenTo(this.collection, 'remove', this.onRemoveItem);
|
||||
this.template = this.loadTemplate('list');
|
||||
|
||||
// Don't render the add button when editing a form
|
||||
this.listenTo(this.collection, 'change:editing', this.toggleAddButton);
|
||||
this.listenTo(this.collection, 'add', this.toggleAddButton);
|
||||
this.listenTo(this.collection, 'remove', this.toggleAddButton);
|
||||
},
|
||||
|
||||
render: function(model) {
|
||||
this.$el.html(this.template({
|
||||
itemCategoryDisplayName: this.itemCategoryDisplayName,
|
||||
emptyMessage: this.emptyMessage,
|
||||
length: this.collection.length,
|
||||
isEditing: model && model.get('editing')
|
||||
}));
|
||||
|
||||
this.collection.each(function(model) {
|
||||
this.$(this.listContainerCss).append(this.createItemView({model: model}).render().el);
|
||||
}, this);
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
hideOrShowAddButton: function(shouldShow) {
|
||||
var addButtonCss = '.action-add';
|
||||
if (this.collection.length) {
|
||||
if (shouldShow) {
|
||||
this.$(addButtonCss).removeClass('is-hidden');
|
||||
} else {
|
||||
this.$(addButtonCss).addClass('is-hidden');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toggleAddButton: function(model) {
|
||||
if (model.get('editing') && this.collection.contains(model)) {
|
||||
this.hideOrShowAddButton(false);
|
||||
} else {
|
||||
this.hideOrShowAddButton(true);
|
||||
}
|
||||
},
|
||||
|
||||
addNewItemView: function (model) {
|
||||
var view = this.createItemView({model: model});
|
||||
|
||||
// If items already exist, just append one new.
|
||||
// Otherwise re-render the empty list HTML.
|
||||
if (this.collection.length > 1) {
|
||||
this.$(this.listContainerCss).append(view.render().el);
|
||||
} else {
|
||||
this.render();
|
||||
}
|
||||
|
||||
view.$el.focus();
|
||||
},
|
||||
|
||||
onAddItem: function(event) {
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
this.collection.add({editing: true}, this.newModelOptions);
|
||||
},
|
||||
|
||||
onRemoveItem: function () {
|
||||
if (this.collection.length === 0) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ListView;
|
||||
});
|
||||
90
cms/static/js/views/list_item.js
Normal file
90
cms/static/js/views/list_item.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* A generic view to represent an editable item in a list. The item
|
||||
* has a edit view and a details view.
|
||||
*
|
||||
* Subclasses must implement:
|
||||
* - itemDisplayName (string): Display name for the list item.
|
||||
* Must be translated.
|
||||
* - baseClassName (string): CSS class name representing the item.
|
||||
* - createEditView (function): Render and append the edit view to the
|
||||
* DOM.
|
||||
* - createDetailsView (function): Render and append the details view
|
||||
* to the DOM.
|
||||
*/
|
||||
define([
|
||||
'js/views/baseview', 'jquery', "gettext", "js/views/utils/view_utils"
|
||||
], function(
|
||||
BaseView, $, gettext, ViewUtils
|
||||
) {
|
||||
'use strict';
|
||||
|
||||
var ListItemView = BaseView.extend({
|
||||
canDelete: false,
|
||||
|
||||
initialize: function() {
|
||||
this.listenTo(this.model, 'change:editing', this.render);
|
||||
this.listenTo(this.model, 'remove', this.remove);
|
||||
},
|
||||
|
||||
className: function () {
|
||||
var index = this.model.collection.indexOf(this.model);
|
||||
|
||||
return [
|
||||
'wrapper-collection',
|
||||
'wrapper-collection-' + index,
|
||||
this.baseClassName,
|
||||
this.baseClassName + 's-list-item',
|
||||
this.baseClassName + 's-list-item-' + index
|
||||
].join(' ');
|
||||
},
|
||||
|
||||
deleteItem: function(event) {
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
if (!this.canDelete) { return; }
|
||||
var model = this.model,
|
||||
itemDisplayName = this.itemDisplayName;
|
||||
ViewUtils.confirmThenRunOperation(
|
||||
interpolate(
|
||||
// Translators: "item_display_name" is the name of the item to be deleted.
|
||||
gettext('Delete this %(item_display_name)s?'),
|
||||
{item_display_name: itemDisplayName}, true
|
||||
),
|
||||
interpolate(
|
||||
// Translators: "item_display_name" is the name of the item to be deleted.
|
||||
gettext('Deleting this %(item_display_name)s is permanent and cannot be undone.'),
|
||||
{item_display_name: itemDisplayName},
|
||||
true
|
||||
),
|
||||
gettext('Delete'),
|
||||
function() {
|
||||
return ViewUtils.runOperationShowingMessage(
|
||||
gettext('Deleting'),
|
||||
function () {
|
||||
return model.destroy({wait: true});
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// Removes a view from the DOM, and calls stopListening to remove
|
||||
// any bound events that the view has listened to.
|
||||
if (this.view) {
|
||||
this.view.remove();
|
||||
}
|
||||
|
||||
if (this.model.get('editing')) {
|
||||
this.view = this.createEditView();
|
||||
} else {
|
||||
this.view = this.createDetailsView();
|
||||
}
|
||||
|
||||
this.$el.html(this.view.render().el);
|
||||
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
return ListItemView;
|
||||
});
|
||||
76
cms/static/js/views/list_item_editor.js
Normal file
76
cms/static/js/views/list_item_editor.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* A generic view to represent a list item in its editing state.
|
||||
*
|
||||
* Subclasses must implement:
|
||||
* - getTemplateOptions (function): Return an object to pass to the
|
||||
* template.
|
||||
* - setValues (function): Set values on the model according to the
|
||||
* DOM.
|
||||
* - getSaveableModel (function): Return the model which should be
|
||||
* saved by this view. Note this may be a parent model.
|
||||
*/
|
||||
define([
|
||||
'js/views/baseview', 'js/views/utils/view_utils', 'underscore', 'gettext'
|
||||
], function(BaseView, ViewUtils, _, gettext) {
|
||||
'use strict';
|
||||
|
||||
var ListItemEditorView = BaseView.extend({
|
||||
initialize: function() {
|
||||
this.listenTo(this.model, 'invalid', this.render);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
this.$el.html(this.template(_.extend({
|
||||
error: this.model.validationError
|
||||
}, this.getTemplateOptions())));
|
||||
},
|
||||
|
||||
setAndClose: function(event) {
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
|
||||
this.setValues();
|
||||
if (!this.model.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ViewUtils.runOperationShowingMessage(
|
||||
gettext('Saving'),
|
||||
function () {
|
||||
var dfd = $.Deferred();
|
||||
var actionableModel = this.getSaveableModel();
|
||||
|
||||
actionableModel.save({}, {
|
||||
success: function() {
|
||||
actionableModel.setOriginalAttributes();
|
||||
this.close();
|
||||
dfd.resolve();
|
||||
}.bind(this)
|
||||
});
|
||||
|
||||
return dfd;
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
cancel: function(event) {
|
||||
if (event && event.preventDefault) { event.preventDefault(); }
|
||||
|
||||
this.getSaveableModel().reset();
|
||||
return this.close();
|
||||
},
|
||||
|
||||
close: function() {
|
||||
this.remove();
|
||||
if (this.model.isNew() && !_.isUndefined(this.model.collection)) {
|
||||
// if the item has never been saved, remove it
|
||||
this.model.collection.remove(this.model);
|
||||
} else {
|
||||
// tell the model that it's no longer being edited
|
||||
this.model.set('editing', false);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
});
|
||||
|
||||
return ListItemEditorView;
|
||||
});
|
||||
@@ -49,7 +49,7 @@ function(BaseView, _, MetadataModel, AbstractEditor, FileUpload, UploadDialog, V
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the just the modified metadata values, in the format used to persist to the server.
|
||||
* Returns just the modified metadata values, in the format used to persist to the server.
|
||||
*/
|
||||
getModifiedMetadataValues: function () {
|
||||
var modified_values = {};
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
/**
|
||||
* This is a base modal implementation that provides common utilities.
|
||||
*
|
||||
* A modal implementation should override the following methods:
|
||||
*
|
||||
* getTitle():
|
||||
* returns the title for the modal.
|
||||
* getHTMLContent():
|
||||
* returns the HTML content to be shown inside the modal.
|
||||
*
|
||||
* A modal implementation should also provide the following options:
|
||||
*
|
||||
* modalName: A string identifying the modal.
|
||||
* modalType: A string identifying the type of the modal.
|
||||
* modalSize: A string, either 'sm', 'med', or 'lg' indicating the
|
||||
* size of the modal.
|
||||
* viewSpecificClasses: A string of CSS classes to be attached to
|
||||
* the modal window.
|
||||
* addSaveButton: A boolean indicating whether to include a save
|
||||
* button on the modal.
|
||||
*/
|
||||
define(["jquery", "underscore", "gettext", "js/views/baseview"],
|
||||
function($, _, gettext, BaseView) {
|
||||
@@ -41,7 +59,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
|
||||
name: this.options.modalName,
|
||||
type: this.options.modalType,
|
||||
size: this.options.modalSize,
|
||||
title: this.options.title,
|
||||
title: this.getTitle(),
|
||||
viewSpecificClasses: this.options.viewSpecificClasses
|
||||
}));
|
||||
this.addActionButtons();
|
||||
@@ -49,6 +67,10 @@ define(["jquery", "underscore", "gettext", "js/views/baseview"],
|
||||
this.parentElement.append(this.$el);
|
||||
},
|
||||
|
||||
getTitle: function() {
|
||||
return this.options.title;
|
||||
},
|
||||
|
||||
renderContents: function() {
|
||||
var contentHtml = this.getContentHtml();
|
||||
this.$('.modal-content').html(contentHtml);
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/views/utils/view_utils",
|
||||
"js/models/xblock_info", "js/views/xblock_editor"],
|
||||
function($, _, gettext, BaseModal, ViewUtils, XBlockInfo, XBlockEditorView) {
|
||||
"strict mode";
|
||||
|
||||
var EditXBlockModal = BaseModal.extend({
|
||||
events : {
|
||||
"click .action-save": "save",
|
||||
@@ -15,7 +17,10 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
|
||||
options: $.extend({}, BaseModal.prototype.options, {
|
||||
modalName: 'edit-xblock',
|
||||
addSaveButton: true,
|
||||
viewSpecificClasses: 'modal-editor confirm'
|
||||
view: 'studio_view',
|
||||
viewSpecificClasses: 'modal-editor confirm',
|
||||
// Translators: "title" is the name of the current component being edited.
|
||||
titleFormat: gettext("Editing: %(title)s")
|
||||
}),
|
||||
|
||||
initialize: function() {
|
||||
@@ -56,7 +61,8 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
|
||||
displayXBlock: function() {
|
||||
this.editorView = new XBlockEditorView({
|
||||
el: this.$('.xblock-editor'),
|
||||
model: this.xblockInfo
|
||||
model: this.xblockInfo,
|
||||
view: this.options.view
|
||||
});
|
||||
this.editorView.render({
|
||||
success: _.bind(this.onDisplayXBlock, this)
|
||||
@@ -66,7 +72,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
|
||||
onDisplayXBlock: function() {
|
||||
var editorView = this.editorView,
|
||||
title = this.getTitle(),
|
||||
readOnlyView = (this.editOptions && this.editOptions.readOnlyView) || !editorView.xblock.save;
|
||||
readOnlyView = (this.editOptions && this.editOptions.readOnlyView) || !this.canSave();
|
||||
|
||||
// Notify the runtime that the modal has been shown
|
||||
editorView.notifyRuntime('modal-shown', this);
|
||||
@@ -99,6 +105,10 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
|
||||
this.resize();
|
||||
},
|
||||
|
||||
canSave: function() {
|
||||
return this.editorView.xblock.save || this.editorView.xblock.collectFieldData;
|
||||
},
|
||||
|
||||
disableSave: function() {
|
||||
var saveButton = this.getActionButton('save'),
|
||||
cancelButton = this.getActionButton('cancel');
|
||||
@@ -112,7 +122,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
|
||||
if (!displayName) {
|
||||
displayName = gettext('Component');
|
||||
}
|
||||
return interpolate(gettext("Editing: %(title)s"), { title: displayName }, true);
|
||||
return interpolate(this.options.titleFormat, { title: displayName }, true);
|
||||
},
|
||||
|
||||
addDefaultModes: function() {
|
||||
@@ -147,7 +157,7 @@ define(["jquery", "underscore", "gettext", "js/views/modals/base_modal", "js/vie
|
||||
var self = this,
|
||||
editorView = this.editorView,
|
||||
xblockInfo = this.xblockInfo,
|
||||
data = editorView.getXModuleData();
|
||||
data = editorView.getXBlockFieldData();
|
||||
event.preventDefault();
|
||||
if (data) {
|
||||
ViewUtils.runOperationShowingMessage(gettext('Saving'),
|
||||
|
||||
@@ -15,6 +15,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
|
||||
events: {
|
||||
"click .edit-button": "editXBlock",
|
||||
"click .visibility-button": "editVisibilitySettings",
|
||||
"click .duplicate-button": "duplicateXBlock",
|
||||
"click .delete-button": "deleteXBlock",
|
||||
"click .new-component-button": "scrollToNewComponentButtons"
|
||||
@@ -161,10 +162,10 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
}
|
||||
},
|
||||
|
||||
editXBlock: function(event) {
|
||||
editXBlock: function(event, options) {
|
||||
var xblockElement = this.findXBlockElement(event.target),
|
||||
self = this,
|
||||
modal = new EditXBlockModal({ });
|
||||
modal = new EditXBlockModal(options);
|
||||
event.preventDefault();
|
||||
|
||||
modal.edit(xblockElement, this.model, {
|
||||
@@ -175,6 +176,16 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
});
|
||||
},
|
||||
|
||||
editVisibilitySettings: function(event) {
|
||||
this.editXBlock(event, {
|
||||
view: 'visibility_view',
|
||||
// Translators: "title" is the name of the current component being edited.
|
||||
titleFormat: gettext("Editing visibility for: %(title)s"),
|
||||
viewSpecificClasses: '',
|
||||
modalSize: 'med'
|
||||
});
|
||||
},
|
||||
|
||||
duplicateXBlock: function(event) {
|
||||
event.preventDefault();
|
||||
this.duplicateComponent(this.findXBlockElement(event.target));
|
||||
|
||||
@@ -100,7 +100,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
|
||||
|
||||
onSync: function(model) {
|
||||
if (ViewUtils.hasChangedAttributes(model, [
|
||||
'has_changes', 'published', 'edited_on', 'edited_by', 'visibility_state', 'has_explicit_staff_lock'
|
||||
'has_changes', 'published', 'edited_on', 'edited_by', 'visibility_state',
|
||||
'has_explicit_staff_lock', 'has_content_group_components'
|
||||
])) {
|
||||
this.render();
|
||||
}
|
||||
@@ -120,7 +121,8 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/views/utils/
|
||||
releaseDate: this.model.get('release_date'),
|
||||
releaseDateFrom: this.model.get('release_date_from'),
|
||||
hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'),
|
||||
staffLockFrom: this.model.get('staff_lock_from')
|
||||
staffLockFrom: this.model.get('staff_lock_from'),
|
||||
hasContentGroupComponents: this.model.get('has_content_group_components')
|
||||
}));
|
||||
|
||||
return this;
|
||||
|
||||
@@ -26,7 +26,7 @@ define(["jquery", "underscore", "gettext", "js/views/pages/base_page", "js/views
|
||||
});
|
||||
this.model.on('change', this.setCollapseExpandVisibility, this);
|
||||
$('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () {
|
||||
$('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden')
|
||||
$('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden');
|
||||
}));
|
||||
},
|
||||
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
define([
|
||||
'jquery', 'underscore', 'gettext', 'js/views/pages/base_page',
|
||||
'js/views/group_configurations_list'
|
||||
'js/views/group_configurations_list', 'js/views/content_group_list'
|
||||
],
|
||||
function ($, _, gettext, BasePage, GroupConfigurationsList) {
|
||||
function ($, _, gettext, BasePage, GroupConfigurationsListView, ContentGroupListView) {
|
||||
'use strict';
|
||||
var GroupConfigurationsPage = BasePage.extend({
|
||||
initialize: function() {
|
||||
initialize: function(options) {
|
||||
BasePage.prototype.initialize.call(this);
|
||||
this.listView = new GroupConfigurationsList({
|
||||
collection: this.collection
|
||||
this.experimentsEnabled = options.experimentsEnabled;
|
||||
if (this.experimentsEnabled) {
|
||||
this.experimentGroupConfigurations = options.experimentGroupConfigurations;
|
||||
this.experimentGroupsListView = new GroupConfigurationsListView({
|
||||
collection: this.experimentGroupConfigurations
|
||||
});
|
||||
}
|
||||
this.contentGroupConfiguration = options.contentGroupConfiguration;
|
||||
this.cohortGroupsListView = new ContentGroupListView({
|
||||
collection: this.contentGroupConfiguration.get('groups')
|
||||
});
|
||||
},
|
||||
|
||||
renderPage: function() {
|
||||
var hash = this.getLocationHash();
|
||||
this.$('.content-primary').append(this.listView.render().el);
|
||||
this.addButtonActions();
|
||||
if (this.experimentsEnabled) {
|
||||
this.$('.wrapper-groups.experiment-groups').append(this.experimentGroupsListView.render().el);
|
||||
}
|
||||
this.$('.wrapper-groups.content-groups').append(this.cohortGroupsListView.render().el);
|
||||
this.addWindowActions();
|
||||
if (hash) {
|
||||
// Strip leading '#' to get id string to match
|
||||
@@ -24,22 +34,17 @@ function ($, _, gettext, BasePage, GroupConfigurationsList) {
|
||||
return $.Deferred().resolve().promise();
|
||||
},
|
||||
|
||||
addButtonActions: function () {
|
||||
this.$('.nav-actions .new-button').click(function (event) {
|
||||
this.listView.addOne(event);
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
addWindowActions: function () {
|
||||
$(window).on('beforeunload', this.onBeforeUnload.bind(this));
|
||||
},
|
||||
|
||||
onBeforeUnload: function () {
|
||||
var dirty = this.collection.find(function(configuration) {
|
||||
return configuration.isDirty();
|
||||
});
|
||||
var dirty = this.contentGroupConfiguration.isDirty() ||
|
||||
(this.experimentsEnabled && this.experimentGroupConfigurations.find(function(configuration) {
|
||||
return configuration.isDirty();
|
||||
}));
|
||||
|
||||
if(dirty) {
|
||||
if (dirty) {
|
||||
return gettext('You have unsaved changes. Do you really want to leave this page?');
|
||||
}
|
||||
},
|
||||
@@ -57,7 +62,7 @@ function ($, _, gettext, BasePage, GroupConfigurationsList) {
|
||||
* @param {String|Number} Id of the group configuration.
|
||||
*/
|
||||
expandConfiguration: function (id) {
|
||||
var groupConfig = this.collection.findWhere({
|
||||
var groupConfig = this.experimentsEnabled && this.experimentGroupConfigurations.findWhere({
|
||||
id: parseInt(id)
|
||||
});
|
||||
|
||||
|
||||
@@ -89,17 +89,23 @@ define(["jquery", "underscore", "gettext", "js/views/xblock", "js/views/metadata
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns the data saved for the xmodule. Note that this *does not* work for XBlocks.
|
||||
* Returns the updated field data for the xblock. Note that this works for all
|
||||
* XModules as well as for XBlocks that provide a 'collectFieldData' API.
|
||||
*/
|
||||
getXModuleData: function() {
|
||||
getXBlockFieldData: function() {
|
||||
var xblock = this.xblock,
|
||||
metadataEditor = this.getMetadataEditor(),
|
||||
data = null;
|
||||
if (xblock.save) {
|
||||
// If the xblock supports returning its field data then collect it
|
||||
if (xblock.collectFieldData) {
|
||||
data = xblock.collectFieldData();
|
||||
// ... else if this is an XModule then call its save method
|
||||
} else if (xblock.save) {
|
||||
data = xblock.save();
|
||||
if (metadataEditor) {
|
||||
data.metadata = _.extend(data.metadata || {}, this.getChangedMetadata());
|
||||
}
|
||||
// ... else log an error
|
||||
} else {
|
||||
console.error('Cannot save xblock as it has no save method');
|
||||
}
|
||||
|
||||
48
cms/static/js/xblock/authoring.js
Normal file
48
cms/static/js/xblock/authoring.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Client-side logic to support XBlock authoring.
|
||||
*/
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
function VisibilityEditorView(runtime, element) {
|
||||
this.getGroupAccess = function() {
|
||||
var groupAccess, userPartitionId, selectedGroupIds;
|
||||
if (element.find('.visibility-level-all').prop('checked')) {
|
||||
return {};
|
||||
}
|
||||
userPartitionId = element.find('.wrapper-visibility-specific').data('user-partition-id').toString();
|
||||
selectedGroupIds = [];
|
||||
element.find('.field-visibility-content-group input:checked').each(function(index, input) {
|
||||
selectedGroupIds.push(parseInt($(input).val()));
|
||||
});
|
||||
groupAccess = {};
|
||||
groupAccess[userPartitionId] = selectedGroupIds;
|
||||
return groupAccess;
|
||||
};
|
||||
|
||||
element.find('.field-visibility-level input').change(function(event) {
|
||||
if ($(event.target).hasClass('visibility-level-all')) {
|
||||
element.find('.field-visibility-content-group input').prop('checked', false);
|
||||
}
|
||||
});
|
||||
element.find('.field-visibility-content-group input').change(function(event) {
|
||||
element.find('.visibility-level-all').prop('checked', false);
|
||||
element.find('.visibility-level-specific').prop('checked', true);
|
||||
});
|
||||
}
|
||||
|
||||
VisibilityEditorView.prototype.collectFieldData = function collectFieldData() {
|
||||
return {
|
||||
metadata: {
|
||||
"group_access": this.getGroupAccess()
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
function initializeVisibilityEditor(runtime, element) {
|
||||
return new VisibilityEditorView(runtime, element);
|
||||
}
|
||||
|
||||
// XBlock initialization functions must be global
|
||||
window.VisibilityEditorInit = initializeVisibilityEditor;
|
||||
})($);
|
||||
@@ -183,6 +183,7 @@ $color-ready: $green;
|
||||
$color-warning: $orange-l2;
|
||||
$color-error: $red-l2;
|
||||
$color-staff-only: $black;
|
||||
$color-visibility-set: $black;
|
||||
|
||||
$color-heading-base: $gray-d2;
|
||||
$color-copy-base: $gray-l1;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// studio - elements - forms
|
||||
// ====================
|
||||
|
||||
// Table of Contents
|
||||
// Table of Contents
|
||||
// * +Forms - General
|
||||
// * +Field - Is Editable
|
||||
// * +Field - With Error
|
||||
@@ -12,7 +12,23 @@
|
||||
// * +Form - Grandfathered
|
||||
|
||||
// +Forms - General
|
||||
// ====================
|
||||
// ====================
|
||||
// element-specific utilities
|
||||
// --------------------
|
||||
// UI: checkbox/radio inputs
|
||||
%input-tickable {
|
||||
|
||||
~ label {
|
||||
color: $color-copy-base;
|
||||
}
|
||||
|
||||
// STATE: checked/selected
|
||||
&:checked ~ label {
|
||||
@extend %t-strong;
|
||||
color: $ui-action-primary-color-focus;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
@@ -77,7 +93,7 @@ form {
|
||||
}
|
||||
|
||||
.input-checkbox-checked, .input-checkbox-unchecked {
|
||||
width: $baseline;
|
||||
width: ($baseline*0.75);
|
||||
}
|
||||
|
||||
.input-checkbox {
|
||||
@@ -107,8 +123,18 @@ form {
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: checkbox input
|
||||
.field-checkbox .input-checkbox {
|
||||
@extend %input-tickable;
|
||||
}
|
||||
|
||||
// CASE: radio input
|
||||
.field-radio .input-radio {
|
||||
@extend %input-tickable;
|
||||
}
|
||||
|
||||
// CASE: file input
|
||||
input[type=file] {
|
||||
input[type="file"] {
|
||||
@extend %t-copy-sub1;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
// UI: summary messages
|
||||
.summary-message {
|
||||
margin-bottom: $baseline;
|
||||
padding: ($baseline*0.75);
|
||||
background: $gray-d3;
|
||||
|
||||
.icon, .copy {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@extend %t-icon4;
|
||||
@include margin-right($baseline/2);
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.copy {
|
||||
@extend %t-copy-sub1;
|
||||
max-width: 85%;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: Warning summary message
|
||||
.summary-message-warning {
|
||||
border-top: ($baseline/5) solid $color-warning;
|
||||
|
||||
.icon {
|
||||
color: $color-warning;
|
||||
}
|
||||
}
|
||||
|
||||
// visual dividers
|
||||
.divider-visual {
|
||||
margin: ($baseline*0.75) 0;
|
||||
border: ($baseline/20) solid $gray-l4;
|
||||
}
|
||||
|
||||
// sections within a modal
|
||||
.modal-section {
|
||||
margin-bottom: ($baseline*0.75);
|
||||
@@ -64,11 +103,20 @@
|
||||
.modal-section-title {
|
||||
@extend %t-title6;
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
border-bottom: 1px solid $gray-l4;
|
||||
border-bottom: ($baseline/10) solid $gray-l4;
|
||||
padding-bottom: ($baseline/4);
|
||||
color: $gray-d2;
|
||||
}
|
||||
|
||||
.modal-subsection-title {
|
||||
@extend %t-title8;
|
||||
@extend %t-strong;
|
||||
margin-bottom: ($baseline/4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1;
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.modal-section-content {
|
||||
|
||||
.list-fields, .list-actions {
|
||||
@@ -238,143 +286,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// outline: edit item settings
|
||||
.wrapper-modal-window-bulkpublish-section,
|
||||
.wrapper-modal-window-bulkpublish-subsection,
|
||||
.wrapper-modal-window-bulkpublish-unit,
|
||||
.course-outline-modal {
|
||||
|
||||
.list-fields {
|
||||
|
||||
.field {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
margin-right: ($baseline/2);
|
||||
margin-bottom: ($baseline/4);
|
||||
|
||||
label {
|
||||
@extend %t-copy-sub1;
|
||||
@extend %t-strong;
|
||||
@include transition(color $tmg-f3 ease-in-out 0s);
|
||||
margin: 0 0 ($baseline/4) 0;
|
||||
|
||||
&.is-focused {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
input, textarea {
|
||||
@extend %t-copy-base;
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: ($baseline/2);
|
||||
|
||||
// CASE: long length
|
||||
&.long {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// CASE: short length
|
||||
&.short {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: specific release + due times/dates
|
||||
.start-date,
|
||||
.start-time,
|
||||
.due-date,
|
||||
.due-time {
|
||||
width: ($baseline*7);
|
||||
}
|
||||
|
||||
.tip {
|
||||
@extend %t-copy-sub1;
|
||||
@include transition(color, 0.15s, ease-in-out);
|
||||
display: block;
|
||||
margin-top: ($baseline/4);
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.tip-warning {
|
||||
color: $gray-d2;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: type-based input
|
||||
.field-text {
|
||||
|
||||
// TODO: refactor the _forms.scss partial to allow for this area to inherit from it
|
||||
label, input, textarea {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: select input
|
||||
.field-select {
|
||||
|
||||
.label, .input {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-right: ($baseline/2);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// CASE: checkbox input
|
||||
.field-checkbox {
|
||||
|
||||
.label, label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// UI: grading section
|
||||
.edit-settings-grading {
|
||||
|
||||
.grading-type {
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
// UI: staff lock section
|
||||
.edit-staff-lock {
|
||||
|
||||
.checkbox-cosmetic .input-checkbox {
|
||||
@extend %cont-text-sr;
|
||||
|
||||
// CASE: unchecked
|
||||
~ .tip-warning {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// CASE: checked
|
||||
&:checked {
|
||||
|
||||
~ .tip-warning {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// needed to override poorly scoped margin-bottom on any label element in a view (from _forms.scss)
|
||||
.checkbox-cosmetic .label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xblock custom actions
|
||||
.modal-window .editor-with-buttons {
|
||||
margin-bottom: ($baseline*3);
|
||||
@@ -394,7 +305,7 @@
|
||||
}
|
||||
|
||||
|
||||
// special overrides for video module editor/hidden tab editors
|
||||
// MODAL TYPE: component - video modal (includes special overrides for xblock-related editing view)
|
||||
.modal-lg.modal-type-video {
|
||||
|
||||
.modal-content {
|
||||
@@ -517,4 +428,225 @@
|
||||
opacity: 0.5;
|
||||
filter: alpha(opacity=50);
|
||||
}
|
||||
|
||||
// MODAL TYPE: component - visibility modal
|
||||
.xblock-visibility_view {
|
||||
|
||||
.visibility-controls-secondary {
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
@include margin(($baseline*0.75), 0, 0, $baseline);
|
||||
}
|
||||
|
||||
.visibility-controls-group {
|
||||
@extend %wipe-last-child;
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
|
||||
// UI: form fields
|
||||
.list-fields {
|
||||
|
||||
.field {
|
||||
@extend %wipe-last-child;
|
||||
margin-bottom: ($baseline/4);
|
||||
|
||||
label {
|
||||
@extend %t-copy-sub1;
|
||||
}
|
||||
}
|
||||
|
||||
// UI: radio and checkbox inputs
|
||||
.field-radio, .field-checkbox {
|
||||
|
||||
label {
|
||||
@include margin-left($baseline/4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: content group has been removed
|
||||
.field-visibility-content-group.was-removed {
|
||||
|
||||
.input-checkbox:checked ~ label {
|
||||
color: $color-error;
|
||||
}
|
||||
|
||||
.note {
|
||||
@extend %t-copy-sub2;
|
||||
@extend %t-regular;
|
||||
display: block;
|
||||
color: $color-error;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: no groups configured for visibility
|
||||
.is-not-configured {
|
||||
@extend %no-content;
|
||||
padding: ($baseline);
|
||||
@include text-align(left); // reset for %no-content's default styling
|
||||
|
||||
.title {
|
||||
@extend %t-title6;
|
||||
font-weight: 600; // needed for poorly scoped .title rule in modals
|
||||
margin: 0 0 ($baseline/2) 0; // needed for poorly scoped .title rule in modals
|
||||
}
|
||||
|
||||
.copy {
|
||||
@extend %t-copy-sub1;
|
||||
|
||||
p {
|
||||
@extend %wipe-last-child;
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
&.has-actions {
|
||||
|
||||
.actions {
|
||||
margin-top: $baseline;
|
||||
}
|
||||
|
||||
.action {
|
||||
@include margin-left(0); // reset for %no-content's default styling
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MODAL TYPE: outline - edit item settings
|
||||
.wrapper-modal-window-bulkpublish-section,
|
||||
.wrapper-modal-window-bulkpublish-subsection,
|
||||
.wrapper-modal-window-bulkpublish-unit,
|
||||
.course-outline-modal {
|
||||
|
||||
.list-fields {
|
||||
|
||||
.field {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
@include margin-right($baseline/2);
|
||||
margin-bottom: ($baseline/4);
|
||||
|
||||
label {
|
||||
@extend %t-copy-sub1;
|
||||
@extend %t-strong;
|
||||
@include transition(color $tmg-f3 ease-in-out 0s);
|
||||
margin: 0 0 ($baseline/4) 0;
|
||||
|
||||
&.is-focused {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
input, textarea {
|
||||
@extend %t-copy-base;
|
||||
@include transition(all $tmg-f2 ease-in-out 0s);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: ($baseline/2);
|
||||
|
||||
// CASE: long length
|
||||
&.long {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// CASE: short length
|
||||
&.short {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: specific release + due times/dates
|
||||
.start-date,
|
||||
.start-time,
|
||||
.due-date,
|
||||
.due-time {
|
||||
width: ($baseline*7);
|
||||
}
|
||||
|
||||
.tip {
|
||||
@extend %t-copy-sub1;
|
||||
@include transition(color, 0.15s, ease-in-out);
|
||||
display: block;
|
||||
margin-top: ($baseline/4);
|
||||
color: $gray-l2;
|
||||
}
|
||||
|
||||
.tip-warning {
|
||||
color: $gray-d2;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: type-based input
|
||||
.field-text {
|
||||
|
||||
// TODO: refactor the _forms.scss partial to allow for this area to inherit from it
|
||||
label, input, textarea {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// CASE: select input
|
||||
.field-select {
|
||||
|
||||
.label, .input {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.label {
|
||||
@include margin-right($baseline/2);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// CASE: checkbox input
|
||||
.field-checkbox {
|
||||
|
||||
.label, label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// UI: grading section
|
||||
.edit-settings-grading {
|
||||
|
||||
.grading-type {
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
// UI: staff lock section
|
||||
.edit-staff-lock {
|
||||
|
||||
.checkbox-cosmetic .input-checkbox {
|
||||
@extend %cont-text-sr;
|
||||
|
||||
// CASE: unchecked
|
||||
~ .tip-warning {
|
||||
display: block;
|
||||
}
|
||||
|
||||
// CASE: checked
|
||||
&:checked {
|
||||
|
||||
~ .tip-warning {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// needed to override poorly scoped margin-bottom on any label element in a view (from _forms.scss)
|
||||
.checkbox-cosmetic .label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,42 +150,50 @@
|
||||
|
||||
// ====================
|
||||
|
||||
// UI: xblocks - calls-to-action
|
||||
.wrapper-xblock .header-actions {
|
||||
.wrapper-xblock {
|
||||
|
||||
.actions-list {
|
||||
// UI: xblocks - calls-to-action
|
||||
.header-actions .actions-list {
|
||||
@extend %actions-list;
|
||||
}
|
||||
}
|
||||
|
||||
// UI: xblock is collapsible
|
||||
.wrapper-xblock.is-collapsible,
|
||||
.wrapper-xblock.xblock-type-container {
|
||||
// CASE: xblock is collapsible
|
||||
&.is-collapsible,
|
||||
&.xblock-type-container {
|
||||
|
||||
.icon {
|
||||
font-style: normal;
|
||||
}
|
||||
.icon {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.expand-collapse {
|
||||
@extend %expand-collapse;
|
||||
margin: 0 ($baseline/4);
|
||||
height: ($baseline*1.25);
|
||||
width: $baseline;
|
||||
.expand-collapse {
|
||||
@extend %expand-collapse;
|
||||
margin: 0 ($baseline/4);
|
||||
height: ($baseline*1.25);
|
||||
width: $baseline;
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.action-view {
|
||||
|
||||
.action-button {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.action-button-text {
|
||||
padding-right: ($baseline/5);
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-view {
|
||||
// CASE: xblock has specific visibility based on content groups set
|
||||
&.has-group-visibility-set {
|
||||
|
||||
.action-button {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.action-button-text {
|
||||
padding-right: ($baseline/5);
|
||||
padding-left: 0;
|
||||
.action-visibility .visibility-button.visibility-button { // needed to cascade in front of overscoped header-actions CSS rule
|
||||
color: $color-visibility-set;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,20 @@
|
||||
|
||||
// ====================
|
||||
|
||||
// view-specific utilities
|
||||
// --------------------
|
||||
%status-value-base {
|
||||
@extend %t-title7;
|
||||
@extend %t-strong;
|
||||
}
|
||||
|
||||
%status-value-sub1 {
|
||||
@extend %t-title8;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// UI: container page view
|
||||
// --------------------
|
||||
.view-container {
|
||||
@extend %two-col-1;
|
||||
|
||||
@@ -102,6 +115,7 @@
|
||||
@extend %t-title8;
|
||||
}
|
||||
|
||||
// UI: publishing details/summary
|
||||
.bit-publishing {
|
||||
@extend %bar-module;
|
||||
|
||||
@@ -159,37 +173,43 @@
|
||||
.wrapper-release {
|
||||
|
||||
.release-date {
|
||||
@extend %t-strong;
|
||||
@extend %status-value-base;
|
||||
}
|
||||
|
||||
.release-with {
|
||||
@extend %t-title8;
|
||||
display: block;
|
||||
@extend %status-value-sub1;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-visibility {
|
||||
|
||||
.copy {
|
||||
@extend %t-strong;
|
||||
@extend %status-value-base;
|
||||
margin-bottom: ($baseline/10);
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: ($baseline/4);
|
||||
color: $gray-d1;
|
||||
}
|
||||
|
||||
.inherited-from {
|
||||
@extend %t-title8;
|
||||
display: block;
|
||||
@extend %status-value-sub1;
|
||||
}
|
||||
|
||||
// UI: note about specific access
|
||||
.note-visibility {
|
||||
@extend %status-value-sub1;
|
||||
|
||||
.icon {
|
||||
@include margin-right($baseline/4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-pub-actions {
|
||||
padding: ($baseline*0.75);
|
||||
border-top: 1px solid $gray-l4;
|
||||
margin-top: ($baseline/2);
|
||||
padding: $baseline ($baseline*0.75) ($baseline*0.75) ($baseline*0.75);
|
||||
|
||||
.action-publish {
|
||||
@extend %btn-primary-blue;
|
||||
@@ -209,7 +229,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// versioning widget
|
||||
@@ -244,8 +263,7 @@
|
||||
.wrapper-unit-id, .wrapper-library-id {
|
||||
|
||||
.unit-id-value, .library-id-value {
|
||||
@extend %cont-text-wrap;
|
||||
@extend %t-copy-sub1;
|
||||
@extend %status-value-base;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -308,5 +326,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -19,206 +19,199 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-group-configurations-content {
|
||||
@extend %ui-well;
|
||||
padding: ($baseline*2);
|
||||
background-color: $gray-l4;
|
||||
text-align: center;
|
||||
color: $gray;
|
||||
.no-content {
|
||||
@extend %no-content;
|
||||
}
|
||||
|
||||
.new-button {
|
||||
@extend %t-action3;
|
||||
margin-left: $baseline;
|
||||
.wrapper-groups {
|
||||
margin-bottom: ($baseline*1.5);
|
||||
|
||||
.icon {
|
||||
margin-right: ($baseline/2);
|
||||
.title {
|
||||
@extend %t-title4;
|
||||
@extend %t-strong;
|
||||
margin-bottom: ($baseline/2);
|
||||
}
|
||||
|
||||
.copy {
|
||||
@extend %t-copy-sub1;
|
||||
}
|
||||
}
|
||||
|
||||
.group-configuration {
|
||||
.wrapper-collection {
|
||||
@extend %ui-window;
|
||||
position: relative;
|
||||
outline: none;
|
||||
|
||||
.group-configuration-details {
|
||||
.wrapper-group-configuration {
|
||||
padding: $baseline ($baseline*1.5);
|
||||
&:hover .collection .actions {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.group-configuration-header {
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.collection-details {
|
||||
padding: $baseline ($baseline*1.5);
|
||||
}
|
||||
|
||||
.group-configuration-title {
|
||||
@extend %t-title4;
|
||||
@extend %t-strong;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: ($baseline*14);
|
||||
.collection-header {
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
.group-toggle {
|
||||
display: inline-block;
|
||||
padding-left: $baseline;
|
||||
color: $black;
|
||||
.title {
|
||||
@extend %cont-truncated;
|
||||
@extend %t-title5;
|
||||
@extend %t-strong;
|
||||
margin-right: ($baseline*14);
|
||||
color: $black;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-configuration-info {
|
||||
@extend %t-copy-sub1;
|
||||
color: $gray-l1;
|
||||
margin-left: $baseline;
|
||||
|
||||
&.group-configuration-info-inline {
|
||||
display: table;
|
||||
width: 70%;
|
||||
margin: ($baseline/4) 0 ($baseline/2) $baseline;
|
||||
|
||||
li {
|
||||
@include box-sizing(border-box);
|
||||
display: table-cell;
|
||||
margin-right: 1%;
|
||||
|
||||
&.group-configuration-usage-count {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.group-configuration-info-block {
|
||||
li {
|
||||
padding: ($baseline/4) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.group-configuration-label {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.group-configuration-description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-toggle-expansion {
|
||||
@include transition(rotate .15s ease-in-out .25s);
|
||||
@extend %t-action1;
|
||||
.toggle {
|
||||
display: inline-block;
|
||||
width: ($baseline*0.75);
|
||||
vertical-align: baseline;
|
||||
margin-left: -$baseline;
|
||||
}
|
||||
padding-left: $baseline;
|
||||
color: $black;
|
||||
|
||||
&.is-selectable {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
&:hover {
|
||||
&:hover, &:focus {
|
||||
color: $blue;
|
||||
|
||||
.ui-toggle-expansion {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.groups {
|
||||
margin-left: $baseline;
|
||||
margin-bottom: ($baseline*0.75);
|
||||
|
||||
.group {
|
||||
@extend %t-copy-lead1;
|
||||
padding: ($baseline/7) 0 ($baseline/4);
|
||||
border-top: 1px solid $gray-l4;
|
||||
white-space: nowrap;
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 75%;
|
||||
margin-right: 5%;
|
||||
}
|
||||
|
||||
.group-allocation {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 20%;
|
||||
color: $gray-l1;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
@include transition(opacity .15s .25s ease-in-out);
|
||||
opacity: 0.0;
|
||||
position: absolute;
|
||||
top: $baseline;
|
||||
right: $baseline;
|
||||
|
||||
.action {
|
||||
.ui-toggle-expansion {
|
||||
@include transition(rotate .15s ease-in-out .25s);
|
||||
@extend %t-action1;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
|
||||
.edit {
|
||||
@include blue-button;
|
||||
@extend %t-action4;
|
||||
}
|
||||
|
||||
.delete {
|
||||
@extend %ui-btn-non;
|
||||
|
||||
&.is-disabled {
|
||||
background-color: $gray-l3;
|
||||
color: $gray-l6;
|
||||
}
|
||||
}
|
||||
width: ($baseline*0.75);
|
||||
vertical-align: baseline;
|
||||
margin-left: -$baseline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-group-configuration-usages {
|
||||
@extend %t-copy-sub1;
|
||||
box-shadow: 0 2px 2px 0 $shadow inset;
|
||||
padding: $baseline ($baseline*1.5) $baseline ($baseline*2.5);
|
||||
color: $gray-l1;
|
||||
&.is-selectable {
|
||||
@extend %ui-fake-link;
|
||||
|
||||
.group-configuration-usage {
|
||||
margin-left: $baseline;
|
||||
&:hover {
|
||||
color: $blue;
|
||||
|
||||
.group-configuration-usage-unit {
|
||||
padding: ($baseline/4) 0;
|
||||
|
||||
a {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fa-warning {
|
||||
margin: ($baseline/4) ($baseline/2) 0 ($baseline*1.5);
|
||||
color: $orange;
|
||||
}
|
||||
|
||||
.fa-times-circle {
|
||||
margin: ($baseline/4) ($baseline/2) 0 ($baseline*1.5);
|
||||
color: $red-l2;
|
||||
.ui-toggle-expansion {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-group-configuration-validation {
|
||||
.collection-info {
|
||||
@extend %t-copy-sub1;
|
||||
color: $gray-l1;
|
||||
margin-left: $baseline;
|
||||
|
||||
&.collection-info-inline {
|
||||
display: table;
|
||||
width: 70%;
|
||||
margin: ($baseline/4) 0 ($baseline/2) $baseline;
|
||||
|
||||
li {
|
||||
@include box-sizing(border-box);
|
||||
display: table-cell;
|
||||
margin-right: 1%;
|
||||
padding: ($baseline/4) 0;
|
||||
|
||||
&.collection-usage-count {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collection-label {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.collection-description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.collection-items {
|
||||
margin-left: $baseline;
|
||||
margin-bottom: ($baseline*0.75);
|
||||
|
||||
.item {
|
||||
@extend %t-copy-lead1;
|
||||
padding: ($baseline/7) 0 ($baseline/4);
|
||||
border-top: 1px solid $gray-l4;
|
||||
white-space: nowrap;
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 75%;
|
||||
margin-right: 5%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collection-details {
|
||||
|
||||
.actions {
|
||||
@include transition(opacity .15s .25s ease-in-out);
|
||||
position: absolute;
|
||||
top: $baseline;
|
||||
right: $baseline;
|
||||
opacity: 0.0;
|
||||
|
||||
.action {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: ($baseline/4);
|
||||
|
||||
.edit {
|
||||
@extend %ui-btn-non-blue;
|
||||
}
|
||||
|
||||
.delete {
|
||||
@extend %ui-btn-non;
|
||||
|
||||
&.is-disabled {
|
||||
background-color: $gray-l3;
|
||||
color: $gray-l6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collection-references {
|
||||
@extend %t-copy-sub1;
|
||||
box-shadow: 0 2px 2px 0 $shadow inset;
|
||||
padding: $baseline ($baseline*1.5) $baseline ($baseline*2.5);
|
||||
color: $gray-l1;
|
||||
|
||||
.usage {
|
||||
margin-left: $baseline;
|
||||
|
||||
.usage-unit {
|
||||
padding: ($baseline/4) 0;
|
||||
|
||||
a {
|
||||
@extend %t-strong;
|
||||
}
|
||||
|
||||
.fa-warning {
|
||||
margin: ($baseline/4) ($baseline/2) 0 ($baseline*1.5);
|
||||
color: $orange;
|
||||
}
|
||||
|
||||
.fa-times-circle {
|
||||
margin: ($baseline/4) ($baseline/2) 0 ($baseline*1.5);
|
||||
color: $red-l2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.usage-validation {
|
||||
@extend %t-copy-sub1;
|
||||
background-color: $gray-l6;
|
||||
margin-top: $baseline;
|
||||
@@ -230,17 +223,21 @@
|
||||
float: left;
|
||||
}
|
||||
|
||||
.group-configuration-validation-text {
|
||||
.collection-validation-text {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.group-configuration-edit {
|
||||
.collection-edit {
|
||||
@include box-sizing(border-box);
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
background: $white;
|
||||
|
||||
.message {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.wrapper-form {
|
||||
padding: $baseline ($baseline*1.5);
|
||||
}
|
||||
@@ -253,10 +250,117 @@
|
||||
color: $gray-l3;
|
||||
}
|
||||
|
||||
.is-focused .tip{
|
||||
.is-focused .tip {
|
||||
color: $gray;
|
||||
}
|
||||
|
||||
|
||||
.collection-fields {
|
||||
@extend %cont-no-list;
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin: 0 0 ($baseline*0.75) 0;
|
||||
|
||||
&:last-child {
|
||||
@extend %wipe-last-child;
|
||||
}
|
||||
|
||||
&.required {
|
||||
|
||||
label {
|
||||
@extend %t-strong;
|
||||
}
|
||||
|
||||
label:after {
|
||||
margin-left: ($baseline/4);
|
||||
content: "*";
|
||||
}
|
||||
}
|
||||
|
||||
label, input, textarea {
|
||||
display: block;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
label {
|
||||
@extend %t-copy-sub1;
|
||||
@include transition(color, 0.15s, ease-in-out);
|
||||
margin: 0 0 ($baseline/4) 0;
|
||||
|
||||
&.is-focused {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
//this section is borrowed from _account.scss - we should clean up and unify later
|
||||
input, textarea {
|
||||
@extend %t-copy-base;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: ($baseline/2);
|
||||
|
||||
&.long {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.short {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
::-webkit-input-placeholder {
|
||||
color: $gray-l4;
|
||||
}
|
||||
|
||||
:-moz-placeholder {
|
||||
color: $gray-l3;
|
||||
}
|
||||
|
||||
::-moz-placeholder {
|
||||
color: $gray-l3;
|
||||
}
|
||||
|
||||
:-ms-input-placeholder {
|
||||
color: $gray-l3;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
+ .tip {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
label {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
input {
|
||||
border-color: $red;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
label.required {
|
||||
@extend %t-strong;
|
||||
|
||||
&:after {
|
||||
margin-left: ($baseline/4);
|
||||
content: "*";
|
||||
}
|
||||
}
|
||||
|
||||
.field.add-collection-name label {
|
||||
@extend %t-title5;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.actions {
|
||||
box-shadow: inset 0 1px 2px $shadow;
|
||||
border-top: 1px solid $gray-l1;
|
||||
@@ -273,23 +377,13 @@
|
||||
|
||||
// add a group is below with groups styling
|
||||
.action-primary {
|
||||
@include blue-button;
|
||||
@include transition(all .15s);
|
||||
@extend %t-action2;
|
||||
@extend %t-strong;
|
||||
display: inline-block;
|
||||
padding: ($baseline/5) $baseline;
|
||||
text-transform: uppercase;
|
||||
@extend %btn-primary-blue;
|
||||
padding: ($baseline/4) $baseline;
|
||||
}
|
||||
|
||||
.action-secondary {
|
||||
@include grey-button;
|
||||
@include transition(all .15s);
|
||||
@extend %t-action2;
|
||||
@extend %t-strong;
|
||||
display: inline-block;
|
||||
padding: ($baseline/5) $baseline;
|
||||
text-transform: uppercase;
|
||||
@extend %btn-secondary-gray;
|
||||
padding: ($baseline/4) $baseline;
|
||||
}
|
||||
|
||||
.wrapper-delete-button {
|
||||
@@ -311,191 +405,9 @@
|
||||
@extend %t-strong;
|
||||
}
|
||||
}
|
||||
|
||||
.groups-fields,
|
||||
.group-configuration-fields {
|
||||
@extend %cont-no-list;
|
||||
|
||||
.field {
|
||||
margin: 0 0 ($baseline*0.75) 0;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.required {
|
||||
|
||||
label {
|
||||
@extend %t-strong;
|
||||
}
|
||||
|
||||
label:after {
|
||||
margin-left: ($baseline/4);
|
||||
content: "*";
|
||||
}
|
||||
}
|
||||
|
||||
label, input, textarea {
|
||||
display: block;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
label {
|
||||
@extend %t-copy-sub1;
|
||||
@include transition(color, 0.15s, ease-in-out);
|
||||
margin: 0 0 ($baseline/4) 0;
|
||||
|
||||
&.is-focused {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
|
||||
//this section is borrowed from _account.scss - we should clean up and unify later
|
||||
input, textarea {
|
||||
@extend %t-copy-base;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: ($baseline/2);
|
||||
|
||||
&.long {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.short {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
::-webkit-input-placeholder {
|
||||
color: $gray-l4;
|
||||
}
|
||||
|
||||
:-moz-placeholder {
|
||||
color: $gray-l3;
|
||||
}
|
||||
|
||||
::-moz-placeholder {
|
||||
color: $gray-l3;
|
||||
}
|
||||
|
||||
:-ms-input-placeholder {
|
||||
color: $gray-l3;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
+ .tip {
|
||||
color: $gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
label {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
input {
|
||||
border-color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
&.add-group-configuration-name label {
|
||||
@extend %t-title5;
|
||||
display: inline-block;
|
||||
width: 50%;
|
||||
padding-right: 5%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.group-configuration-id {
|
||||
display: inline-block;
|
||||
width: 45%;
|
||||
text-align: right;
|
||||
vertical-align: top;
|
||||
color: $gray-l1;
|
||||
|
||||
.group-configuration-value {
|
||||
@extend %t-strong;
|
||||
white-space: nowrap;
|
||||
margin-left: ($baseline*0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&.group-allocation {
|
||||
color: $gray-l1;
|
||||
}
|
||||
}
|
||||
|
||||
label.required {
|
||||
@extend %t-strong;
|
||||
|
||||
&:after {
|
||||
margin-left: ($baseline/4);
|
||||
content: "*";
|
||||
}
|
||||
}
|
||||
|
||||
.field-group {
|
||||
@include clearfix();
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
padding: ($baseline/4) 0 0 0;
|
||||
|
||||
.group-allocation,
|
||||
.field {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 0 3% 0 0;
|
||||
}
|
||||
|
||||
.group-allocation {
|
||||
max-width: 10%;
|
||||
min-width: 5%;
|
||||
color: $gray-l1;
|
||||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
|
||||
&.long {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
&.short {
|
||||
width: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
.action-close {
|
||||
@include transition(color $tmg-f2 ease-in-out);
|
||||
@extend %t-action1;
|
||||
display: inline-block;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: $blue-l3;
|
||||
vertical-align: middle;
|
||||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-configuration-fields {
|
||||
margin-bottom: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .wrapper-group-configuration .actions {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.action-add-group {
|
||||
.action-add-item {
|
||||
@extend %ui-btn-flat-outline;
|
||||
@extend %t-action2;
|
||||
@extend %t-strong;
|
||||
@@ -505,6 +417,183 @@
|
||||
padding: ($baseline/2);
|
||||
}
|
||||
}
|
||||
|
||||
// add/new collection
|
||||
.action-add {
|
||||
@extend %ui-btn-flat-outline;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: ($baseline*0.75);
|
||||
padding: ($baseline/2) $baseline;
|
||||
|
||||
&.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
@include margin-right($baseline/2);
|
||||
}
|
||||
}
|
||||
|
||||
// specific group-type styles
|
||||
.content-groups {
|
||||
|
||||
.collection-header{
|
||||
|
||||
.title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.experiment-groups {
|
||||
|
||||
.group-configuration-details {
|
||||
|
||||
.group-configuration-info {
|
||||
@extend %t-copy-sub1;
|
||||
color: $gray-l1;
|
||||
margin-left: $baseline;
|
||||
|
||||
&.group-configuration-info-inline {
|
||||
display: table;
|
||||
width: 70%;
|
||||
margin: ($baseline/4) 0 ($baseline/2) $baseline;
|
||||
|
||||
li {
|
||||
@include box-sizing(border-box);
|
||||
display: table-cell;
|
||||
margin-right: 1%;
|
||||
|
||||
&.group-configuration-usage-count {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.group-configuration-info-block {
|
||||
li {
|
||||
padding: ($baseline/4) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.group-configuration-label {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.group-configuration-description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.groups {
|
||||
margin-left: $baseline;
|
||||
margin-bottom: ($baseline*0.75);
|
||||
|
||||
.group {
|
||||
@extend %t-copy-lead1;
|
||||
padding: ($baseline/7) 0 ($baseline/4);
|
||||
border-top: 1px solid $gray-l4;
|
||||
white-space: nowrap;
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 75%;
|
||||
margin-right: 5%;
|
||||
}
|
||||
|
||||
.group-allocation {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 20%;
|
||||
color: $gray-l1;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-configuration-edit {
|
||||
|
||||
.add-collection-name label {
|
||||
width: 50%;
|
||||
padding-right: 5%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.group-configuration-id {
|
||||
display: inline-block;
|
||||
width: 45%;
|
||||
text-align: right;
|
||||
vertical-align: top;
|
||||
color: $gray-l1;
|
||||
|
||||
.group-configuration-value {
|
||||
@extend %t-strong;
|
||||
white-space: nowrap;
|
||||
margin-left: ($baseline*0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.field-group {
|
||||
@include clearfix();
|
||||
margin: 0 0 ($baseline/2) 0;
|
||||
padding: ($baseline/4) 0 0 0;
|
||||
|
||||
.group-allocation,
|
||||
.field {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 0 3% 0 0;
|
||||
}
|
||||
|
||||
.group-allocation {
|
||||
max-width: 10%;
|
||||
min-width: 5%;
|
||||
color: $gray-l1;
|
||||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
|
||||
&.long {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
&.short {
|
||||
width: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
.action-close {
|
||||
@include transition(color $tmg-f2 ease-in-out);
|
||||
@extend %t-action1;
|
||||
display: inline-block;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: $blue-l3;
|
||||
vertical-align: middle;
|
||||
|
||||
&:hover {
|
||||
color: $blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-supplementary {
|
||||
|
||||
@@ -86,6 +86,9 @@ import json
|
||||
</div>
|
||||
|
||||
<div id="page-prompt"></div>
|
||||
|
||||
<%block name="modal_placeholder"></%block>
|
||||
|
||||
<%block name="jsextra"></%block>
|
||||
<script type="text/javascript">
|
||||
require(['js/factories/common_deps'], function () {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<%inherit file="base.html" />
|
||||
<%def name="online_help_token()"><% return "group_configurations" %></%def>
|
||||
<%def name="content_groups_help_token()"><% return "content_groups" %></%def>
|
||||
<%def name="experiment_group_configurations_help_token()"><% return "group_configurations" %></%def>
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%! import json %>
|
||||
<%!
|
||||
@@ -11,7 +12,7 @@
|
||||
<%block name="bodyclass">is-signedin course view-group-configurations</%block>
|
||||
|
||||
<%block name="header_extras">
|
||||
% for template_name in ["group-configuration-details", "group-configuration-edit", "no-group-configurations", "group-edit", "basic-modal", "modal-button"]:
|
||||
% for template_name in ["group-configuration-details", "group-configuration-editor", "group-edit", "content-group-editor", "content-group-details", "basic-modal", "modal-button", "list"]:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
@@ -19,11 +20,9 @@
|
||||
</%block>
|
||||
|
||||
<%block name="requirejs">
|
||||
% if configurations is not None:
|
||||
require(["js/factories/group_configurations"], function(GroupConfigurationsFactory) {
|
||||
GroupConfigurationsFactory(${json.dumps(configurations)}, "${group_configuration_url}", "${course_outline_url}");
|
||||
GroupConfigurationsFactory(${json.dumps(should_show_experiment_groups)}, ${json.dumps(experiment_group_configurations)}, ${json.dumps(content_group_configuration)}, "${group_configuration_url}", "${course_outline_url}");
|
||||
});
|
||||
% endif
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
@@ -33,45 +32,56 @@
|
||||
<small class="subtitle">${_("Settings")}</small>
|
||||
<span class="sr">> </span>${_("Group Configurations")}
|
||||
</h1>
|
||||
<nav class="nav-actions">
|
||||
<h3 class="sr">${_("Page Actions")}</h3>
|
||||
<ul>
|
||||
<li class="nav-item">
|
||||
<a href="#" class="button new-button"><i class="icon fa fa-plus"></i> ${_("New Group Configuration")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
% if configurations is None:
|
||||
<div class="notice notice-incontext notice-moduledisabled">
|
||||
<p class="copy">
|
||||
${_("This module is disabled at the moment.")}
|
||||
</p>
|
||||
<div class="wrapper-groups content-groups">
|
||||
<h3 class="title">${_("Content Groups")}</h3>
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">${_("Loading")}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
% else:
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">${_("Loading")}</span></p>
|
||||
|
||||
% if should_show_experiment_groups:
|
||||
<div class="wrapper-groups experiment-groups">
|
||||
<h3 class="title">${_("Experiment Group Configurations")}</h3>
|
||||
% if experiment_group_configurations is None:
|
||||
<div class="notice notice-incontext notice-moduledisabled">
|
||||
<p class="copy">
|
||||
${_("This module is disabled at the moment.")}
|
||||
</p>
|
||||
</div>
|
||||
% else:
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">${_("Loading")}</span></p>
|
||||
</div>
|
||||
% endif
|
||||
</div>
|
||||
% endif
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complementary">
|
||||
<div class="bit">
|
||||
<h3 class="title-3">${_("What can I do on this page?")}</h3>
|
||||
<p>${_("You can create, edit, and delete group configurations.")}</p>
|
||||
|
||||
<p>${_("A group configuration defines how many groups of students are in an experiment. When you create an experiment, you select the group configuration to use.")}</p>
|
||||
|
||||
<p>${_("Click {em_start}New Group Configuration{em_end} to add a new configuration. To edit a configuration, hover over its box and click {em_start}Edit{em_end}.").format(em_start='<strong>', em_end="</strong>")}</p>
|
||||
|
||||
<p>${_("You can delete a group configuration only if it is not in use in an experiment. To delete a configuration, hover over its box and click the delete icon.")}</p>
|
||||
|
||||
<p><a href="${get_online_help_info(online_help_token())['doc_url']}" target="_blank">${_("Learn More")}</a></p>
|
||||
</div>
|
||||
<div class="bit">
|
||||
<div class="content-groups-doc">
|
||||
<h3 class="title-3">${_("Content Groups")}</h3>
|
||||
<p>${_("Use content groups to give groups of students access to a specific set of course content. In addition to course content that is intended for all students, each content group sees content that you specifically designate as visible to it. By associating a content group with one or more cohorts, you can customize the content that a particular cohort or cohorts sees in your course.")}</p>
|
||||
<p>${_("Click {em_start}New content group{em_end} to add a new content group. To edit the name of a content group, hover over its box and click {em_start}Edit{em_end}. Content groups cannot be deleted.").format(em_start="<strong>", em_end="</strong>")}</p>
|
||||
<p><a href="${get_online_help_info(content_groups_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn More")}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
% if should_show_experiment_groups:
|
||||
<div class="bit">
|
||||
<div class="experiment-groups-doc">
|
||||
<h3 class="title-3">${_("Experiment Group Configurations")}</h3>
|
||||
<p>${_("Use experiment group configurations to define how many groups of students are in a content experiment. When you create a content experiment for a course, you select the group configuration to use.")}</p>
|
||||
<p>${_("Click {em_start}New Group Configuration{em_end} to add a new configuration. To edit a configuration, hover over its box and click {em_start}Edit{em_end}.").format(em_start="<strong>", em_end="</strong>")}</p>
|
||||
<p>${_("You can delete a group configuration only if it is not in use in an experiment. To delete a configuration, hover over its box and click the delete icon.")}</p>
|
||||
<p><a href="${get_online_help_info(experiment_group_configurations_help_token())['doc_url']}" target="_blank" class="button external-help-button">${_("Learn More")}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
<div class="bit">
|
||||
% if context_course:
|
||||
<%
|
||||
|
||||
13
cms/templates/js/content-group-details.underscore
Normal file
13
cms/templates/js/content-group-details.underscore
Normal file
@@ -0,0 +1,13 @@
|
||||
<div class="collection-details">
|
||||
<header class="collection-header">
|
||||
<h3 class="title">
|
||||
<%- name %>
|
||||
</h3>
|
||||
</header>
|
||||
|
||||
<ul class="actions">
|
||||
<li class="action action-edit">
|
||||
<button class="edit"><i class="icon fa fa-pencil"></i> <%= gettext("Edit") %></button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
19
cms/templates/js/content-group-editor.underscore
Normal file
19
cms/templates/js/content-group-editor.underscore
Normal file
@@ -0,0 +1,19 @@
|
||||
<form class="collection-edit-form">
|
||||
<% if (error && error.message) { %>
|
||||
<div class="content-group-edit-error message message-status message-status error is-shown">
|
||||
<%= gettext(error.message) %>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="wrapper-form">
|
||||
<fieldset class="collection-fields">
|
||||
<div class="input-wrap field text required add-collection-name <% if(error && error.attributes && error.attributes.name) { print('error'); } %>">
|
||||
<label for="group-cohort-name-<%= uniqueId %>"><%= gettext("Content Group Name") %></label>
|
||||
<input name="group-cohort-name" id="group-cohort-name-<%= uniqueId %>" class="collection-name-input input-text" value="<%- name %>" type="text" placeholder="<%= gettext("This is the name of the group") %>">
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="action action-primary" type="submit"><% if (isNew) { print(gettext("Create")) } else { print(gettext("Save")) } %></button>
|
||||
<button class="action action-secondary action-cancel"><%= gettext("Cancel") %></button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,14 +1,14 @@
|
||||
<div class="wrapper-group-configuration">
|
||||
<header class="group-configuration-header">
|
||||
<h3 class="group-configuration-title">
|
||||
<a href="#" class="group-toggle <% if(showGroups){ print('hide'); } else { print('show'); } %>-groups">
|
||||
<div class="collection-details wrapper-group-configuration">
|
||||
<header class="collection-header group-configuration-header">
|
||||
<h3 class="title group-configuration-title">
|
||||
<a href="#" class="toggle group-toggle <% if(showGroups){ print('hide'); } else { print('show'); } %>-groups">
|
||||
<i class="ui-toggle-expansion icon fa fa-caret-<% if(showGroups){ print('down'); } else { print('right'); } %>"></i>
|
||||
<%= name %>
|
||||
</a>
|
||||
</h3>
|
||||
</header>
|
||||
|
||||
<ol class="group-configuration-info group-configuration-info-<% if(showGroups){ print('block'); } else { print('inline'); } %>">
|
||||
<ol class="collection-info group-configuration-info group-configuration-info-<% if(showGroups){ print('block'); } else { print('inline'); } %>">
|
||||
<% if (!_.isUndefined(id)) { %>
|
||||
<li class="group-configuration-id"
|
||||
><span class="group-configuration-label"><%= gettext('ID') %>: </span
|
||||
@@ -16,7 +16,7 @@
|
||||
></li>
|
||||
<% } %>
|
||||
<% if (showGroups) { %>
|
||||
<li class="group-configuration-description">
|
||||
<li class="collection-description group-configuration-description">
|
||||
<%= description %>
|
||||
</li>
|
||||
<% } else { %>
|
||||
@@ -31,18 +31,18 @@
|
||||
|
||||
<% if(showGroups) { %>
|
||||
<% allocation = Math.floor(100 / groups.length) %>
|
||||
<ol class="groups groups-<%= index %>">
|
||||
<ol class="collection-items groups groups-<%= index %>">
|
||||
<% groups.each(function(group, groupIndex) { %>
|
||||
<li class="group group-<%= groupIndex %>">
|
||||
<span class="group-name"><%= group.get('name') %></span>
|
||||
<span class="group-allocation"><%= allocation %>%</span>
|
||||
<li class="item group group-<%= groupIndex %>">
|
||||
<span class="name group-name"><%= group.get('name') %></span>
|
||||
<span class="meta group-allocation"><%= allocation %>%</span>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ol>
|
||||
<% } %>
|
||||
<ul class="actions group-configuration-actions">
|
||||
<li class="action action-edit">
|
||||
<button class="edit"><%= gettext("Edit") %></button>
|
||||
<button class="edit"><i class="icon fa fa-pencil"></i> <%= gettext("Edit") %></button>
|
||||
</li>
|
||||
<% if (_.isEmpty(usage)) { %>
|
||||
<li class="action action-delete wrapper-delete-button">
|
||||
@@ -56,12 +56,12 @@
|
||||
</ul>
|
||||
</div>
|
||||
<% if(showGroups) { %>
|
||||
<div class="wrapper-group-configuration-usages">
|
||||
<div class="collection-references wrapper-group-configuration-usages">
|
||||
<% if (!_.isEmpty(usage)) { %>
|
||||
<h4 class="group-configuration-usage-text"><%= gettext('This Group Configuration is used in:') %></h4>
|
||||
<ol class="group-configuration-usage">
|
||||
<h4 class="intro group-configuration-usage-text"><%= gettext('This Group Configuration is used in:') %></h4>
|
||||
<ol class="usage group-configuration-usage">
|
||||
<% _.each(usage, function(unit) { %>
|
||||
<li class="group-configuration-usage-unit">
|
||||
<li class="usage-unit group-configuration-usage-unit">
|
||||
<p><a href=<%= unit.url %> ><%= unit.label %></a></p>
|
||||
<% if (unit.validation) { %>
|
||||
<p>
|
||||
@@ -70,7 +70,7 @@
|
||||
<% } else if (unit.validation.type === 'error') { %>
|
||||
<i class="icon fa fa-exclamation-circle"></i>
|
||||
<% } %>
|
||||
<span class="group-configuration-validation-message">
|
||||
<span class="usage-validation-message group-configuration-validation-message">
|
||||
<%= unit.validation.text %>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<form class="group-configuration-edit-form">
|
||||
<div class="wrapper-form">
|
||||
<form class="collection-edit-form group-configuration-edit-form">
|
||||
<% if (error && error.message) { %>
|
||||
<div class="group-configuration-edit-error message message-status message-status error is-shown" name="group-configuration-edit-error">
|
||||
<%= gettext(error.message) %>
|
||||
</div>
|
||||
<% } %>
|
||||
<fieldset class="group-configuration-fields">
|
||||
<div class="wrapper-form">
|
||||
<fieldset class="collection-fields group-configuration-fields">
|
||||
<legend class="sr"><%= gettext("Group Configuration information") %></legend>
|
||||
<div class="input-wrap field text required add-group-configuration-name <% if(error && error.attributes && error.attributes.name) { print('error'); } %>">
|
||||
<div class="input-wrap field text required add-collection-name add-group-configuration-name <% if(error && error.attributes && error.attributes.name) { print('error'); } %>">
|
||||
<label for="group-configuration-name-<%= uniqueId %>"><%= gettext("Group Configuration Name") %></label><%
|
||||
if (!_.isUndefined(id)) {
|
||||
%><span class="group-configuration-id">
|
||||
@@ -16,7 +16,7 @@
|
||||
</span><%
|
||||
}
|
||||
%>
|
||||
<input id="group-configuration-name-<%= uniqueId %>" class="group-configuration-name-input input-text" name="group-configuration-name" type="text" placeholder="<%= gettext("This is the Name of the Group Configuration") %>" value="<%= name %>">
|
||||
<input id="group-configuration-name-<%= uniqueId %>" class="collection-name-input input-text" name="group-configuration-name" type="text" placeholder="<%= gettext("This is the Name of the Group Configuration") %>" value="<%= name %>">
|
||||
<span class="tip tip-stacked"><%= gettext("Name or short description of the configuration") %></span>
|
||||
</div>
|
||||
<div class="input-wrap field text add-group-configuration-description">
|
||||
@@ -30,10 +30,10 @@
|
||||
<label class="groups-fields-label required"><%= gettext("Groups") %></label>
|
||||
<span class="tip tip-stacked"><%= gettext("Name of the groups that students will be assigned to, for example, Control, Video, Problems. You must have two or more groups.") %></span>
|
||||
<ol class="groups list-input enum"></ol>
|
||||
<button class="action action-add-group"><i class="icon fa fa-plus"></i> <%= gettext("Add another group") %></button>
|
||||
<button class="action action-add-group action-add-item"><i class="icon fa fa-plus"></i> <%= gettext("Add another group") %></button>
|
||||
</fieldset>
|
||||
<% if (!_.isEmpty(usage)) { %>
|
||||
<div class="wrapper-group-configuration-validation">
|
||||
<div class="wrapper-group-configuration-validation usage-validation">
|
||||
<i class="icon fa fa-warning"></i>
|
||||
<p class="group-configuration-validation-text">
|
||||
<%= gettext('This configuration is currently used in content experiments. If you make changes to the groups, you may need to edit those experiments.') %>
|
||||
15
cms/templates/js/list.underscore
Normal file
15
cms/templates/js/list.underscore
Normal file
@@ -0,0 +1,15 @@
|
||||
<% if (length === 0) { %>
|
||||
<div class="no-content">
|
||||
<p>
|
||||
<%- emptyMessage %>
|
||||
<a href="#" class="button new-button"><i class="icon fa fa-plus"></i> <%= interpolate(gettext("Add your first %(item_type)s"), {item_type: itemCategoryDisplayName}, true) %></a>
|
||||
</p>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="list-items"></div>
|
||||
<% if (!isEditing) { %>
|
||||
<button class="action action-add">
|
||||
<i class="icon fa fa-plus"></i><%- interpolate(gettext('New %(item_type)s'), {item_type: itemCategoryDisplayName}, true) %>
|
||||
</button>
|
||||
<% } %>
|
||||
<% } %>
|
||||
@@ -46,6 +46,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
@@ -71,6 +74,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
@@ -96,6 +102,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
@@ -151,6 +160,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
@@ -176,6 +188,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
@@ -201,6 +216,9 @@
|
||||
<li class="action-item action-edit">
|
||||
<a href="#" class="edit-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" class="visibility-button action-button"></a>
|
||||
</li>
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" class="duplicate-button action-button"></a>
|
||||
</li>
|
||||
|
||||
@@ -3,24 +3,23 @@
|
||||
<header class="mast has-actions has-subtitle">
|
||||
<h1 class="page-header">
|
||||
<small class="subtitle">Settings</small>
|
||||
<span class="sr">> </span>Group Configurations
|
||||
<span class="sr">> </span>Group Configurations"
|
||||
</h1>
|
||||
<nav class="nav-actions">
|
||||
<h3 class="sr">Page Actions</h3>
|
||||
<ul>
|
||||
<li class="nav-item">
|
||||
<a href="#" class="button new-button"><i class="icon fa fa-plus"></i> New Group Configuration</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<section class="content">
|
||||
<article class="content-primary" role="main">
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">Loading</span></p>
|
||||
<div class="wrapper-groups content-groups">
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">Loading</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapper-groups experiment-groups">
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><i class="icon fa fa-refresh"></i></span> <span class="copy">Loading</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<aside class="content-supplementary" role="complementary"></aside>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="xblock xblock-visibility_view" data-runtime-version="1" data-usage-id="i4x:;_;_edX;_mock"
|
||||
data-init="MockXBlock" data-runtime-class="StudioRuntime" tabindex="0">
|
||||
</div>
|
||||
@@ -1,3 +0,0 @@
|
||||
<div class="no-group-configurations-content">
|
||||
<p><%= gettext("You haven't created any group configurations yet.") %><a href="#" class="button new-button"><i class="icon fa fa-plus"></i><%= gettext("Add your first Group Configuration") %></a></p>
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@ var visibleToStaffOnly = visibilityState === 'staff_only';
|
||||
<% } %>
|
||||
</h5>
|
||||
<% if (visibleToStaffOnly) { %>
|
||||
<p class="copy">
|
||||
<p class="visbility-copy copy">
|
||||
<%= gettext("Staff Only") %>
|
||||
<% if (!hasExplicitStaffLock) { %>
|
||||
<span class="inherited-from">
|
||||
@@ -75,18 +75,26 @@ var visibleToStaffOnly = visibilityState === 'staff_only';
|
||||
<% } %>
|
||||
</p>
|
||||
<% } else { %>
|
||||
<p class="copy"><%= gettext("Staff and Students") %></p>
|
||||
<p class="visbility-copy copy"><%= gettext("Staff and Students") %></p>
|
||||
<% } %>
|
||||
<p class="action-inline">
|
||||
<a href="" class="action-staff-lock" role="button" aria-pressed="<%= hasExplicitStaffLock %>">
|
||||
<% if (hasContentGroupComponents) { %>
|
||||
<p class="note-visibility">
|
||||
<i class="icon fa fa-eye" aria-hidden="true"></i>
|
||||
<span class="note-copy"><%= gettext("Some content in this unit is visible only to particular content groups") %></span>
|
||||
</p>
|
||||
<% } %>
|
||||
<ul class="actions-inline">
|
||||
<li class="action-inline">
|
||||
<a href="" class="action-staff-lock" role="button" aria-pressed="<%= hasExplicitStaffLock %>">
|
||||
<% if (hasExplicitStaffLock) { %>
|
||||
<i class="icon fa fa-check-square-o"></i>
|
||||
<i class="icon fa fa-check-square-o" aria-hidden="true"></i>
|
||||
<% } else { %>
|
||||
<i class="icon fa fa-square-o"></i>
|
||||
<i class="icon fa fa-square-o" aria-hidden="true"></i>
|
||||
<% } %>
|
||||
<%= gettext('Hide from students') %>
|
||||
</a>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="wrapper-pub-actions bar-mod-actions">
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore import utils
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
import urllib
|
||||
%>
|
||||
|
||||
@@ -333,9 +332,7 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
|
||||
<ul>
|
||||
<li class="nav-item"><a href="${grading_config_url}">${_("Grading")}</a></li>
|
||||
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
<li class="nav-item"><a href="${advanced_config_url}">${_("Advanced Settings")}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore import utils
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
from django.utils.html import escapejs
|
||||
%>
|
||||
<%block name="title">${_("Advanced Settings")}</%block>
|
||||
@@ -92,9 +91,7 @@
|
||||
<li class="nav-item"><a href="${details_url}">${_("Details & Schedule")}</a></li>
|
||||
<li class="nav-item"><a href="${grading_url}">${_("Grading")}</a></li>
|
||||
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
% endif
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
from contentstore import utils
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
from django.utils.translation import ugettext as _
|
||||
%>
|
||||
|
||||
@@ -135,9 +134,7 @@
|
||||
<ul>
|
||||
<li class="nav-item"><a href="${detailed_settings_url}">${_("Details & Schedule")}</a></li>
|
||||
<li class="nav-item"><a href="${course_team_url}">${_("Course Team")}</a></li>
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
% endif
|
||||
<li class="nav-item"><a href="${utils.reverse_course_url('group_configurations_list_handler', context_course.id)}">${_("Group Configurations")}</a></li>
|
||||
<li class="nav-item"><a href="${advanced_settings_url}">${_("Advanced Settings")}</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<%!
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore.views.helpers import xblock_studio_url
|
||||
from contentstore.utils import is_visible_to_specific_content_groups
|
||||
import json
|
||||
%>
|
||||
<%
|
||||
@@ -38,7 +39,11 @@ messages = json.dumps(xblock.validate().to_json())
|
||||
<div class="studio-xblock-wrapper" data-locator="${xblock.location | h}" data-course-key="${xblock.location.course_key | h}">
|
||||
% endif
|
||||
|
||||
<section class="wrapper-xblock ${section_class} ${collapsible_class}">
|
||||
<section class="wrapper-xblock ${section_class} ${collapsible_class}
|
||||
% if is_visible_to_specific_content_groups(xblock):
|
||||
has-group-visibility-set
|
||||
% endif
|
||||
">
|
||||
% endif
|
||||
|
||||
<header class="xblock-header xblock-header-${xblock.category}">
|
||||
@@ -63,6 +68,14 @@ messages = json.dumps(xblock.validate().to_json())
|
||||
<span class="action-button-text">${_("Edit")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% if can_edit_visibility:
|
||||
<li class="action-item action-visibility">
|
||||
<a href="#" data-tooltip="${_("Visibility Settings")}" class="visibility-button action-button">
|
||||
<i class="icon fa fa-eye" aria-hidden="true"></i>
|
||||
<span class="sr">${_("Visibility")}</span>
|
||||
</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="action-item action-duplicate">
|
||||
<a href="#" data-tooltip="${_("Duplicate")}" class="duplicate-button action-button">
|
||||
<i class="icon fa fa-copy"></i>
|
||||
|
||||
126
cms/templates/ux/reference/modal_access-component.html
Normal file
126
cms/templates/ux/reference/modal_access-component.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<div class="wrapper wrapper-modal-window wrapper-modal-window-edit-xblock" aria-describedby="modal-window-description" aria-labelledby="modal-window-title" aria-hidden="" role="dialog">
|
||||
<div class="modal-window-overlay"></div>
|
||||
<div class="modal-window confirm modal-med modal-type-html modal-editor" style="top: 50px; left: 400px;">
|
||||
<div class="edit-xblock-modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="title modal-window-title">Editing visibility for: [Component Name]</h2>
|
||||
</div>
|
||||
|
||||
<div class="modal-content">
|
||||
<div class="xblock-editor" data-locator="i4x://TestU/cohorts001/chapter/748152225449412a846bc24811a5621c" data-course-key="">
|
||||
|
||||
<div class="xblock xblock-visibility_view">
|
||||
|
||||
<div class="modal-section visibility-summary">
|
||||
<div class="summary-message summary-message-warning visibility-summary-message">
|
||||
<i class="icon fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
<p class="copy"><span class="sr">Warning: </span>This component is contained in a unit that is hidden from students. Component visibility settings are overridden by the unit visibility settings.</p>
|
||||
</div>
|
||||
|
||||
<!-- NOTE: use when no group configuration has been set -->
|
||||
<div class="is-not-configured has-actions">
|
||||
<h4 class="title">You have not set up any groups</h4>
|
||||
|
||||
<div class="copy">
|
||||
<p>Groups are a way for you to organize content in your course with a particular student experience in mind. They are commonly used to facilitate content and pedagogical experiments as well as to provide different tracks of content.</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="" class="action action-primary action-settings">Manage groups in this course</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="visibility-controls-form" method="post" action="">
|
||||
|
||||
<div class="modal-section visibility-controls">
|
||||
<h3 class="modal-section-title">Set visibility to:</h3>
|
||||
|
||||
<div class="modal-section-content">
|
||||
|
||||
<section class="visibility-controls-primary">
|
||||
<ul class="list-fields list-radio">
|
||||
<li class="field field-radio field-visibility-level">
|
||||
<input type="radio" id="visibility-level-all" name="visibility-level" value="" class="input input-radio visibility-level-all" />
|
||||
<label for="visibility-level-all" class="label">All Students and Staff</label>
|
||||
</li>
|
||||
|
||||
<li class="field field-radio field-visibility-level">
|
||||
<input type="radio" id="visibility-level-specific" name="visibility-level" value="" class="input input-radio visibility-level-specific" checked="checked" />
|
||||
<label for="visibility-level-specific" class="label">Specific Groups</label>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- NOTE: @andyarmstrong, if you need this wrapper to show and hide, great. If not, please remove it from the DOM -->
|
||||
<div class="wrapper-visibility-specific">
|
||||
|
||||
<section class="visibility-controls-secondary">
|
||||
<div class="visibility-controls-group">
|
||||
<h4 class="visibility-controls-title modal-subsection-title sr">Content Groups</h4>
|
||||
<ul class="list-fields list-checkbox">
|
||||
<li class="field field-checkbox field-visibility-content-group">
|
||||
<input type="checkbox" id="visibility-content-group-NAME1" name="visibility-content-group" value="" class="input input-checkbox visibility-content-group-NAME1" />
|
||||
<label for="visibility-content-group-NAME1" class="label">Content Group NAME 1</label>
|
||||
</li>
|
||||
|
||||
<li class="field field-checkbox field-visibility-content-group">
|
||||
<input type="checkbox" id="visibility-content-group-NAME2" name="visibility-content-group" value="" class="input input-checkbox visibility-content-group-NAME2" />
|
||||
<label for="visibility-content-group-NAME2" class="label">Content Group NAME 2</label>
|
||||
</li>
|
||||
|
||||
<li class="field field-checkbox field-visibility-content-group">
|
||||
<input type="checkbox" id="visibility-content-group-NAME3" name="visibility-content-group" value="" class="input input-checkbox visibility-content-group-NAME3" />
|
||||
<label for="visibility-content-group-NAME3" class="label">Content Group NAME 3</label>
|
||||
</li>
|
||||
|
||||
<li class="field field-checkbox field-visibility-content-group">
|
||||
<input type="checkbox" id="visibility-content-group-NAME4" name="visibility-content-group" value="" class="input input-checkbox visibility-content-group-NAME4" />
|
||||
<label for="visibility-content-group-NAME4" class="label">Content Group NAME 4</label>
|
||||
</li>
|
||||
|
||||
<!-- NOTE: @andyarmstrong, here's an example of how we would treat a group that was deleted/removed - we need a .was removed class and an additional UI element called a .note -->
|
||||
<li class="field field-checkbox field-visibility-content-group was-removed">
|
||||
<input type="checkbox" id="visibility-content-group-deleted1" name="visibility-content-group" value="" class="input input-checkbox visibility-content-group-deleted" checked="checked" />
|
||||
<label for="visibility-content-group-deleted1" class="label">
|
||||
Deleted Content Group
|
||||
</label>
|
||||
<span class="note">The selected group no longer exists. Choose another group or make the component visible to All Students and Staff</span>
|
||||
</li>
|
||||
|
||||
<!-- NOTE: @andyarmstrong, here's an example of how we would treat a group that was deleted/removed - we need a .was removed class and an additional UI element called a .note -->
|
||||
<li class="field field-checkbox field-visibility-content-group was-removed">
|
||||
<input type="checkbox" id="visibility-content-group-deleted1" name="visibility-content-group" value="" class="input input-checkbox visibility-content-group-deleted" checked="checked" />
|
||||
<label for="visibility-content-group-deleted1" class="label">
|
||||
Deleted Content Group
|
||||
</label>
|
||||
<span class="note">The selected group no longer exists. Choose another group or make the component visible to All Students and Staff</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div><!-- .xblock -->
|
||||
</div><!-- .xblock-editor -->
|
||||
</div><!-- .modal-content -->
|
||||
|
||||
<div class="modal-actions">
|
||||
<h3 class="sr">Actions</h3>
|
||||
<ul>
|
||||
<li class="action-item">
|
||||
<a href="#" class="button action-primary action-save">Save</a>
|
||||
</li>
|
||||
|
||||
<li class="action-item">
|
||||
<a href="#" class="button action-cancel">Cancel</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div><!-- .xblock-visibility-modal -->
|
||||
</div><!-- .modal-window -->
|
||||
</div><!-- .wrapper-modal-window -->
|
||||
103
cms/templates/visibility_editor.html
Normal file
103
cms/templates/visibility_editor.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<%
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
|
||||
from contentstore.utils import ancestor_has_staff_lock
|
||||
|
||||
cohorted_user_partition = get_cohorted_user_partition(xblock.location.course_key)
|
||||
unsorted_groups = cohorted_user_partition.groups if cohorted_user_partition else []
|
||||
groups = sorted(unsorted_groups, key=lambda group: group.name)
|
||||
selected_group_ids = xblock.group_access.get(cohorted_user_partition.id, []) if cohorted_user_partition else []
|
||||
has_selected_groups = len(selected_group_ids) > 0
|
||||
is_staff_locked = ancestor_has_staff_lock(xblock)
|
||||
%>
|
||||
|
||||
<div class="modal-section visibility-summary">
|
||||
% if len(groups) == 0:
|
||||
<div class="is-not-configured has-actions">
|
||||
<h4 class="title">${_('No content groups exist')}</h4>
|
||||
|
||||
<div class="copy">
|
||||
<p>${_('Use content groups to give groups of students access to a specific set of course content. Create one or more content groups, and make specific components visible to them.')}</p>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a href="${manage_groups_url}" class="action action-primary action-settings">${_('Manage content groups')}</a>
|
||||
</div>
|
||||
</div>
|
||||
% elif is_staff_locked:
|
||||
<div class="summary-message summary-message-warning visibility-summary-message">
|
||||
<i class="icon fa fa-exclamation-triangle" aria-hidden="true"></i>
|
||||
<p class="copy">
|
||||
## Translators: Any text between {screen_reader_start} and {screen_reader_end} is only read by screen readers and never shown in the browser.
|
||||
${_(
|
||||
"{screen_reader_start}Warning:{screen_reader_end} The Unit this component is contained in is hidden from students. Visibility settings here will be trumped by this."
|
||||
).format(
|
||||
screen_reader_start='<span class="sr">',
|
||||
screen_reader_end='</span>',
|
||||
)
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
% endif
|
||||
</div>
|
||||
|
||||
% if len(groups) > 0:
|
||||
<form class="visibility-controls-form" method="post" action="">
|
||||
|
||||
<div class="modal-section visibility-controls">
|
||||
<h3 class="modal-section-title">${_('Make visible to:')}</h3>
|
||||
|
||||
<div class="modal-section-content">
|
||||
|
||||
<section class="visibility-controls-primary">
|
||||
<ul class="list-fields list-radio">
|
||||
<li class="field field-radio field-visibility-level">
|
||||
<input type="radio" id="visibility-level-all" name="visibility-level" value="" class="input input-radio visibility-level-all" ${'checked="checked"' if not has_selected_groups else ''} />
|
||||
<label for="visibility-level-all" class="label">${_('All Students and Staff')}</label>
|
||||
</li>
|
||||
|
||||
<li class="field field-radio field-visibility-level">
|
||||
<input type="radio" id="visibility-level-specific" name="visibility-level" value="" class="input input-radio visibility-level-specific" ${'checked="checked"' if has_selected_groups else ''} />
|
||||
<label for="visibility-level-specific" class="label">${_('Specific Content Groups')}</label>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="wrapper-visibility-specific" data-user-partition-id="${cohorted_user_partition.id}">
|
||||
<section class="visibility-controls-secondary">
|
||||
<div class="visibility-controls-group">
|
||||
<h4 class="visibility-controls-title modal-subsection-title sr">${_('Content Groups')}</h4>
|
||||
<ul class="list-fields list-checkbox">
|
||||
<%
|
||||
missing_group_ids = set(selected_group_ids)
|
||||
%>
|
||||
% for group in groups:
|
||||
<%
|
||||
is_group_selected = group.id in selected_group_ids
|
||||
if is_group_selected:
|
||||
missing_group_ids.remove(group.id)
|
||||
%>
|
||||
<li class="field field-checkbox field-visibility-content-group">
|
||||
<input type="checkbox" id="visibility-content-group-${group.id}" name="visibility-content-group" value="${group.id}" class="input input-checkbox" ${'checked="checked"' if group.id in selected_group_ids else ''}/>
|
||||
<label for="visibility-content-group-${group.id}" class="label">${group.name | h}</label>
|
||||
</li>
|
||||
% endfor
|
||||
|
||||
% for group_id in missing_group_ids:
|
||||
<li class="field field-checkbox field-visibility-content-group was-removed">
|
||||
<input type="checkbox" id="visibility-content-group-${group_id}" name="visibility-content-group" value="${group_id}" class="input input-checkbox" checked="checked" />
|
||||
<label for="visibility-content-group-${group_id}" class="label">
|
||||
${_('Deleted Content Group')}
|
||||
<span class="note">${_('Content group no longer exists. Please choose another or allow access to All Students and staff')}</span>
|
||||
</label>
|
||||
</li>
|
||||
% endfor
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
% endif
|
||||
@@ -3,7 +3,6 @@
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext as _
|
||||
from contentstore.context_processors import doc_url
|
||||
from contentstore.views.course import should_show_group_configurations_page
|
||||
%>
|
||||
<%page args="online_help_token"/>
|
||||
|
||||
@@ -93,11 +92,9 @@
|
||||
<li class="nav-item nav-course-settings-team">
|
||||
<a href="${course_team_url}">${_("Course Team")}</a>
|
||||
</li>
|
||||
% if should_show_group_configurations_page(context_course):
|
||||
<li class="nav-item nav-course-settings-group-configurations">
|
||||
<a href="${reverse('contentstore.views.group_configurations_list_handler', kwargs={'course_key_string': unicode(course_key)})}">${_("Group Configurations")}</a>
|
||||
</li>
|
||||
% endif
|
||||
<li class="nav-item nav-course-settings-group-configurations">
|
||||
<a href="${reverse('contentstore.views.group_configurations_list_handler', kwargs={'course_key_string': unicode(course_key)})}">${_("Group Configurations")}</a>
|
||||
</li>
|
||||
<li class="nav-item nav-course-settings-advanced">
|
||||
<a href="${advanced_settings_url}">${_("Advanced Settings")}</a>
|
||||
</li>
|
||||
|
||||
@@ -91,6 +91,7 @@ urlpatterns += patterns(
|
||||
url(r'^import_status/{}/(?P<filename>.+)$'.format(settings.COURSE_KEY_PATTERN), 'import_status_handler'),
|
||||
url(r'^export/{}$'.format(settings.COURSE_KEY_PATTERN), 'export_handler'),
|
||||
url(r'^xblock/outline/{}$'.format(settings.USAGE_KEY_PATTERN), 'xblock_outline_handler'),
|
||||
url(r'^xblock/container/{}$'.format(settings.USAGE_KEY_PATTERN), 'xblock_container_handler'),
|
||||
url(r'^xblock/{}/(?P<view_name>[^/]+)$'.format(settings.USAGE_KEY_PATTERN), 'xblock_view_handler'),
|
||||
url(r'^xblock/{}?$'.format(settings.USAGE_KEY_PATTERN), 'xblock_handler'),
|
||||
url(r'^tabs/{}$'.format(settings.COURSE_KEY_PATTERN), 'tabs_handler'),
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
Utility functions related to databases.
|
||||
"""
|
||||
from functools import wraps
|
||||
import random
|
||||
|
||||
from django.db import connection, transaction
|
||||
|
||||
|
||||
MYSQL_MAX_INT = (2 ** 31) - 1
|
||||
|
||||
|
||||
def commit_on_success_with_read_committed(func): # pylint: disable=invalid-name
|
||||
"""
|
||||
Decorator which executes the decorated function inside a transaction with isolation level set to READ COMMITTED.
|
||||
@@ -38,3 +42,18 @@ def commit_on_success_with_read_committed(func): # pylint: disable=invalid-name
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def generate_int_id(minimum=0, maximum=MYSQL_MAX_INT, used_ids=None):
|
||||
"""
|
||||
Return a unique integer in the range [minimum, maximum], inclusive.
|
||||
"""
|
||||
if used_ids is None:
|
||||
used_ids = []
|
||||
|
||||
cid = random.randint(minimum, maximum)
|
||||
|
||||
while cid in used_ids:
|
||||
cid = random.randint(minimum, maximum)
|
||||
|
||||
return cid
|
||||
|
||||
@@ -8,9 +8,9 @@ import unittest
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import connection, IntegrityError
|
||||
from django.db.transaction import commit_on_success, TransactionManagementError
|
||||
from django.test import TransactionTestCase
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
|
||||
from util.db import commit_on_success_with_read_committed
|
||||
from util.db import commit_on_success_with_read_committed, generate_int_id
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
@@ -99,3 +99,31 @@ class TransactionIsolationLevelsTestCase(TransactionTestCase):
|
||||
with commit_on_success():
|
||||
with commit_on_success():
|
||||
commit_on_success_with_read_committed(do_nothing)()
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class GenerateIntIdTestCase(TestCase):
|
||||
"""Tests for `generate_int_id`"""
|
||||
@ddt.data(10)
|
||||
def test_no_used_ids(self, times):
|
||||
"""
|
||||
Verify that we get a random integer within the specified range
|
||||
when there are no used ids.
|
||||
"""
|
||||
minimum = 1
|
||||
maximum = times
|
||||
for i in range(times):
|
||||
self.assertIn(generate_int_id(minimum, maximum), range(minimum, maximum + 1))
|
||||
|
||||
@ddt.data(10)
|
||||
def test_used_ids(self, times):
|
||||
"""
|
||||
Verify that we get a random integer within the specified range
|
||||
but not in a list of used ids.
|
||||
"""
|
||||
minimum = 1
|
||||
maximum = times
|
||||
used_ids = {2, 4, 6, 8}
|
||||
for i in range(times):
|
||||
int_id = generate_int_id(minimum, maximum, used_ids)
|
||||
self.assertIn(int_id, list(set(range(minimum, maximum + 1)) - used_ids))
|
||||
|
||||
@@ -57,15 +57,6 @@ class InheritanceMixin(XBlockMixin):
|
||||
default=False,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
group_access = Dict(
|
||||
help="A dictionary that maps which groups can be shown this block. The keys "
|
||||
"are group configuration ids and the values are a list of group IDs. "
|
||||
"If there is no key for a group configuration or if the list of group IDs "
|
||||
"is empty then the block is considered visible to all. Note that this "
|
||||
"field is ignored if the block is visible_to_staff_only.",
|
||||
default={},
|
||||
scope=Scope.settings,
|
||||
)
|
||||
course_edit_method = String(
|
||||
display_name=_("Course Editor"),
|
||||
help=_("Enter the method by which this course is edited (\"XML\" or \"Studio\")."),
|
||||
|
||||
@@ -98,6 +98,7 @@ def update_module_store_settings(
|
||||
module_store_options=None,
|
||||
xml_store_options=None,
|
||||
default_store=None,
|
||||
mappings=None,
|
||||
):
|
||||
"""
|
||||
Updates the settings for each store defined in the given module_store_setting settings
|
||||
@@ -123,6 +124,9 @@ def update_module_store_settings(
|
||||
return
|
||||
raise Exception("Could not find setting for requested default store: {}".format(default_store))
|
||||
|
||||
if mappings and 'mappings' in module_store_setting['default']['OPTIONS']:
|
||||
module_store_setting['default']['OPTIONS']['mappings'] = mappings
|
||||
|
||||
|
||||
def get_mixed_stores(mixed_setting):
|
||||
"""
|
||||
|
||||
@@ -29,7 +29,7 @@ from contracts import contract, new_contract
|
||||
|
||||
from importlib import import_module
|
||||
from opaque_keys.edx.keys import UsageKey, CourseKey, AssetKey
|
||||
from opaque_keys.edx.locations import Location
|
||||
from opaque_keys.edx.locations import Location, BlockUsageLocator
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from opaque_keys.edx.locator import CourseLocator, LibraryLocator
|
||||
|
||||
@@ -56,6 +56,7 @@ new_contract('CourseKey', CourseKey)
|
||||
new_contract('AssetKey', AssetKey)
|
||||
new_contract('AssetMetadata', AssetMetadata)
|
||||
new_contract('long', long)
|
||||
new_contract('BlockUsageLocator', BlockUsageLocator)
|
||||
|
||||
# sort order that returns DRAFT items first
|
||||
SORT_REVISION_FAVOR_DRAFT = ('_id.revision', pymongo.DESCENDING)
|
||||
@@ -93,12 +94,13 @@ class MongoKeyValueStore(InheritanceKeyValueStore):
|
||||
A KeyValueStore that maps keyed data access to one of the 3 data areas
|
||||
known to the MongoModuleStore (data, children, and metadata)
|
||||
"""
|
||||
def __init__(self, data, children, metadata):
|
||||
def __init__(self, data, parent, children, metadata):
|
||||
super(MongoKeyValueStore, self).__init__()
|
||||
if not isinstance(data, dict):
|
||||
self._data = {'data': data}
|
||||
else:
|
||||
self._data = data
|
||||
self._parent = parent
|
||||
self._children = children
|
||||
self._metadata = metadata
|
||||
|
||||
@@ -106,7 +108,7 @@ class MongoKeyValueStore(InheritanceKeyValueStore):
|
||||
if key.scope == Scope.children:
|
||||
return self._children
|
||||
elif key.scope == Scope.parent:
|
||||
return None
|
||||
return self._parent
|
||||
elif key.scope == Scope.settings:
|
||||
return self._metadata[key.field_name]
|
||||
elif key.scope == Scope.content:
|
||||
@@ -219,15 +221,35 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin):
|
||||
self._convert_reference_to_key(childloc)
|
||||
for childloc in definition.get('children', [])
|
||||
]
|
||||
|
||||
parent = None
|
||||
if self.cached_metadata is not None:
|
||||
# fish the parent out of here if it's available
|
||||
parent_url = self.cached_metadata.get(unicode(location), {}).get('parent', {}).get(
|
||||
ModuleStoreEnum.Branch.published_only if location.revision is None
|
||||
else ModuleStoreEnum.Branch.draft_preferred
|
||||
)
|
||||
if parent_url:
|
||||
parent = BlockUsageLocator.from_string(parent_url)
|
||||
if not parent and category != 'course':
|
||||
# try looking it up just-in-time (but not if we're working with a root node (course).
|
||||
parent = self.modulestore.get_parent_location(
|
||||
as_published(location),
|
||||
ModuleStoreEnum.RevisionOption.published_only if location.revision is None
|
||||
else ModuleStoreEnum.RevisionOption.draft_preferred
|
||||
)
|
||||
|
||||
data = definition.get('data', {})
|
||||
if isinstance(data, basestring):
|
||||
data = {'data': data}
|
||||
|
||||
mixed_class = self.mixologist.mix(class_)
|
||||
if data: # empty or None means no work
|
||||
data = self._convert_reference_fields_to_keys(mixed_class, location.course_key, data)
|
||||
metadata = self._convert_reference_fields_to_keys(mixed_class, location.course_key, metadata)
|
||||
kvs = MongoKeyValueStore(
|
||||
data,
|
||||
parent,
|
||||
children,
|
||||
metadata,
|
||||
)
|
||||
@@ -439,6 +461,27 @@ class MongoBulkOpsMixin(BulkOperationsMixin):
|
||||
)
|
||||
|
||||
|
||||
class ParentLocationCache(dict):
|
||||
"""
|
||||
Dict-based object augmented with a more cache-like interface, for internal use.
|
||||
"""
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
@contract(key=unicode)
|
||||
def has(self, key):
|
||||
return key in self
|
||||
|
||||
@contract(key=unicode, value="BlockUsageLocator | None")
|
||||
def set(self, key, value):
|
||||
self[key] = value
|
||||
|
||||
@contract(value="BlockUsageLocator")
|
||||
def delete_by_value(self, value):
|
||||
keys_to_delete = [k for k, v in self.iteritems() if v == value]
|
||||
for key in keys_to_delete:
|
||||
del self[key]
|
||||
|
||||
|
||||
class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, MongoBulkOpsMixin):
|
||||
"""
|
||||
A Mongodb backed ModuleStore
|
||||
@@ -572,13 +615,21 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
return location.replace(revision=MongoRevisionKey.draft)
|
||||
return location.replace(revision=MongoRevisionKey.published)
|
||||
|
||||
def _get_parent_cache(self, branch):
|
||||
"""
|
||||
Provides a reference to one of the two branch-specific
|
||||
ParentLocationCaches associated with the current request (if any).
|
||||
"""
|
||||
if self.request_cache is not None:
|
||||
return self.request_cache.data.setdefault('parent-location-{}'.format(branch), ParentLocationCache())
|
||||
else:
|
||||
return ParentLocationCache()
|
||||
|
||||
def _compute_metadata_inheritance_tree(self, course_id):
|
||||
'''
|
||||
TODO (cdodge) This method can be deleted when the 'split module store' work has been completed
|
||||
Find all inheritable fields from all xblocks in the course which may define inheritable data
|
||||
'''
|
||||
# get all collections in the course, this query should not return any leaf nodes
|
||||
# note this is a bit ugly as when we add new categories of containers, we have to add it here
|
||||
|
||||
course_id = self.fill_in_run(course_id)
|
||||
query = SON([
|
||||
('_id.tag', 'i4x'),
|
||||
@@ -586,6 +637,9 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
('_id.course', course_id.course),
|
||||
('_id.category', {'$in': BLOCK_TYPES_WITH_CHILDREN})
|
||||
])
|
||||
# if we're only dealing in the published branch, then only get published containers
|
||||
if self.get_branch_setting() == ModuleStoreEnum.Branch.published_only:
|
||||
query['_id.revision'] = None
|
||||
# we just want the Location, children, and inheritable metadata
|
||||
record_filter = {'_id': 1, 'definition.children': 1}
|
||||
|
||||
@@ -610,6 +664,8 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
location_url = unicode(location)
|
||||
if location_url in results_by_url:
|
||||
# found either draft or live to complement the other revision
|
||||
# FIXME this is wrong. If the child was moved in draft from one parent to the other, it will
|
||||
# show up under both in this logic: https://openedx.atlassian.net/browse/TNL-1075
|
||||
existing_children = results_by_url[location_url].get('definition', {}).get('children', [])
|
||||
additional_children = result.get('definition', {}).get('children', [])
|
||||
total_children = existing_children + additional_children
|
||||
@@ -640,7 +696,13 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
_compute_inherited_metadata(child)
|
||||
else:
|
||||
# this is likely a leaf node, so let's record what metadata we need to inherit
|
||||
metadata_to_inherit[child] = my_metadata
|
||||
metadata_to_inherit[child] = my_metadata.copy()
|
||||
# WARNING: 'parent' is not part of inherited metadata, but
|
||||
# we're piggybacking on this recursive traversal to grab
|
||||
# and cache the child's parent, as a performance optimization.
|
||||
# The 'parent' key will be popped out of the dictionary during
|
||||
# CachingDescriptorSystem.load_item
|
||||
metadata_to_inherit[child].setdefault('parent', {})[self.get_branch_setting()] = url
|
||||
|
||||
if root is not None:
|
||||
_compute_inherited_metadata(root)
|
||||
@@ -735,12 +797,18 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
data = {}
|
||||
to_process = list(items)
|
||||
course_key = self.fill_in_run(course_key)
|
||||
parent_cache = self._get_parent_cache(self.get_branch_setting())
|
||||
|
||||
while to_process and depth is None or depth >= 0:
|
||||
children = []
|
||||
for item in to_process:
|
||||
self._clean_item_data(item)
|
||||
children.extend(item.get('definition', {}).get('children', []))
|
||||
data[Location._from_deprecated_son(item['location'], course_key.run)] = item
|
||||
item_location = Location._from_deprecated_son(item['location'], course_key.run)
|
||||
item_children = item.get('definition', {}).get('children', [])
|
||||
children.extend(item_children)
|
||||
for item_child in item_children:
|
||||
parent_cache.set(item_child, item_location)
|
||||
data[item_location] = item
|
||||
|
||||
if depth == 0:
|
||||
break
|
||||
@@ -1245,6 +1313,13 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
if xblock.has_children:
|
||||
children = self._serialize_scope(xblock, Scope.children)
|
||||
payload.update({'definition.children': children['children']})
|
||||
|
||||
# Remove all old pointers to me, then add my current children back
|
||||
parent_cache = self._get_parent_cache(self.get_branch_setting())
|
||||
parent_cache.delete_by_value(xblock.location)
|
||||
for child in xblock.children:
|
||||
parent_cache.set(unicode(child), xblock.location)
|
||||
|
||||
self._update_single_item(xblock.scope_ids.usage_id, payload, allow_not_found=allow_not_found)
|
||||
|
||||
# update subtree edited info for ancestors
|
||||
@@ -1339,6 +1414,10 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
assert revision == ModuleStoreEnum.RevisionOption.published_only \
|
||||
or revision == ModuleStoreEnum.RevisionOption.draft_preferred
|
||||
|
||||
parent_cache = self._get_parent_cache(self.get_branch_setting())
|
||||
if parent_cache.has(unicode(location)):
|
||||
return parent_cache.get(unicode(location))
|
||||
|
||||
# create a query with tag, org, course, and the children field set to the given location
|
||||
query = self._course_key_to_son(location.course_key)
|
||||
query['definition.children'] = unicode(location)
|
||||
@@ -1347,30 +1426,35 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
if revision == ModuleStoreEnum.RevisionOption.published_only:
|
||||
query['_id.revision'] = MongoRevisionKey.published
|
||||
|
||||
# query the collection, sorting by DRAFT first
|
||||
parents = self.collection.find(query, {'_id': True}, sort=[SORT_REVISION_FAVOR_DRAFT])
|
||||
def cache_and_return(parent_loc): # pylint:disable=missing-docstring
|
||||
parent_cache.set(unicode(location), parent_loc)
|
||||
return parent_loc
|
||||
|
||||
if parents.count() == 0:
|
||||
# query the collection, sorting by DRAFT first
|
||||
parents = list(
|
||||
self.collection.find(query, {'_id': True}, sort=[SORT_REVISION_FAVOR_DRAFT])
|
||||
)
|
||||
if len(parents) == 0:
|
||||
# no parents were found
|
||||
return None
|
||||
return cache_and_return(None)
|
||||
|
||||
if revision == ModuleStoreEnum.RevisionOption.published_only:
|
||||
if parents.count() > 1:
|
||||
if len(parents) > 1:
|
||||
non_orphan_parents = self._get_non_orphan_parents(location, parents, revision)
|
||||
if len(non_orphan_parents) == 0:
|
||||
# no actual parent found
|
||||
return None
|
||||
return cache_and_return(None)
|
||||
|
||||
if len(non_orphan_parents) > 1:
|
||||
# should never have multiple PUBLISHED parents
|
||||
raise ReferentialIntegrityError(
|
||||
u"{} parents claim {}".format(parents.count(), location)
|
||||
u"{} parents claim {}".format(len(parents), location)
|
||||
)
|
||||
else:
|
||||
return non_orphan_parents[0]
|
||||
return cache_and_return(non_orphan_parents[0].replace(run=location.course_key.run))
|
||||
else:
|
||||
# return the single PUBLISHED parent
|
||||
return Location._from_deprecated_son(parents[0]['_id'], location.course_key.run)
|
||||
return cache_and_return(Location._from_deprecated_son(parents[0]['_id'], location.course_key.run))
|
||||
else:
|
||||
# there could be 2 different parents if
|
||||
# (1) the draft item was moved or
|
||||
@@ -1386,11 +1470,11 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
# since we sorted by SORT_REVISION_FAVOR_DRAFT, the 0'th parent is the one we want
|
||||
if published_parents > 1:
|
||||
non_orphan_parents = self._get_non_orphan_parents(location, all_parents, revision)
|
||||
return non_orphan_parents[0]
|
||||
return cache_and_return(non_orphan_parents[0].replace(run=location.course_key.run))
|
||||
|
||||
found_id = all_parents[0]['_id']
|
||||
# don't disclose revision outside modulestore
|
||||
return Location._from_deprecated_son(found_id, location.course_key.run)
|
||||
return cache_and_return(Location._from_deprecated_son(found_id, location.course_key.run))
|
||||
|
||||
def get_parent_location(self, location, revision=ModuleStoreEnum.RevisionOption.published_only, **kwargs):
|
||||
'''
|
||||
@@ -1409,7 +1493,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
'''
|
||||
parent = self._get_raw_parent_location(location, revision)
|
||||
if parent:
|
||||
return as_published(parent)
|
||||
return parent
|
||||
return None
|
||||
|
||||
def get_modulestore_type(self, course_key=None):
|
||||
@@ -1463,6 +1547,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
|
||||
"""
|
||||
kvs = MongoKeyValueStore(
|
||||
definition_data,
|
||||
None,
|
||||
[],
|
||||
metadata,
|
||||
)
|
||||
|
||||
@@ -643,6 +643,9 @@ class DraftModuleStore(MongoModuleStore):
|
||||
|
||||
Raises:
|
||||
ItemNotFoundError: if any of the draft subtree nodes aren't found
|
||||
|
||||
Returns:
|
||||
The newly published xblock
|
||||
"""
|
||||
# NOTE: cannot easily use self._breadth_first b/c need to get pub'd and draft as pairs
|
||||
# (could do it by having 2 breadth first scans, the first to just get all published children
|
||||
|
||||
@@ -210,26 +210,18 @@ class ItemFactory(XModuleFactory):
|
||||
# replace the display name with an optional parameter passed in from the caller
|
||||
if display_name is not None:
|
||||
metadata['display_name'] = display_name
|
||||
runtime = parent.runtime if parent else None
|
||||
store.create_item(
|
||||
|
||||
module = store.create_child(
|
||||
user_id,
|
||||
location.course_key,
|
||||
parent.location,
|
||||
location.block_type,
|
||||
block_id=location.block_id,
|
||||
metadata=metadata,
|
||||
definition_data=data,
|
||||
runtime=runtime
|
||||
runtime=parent.runtime,
|
||||
fields=kwargs,
|
||||
)
|
||||
|
||||
module = store.get_item(location)
|
||||
|
||||
for attr, val in kwargs.items():
|
||||
setattr(module, attr, val)
|
||||
# Save the attributes we just set
|
||||
module.save()
|
||||
|
||||
store.update_item(module, user_id)
|
||||
|
||||
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
|
||||
# if we add one then we need to also add it to the policy information (i.e. metadata)
|
||||
# we should remove this once we can break this reference from the course to static tabs
|
||||
@@ -248,12 +240,15 @@ class ItemFactory(XModuleFactory):
|
||||
parent.children.append(location)
|
||||
store.update_item(parent, user_id)
|
||||
if publish_item:
|
||||
store.publish(parent.location, user_id)
|
||||
published_parent = store.publish(parent.location, user_id)
|
||||
# module is last child of parent
|
||||
return published_parent.get_children()[-1]
|
||||
else:
|
||||
return store.get_item(location)
|
||||
elif publish_item:
|
||||
store.publish(location, user_id)
|
||||
|
||||
# return the published item
|
||||
return store.get_item(location)
|
||||
return store.publish(location, user_id)
|
||||
else:
|
||||
return module
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -5,7 +5,6 @@ Unit tests for the Mixed Modulestore, with DDT for the various stores (Split, Dr
|
||||
from collections import namedtuple
|
||||
import datetime
|
||||
import ddt
|
||||
from importlib import import_module
|
||||
import itertools
|
||||
import mimetypes
|
||||
from uuid import uuid4
|
||||
@@ -33,7 +32,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
|
||||
from xmodule.exceptions import InvalidVersionError
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.draft_and_published import UnsupportedRevisionError, ModuleStoreDraftAndPublished
|
||||
from xmodule.modulestore.draft_and_published import UnsupportedRevisionError
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateCourseError, ReferentialIntegrityError, NoPathToItem
|
||||
from xmodule.modulestore.mixed import MixedModuleStore
|
||||
from xmodule.modulestore.search import path_to_location
|
||||
@@ -358,12 +357,12 @@ class TestMixedModuleStore(CourseComparisonTest):
|
||||
self.store.has_item(self.fake_location, revision=ModuleStoreEnum.RevisionOption.draft_preferred)
|
||||
|
||||
# draft queries:
|
||||
# problem: find draft item, find all items pertinent to inheritance computation
|
||||
# problem: find draft item, find all items pertinent to inheritance computation, find parent
|
||||
# non-existent problem: find draft, find published
|
||||
# split:
|
||||
# problem: active_versions, structure
|
||||
# non-existent problem: ditto
|
||||
@ddt.data(('draft', [2, 2], 0), ('split', [2, 2], 0))
|
||||
@ddt.data(('draft', [3, 2], 0), ('split', [2, 2], 0))
|
||||
@ddt.unpack
|
||||
def test_get_item(self, default_ms, max_find, max_send):
|
||||
self.initdb(default_ms)
|
||||
@@ -388,10 +387,10 @@ class TestMixedModuleStore(CourseComparisonTest):
|
||||
self.store.get_item(self.fake_location, revision=ModuleStoreEnum.RevisionOption.draft_preferred)
|
||||
|
||||
# Draft:
|
||||
# wildcard query, 6! load pertinent items for inheritance calls, course root fetch (why)
|
||||
# wildcard query, 6! load pertinent items for inheritance calls, load parents, course root fetch (why)
|
||||
# Split:
|
||||
# active_versions (with regex), structure, and spurious active_versions refetch
|
||||
@ddt.data(('draft', 8, 0), ('split', 3, 0))
|
||||
@ddt.data(('draft', 14, 0), ('split', 3, 0))
|
||||
@ddt.unpack
|
||||
def test_get_items(self, default_ms, max_find, max_send):
|
||||
self.initdb(default_ms)
|
||||
@@ -405,7 +404,6 @@ class TestMixedModuleStore(CourseComparisonTest):
|
||||
|
||||
course_locn = self.course_locations[self.MONGO_COURSEID]
|
||||
with check_mongo_calls(max_find, max_send):
|
||||
# NOTE: use get_course if you just want the course. get_items is expensive
|
||||
modules = self.store.get_items(course_locn.course_key, qualifiers={'category': 'problem'})
|
||||
self.assertEqual(len(modules), 6)
|
||||
|
||||
@@ -416,12 +414,11 @@ class TestMixedModuleStore(CourseComparisonTest):
|
||||
revision=ModuleStoreEnum.RevisionOption.draft_preferred
|
||||
)
|
||||
|
||||
# draft: get draft, count parents, get parents, count & get grandparents, count & get greatgrand,
|
||||
# count & get next ancestor (chapter's parent), count non-existent next ancestor, get inheritance
|
||||
# draft: get draft, get ancestors up to course (2-6), compute inheritance
|
||||
# sends: update problem and then each ancestor up to course (edit info)
|
||||
# split: active_versions, definitions (calculator field), structures
|
||||
# 2 sends to update index & structure (note, it would also be definition if a content field changed)
|
||||
@ddt.data(('draft', 11, 5), ('split', 3, 2))
|
||||
@ddt.data(('draft', 7, 5), ('split', 3, 2))
|
||||
@ddt.unpack
|
||||
def test_update_item(self, default_ms, max_find, max_send):
|
||||
"""
|
||||
@@ -886,9 +883,9 @@ class TestMixedModuleStore(CourseComparisonTest):
|
||||
|
||||
# notice this doesn't test getting a public item via draft_preferred which draft would have 2 hits (split
|
||||
# still only 2)
|
||||
# Draft: count via definition.children query, then fetch via that query
|
||||
# Draft: get_parent
|
||||
# Split: active_versions, structure
|
||||
@ddt.data(('draft', 2, 0), ('split', 2, 0))
|
||||
@ddt.data(('draft', 1, 0), ('split', 2, 0))
|
||||
@ddt.unpack
|
||||
def test_get_parent_locations(self, default_ms, max_find, max_send):
|
||||
"""
|
||||
@@ -922,35 +919,47 @@ class TestMixedModuleStore(CourseComparisonTest):
|
||||
# publish the course
|
||||
self.course = self.store.publish(self.course.location, self.user_id)
|
||||
|
||||
# make drafts of verticals
|
||||
self.store.convert_to_draft(self.vertical_x1a, self.user_id)
|
||||
self.store.convert_to_draft(self.vertical_y1a, self.user_id)
|
||||
with self.store.bulk_operations(self.course.id):
|
||||
# make drafts of verticals
|
||||
self.store.convert_to_draft(self.vertical_x1a, self.user_id)
|
||||
self.store.convert_to_draft(self.vertical_y1a, self.user_id)
|
||||
|
||||
# move child problem_x1a_1 to vertical_y1a
|
||||
child_to_move_location = self.problem_x1a_1
|
||||
new_parent_location = self.vertical_y1a
|
||||
old_parent_location = self.vertical_x1a
|
||||
# move child problem_x1a_1 to vertical_y1a
|
||||
child_to_move_location = self.problem_x1a_1
|
||||
new_parent_location = self.vertical_y1a
|
||||
old_parent_location = self.vertical_x1a
|
||||
|
||||
old_parent = self.store.get_item(old_parent_location)
|
||||
old_parent.children.remove(child_to_move_location.replace(version_guid=old_parent.location.version_guid))
|
||||
self.store.update_item(old_parent, self.user_id)
|
||||
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
||||
old_parent = self.store.get_item(child_to_move_location).get_parent()
|
||||
|
||||
new_parent = self.store.get_item(new_parent_location)
|
||||
new_parent.children.append(child_to_move_location.replace(version_guid=new_parent.location.version_guid))
|
||||
self.store.update_item(new_parent, self.user_id)
|
||||
self.assertEqual(old_parent_location, old_parent.location)
|
||||
|
||||
self.verify_get_parent_locations_results([
|
||||
(child_to_move_location, new_parent_location, None),
|
||||
(child_to_move_location, new_parent_location, ModuleStoreEnum.RevisionOption.draft_preferred),
|
||||
(child_to_move_location, old_parent_location.for_branch(ModuleStoreEnum.BranchName.published), ModuleStoreEnum.RevisionOption.published_only),
|
||||
])
|
||||
child_to_move_contextualized = child_to_move_location.map_into_course(old_parent.location.course_key)
|
||||
old_parent.children.remove(child_to_move_contextualized)
|
||||
self.store.update_item(old_parent, self.user_id)
|
||||
|
||||
new_parent = self.store.get_item(new_parent_location)
|
||||
new_parent.children.append(child_to_move_location)
|
||||
self.store.update_item(new_parent, self.user_id)
|
||||
|
||||
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
||||
self.assertEqual(new_parent_location, self.store.get_item(child_to_move_location).get_parent().location)
|
||||
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
|
||||
self.assertEqual(old_parent_location, self.store.get_item(child_to_move_location).get_parent().location)
|
||||
old_parent_published_location = old_parent_location.for_branch(ModuleStoreEnum.BranchName.published)
|
||||
self.verify_get_parent_locations_results([
|
||||
(child_to_move_location, new_parent_location, None),
|
||||
(child_to_move_location, new_parent_location, ModuleStoreEnum.RevisionOption.draft_preferred),
|
||||
(child_to_move_location, old_parent_published_location, ModuleStoreEnum.RevisionOption.published_only),
|
||||
])
|
||||
|
||||
# publish the course again
|
||||
self.store.publish(self.course.location, self.user_id)
|
||||
new_parent_published_location = new_parent_location.for_branch(ModuleStoreEnum.BranchName.published)
|
||||
self.verify_get_parent_locations_results([
|
||||
(child_to_move_location, new_parent_location, None),
|
||||
(child_to_move_location, new_parent_location, ModuleStoreEnum.RevisionOption.draft_preferred),
|
||||
(child_to_move_location, new_parent_location.for_branch(ModuleStoreEnum.BranchName.published), ModuleStoreEnum.RevisionOption.published_only),
|
||||
(child_to_move_location, new_parent_published_location, ModuleStoreEnum.RevisionOption.published_only),
|
||||
])
|
||||
|
||||
@ddt.data('draft')
|
||||
@@ -1022,20 +1031,12 @@ class TestMixedModuleStore(CourseComparisonTest):
|
||||
# Draft:
|
||||
# Problem path:
|
||||
# 1. Get problem
|
||||
# 2-3. count matches definition.children called 2x?
|
||||
# 4. get parent via definition.children query
|
||||
# 5-7. 2 counts and 1 get grandparent via definition.children
|
||||
# 8-10. ditto for great-grandparent
|
||||
# 11-13. ditto for next ancestor
|
||||
# 14. fail count query looking for parent of course (unnecessary)
|
||||
# 15. get course record direct query (not via definition.children) (already fetched in 13)
|
||||
# 16. get items for inheritance computation
|
||||
# 17. get vertical (parent of problem)
|
||||
# 18. get items for inheritance computation (why? caching should handle)
|
||||
# 19-20. get vertical_x1b (? why? this is the only ref in trace) & items for inheritance computation
|
||||
# Chapter path: get chapter, count parents 2x, get parents, count non-existent grandparents
|
||||
# 2-6. get parent and rest of ancestors up to course
|
||||
# 7-8. get sequential, compute inheritance
|
||||
# 8-9. get vertical, compute inheritance
|
||||
# 10-11. get other vertical_x1b (why?) and compute inheritance
|
||||
# Split: active_versions & structure
|
||||
@ddt.data(('draft', [20, 5], 0), ('split', [2, 2], 0))
|
||||
@ddt.data(('draft', [12, 3], 0), ('split', [2, 2], 0))
|
||||
@ddt.unpack
|
||||
def test_path_to_location(self, default_ms, num_finds, num_sends):
|
||||
"""
|
||||
|
||||
@@ -717,15 +717,16 @@ class TestMongoKeyValueStore(object):
|
||||
def setUp(self):
|
||||
self.data = {'foo': 'foo_value'}
|
||||
self.course_id = SlashSeparatedCourseKey('org', 'course', 'run')
|
||||
self.parent = self.course_id.make_usage_key('parent', 'p')
|
||||
self.children = [self.course_id.make_usage_key('child', 'a'), self.course_id.make_usage_key('child', 'b')]
|
||||
self.metadata = {'meta': 'meta_val'}
|
||||
self.kvs = MongoKeyValueStore(self.data, self.children, self.metadata)
|
||||
self.kvs = MongoKeyValueStore(self.data, self.parent, self.children, self.metadata)
|
||||
|
||||
def test_read(self):
|
||||
assert_equals(self.data['foo'], self.kvs.get(KeyValueStore.Key(Scope.content, None, None, 'foo')))
|
||||
assert_equals(self.parent, self.kvs.get(KeyValueStore.Key(Scope.parent, None, None, 'parent')))
|
||||
assert_equals(self.children, self.kvs.get(KeyValueStore.Key(Scope.children, None, None, 'children')))
|
||||
assert_equals(self.metadata['meta'], self.kvs.get(KeyValueStore.Key(Scope.settings, None, None, 'meta')))
|
||||
assert_equals(None, self.kvs.get(KeyValueStore.Key(Scope.parent, None, None, 'parent')))
|
||||
|
||||
def test_read_invalid_scope(self):
|
||||
for scope in (Scope.preferences, Scope.user_info, Scope.user_state):
|
||||
@@ -735,7 +736,7 @@ class TestMongoKeyValueStore(object):
|
||||
assert_false(self.kvs.has(key))
|
||||
|
||||
def test_read_non_dict_data(self):
|
||||
self.kvs = MongoKeyValueStore('xml_data', self.children, self.metadata)
|
||||
self.kvs = MongoKeyValueStore('xml_data', self.parent, self.children, self.metadata)
|
||||
assert_equals('xml_data', self.kvs.get(KeyValueStore.Key(Scope.content, None, None, 'data')))
|
||||
|
||||
def _check_write(self, key, value):
|
||||
@@ -746,9 +747,10 @@ class TestMongoKeyValueStore(object):
|
||||
yield (self._check_write, KeyValueStore.Key(Scope.content, None, None, 'foo'), 'new_data')
|
||||
yield (self._check_write, KeyValueStore.Key(Scope.children, None, None, 'children'), [])
|
||||
yield (self._check_write, KeyValueStore.Key(Scope.settings, None, None, 'meta'), 'new_settings')
|
||||
# write Scope.parent raises InvalidScope, which is covered in test_write_invalid_scope
|
||||
|
||||
def test_write_non_dict_data(self):
|
||||
self.kvs = MongoKeyValueStore('xml_data', self.children, self.metadata)
|
||||
self.kvs = MongoKeyValueStore('xml_data', self.parent, self.children, self.metadata)
|
||||
self._check_write(KeyValueStore.Key(Scope.content, None, None, 'data'), 'new_data')
|
||||
|
||||
def test_write_invalid_scope(self):
|
||||
|
||||
@@ -47,14 +47,10 @@ class TestPublish(SplitWMongoCourseBoostrapper):
|
||||
# For each (4) item created
|
||||
# - try to find draft
|
||||
# - try to find non-draft
|
||||
# - retrieve draft of new parent
|
||||
# - get last error
|
||||
# - load parent
|
||||
# - load inheritable data
|
||||
# - load parent
|
||||
# - load ancestors
|
||||
# - compute what is parent
|
||||
# - load draft parent again & compute its parent chain up to course
|
||||
# count for updates increased to 16 b/c of edit_info updating
|
||||
with check_mongo_calls(40, 16):
|
||||
with check_mongo_calls(36, 16):
|
||||
self._create_item('html', 'Html1', "<p>Goodbye</p>", {'display_name': 'Parented Html'}, 'vertical', 'Vert1', split=False)
|
||||
self._create_item(
|
||||
'discussion', 'Discussion1',
|
||||
@@ -96,22 +92,22 @@ class TestPublish(SplitWMongoCourseBoostrapper):
|
||||
item = self.draft_mongo.get_item(vert_location, 2)
|
||||
# Finds:
|
||||
# 1 get draft vert,
|
||||
# 2-10 for each child: (3 children x 3 queries each)
|
||||
# get draft and then published child
|
||||
# 2 compute parent
|
||||
# 3-14 for each child: (3 children x 4 queries each)
|
||||
# get draft, compute parent, and then published child
|
||||
# compute inheritance
|
||||
# 11 get published vert
|
||||
# 12-15 get each ancestor (count then get): (2 x 2),
|
||||
# 16 then fail count of course parent (1)
|
||||
# 17 compute inheritance
|
||||
# 18-19 get draft and published vert
|
||||
# 15 get published vert
|
||||
# 16-18 get ancestor chain
|
||||
# 19 compute inheritance
|
||||
# 20-22 get draft and published vert, compute parent
|
||||
# Sends:
|
||||
# delete the subtree of drafts (1 call),
|
||||
# update the published version of each node in subtree (4 calls),
|
||||
# update the ancestors up to course (2 calls)
|
||||
if mongo_uses_error_check(self.draft_mongo):
|
||||
max_find = 20
|
||||
max_find = 23
|
||||
else:
|
||||
max_find = 19
|
||||
max_find = 22
|
||||
with check_mongo_calls(max_find, 7):
|
||||
self.draft_mongo.publish(item.location, self.user_id)
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestXMLModuleStore(unittest.TestCase):
|
||||
Test around the XML modulestore
|
||||
"""
|
||||
def test_xml_modulestore_type(self):
|
||||
store = XMLModuleStore(DATA_DIR, course_dirs=['toy', 'simple'])
|
||||
store = XMLModuleStore(DATA_DIR, course_dirs=[])
|
||||
self.assertEqual(store.get_modulestore_type(), ModuleStoreEnum.Type.xml)
|
||||
|
||||
def test_unicode_chars_in_xml_content(self):
|
||||
@@ -102,14 +102,39 @@ class TestXMLModuleStore(unittest.TestCase):
|
||||
Test the branch setting context manager
|
||||
"""
|
||||
store = XMLModuleStore(DATA_DIR, course_dirs=['toy'])
|
||||
course_key = store.get_courses()[0]
|
||||
course = store.get_courses()[0]
|
||||
|
||||
# XML store allows published_only branch setting
|
||||
with store.branch_setting(ModuleStoreEnum.Branch.published_only, course_key):
|
||||
store.get_item(course_key.location)
|
||||
with store.branch_setting(ModuleStoreEnum.Branch.published_only, course.id):
|
||||
store.get_item(course.location)
|
||||
|
||||
# XML store does NOT allow draft_preferred branch setting
|
||||
with self.assertRaises(ValueError):
|
||||
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course_key):
|
||||
with store.branch_setting(ModuleStoreEnum.Branch.draft_preferred, course.id):
|
||||
# verify that the above context manager raises a ValueError
|
||||
pass # pragma: no cover
|
||||
|
||||
@patch('xmodule.modulestore.xml.log')
|
||||
def test_dag_course(self, mock_logging):
|
||||
"""
|
||||
Test a course whose structure is not a tree.
|
||||
"""
|
||||
store = XMLModuleStore(DATA_DIR, course_dirs=['xml_dag'])
|
||||
course_key = store.get_courses()[0].id
|
||||
|
||||
mock_logging.warning.assert_called_with(
|
||||
"%s has more than one definition", course_key.make_usage_key('discussion', 'duplicate_def')
|
||||
)
|
||||
|
||||
shared_item_loc = course_key.make_usage_key('html', 'toyhtml')
|
||||
shared_item = store.get_item(shared_item_loc)
|
||||
parent = shared_item.get_parent()
|
||||
self.assertIsNotNone(parent, "get_parent failed to return a value")
|
||||
parent_loc = course_key.make_usage_key('vertical', 'vertical_test')
|
||||
self.assertEqual(parent.location, parent_loc)
|
||||
self.assertIn(shared_item, parent.get_children())
|
||||
# ensure it's still a child of the other parent even tho it doesn't claim the other parent as its parent
|
||||
other_parent_loc = course_key.make_usage_key('vertical', 'zeta')
|
||||
other_parent = store.get_item(other_parent_loc)
|
||||
# children rather than get_children b/c the instance returned by get_children != shared_item
|
||||
self.assertIn(shared_item_loc, other_parent.children)
|
||||
|
||||
@@ -53,7 +53,7 @@ def clean_out_mako_templating(xml_string):
|
||||
|
||||
class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
|
||||
def __init__(self, xmlstore, course_id, course_dir,
|
||||
error_tracker, parent_tracker,
|
||||
error_tracker,
|
||||
load_error_modules=True, **kwargs):
|
||||
"""
|
||||
A class that handles loading from xml. Does some munging to ensure that
|
||||
@@ -205,11 +205,20 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
|
||||
|
||||
descriptor.data_dir = course_dir
|
||||
|
||||
if descriptor.scope_ids.usage_id in xmlstore.modules[course_id]:
|
||||
# keep the parent pointer if any but allow everything else to overwrite
|
||||
other_copy = xmlstore.modules[course_id][descriptor.scope_ids.usage_id]
|
||||
descriptor.parent = other_copy.parent
|
||||
if descriptor != other_copy:
|
||||
log.warning("%s has more than one definition", descriptor.scope_ids.usage_id)
|
||||
xmlstore.modules[course_id][descriptor.scope_ids.usage_id] = descriptor
|
||||
|
||||
if descriptor.has_children:
|
||||
for child in descriptor.get_children():
|
||||
parent_tracker.add_parent(child.scope_ids.usage_id, descriptor.scope_ids.usage_id)
|
||||
# parent is alphabetically least
|
||||
if child.parent is None or child.parent > descriptor.scope_ids.usage_id:
|
||||
child.parent = descriptor.location
|
||||
child.save()
|
||||
|
||||
# After setting up the descriptor, save any changes that we have
|
||||
# made to attributes on the descriptor to the underlying KeyValueStore.
|
||||
@@ -278,41 +287,6 @@ class CourseLocationManager(OpaqueKeyReader, AsideKeyGenerator):
|
||||
return usage_id
|
||||
|
||||
|
||||
class ParentTracker(object):
|
||||
"""A simple class to factor out the logic for tracking location parent pointers."""
|
||||
def __init__(self):
|
||||
"""
|
||||
Init
|
||||
"""
|
||||
# location -> parent. Not using defaultdict because we care about the empty case.
|
||||
self._parents = dict()
|
||||
|
||||
def add_parent(self, child, parent):
|
||||
"""
|
||||
Add a parent of child location to the set of parents. Duplicate calls have no effect.
|
||||
|
||||
child and parent must be :class:`.Location` instances.
|
||||
"""
|
||||
self._parents[child] = parent
|
||||
|
||||
def is_known(self, child):
|
||||
"""
|
||||
returns True iff child has some parents.
|
||||
"""
|
||||
return child in self._parents
|
||||
|
||||
def make_known(self, location):
|
||||
"""Tell the parent tracker about an object, without registering any
|
||||
parents for it. Used for the top level course descriptor locations."""
|
||||
self._parents.setdefault(location, None)
|
||||
|
||||
def parent(self, child):
|
||||
"""
|
||||
Return the parent of this child. If not is_known(child), will throw a KeyError
|
||||
"""
|
||||
return self._parents[child]
|
||||
|
||||
|
||||
class XMLModuleStore(ModuleStoreReadBase):
|
||||
"""
|
||||
An XML backed ModuleStore
|
||||
@@ -352,8 +326,6 @@ class XMLModuleStore(ModuleStoreReadBase):
|
||||
class_ = getattr(import_module(module_path), class_name)
|
||||
self.default_class = class_
|
||||
|
||||
self.parent_trackers = defaultdict(ParentTracker)
|
||||
|
||||
# All field data will be stored in an inheriting field data.
|
||||
self.field_data = inheriting_field_data(kvs=DictKeyValueStore())
|
||||
|
||||
@@ -400,7 +372,7 @@ class XMLModuleStore(ModuleStoreReadBase):
|
||||
else:
|
||||
self.courses[course_dir] = course_descriptor
|
||||
self._course_errors[course_descriptor.id] = errorlog
|
||||
self.parent_trackers[course_descriptor.id].make_known(course_descriptor.scope_ids.usage_id)
|
||||
course_descriptor.parent = None
|
||||
|
||||
def __unicode__(self):
|
||||
'''
|
||||
@@ -512,7 +484,6 @@ class XMLModuleStore(ModuleStoreReadBase):
|
||||
course_id=course_id,
|
||||
course_dir=course_dir,
|
||||
error_tracker=tracker,
|
||||
parent_tracker=self.parent_trackers[course_id],
|
||||
load_error_modules=self.load_error_modules,
|
||||
get_policy=get_policy,
|
||||
mixins=self.xblock_mixins,
|
||||
@@ -756,10 +727,8 @@ class XMLModuleStore(ModuleStoreReadBase):
|
||||
'''Find the location that is the parent of this location in this
|
||||
course. Needed for path_to_location().
|
||||
'''
|
||||
if not self.parent_trackers[location.course_key].is_known(location):
|
||||
raise ItemNotFoundError("{0} not in {1}".format(location, location.course_key))
|
||||
|
||||
return self.parent_trackers[location.course_key].parent(location)
|
||||
block = self.get_item(location, 0)
|
||||
return block.parent
|
||||
|
||||
def get_modulestore_type(self, course_key=None):
|
||||
"""
|
||||
|
||||
@@ -28,7 +28,7 @@ import json
|
||||
import re
|
||||
from lxml import etree
|
||||
|
||||
from .xml import XMLModuleStore, ImportSystem, ParentTracker
|
||||
from .xml import XMLModuleStore, ImportSystem
|
||||
from xblock.runtime import KvsFieldData, DictKeyValueStore
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
@@ -479,11 +479,13 @@ def _import_module_and_update_references(
|
||||
|
||||
fields = {}
|
||||
for field_name, field in module.fields.iteritems():
|
||||
if field.is_set_on(module):
|
||||
if field.scope == Scope.parent:
|
||||
continue
|
||||
if field.scope != Scope.parent and field.is_set_on(module):
|
||||
if isinstance(field, Reference):
|
||||
fields[field_name] = _convert_reference_fields_to_new_namespace(field.read_from(module))
|
||||
value = field.read_from(module)
|
||||
if value is None:
|
||||
fields[field_name] = None
|
||||
else:
|
||||
fields[field_name] = _convert_reference_fields_to_new_namespace(field.read_from(module))
|
||||
elif isinstance(field, ReferenceList):
|
||||
references = field.read_from(module)
|
||||
fields[field_name] = [_convert_reference_fields_to_new_namespace(reference) for reference in references]
|
||||
@@ -548,7 +550,6 @@ def _import_course_draft(
|
||||
course_id=source_course_id,
|
||||
course_dir=draft_course_dir,
|
||||
error_tracker=errorlog.tracker,
|
||||
parent_tracker=ParentTracker(),
|
||||
load_error_modules=False,
|
||||
mixins=xml_module_store.xblock_mixins,
|
||||
field_data=KvsFieldData(kvs=DictKeyValueStore()),
|
||||
|
||||
@@ -10,7 +10,21 @@ from stevedore.extension import ExtensionManager
|
||||
|
||||
class UserPartitionError(Exception):
|
||||
"""
|
||||
An error was found regarding user partitions.
|
||||
Base Exception for when an error was found regarding user partitions.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class NoSuchUserPartitionError(UserPartitionError):
|
||||
"""
|
||||
Exception to be raised when looking up a UserPartition by its ID fails.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class NoSuchUserPartitionGroupError(UserPartitionError):
|
||||
"""
|
||||
Exception to be raised when looking up a UserPartition Group by its ID fails.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -171,9 +185,14 @@ class UserPartition(namedtuple("UserPartition", "id name description groups sche
|
||||
|
||||
def get_group(self, group_id):
|
||||
"""
|
||||
Returns the group with the specified id.
|
||||
Returns the group with the specified id. Raises NoSuchUserPartitionGroupError if not found.
|
||||
"""
|
||||
for group in self.groups: # pylint: disable=no-member
|
||||
# pylint: disable=no-member
|
||||
|
||||
for group in self.groups:
|
||||
if group.id == group_id:
|
||||
return group
|
||||
return None
|
||||
|
||||
raise NoSuchUserPartitionGroupError(
|
||||
"could not find a Group with ID [{}] in UserPartition [{}]".format(group_id, self.id)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,9 @@ from mock import Mock
|
||||
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from stevedore.extension import Extension, ExtensionManager
|
||||
from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError, USER_PARTITION_SCHEME_NAMESPACE
|
||||
from xmodule.partitions.partitions import (
|
||||
Group, UserPartition, UserPartitionError, NoSuchUserPartitionGroupError, USER_PARTITION_SCHEME_NAMESPACE
|
||||
)
|
||||
from xmodule.partitions.partitions_service import PartitionService
|
||||
|
||||
|
||||
@@ -113,13 +115,14 @@ class PartitionTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Set up two user partition schemes: mock and random
|
||||
self.non_random_scheme = MockUserPartitionScheme(self.TEST_SCHEME_NAME)
|
||||
self.random_scheme = MockUserPartitionScheme("random")
|
||||
extensions = [
|
||||
Extension(
|
||||
self.TEST_SCHEME_NAME, USER_PARTITION_SCHEME_NAMESPACE,
|
||||
MockUserPartitionScheme(self.TEST_SCHEME_NAME), None
|
||||
self.non_random_scheme.name, USER_PARTITION_SCHEME_NAMESPACE, self.non_random_scheme, None
|
||||
),
|
||||
Extension(
|
||||
"random", USER_PARTITION_SCHEME_NAMESPACE, MockUserPartitionScheme("random"), None
|
||||
self.random_scheme.name, USER_PARTITION_SCHEME_NAMESPACE, self.random_scheme, None
|
||||
),
|
||||
]
|
||||
UserPartition.scheme_extensions = ExtensionManager.make_test_instance(
|
||||
@@ -135,6 +138,10 @@ class PartitionTestCase(TestCase):
|
||||
extensions[0].plugin
|
||||
)
|
||||
|
||||
# Make sure the names are set on the schemes (which happens normally in code, but may not happen in tests).
|
||||
self.user_partition.get_scheme(self.non_random_scheme.name)
|
||||
self.user_partition.get_scheme(self.random_scheme.name)
|
||||
|
||||
|
||||
class TestUserPartition(PartitionTestCase):
|
||||
"""Test constructing UserPartitions"""
|
||||
@@ -259,6 +266,23 @@ class TestUserPartition(PartitionTestCase):
|
||||
user_partition = UserPartition.from_json(jsonified)
|
||||
self.assertNotIn("programmer", user_partition.to_json())
|
||||
|
||||
def test_get_group(self):
|
||||
"""
|
||||
UserPartition.get_group correctly returns the group referenced by the
|
||||
`group_id` parameter, or raises NoSuchUserPartitionGroupError when
|
||||
the lookup fails.
|
||||
"""
|
||||
self.assertEqual(
|
||||
self.user_partition.get_group(self.TEST_GROUPS[0].id), # pylint: disable=no-member
|
||||
self.TEST_GROUPS[0]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.user_partition.get_group(self.TEST_GROUPS[1].id), # pylint: disable=no-member
|
||||
self.TEST_GROUPS[1]
|
||||
)
|
||||
with self.assertRaises(NoSuchUserPartitionGroupError):
|
||||
self.user_partition.get_group(3)
|
||||
|
||||
|
||||
class StaticPartitionService(PartitionService):
|
||||
"""
|
||||
|
||||
@@ -49,8 +49,10 @@ class SplitTestFields(object):
|
||||
# Add "No selection" value if there is not a valid selected user partition.
|
||||
if not selected_user_partition:
|
||||
SplitTestFields.user_partition_values.append(SplitTestFields.no_partition_selected)
|
||||
for user_partition in all_user_partitions:
|
||||
SplitTestFields.user_partition_values.append({"display_name": user_partition.name, "value": user_partition.id})
|
||||
for user_partition in get_split_user_partitions(all_user_partitions):
|
||||
SplitTestFields.user_partition_values.append(
|
||||
{"display_name": user_partition.name, "value": user_partition.id}
|
||||
)
|
||||
return SplitTestFields.user_partition_values
|
||||
|
||||
display_name = String(
|
||||
@@ -86,6 +88,14 @@ class SplitTestFields(object):
|
||||
)
|
||||
|
||||
|
||||
def get_split_user_partitions(user_partitions):
|
||||
"""
|
||||
Helper method that filters a list of user_partitions and returns just the
|
||||
ones that are suitable for the split_test module.
|
||||
"""
|
||||
return [user_partition for user_partition in user_partitions if user_partition.scheme.name == "random"]
|
||||
|
||||
|
||||
@XBlock.needs('user_tags') # pylint: disable=abstract-method
|
||||
@XBlock.wants('partitions')
|
||||
class SplitTestModule(SplitTestFields, XModule, StudioEditableModule):
|
||||
@@ -566,23 +576,35 @@ class SplitTestDescriptor(SplitTestFields, SequenceDescriptor, StudioEditableDes
|
||||
)
|
||||
)
|
||||
else:
|
||||
[active_children, inactive_children] = self.active_and_inactive_children()
|
||||
if len(active_children) < len(user_partition.groups):
|
||||
# If the user_partition selected is not valid for the split_test module, error.
|
||||
# This can only happen via XML and import/export.
|
||||
if not get_split_user_partitions([user_partition]):
|
||||
split_validation.add(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.ERROR,
|
||||
_(u"The experiment does not contain all of the groups in the configuration."),
|
||||
action_runtime_event='add-missing-groups',
|
||||
action_label=_(u"Add Missing Groups")
|
||||
_(u"The experiment uses a group configuration that is not supported for experiments. "
|
||||
u"Select a valid group configuration or delete this experiment.")
|
||||
)
|
||||
)
|
||||
if len(inactive_children) > 0:
|
||||
split_validation.add(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.WARNING,
|
||||
_(u"The experiment has an inactive group. Move content into active groups, then delete the inactive group.")
|
||||
else:
|
||||
[active_children, inactive_children] = self.active_and_inactive_children()
|
||||
if len(active_children) < len(user_partition.groups):
|
||||
split_validation.add(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.ERROR,
|
||||
_(u"The experiment does not contain all of the groups in the configuration."),
|
||||
action_runtime_event='add-missing-groups',
|
||||
action_label=_(u"Add Missing Groups")
|
||||
)
|
||||
)
|
||||
if len(inactive_children) > 0:
|
||||
split_validation.add(
|
||||
StudioValidationMessage(
|
||||
StudioValidationMessage.WARNING,
|
||||
_(u"The experiment has an inactive group. "
|
||||
u"Move content into active groups, then delete the inactive group.")
|
||||
)
|
||||
)
|
||||
)
|
||||
return split_validation
|
||||
|
||||
def general_validation_message(self, validation=None):
|
||||
|
||||
@@ -30,7 +30,6 @@ class DummySystem(ImportSystem):
|
||||
course_id=SlashSeparatedCourseKey(ORG, COURSE, 'test_run'),
|
||||
course_dir='test_dir',
|
||||
error_tracker=Mock(),
|
||||
parent_tracker=Mock(),
|
||||
load_error_modules=load_error_modules,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,14 +36,12 @@ class DummySystem(ImportSystem):
|
||||
course_id = SlashSeparatedCourseKey(ORG, COURSE, 'test_run')
|
||||
course_dir = "test_dir"
|
||||
error_tracker = Mock()
|
||||
parent_tracker = Mock()
|
||||
|
||||
super(DummySystem, self).__init__(
|
||||
xmlstore=xmlstore,
|
||||
course_id=course_id,
|
||||
course_dir=course_dir,
|
||||
error_tracker=error_tracker,
|
||||
parent_tracker=parent_tracker,
|
||||
load_error_modules=load_error_modules,
|
||||
field_data=KvsFieldData(DictKeyValueStore()),
|
||||
)
|
||||
|
||||
@@ -39,14 +39,12 @@ class DummySystem(ImportSystem):
|
||||
course_id = SlashSeparatedCourseKey(ORG, COURSE, 'test_run')
|
||||
course_dir = "test_dir"
|
||||
error_tracker = Mock()
|
||||
parent_tracker = Mock()
|
||||
|
||||
super(DummySystem, self).__init__(
|
||||
xmlstore=xmlstore,
|
||||
course_id=course_id,
|
||||
course_dir=course_dir,
|
||||
error_tracker=error_tracker,
|
||||
parent_tracker=parent_tracker,
|
||||
load_error_modules=load_error_modules,
|
||||
mixins=(InheritanceMixin, XModuleMixin),
|
||||
field_data=KvsFieldData(DictKeyValueStore()),
|
||||
|
||||
@@ -12,7 +12,7 @@ from xmodule.tests.xml import XModuleXmlImportTest
|
||||
from xmodule.tests import get_test_system
|
||||
from xmodule.x_module import AUTHOR_VIEW, STUDENT_VIEW
|
||||
from xmodule.validation import StudioValidationMessage
|
||||
from xmodule.split_test_module import SplitTestDescriptor, SplitTestFields
|
||||
from xmodule.split_test_module import SplitTestDescriptor, SplitTestFields, get_split_user_partitions
|
||||
from xmodule.partitions.partitions import Group, UserPartition
|
||||
|
||||
|
||||
@@ -23,6 +23,37 @@ class SplitTestModuleFactory(xml.XmlImportFactory):
|
||||
tag = 'split_test'
|
||||
|
||||
|
||||
class SplitTestUtilitiesTest(PartitionTestCase):
|
||||
"""
|
||||
Tests for utility methods related to split_test module.
|
||||
"""
|
||||
def test_split_user_partitions(self):
|
||||
"""
|
||||
Tests the get_split_user_partitions helper method.
|
||||
"""
|
||||
first_random_partition = UserPartition(
|
||||
0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')],
|
||||
self.random_scheme
|
||||
)
|
||||
second_random_partition = UserPartition(
|
||||
0, 'second_partition', 'Second Partition', [Group("4", 'zeta'), Group("5", 'omega')],
|
||||
self.random_scheme
|
||||
)
|
||||
all_partitions = [
|
||||
first_random_partition,
|
||||
# Only UserPartitions with scheme "random" will be returned as available options.
|
||||
UserPartition(
|
||||
1, 'non_random_partition', 'Will Not Be Returned', [Group("1", 'apple'), Group("2", 'banana')],
|
||||
self.non_random_scheme
|
||||
),
|
||||
second_random_partition
|
||||
]
|
||||
self.assertEqual(
|
||||
[first_random_partition, second_random_partition],
|
||||
get_split_user_partitions(all_partitions)
|
||||
)
|
||||
|
||||
|
||||
class SplitTestModuleTest(XModuleXmlImportTest, PartitionTestCase):
|
||||
"""
|
||||
Base class for all split_module tests.
|
||||
@@ -221,7 +252,15 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
|
||||
|
||||
# Populate user_partitions and call editable_metadata_fields again
|
||||
self.split_test_module.user_partitions = [
|
||||
UserPartition(0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')])
|
||||
UserPartition(
|
||||
0, 'first_partition', 'First Partition', [Group("0", 'alpha'), Group("1", 'beta')],
|
||||
self.random_scheme
|
||||
),
|
||||
# Only UserPartitions with scheme "random" will be returned as available options.
|
||||
UserPartition(
|
||||
1, 'non_random_partition', 'Will Not Be Returned', [Group("1", 'apple'), Group("2", 'banana')],
|
||||
self.non_random_scheme
|
||||
)
|
||||
]
|
||||
self.split_test_module.editable_metadata_fields # pylint: disable=pointless-statement
|
||||
partitions = SplitTestDescriptor.user_partition_id.values
|
||||
@@ -423,3 +462,25 @@ class SplitTestModuleStudioTest(SplitTestModuleTest):
|
||||
u"This content experiment has issues that affect content visibility.",
|
||||
StudioValidationMessage.ERROR
|
||||
)
|
||||
|
||||
# Verify the message for a split test referring to a non-random user partition
|
||||
split_test_module.user_partitions = [
|
||||
UserPartition(
|
||||
10, 'incorrect_partition', 'Non Random Partition', [Group("0", 'alpha'), Group("2", 'gamma')],
|
||||
scheme=self.non_random_scheme
|
||||
)
|
||||
]
|
||||
split_test_module.user_partition_id = 10
|
||||
validation = split_test_module.validate()
|
||||
self.assertEqual(len(validation.messages), 1)
|
||||
verify_validation_message(
|
||||
validation.messages[0],
|
||||
u"The experiment uses a group configuration that is not supported for experiments. "
|
||||
u"Select a valid group configuration or delete this experiment.",
|
||||
StudioValidationMessage.ERROR
|
||||
)
|
||||
verify_summary_message(
|
||||
validation.summary,
|
||||
u"This content experiment has issues that affect content visibility.",
|
||||
StudioValidationMessage.ERROR
|
||||
)
|
||||
|
||||
@@ -918,7 +918,8 @@ class XModuleDescriptor(XModuleMixin, HTMLSnippet, ResourceTemplates, XBlock):
|
||||
|
||||
# =============================== BUILTIN METHODS ==========================
|
||||
def __eq__(self, other):
|
||||
return (self.scope_ids == other.scope_ids and
|
||||
return (hasattr(other, 'scope_ids') and
|
||||
self.scope_ids == other.scope_ids and
|
||||
self.fields.keys() == other.fields.keys() and
|
||||
all(getattr(self, field.name) == getattr(other, field.name)
|
||||
for field in self.fields.values()))
|
||||
|
||||
@@ -328,7 +328,7 @@ browser and pasting the output. When that file changes, this one should be rege
|
||||
<% }); %>
|
||||
</select>
|
||||
</label><div class="field-help">
|
||||
Discussion admins, moderators, and TAs can make their posts visible to all students or specify a single cohort group.
|
||||
Discussion admins, moderators, and TAs can make their posts visible to all students or specify a single cohort.
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
* Example usages:
|
||||
* interpolate_text('{title} ({count})', {title: expectedTitle, count: expectedCount}
|
||||
* interpolate_text(
|
||||
* ngettext("{numUsersAdded} student has been added to this cohort group",
|
||||
* "{numUsersAdded} students have been added to this cohort group", numUsersAdded),
|
||||
* ngettext("{numUsersAdded} student has been added to this cohort",
|
||||
* "{numUsersAdded} students have been added to this cohort", numUsersAdded),
|
||||
* {numUsersAdded: numUsersAdded}
|
||||
* );
|
||||
*
|
||||
|
||||
@@ -33,12 +33,19 @@ class CoursewarePage(CoursePage):
|
||||
"""
|
||||
return len(self.q(css=self.subsection_selector))
|
||||
|
||||
@property
|
||||
def xblock_components(self):
|
||||
"""
|
||||
Return the xblock components within the unit on the page.
|
||||
"""
|
||||
return self.q(css=self.xblock_component_selector)
|
||||
|
||||
@property
|
||||
def num_xblock_components(self):
|
||||
"""
|
||||
Return the number of rendered xblocks within the unit on the page
|
||||
"""
|
||||
return len(self.q(css=self.xblock_component_selector))
|
||||
return len(self.xblock_components)
|
||||
|
||||
def xblock_component_type(self, index=0):
|
||||
"""
|
||||
|
||||
@@ -552,3 +552,31 @@ class DiscussionTabHomePage(CoursePage, DiscussionPageMixin):
|
||||
lambda: _match_messages(text).results == [],
|
||||
"waiting for dismissed alerts to disappear"
|
||||
).fulfill()
|
||||
|
||||
def click_new_post_button(self):
|
||||
"""
|
||||
Clicks the 'New Post' button.
|
||||
"""
|
||||
self.new_post_button.click()
|
||||
EmptyPromise(
|
||||
lambda: (
|
||||
self.new_post_form
|
||||
),
|
||||
"New post action succeeded"
|
||||
).fulfill()
|
||||
|
||||
@property
|
||||
def new_post_button(self):
|
||||
"""
|
||||
Returns the new post button.
|
||||
"""
|
||||
elements = self.q(css="ol.course-tabs .new-post-btn")
|
||||
return elements.first if elements.visible and len(elements) == 1 else None
|
||||
|
||||
@property
|
||||
def new_post_form(self):
|
||||
"""
|
||||
Returns the new post form.
|
||||
"""
|
||||
elements = self.q(css=".forum-new-post-form")
|
||||
return elements[0] if elements.visible and len(elements) == 1 else None
|
||||
|
||||
@@ -6,6 +6,8 @@ Instructor (2) dashboard page.
|
||||
from bok_choy.page_object import PageObject
|
||||
from .course_page import CoursePage
|
||||
import os
|
||||
from bok_choy.promise import EmptyPromise
|
||||
from ...tests.helpers import select_option_by_text, get_selected_option_text, get_options
|
||||
|
||||
|
||||
class InstructorDashboardPage(CoursePage):
|
||||
@@ -85,8 +87,11 @@ class MembershipPageCohortManagementSection(PageObject):
|
||||
The cohort management subsection of the Membership section of the Instructor dashboard.
|
||||
"""
|
||||
url = None
|
||||
csv_browse_button_selector = '.csv-upload #file-upload-form-file'
|
||||
csv_upload_button_selector = '.csv-upload #file-upload-form-submit'
|
||||
csv_browse_button_selector_css = '.csv-upload #file-upload-form-file'
|
||||
csv_upload_button_selector_css = '.csv-upload #file-upload-form-submit'
|
||||
content_group_selector_css = 'select.input-cohort-group-association'
|
||||
no_content_group_button_css = '.cohort-management-details-association-course input.radio-no'
|
||||
select_content_group_button_css = '.cohort-management-details-association-course input.radio-yes'
|
||||
|
||||
def is_browser_on_page(self):
|
||||
return self.q(css='.cohort-management.membership-section').present
|
||||
@@ -99,7 +104,7 @@ class MembershipPageCohortManagementSection(PageObject):
|
||||
|
||||
def _get_cohort_options(self):
|
||||
"""
|
||||
Returns the available options in the cohort dropdown, including the initial "Select a cohort group".
|
||||
Returns the available options in the cohort dropdown, including the initial "Select a cohort".
|
||||
"""
|
||||
return self.q(css=self._bounded_selector("#cohort-select option"))
|
||||
|
||||
@@ -117,7 +122,7 @@ class MembershipPageCohortManagementSection(PageObject):
|
||||
|
||||
def get_cohorts(self):
|
||||
"""
|
||||
Returns, as a list, the names of the available cohorts in the drop-down, filtering out "Select a cohort group".
|
||||
Returns, as a list, the names of the available cohorts in the drop-down, filtering out "Select a cohort".
|
||||
"""
|
||||
return [
|
||||
self._cohort_name(opt.text)
|
||||
@@ -128,6 +133,10 @@ class MembershipPageCohortManagementSection(PageObject):
|
||||
"""
|
||||
Returns the name of the selected cohort.
|
||||
"""
|
||||
EmptyPromise(
|
||||
lambda: len(self._get_cohort_options().results) > 0,
|
||||
"Waiting for cohort selector to populate"
|
||||
).fulfill()
|
||||
return self._cohort_name(
|
||||
self._get_cohort_options().filter(lambda el: el.is_selected()).first.text[0]
|
||||
)
|
||||
@@ -144,17 +153,28 @@ class MembershipPageCohortManagementSection(PageObject):
|
||||
"""
|
||||
Selects the given cohort in the drop-down.
|
||||
"""
|
||||
self.q(css=self._bounded_selector("#cohort-select option")).filter(
|
||||
EmptyPromise(
|
||||
lambda: cohort_name in self.get_cohorts(),
|
||||
"Waiting for cohort selector to populate"
|
||||
).fulfill()
|
||||
# Note: can't use Select to select by text because the count is also included in the displayed text.
|
||||
self._get_cohort_options().filter(
|
||||
lambda el: self._cohort_name(el.text) == cohort_name
|
||||
).first.click()
|
||||
|
||||
def add_cohort(self, cohort_name):
|
||||
def add_cohort(self, cohort_name, content_group=None):
|
||||
"""
|
||||
Adds a new manual cohort with the specified name.
|
||||
If a content group should also be associated, the name of the content group should be specified.
|
||||
"""
|
||||
self.q(css=self._bounded_selector("div.cohort-management-nav .action-create")).first.click()
|
||||
textinput = self.q(css=self._bounded_selector("#cohort-create-name")).results[0]
|
||||
create_buttons = self.q(css=self._bounded_selector(".action-create"))
|
||||
# There are 2 create buttons on the page. The second one is only present when no cohort yet exists
|
||||
# (in which case the first is not visible). Click on the last present create button.
|
||||
create_buttons.results[len(create_buttons.results) - 1].click()
|
||||
textinput = self.q(css=self._bounded_selector("#cohort-name")).results[0]
|
||||
textinput.send_keys(cohort_name)
|
||||
if content_group:
|
||||
self._select_associated_content_group(content_group)
|
||||
self.q(css=self._bounded_selector("div.form-actions .action-save")).first.click()
|
||||
|
||||
def get_cohort_group_setup(self):
|
||||
@@ -184,6 +204,82 @@ class MembershipPageCohortManagementSection(PageObject):
|
||||
css=self._bounded_selector("#cohort-management-group-add-students")
|
||||
).results[0].get_attribute("value")
|
||||
|
||||
def select_studio_group_settings(self):
|
||||
"""
|
||||
When no content groups have been defined, a messages appears with a link
|
||||
to go to Studio group settings. This method assumes the link is visible and clicks it.
|
||||
"""
|
||||
return self.q(css=self._bounded_selector("a.link-to-group-settings")).first.click()
|
||||
|
||||
def get_all_content_groups(self):
|
||||
"""
|
||||
Returns all the content groups available for associating with the cohort currently being edited.
|
||||
"""
|
||||
selector_query = self.q(css=self._bounded_selector(self.content_group_selector_css))
|
||||
return [
|
||||
option.text for option in get_options(selector_query) if option.text != "Not selected"
|
||||
]
|
||||
|
||||
def get_cohort_associated_content_group(self):
|
||||
"""
|
||||
Returns the content group associated with the cohort currently being edited.
|
||||
If no content group is associated, returns None.
|
||||
"""
|
||||
self.select_cohort_settings()
|
||||
radio_button = self.q(css=self._bounded_selector(self.no_content_group_button_css)).results[0]
|
||||
if radio_button.is_selected():
|
||||
return None
|
||||
return get_selected_option_text(self.q(css=self._bounded_selector(self.content_group_selector_css)))
|
||||
|
||||
def set_cohort_associated_content_group(self, content_group=None, select_settings=True):
|
||||
"""
|
||||
Sets the content group associated with the cohort currently being edited.
|
||||
If content_group is None, un-links the cohort from any content group.
|
||||
Presses Save to update the cohort's settings.
|
||||
"""
|
||||
if select_settings:
|
||||
self.select_cohort_settings()
|
||||
if content_group is None:
|
||||
self.q(css=self._bounded_selector(self.no_content_group_button_css)).first.click()
|
||||
else:
|
||||
self._select_associated_content_group(content_group)
|
||||
self.q(css=self._bounded_selector("div.form-actions .action-save")).first.click()
|
||||
|
||||
def _select_associated_content_group(self, content_group):
|
||||
"""
|
||||
Selects the specified content group from the selector. Assumes that content_group is not None.
|
||||
"""
|
||||
self.select_content_group_radio_button()
|
||||
select_option_by_text(
|
||||
self.q(css=self._bounded_selector(self.content_group_selector_css)), content_group
|
||||
)
|
||||
|
||||
def select_content_group_radio_button(self):
|
||||
"""
|
||||
Clicks the radio button for "No Content Group" association.
|
||||
Returns whether or not the radio button is in the selected state after the click.
|
||||
"""
|
||||
radio_button = self.q(css=self._bounded_selector(self.select_content_group_button_css)).results[0]
|
||||
radio_button.click()
|
||||
return radio_button.is_selected()
|
||||
|
||||
def select_cohort_settings(self):
|
||||
"""
|
||||
Selects the settings tab for the cohort currently being edited.
|
||||
"""
|
||||
self.q(css=self._bounded_selector(".cohort-management-settings li.tab-settings>a")).first.click()
|
||||
|
||||
# pylint: disable=redefined-builtin
|
||||
def get_cohort_settings_messages(self, type="confirmation", wait_for_messages=True):
|
||||
"""
|
||||
Returns an array of messages related to modifying cohort settings. If wait_for_messages
|
||||
is True, will wait for a message to appear.
|
||||
"""
|
||||
title_css = "div.cohort-management-settings .message-" + type + " .message-title"
|
||||
detail_css = "div.cohort-management-settings .message-" + type + " .summary-item"
|
||||
|
||||
return self._get_messages(title_css, detail_css, wait_for_messages=wait_for_messages)
|
||||
|
||||
def _get_cohort_messages(self, type):
|
||||
"""
|
||||
Returns array of messages related to manipulating cohorts directly through the UI for the given type.
|
||||
@@ -201,10 +297,15 @@ class MembershipPageCohortManagementSection(PageObject):
|
||||
detail_css = ".csv-upload .summary-item"
|
||||
return self._get_messages(title_css, detail_css)
|
||||
|
||||
def _get_messages(self, title_css, details_css):
|
||||
def _get_messages(self, title_css, details_css, wait_for_messages=False):
|
||||
"""
|
||||
Helper method to get messages given title and details CSS.
|
||||
"""
|
||||
if wait_for_messages:
|
||||
EmptyPromise(
|
||||
lambda: self.q(css=self._bounded_selector(title_css)).results != 0,
|
||||
"Waiting for messages to appear"
|
||||
).fulfill()
|
||||
message_title = self.q(css=self._bounded_selector(title_css))
|
||||
if len(message_title.results) == 0:
|
||||
return []
|
||||
@@ -228,6 +329,16 @@ class MembershipPageCohortManagementSection(PageObject):
|
||||
"""
|
||||
return self._get_cohort_messages("errors")
|
||||
|
||||
def get_cohort_related_content_group_message(self):
|
||||
"""
|
||||
Gets the error message shown next to the content group selector for the currently selected cohort.
|
||||
If no message, returns None.
|
||||
"""
|
||||
message = self.q(css=self._bounded_selector(".input-group-other .copy-error"))
|
||||
if not message:
|
||||
return None
|
||||
return message.results[0].text
|
||||
|
||||
def select_data_download(self):
|
||||
"""
|
||||
Click on the link to the Data Download Page.
|
||||
@@ -243,9 +354,9 @@ class MembershipPageCohortManagementSection(PageObject):
|
||||
if cvs_upload_toggle:
|
||||
cvs_upload_toggle.click()
|
||||
path = InstructorDashboardPage.get_asset_path(filename)
|
||||
file_input = self.q(css=self._bounded_selector(self.csv_browse_button_selector)).results[0]
|
||||
file_input = self.q(css=self._bounded_selector(self.csv_browse_button_selector_css)).results[0]
|
||||
file_input.send_keys(path)
|
||||
self.q(css=self._bounded_selector(self.csv_upload_button_selector)).first.click()
|
||||
self.q(css=self._bounded_selector(self.csv_upload_button_selector_css)).first.click()
|
||||
|
||||
|
||||
class MembershipPageAutoEnrollSection(PageObject):
|
||||
|
||||
@@ -11,25 +11,26 @@ class StaffPage(CoursewarePage):
|
||||
"""
|
||||
|
||||
url = None
|
||||
STAFF_STATUS_CSS = '#staffstatus'
|
||||
PREVIEW_MENU_CSS = '.preview-menu'
|
||||
VIEW_MODE_OPTIONS_CSS = '.preview-menu .action-preview-select option'
|
||||
|
||||
def is_browser_on_page(self):
|
||||
if not super(StaffPage, self).is_browser_on_page():
|
||||
return False
|
||||
return self.q(css=self.STAFF_STATUS_CSS).present
|
||||
return self.q(css=self.PREVIEW_MENU_CSS).present
|
||||
|
||||
@property
|
||||
def staff_status(self):
|
||||
def staff_view_mode(self):
|
||||
"""
|
||||
Return the current status, either Staff view or Student view
|
||||
Return the currently chosen view mode, e.g. "Staff", "Student" or a content group.
|
||||
"""
|
||||
return self.q(css=self.STAFF_STATUS_CSS).text[0]
|
||||
return self.q(css=self.VIEW_MODE_OPTIONS_CSS).filter(lambda el: el.is_selected()).first.text[0]
|
||||
|
||||
def toggle_staff_view(self):
|
||||
def set_staff_view_mode(self, view_mode):
|
||||
"""
|
||||
Toggle between staff view and student view.
|
||||
Set the current view mode, e.g. "Staff", "Student" or a content group.
|
||||
"""
|
||||
self.q(css=self.STAFF_STATUS_CSS).first.click()
|
||||
self.q(css=self.VIEW_MODE_OPTIONS_CSS).filter(lambda el: el.text == view_mode).first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def open_staff_debug_info(self):
|
||||
|
||||
@@ -4,9 +4,9 @@ from utils import click_css
|
||||
from selenium.webdriver.support.ui import Select
|
||||
|
||||
|
||||
class ComponentEditorView(PageObject):
|
||||
class BaseComponentEditorView(PageObject):
|
||||
"""
|
||||
A :class:`.PageObject` representing the rendered view of a component editor.
|
||||
A base :class:`.PageObject` for the component and visibility editors.
|
||||
|
||||
This class assumes that the editor is our default editor as displayed for xmodules.
|
||||
"""
|
||||
@@ -18,7 +18,7 @@ class ComponentEditorView(PageObject):
|
||||
browser (selenium.webdriver): The Selenium-controlled browser that this page is loaded in.
|
||||
locator (str): The locator that identifies which xblock this :class:`.xblock-editor` relates to.
|
||||
"""
|
||||
super(ComponentEditorView, self).__init__(browser)
|
||||
super(BaseComponentEditorView, self).__init__(browser)
|
||||
self.locator = locator
|
||||
|
||||
def is_browser_on_page(self):
|
||||
@@ -40,6 +40,23 @@ class ComponentEditorView(PageObject):
|
||||
"""
|
||||
return None
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
click_css(self, 'a.action-save')
|
||||
|
||||
def cancel(self):
|
||||
"""
|
||||
Clicks cancel button.
|
||||
"""
|
||||
click_css(self, 'a.action-cancel', require_notification=False)
|
||||
|
||||
|
||||
class ComponentEditorView(BaseComponentEditorView):
|
||||
"""
|
||||
A :class:`.PageObject` representing the rendered view of a component editor.
|
||||
"""
|
||||
def get_setting_element(self, label):
|
||||
"""
|
||||
Returns the index of the setting entry with given label (display name) within the Settings modal.
|
||||
@@ -86,14 +103,48 @@ class ComponentEditorView(PageObject):
|
||||
else:
|
||||
return None
|
||||
|
||||
def save(self):
|
||||
"""
|
||||
Clicks save button.
|
||||
"""
|
||||
click_css(self, 'a.action-save')
|
||||
|
||||
def cancel(self):
|
||||
class ComponentVisibilityEditorView(BaseComponentEditorView):
|
||||
"""
|
||||
A :class:`.PageObject` representing the rendered view of a component visibility editor.
|
||||
"""
|
||||
OPTION_SELECTOR = '.modal-section-content li.field'
|
||||
|
||||
@property
|
||||
def all_options(self):
|
||||
"""
|
||||
Clicks cancel button.
|
||||
Return all visibility 'li' options.
|
||||
"""
|
||||
click_css(self, 'a.action-cancel', require_notification=False)
|
||||
return self.q(css=self._bounded_selector(self.OPTION_SELECTOR)).results
|
||||
|
||||
@property
|
||||
def selected_options(self):
|
||||
"""
|
||||
Return all selected visibility 'li' options.
|
||||
"""
|
||||
results = []
|
||||
for option in self.all_options:
|
||||
button = option.find_element_by_css_selector('input.input')
|
||||
if button.is_selected():
|
||||
results.append(option)
|
||||
return results
|
||||
|
||||
def select_option(self, label_text, save=True):
|
||||
"""
|
||||
Click the first li which has a label matching `label_text`.
|
||||
|
||||
Arguments:
|
||||
label_text (str): Text of a label accompanying the input
|
||||
which should be clicked.
|
||||
save (boolean): Whether the "save" button should be clicked
|
||||
afterwards.
|
||||
Returns:
|
||||
bool: Whether the label was found and clicked.
|
||||
"""
|
||||
for option in self.all_options:
|
||||
if label_text in option.text:
|
||||
option.click()
|
||||
if save:
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user