Commit 48f867bd by David Ormsbee

Merge branch 'master' into ormsbee/verifyuser3

Conflicts:
	lms/envs/common.py
parents 79a9c867 b53d5554
......@@ -3,6 +3,7 @@
from lettuce import world, step
from selenium.webdriver.common.keys import Keys
from nose.tools import assert_true # pylint: disable=E0611
@step(u'I go to the static pages page')
......@@ -21,18 +22,21 @@ def add_page(_step):
@step(u'I should( not)? see a "([^"]*)" static page$')
def see_page(_step, doesnt, page):
index = get_index(page)
if doesnt:
assert index == -1
else:
assert index != -1
should_exist = not doesnt
# Need to retry here because the element
# will sometimes exist before the HTML content is loaded
exists_func = lambda(driver): page_exists(page) == should_exist
world.wait_for(exists_func)
assert_true(exists_func(None))
@step(u'I "([^"]*)" the "([^"]*)" page$')
def click_edit_delete(_step, edit_delete, page):
button_css = 'a.%s-button' % edit_delete
index = get_index(page)
assert index != -1
assert index is not None
world.css_click(button_css, index=index)
......@@ -54,4 +58,7 @@ def get_index(name):
for i in range(len(all_pages)):
if world.css_html(page_name_css, index=i) == '\n {name}\n'.format(name=name):
return i
return -1
return None
def page_exists(page):
return get_index(page) is not None
......@@ -10,6 +10,16 @@ Feature: Upload Files
Then I should see the file "test" was uploaded
And The url for the file "test" is valid
@skip_safari
Scenario: Users can upload multiple files
Given I have opened a new course in studio
And I go to the files and uploads page
When I upload the files "test","test2"
Then I should see the file "test" was uploaded
And I should see the file "test2" was uploaded
And The url for the file "test2" is valid
And The url for the file "test" is valid
# Uploading isn't working on safari with sauce labs
@skip_safari
Scenario: Users can update files
......
......@@ -26,7 +26,24 @@ def upload_file(_step, file_name):
#uploading the file itself
path = os.path.join(TEST_ROOT, 'uploads/', file_name)
world.browser.execute_script("$('input.file-input').css('display', 'block')")
world.browser.attach_file('file', os.path.abspath(path))
world.browser.attach_file('files[]', os.path.abspath(path))
close_css = 'a.close-button'
world.css_click(close_css)
@step(u'I upload the files (".*")$')
def upload_file(_step, files_string):
# Turn files_string to a list of file names
files = files_string.split(",")
files = map(lambda x: string.strip(x, ' "\''), files)
upload_css = 'a.upload-button'
world.css_click(upload_css)
#uploading the files
for f in files:
path = os.path.join(TEST_ROOT, 'uploads/', f)
world.browser.execute_script("$('input.file-input').css('display', 'block')")
world.browser.attach_file('files[]', os.path.abspath(path))
close_css = 'a.close-button'
world.css_click(close_css)
......
......@@ -17,13 +17,13 @@ Feature: Video Component Editor
# Sauce Labs cannot delete cookies
@skip_sauce
Scenario: Captions are hidden when "show captions" is false
Given I have created a Video component
Given I have created a Video component with subtitles
And I have set "show captions" to False
Then when I view the video it does not show the captions
# Sauce Labs cannot delete cookies
@skip_sauce
Scenario: Captions are shown when "show captions" is true
Given I have created a Video component
Given I have created a Video component with subtitles
And I have set "show captions" to True
Then when I view the video it does show the captions
......@@ -7,20 +7,21 @@ from terrain.steps import reload_the_page
@step('I have set "show captions" to (.*)$')
def set_show_captions(step, setting):
# Prevent cookies from overriding course settings
world.browser.cookies.delete('hide_captions')
world.css_click('a.edit-button')
world.wait_for(lambda _driver: world.css_visible('a.save-button'))
world.browser.select('Show Captions', setting)
world.css_click('a.save-button')
@step('when I view the (video.*) it (.*) show the captions$')
def shows_captions(_step, video_type, show_captions):
# Prevent cookies from overriding course settings
world.browser.cookies.delete('hide_captions')
@step('when I view the video it (.*) show the captions$')
def shows_captions(_step, show_captions):
if show_captions == 'does not':
assert world.css_has_class('.%s' % video_type, 'closed')
assert world.is_css_present('div.video.closed')
else:
assert world.is_css_not_present('.%s.closed' % video_type)
assert world.is_css_not_present('div.video.closed')
@step('I see the correct video settings and default values$')
......
......@@ -13,20 +13,20 @@ Feature: Video Component
# Sauce Labs cannot delete cookies
@skip_sauce
Scenario: Captions are hidden correctly
Given I have created a Video component
Given I have created a Video component with subtitles
And I have hidden captions
Then when I view the video it does not show the captions
# Sauce Labs cannot delete cookies
@skip_sauce
Scenario: Captions are shown correctly
Given I have created a Video component
Given I have created a Video component with subtitles
Then when I view the video it does show the captions
# Sauce Labs cannot delete cookies
@skip_sauce
Scenario: Captions are toggled correctly
Given I have created a Video component
Given I have created a Video component with subtitles
And I have toggled captions
Then when I view the video it does show the captions
......
......@@ -8,7 +8,6 @@ from contentstore.utils import get_modulestore
############### ACTIONS ####################
@step('I have created a Video component$')
def i_created_a_video_component(step):
world.create_component_instance(
......@@ -19,6 +18,26 @@ def i_created_a_video_component(step):
)
@step('I have created a Video component with subtitles$')
def i_created_a_video_component(step):
step.given('I have created a Video component')
# Store the current URL so we can return here
video_url = world.browser.url
# Upload subtitles for the video using the upload interface
step.given('I have uploaded subtitles')
# Return to the video
world.visit(video_url)
@step('I have uploaded subtitles')
def i_have_uploaded_subtitles(step):
step.given('I go to the files and uploads page')
step.given('I upload the file "subs_OEoXaMPEzfM.srt.sjson"')
@step('when I view the (.*) it does not have autoplay enabled$')
def does_not_autoplay(_step, video_type):
assert world.css_find('.%s' % video_type)[0]['data-autoplay'] == 'False'
......
......@@ -60,4 +60,3 @@ class Command(BaseCommand):
for item in queried_discussion_items:
if item.location.url() not in discussion_items:
print 'Found dangling discussion module = {0}'.format(item.location.url())
......@@ -64,11 +64,11 @@ def set_module_info(store, location, post_data):
if posted_metadata[metadata_key] is None:
# remove both from passed in collection as well as the collection read in from the modulestore
if metadata_key in module._model_data:
del module._model_data[metadata_key]
if module._field_data.has(module, metadata_key):
module._field_data.delete(module, metadata_key)
del posted_metadata[metadata_key]
else:
module._model_data[metadata_key] = value
module._field_data.set(module, metadata_key, value)
# commit to datastore
# TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata
......
......@@ -118,16 +118,20 @@ class CourseDetailsTestCase(CourseTestCase):
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}):
response = self.client.get(settings_details_url)
self.assertContains(response, "Course Summary Page")
self.assertNotContains(response, "Course Summary Page")
self.assertNotContains(response, "Send a note to students via email")
self.assertContains(response, "course summary page will not be viewable")
self.assertContains(response, "Course Start Date")
self.assertContains(response, "Course End Date")
self.assertNotContains(response, "Enrollment Start Date")
self.assertNotContains(response, "Enrollment End Date")
self.assertContains(response, "Enrollment Start Date")
self.assertContains(response, "Enrollment End Date")
self.assertContains(response, "not the dates shown on your course summary page")
self.assertNotContains(response, "Introducing Your Course")
self.assertContains(response, "Introducing Your Course")
self.assertContains(response, "Course Image")
self.assertNotContains(response,"Course Overview")
self.assertNotContains(response,"Course Introduction Video")
self.assertNotContains(response, "Requirements")
def test_regular_site_fetch(self):
......@@ -143,6 +147,7 @@ class CourseDetailsTestCase(CourseTestCase):
with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}):
response = self.client.get(settings_details_url)
self.assertContains(response, "Course Summary Page")
self.assertContains(response, "Send a note to students via email")
self.assertNotContains(response, "course summary page will not be viewable")
self.assertContains(response, "Course Start Date")
......@@ -152,6 +157,9 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertNotContains(response, "not the dates shown on your course summary page")
self.assertContains(response, "Introducing Your Course")
self.assertContains(response, "Course Image")
self.assertContains(response,"Course Overview")
self.assertContains(response,"Course Introduction Video")
self.assertContains(response, "Requirements")
......@@ -341,8 +349,8 @@ class CourseGradingTest(CourseTestCase):
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
self.assertEqual('Not Graded', section_grader_type['graderType'])
self.assertEqual(None, descriptor.lms.format)
self.assertEqual(False, descriptor.lms.graded)
self.assertEqual(None, descriptor.format)
self.assertEqual(False, descriptor.graded)
# Change the default grader type to Homework, which should also mark the section as graded
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Homework'})
......@@ -350,8 +358,8 @@ class CourseGradingTest(CourseTestCase):
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
self.assertEqual('Homework', section_grader_type['graderType'])
self.assertEqual('Homework', descriptor.lms.format)
self.assertEqual(True, descriptor.lms.graded)
self.assertEqual('Homework', descriptor.format)
self.assertEqual(True, descriptor.graded)
# Change the grader type back to Not Graded, which should also unmark the section as graded
CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Not Graded'})
......@@ -359,8 +367,8 @@ class CourseGradingTest(CourseTestCase):
section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location)
self.assertEqual('Not Graded', section_grader_type['graderType'])
self.assertEqual(None, descriptor.lms.format)
self.assertEqual(False, descriptor.lms.graded)
self.assertEqual(None, descriptor.format)
self.assertEqual(False, descriptor.graded)
class CourseMetadataEditingTest(CourseTestCase):
......
......@@ -218,20 +218,16 @@ class TemplateTests(unittest.TestCase):
)
usage_id = json_data.get('_id', None)
if not '_inherited_settings' in json_data and parent_xblock is not None:
json_data['_inherited_settings'] = parent_xblock.xblock_kvs.get_inherited_settings().copy()
json_data['_inherited_settings'] = parent_xblock.xblock_kvs.inherited_settings.copy()
json_fields = json_data.get('fields', {})
for field in inheritance.INHERITABLE_METADATA:
if field in json_fields:
json_data['_inherited_settings'][field] = json_fields[field]
for field_name in inheritance.InheritanceMixin.fields:
if field_name in json_fields:
json_data['_inherited_settings'][field_name] = json_fields[field_name]
new_block = system.xblock_from_json(class_, usage_id, json_data)
if parent_xblock is not None:
children = parent_xblock.children
children.append(new_block)
# trigger setter method by using top level field access
parent_xblock.children = children
# decache pending children field settings (Note, truly persisting at this point would break b/c
# persistence assumes children is a list of ids not actual xblocks)
parent_xblock.children.append(new_block.scope_ids.usage_id)
# decache pending children field settings
parent_xblock.save()
return new_block
......@@ -97,9 +97,9 @@ class ContentStoreImportNoStaticTest(ModuleStoreTestCase):
self.assertIsNotNone(content)
# make sure course.lms.static_asset_path is correct
print "static_asset_path = {0}".format(course.lms.static_asset_path)
self.assertEqual(course.lms.static_asset_path, 'test_import_course')
# make sure course.static_asset_path is correct
print "static_asset_path = {0}".format(course.static_asset_path)
self.assertEqual(course.static_asset_path, 'test_import_course')
def test_asset_import_nostatic(self):
'''
......
from contentstore.tests.test_course_settings import CourseTestCase
from contentstore.tests.utils import CourseTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
from xmodule.capa_module import CapaDescriptor
......@@ -69,7 +69,7 @@ class TestCreateItem(CourseTestCase):
# get the new item and check its category and display_name
chap_location = self.response_id(resp)
new_obj = modulestore().get_item(chap_location)
self.assertEqual(new_obj.category, 'chapter')
self.assertEqual(new_obj.scope_ids.block_type, 'chapter')
self.assertEqual(new_obj.display_name, display_name)
self.assertEqual(new_obj.location.org, self.course.location.org)
self.assertEqual(new_obj.location.course, self.course.location.course)
......@@ -226,7 +226,7 @@ class TestEditItem(CourseTestCase):
Test setting due & start dates on sequential
"""
sequential = modulestore().get_item(self.seq_location)
self.assertIsNone(sequential.lms.due)
self.assertIsNone(sequential.due)
self.client.post(
reverse('save_item'),
json.dumps({
......@@ -236,7 +236,7 @@ class TestEditItem(CourseTestCase):
content_type="application/json"
)
sequential = modulestore().get_item(self.seq_location)
self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
self.assertEqual(sequential.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
self.client.post(
reverse('save_item'),
json.dumps({
......@@ -246,5 +246,5 @@ class TestEditItem(CourseTestCase):
content_type="application/json"
)
sequential = modulestore().get_item(self.seq_location)
self.assertEqual(sequential.lms.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
self.assertEqual(sequential.lms.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC))
self.assertEqual(sequential.due, datetime.datetime(2010, 11, 22, 4, 0, tzinfo=UTC))
self.assertEqual(sequential.start, datetime.datetime(2010, 9, 12, 14, 0, tzinfo=UTC))
......@@ -149,14 +149,14 @@ def upload_asset(request, org, course, coursename):
logging.error('Could not find course' + location)
return HttpResponseBadRequest()
if 'file' not in request.FILES:
if 'files[]' not in request.FILES:
return HttpResponseBadRequest()
# compute a 'filename' which is similar to the location formatting, we're
# using the 'filename' nomenclature since we're using a FileSystem paradigm
# here. We're just imposing the Location string formatting expectations to
# keep things a bit more consistent
upload_file = request.FILES['file']
upload_file = request.FILES['files[]']
filename = upload_file.name
mime_type = upload_file.content_type
......
......@@ -2,27 +2,27 @@ import json
import logging
from collections import defaultdict
from django.http import ( HttpResponse, HttpResponseBadRequest,
HttpResponseForbidden )
from django.http import (HttpResponse, HttpResponseBadRequest,
HttpResponseForbidden)
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.core.exceptions import PermissionDenied
from django_future.csrf import ensure_csrf_cookie
from django.conf import settings
from xmodule.modulestore.exceptions import ( ItemNotFoundError,
InvalidLocationError )
from xmodule.modulestore.exceptions import (ItemNotFoundError,
InvalidLocationError)
from mitxmako.shortcuts import render_to_response
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.util.date_utils import get_default_time_display
from xblock.core import Scope
from xblock.fields import Scope
from util.json_request import expect_json, JsonResponse
from contentstore.module_info_model import get_module_info, set_module_info
from contentstore.utils import ( get_modulestore, get_lms_link_for_item,
compute_unit_state, UnitState, get_course_for_item )
from contentstore.utils import (get_modulestore, get_lms_link_for_item,
compute_unit_state, UnitState, get_course_for_item)
from models.settings.course_grading import CourseGradingModel
......@@ -30,6 +30,7 @@ from .requests import _xmodule_recurse
from .access import has_access
from xmodule.x_module import XModuleDescriptor
from xblock.plugin import PluginMissingError
from xblock.runtime import Mixologist
__all__ = ['OPEN_ENDED_COMPONENT_TYPES',
'ADVANCED_COMPONENT_POLICY_KEY',
......@@ -91,7 +92,7 @@ def edit_subsection(request, location):
# we're for now assuming a single parent
if len(parent_locs) != 1:
logging.error(
'Multiple (or none) parents have been found for %',
'Multiple (or none) parents have been found for %s',
location
)
......@@ -99,12 +100,14 @@ def edit_subsection(request, location):
parent = modulestore().get_item(parent_locs[0])
# remove all metadata from the generic dictionary that is presented in a
# more normalized UI
# more normalized UI. We only want to display the XBlocks fields, not
# the fields from any mixins that have been added
fields = getattr(item, 'unmixed_class', item.__class__).fields
policy_metadata = dict(
(field.name, field.read_from(item))
for field
in item.fields
in fields.values()
if field.name not in ['display_name', 'start', 'due', 'format']
and field.scope == Scope.settings
)
......@@ -135,6 +138,15 @@ def edit_subsection(request, location):
)
def load_mixed_class(category):
"""
Load an XBlock by category name, and apply all defined mixins
"""
component_class = XModuleDescriptor.load_class(category)
mixologist = Mixologist(settings.XBLOCK_MIXINS)
return mixologist.mix(component_class)
@login_required
def edit_unit(request, location):
"""
......@@ -163,15 +175,22 @@ def edit_unit(request, location):
component_templates = defaultdict(list)
for category in COMPONENT_TYPES:
component_class = XModuleDescriptor.load_class(category)
component_class = load_mixed_class(category)
# add the default template
# TODO: Once mixins are defined per-application, rather than per-runtime,
# this should use a cms mixed-in class. (cpennington)
if hasattr(component_class, 'display_name'):
display_name = component_class.display_name.default or 'Blank'
else:
display_name = 'Blank'
component_templates[category].append((
component_class.display_name.default or 'Blank',
display_name,
category,
False, # No defaults have markdown (hardcoded current default)
None # no boilerplate for overrides
))
# add boilerplates
if hasattr(component_class, 'templates'):
for template in component_class.templates():
component_templates[category].append((
template['metadata'].get('display_name'),
......@@ -194,7 +213,7 @@ def edit_unit(request, location):
# class? i.e., can an advanced have more than one entry in the
# menu? one for default and others for prefilled boilerplates?
try:
component_class = XModuleDescriptor.load_class(category)
component_class = load_mixed_class(category)
component_templates['advanced'].append((
component_class.display_name.default or category,
......@@ -272,13 +291,17 @@ def edit_unit(request, location):
'draft_preview_link': preview_lms_link,
'published_preview_link': lms_link,
'subsection': containing_subsection,
'release_date': get_default_time_display(containing_subsection.lms.start)
if containing_subsection.lms.start is not None else None,
'release_date': (
get_default_time_display(containing_subsection.start)
if containing_subsection.start is not None else None
),
'section': containing_section,
'new_unit_category': 'vertical',
'unit_state': unit_state,
'published_date': get_default_time_display(item.cms.published_date)
if item.cms.published_date is not None else None
'published_date': (
get_default_time_display(item.published_date)
if item.published_date is not None else None
),
})
......
......@@ -134,8 +134,12 @@ def create_new_course(request):
'course number so that it is unique.'),
})
course_search_location = ['i4x', dest_location.org, dest_location.course,
'course', None
course_search_location = [
'i4x',
dest_location.org,
dest_location.course,
'course',
None
]
courses = modulestore().get_items(course_search_location)
if len(courses) > 0:
......@@ -203,13 +207,16 @@ def course_info(request, org, course, name, provided_id=None):
# get current updates
location = Location(['i4x', org, course, 'course_info', "updates"])
return render_to_response('course_info.html', {
return render_to_response(
'course_info.html',
{
'context_course': course_module,
'url_base': "/" + org + "/" + course + "/",
'course_updates': json.dumps(get_course_updates(location)),
'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url(),
'base_asset_url': StaticContent.get_base_url_path_for_course_assets(location) + '/'})
'base_asset_url': StaticContent.get_base_url_path_for_course_assets(location) + '/'
}
)
@expect_json
@require_http_methods(("GET", "POST", "PUT", "DELETE"))
......
......@@ -58,13 +58,13 @@ def save_item(request):
# 'apply' the submitted metadata, so we don't end up deleting system metadata
existing_item = modulestore().get_item(item_location)
for metadata_key in request.POST.get('nullout', []):
_get_xblock_field(existing_item, metadata_key).write_to(existing_item, None)
setattr(existing_item, metadata_key, None)
# update existing metadata with submitted metadata (which can be partial)
# IMPORTANT NOTE: if the client passed 'null' (None) for a piece of metadata that means 'remove it'. If
# the intent is to make it None, use the nullout field
for metadata_key, value in request.POST.get('metadata', {}).items():
field = _get_xblock_field(existing_item, metadata_key)
field = existing_item.fields[metadata_key]
if value is None:
field.delete_from(existing_item)
......@@ -80,16 +80,6 @@ def save_item(request):
return JsonResponse()
def _get_xblock_field(xblock, field_name):
"""
A temporary function to get the xblock field either from the xblock or one of its namespaces by name.
:param xblock:
:param field_name:
"""
for field in xblock.iterfields():
if field.name == field_name:
return field
@login_required
@expect_json
def create_item(request):
......
......@@ -2,6 +2,7 @@ import logging
import sys
from functools import partial
from django.conf import settings
from django.http import HttpResponse, Http404, HttpResponseBadRequest, HttpResponseForbidden
from django.core.urlresolvers import reverse
from django.contrib.auth.decorators import login_required
......@@ -11,12 +12,12 @@ from xmodule_modifiers import replace_static_urls, wrap_xmodule, save_module #
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from xmodule.exceptions import NotFoundError, ProcessingError
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.mongo import MongoUsage
from xmodule.x_module import ModuleSystem
from xblock.runtime import DbModel
from lms.xblock.field_data import lms_field_data
from util.sandboxing import can_execute_unsafe_code
import static_replace
......@@ -97,14 +98,10 @@ def preview_module_system(request, preview_id, descriptor):
descriptor: An XModuleDescriptor
"""
def preview_model_data(descriptor):
def preview_field_data(descriptor):
"Helper method to create a DbModel from a descriptor"
return DbModel(
SessionKeyValueStore(request, descriptor._model_data),
descriptor.module_class,
preview_id,
MongoUsage(preview_id, descriptor.location.url()),
)
student_data = DbModel(SessionKeyValueStore(request))
return lms_field_data(descriptor._field_data, student_data)
course_id = get_course_for_item(descriptor.location).location.course_id
......@@ -118,8 +115,9 @@ def preview_module_system(request, preview_id, descriptor):
debug=True,
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id),
user=request.user,
xblock_model_data=preview_model_data,
xblock_field_data=preview_field_data,
can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
mixins=settings.XBLOCK_MIXINS,
)
......
from xblock.runtime import KeyValueStore, InvalidScopeError
"""
An :class:`~xblock.runtime.KeyValueStore` that stores data in the django session
"""
from xblock.runtime import KeyValueStore
class SessionKeyValueStore(KeyValueStore):
def __init__(self, request, descriptor_model_data):
self._descriptor_model_data = descriptor_model_data
def __init__(self, request):
self._session = request.session
def get(self, key):
try:
return self._descriptor_model_data[key.field_name]
except (KeyError, InvalidScopeError):
return self._session[tuple(key)]
def set(self, key, value):
try:
self._descriptor_model_data[key.field_name] = value
except (KeyError, InvalidScopeError):
self._session[tuple(key)] = value
def delete(self, key):
try:
del self._descriptor_model_data[key.field_name]
except (KeyError, InvalidScopeError):
del self._session[tuple(key)]
def has(self, key):
return key.field_name in self._descriptor_model_data or tuple(key) in self._session
return tuple(key) in self._session
......@@ -125,7 +125,7 @@ class CourseGradingModel(object):
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data)
return CourseGradingModel.jsonize_grader(index, descriptor.raw_grader[index])
......@@ -144,7 +144,7 @@ class CourseGradingModel(object):
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data)
return cutoffs
......@@ -168,12 +168,12 @@ class CourseGradingModel(object):
grace_timedelta = timedelta(**graceperiodjson)
descriptor = get_modulestore(course_location).get_item(course_location)
descriptor.lms.graceperiod = grace_timedelta
descriptor.graceperiod = grace_timedelta
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
get_modulestore(course_location).update_metadata(course_location, descriptor._field_data._kvs._metadata)
@staticmethod
def delete_grader(course_location, index):
......@@ -193,7 +193,7 @@ class CourseGradingModel(object):
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_item(course_location, descriptor._model_data._kvs._data)
get_modulestore(course_location).update_item(course_location, descriptor._field_data._kvs._data)
@staticmethod
def delete_grace_period(course_location):
......@@ -204,12 +204,12 @@ class CourseGradingModel(object):
course_location = Location(course_location)
descriptor = get_modulestore(course_location).get_item(course_location)
del descriptor.lms.graceperiod
del descriptor.graceperiod
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(course_location).update_metadata(course_location, descriptor._model_data._kvs._metadata)
get_modulestore(course_location).update_metadata(course_location, descriptor._field_data._kvs._metadata)
@staticmethod
def get_section_grader_type(location):
......@@ -217,7 +217,7 @@ class CourseGradingModel(object):
location = Location(location)
descriptor = get_modulestore(location).get_item(location)
return {"graderType": descriptor.lms.format if descriptor.lms.format is not None else 'Not Graded',
return {"graderType": descriptor.format if descriptor.format is not None else 'Not Graded',
"location": location,
"id": 99 # just an arbitrary value to
}
......@@ -229,21 +229,21 @@ class CourseGradingModel(object):
descriptor = get_modulestore(location).get_item(location)
if 'graderType' in jsondict and jsondict['graderType'] != u"Not Graded":
descriptor.lms.format = jsondict.get('graderType')
descriptor.lms.graded = True
descriptor.format = jsondict.get('graderType')
descriptor.graded = True
else:
del descriptor.lms.format
del descriptor.lms.graded
del descriptor.format
del descriptor.graded
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
descriptor.save()
get_modulestore(location).update_metadata(location, descriptor._model_data._kvs._metadata)
get_modulestore(location).update_metadata(location, descriptor._field_data._kvs._metadata)
@staticmethod
def convert_set_grace_period(descriptor):
# 5 hours 59 minutes 59 seconds => converted to iso format
rawgrace = descriptor.lms.graceperiod
rawgrace = descriptor.graceperiod
if rawgrace:
hours_from_days = rawgrace.days * 24
seconds = rawgrace.seconds
......
from xmodule.modulestore import Location
from contentstore.utils import get_modulestore
from xmodule.modulestore.inheritance import own_metadata
from xblock.core import Scope
from xblock.fields import Scope
from xmodule.course_module import CourseDescriptor
import copy
from cms.xmodule_namespace import CmsBlockMixin
class CourseMetadata(object):
......@@ -35,11 +35,16 @@ class CourseMetadata(object):
descriptor = get_modulestore(course_location).get_item(course_location)
for field in descriptor.fields + descriptor.lms.fields:
for field in descriptor.fields.values():
if field.name in CmsBlockMixin.fields:
continue
if field.scope != Scope.settings:
continue
if field.name not in cls.FILTERED_LIST:
if field.name in cls.FILTERED_LIST:
continue
course[field.name] = field.read_json(descriptor)
return course
......@@ -55,9 +60,9 @@ class CourseMetadata(object):
dirty = False
#Copy the filtered list to avoid permanently changing the class attribute
filtered_list = copy.copy(cls.FILTERED_LIST)
#Don't filter on the tab attribute if filter_tabs is False
# Copy the filtered list to avoid permanently changing the class attribute.
filtered_list = list(cls.FILTERED_LIST)
# Don't filter on the tab attribute if filter_tabs is False.
if not filter_tabs:
filtered_list.remove("tabs")
......@@ -68,12 +73,8 @@ class CourseMetadata(object):
if hasattr(descriptor, key) and getattr(descriptor, key) != val:
dirty = True
value = getattr(CourseDescriptor, key).from_json(val)
value = descriptor.fields[key].from_json(val)
setattr(descriptor, key, value)
elif hasattr(descriptor.lms, key) and getattr(descriptor.lms, key) != key:
dirty = True
value = getattr(CourseDescriptor.lms, key).from_json(val)
setattr(descriptor.lms, key, value)
if dirty:
# Save the data that we've just changed to the underlying
......@@ -97,8 +98,6 @@ class CourseMetadata(object):
for key in payload['deleteKeys']:
if hasattr(descriptor, key):
delattr(descriptor, key)
elif hasattr(descriptor.lms, key):
delattr(descriptor.lms, key)
# Save the data that we've just changed to the underlying
# MongoKeyValueStore before we update the mongo datastore.
......
......@@ -68,8 +68,8 @@ CONTENTSTORE = {
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': TEST_ROOT / "db" / "test_mitx_%s.db" % seed(),
'TEST_NAME': TEST_ROOT / "db" / "test_mitx_%s.db" % seed(),
'NAME': TEST_ROOT / "db" / "test_edx.db",
'TEST_NAME': TEST_ROOT / "db" / "test_edx.db"
}
}
......
"""
This config file extends the test environment configuration
so that we can run the lettuce acceptance tests.
This is used in the django-admin call as acceptance.py
contains random seeding, causing django-admin to create a random collection
"""
# We intentionally define lots of variables that aren't used, and
# want to import all variables from base settings files
# pylint: disable=W0401, W0614
from .test import *
# You need to start the server in debug mode,
# otherwise the browser will not render the pages correctly
DEBUG = True
# Disable warnings for acceptance tests, to make the logs readable
import logging
logging.disable(logging.ERROR)
import os
import random
MODULESTORE_OPTIONS = {
'default_class': 'xmodule.raw_module.RawDescriptor',
'host': 'localhost',
'db': 'acceptance_xmodule',
'collection': 'acceptance_modulestore',
'fs_root': TEST_ROOT / "data",
'render_template': 'mitxmako.shortcuts.render_to_string',
}
MODULESTORE = {
'default': {
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
},
'direct': {
'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
},
'draft': {
'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore',
'OPTIONS': MODULESTORE_OPTIONS
}
}
CONTENTSTORE = {
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
'OPTIONS': {
'host': 'localhost',
'db': 'acceptance_xcontent',
},
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
'ADDITIONAL_OPTIONS': {
'trashcan': {
'bucket': 'trash_fs'
}
}
}
# Set this up so that rake lms[acceptance] and running the
# harvest command both use the same (test) database
# which they can flush without messing up your dev db
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': TEST_ROOT / "db" / "test_mitx.db",
'TEST_NAME': TEST_ROOT / "db" / "test_mitx.db",
}
}
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
INSTALLED_APPS += ('lettuce.django',)
LETTUCE_APPS = ('contentstore',)
LETTUCE_SERVER_PORT = random.randint(1024, 65535)
LETTUCE_BROWSER = 'chrome'
......@@ -28,6 +28,10 @@ import lms.envs.common
from lms.envs.common import USE_TZ, TECH_SUPPORT_EMAIL, PLATFORM_NAME, BUGS_EMAIL
from path import path
from lms.xblock.mixin import LmsBlockMixin
from cms.xmodule_namespace import CmsBlockMixin
from xmodule.modulestore.inheritance import InheritanceMixin
############################ FEATURE CONFIGURATION #############################
MITX_FEATURES = {
......@@ -55,7 +59,7 @@ MITX_FEATURES = {
# If set to True, new Studio users won't be able to author courses unless
# edX has explicitly added them to the course creator group.
'ENABLE_CREATOR_GROUP': False
'ENABLE_CREATOR_GROUP': False,
}
ENABLE_JASMINE = False
......@@ -160,6 +164,13 @@ MIDDLEWARE_CLASSES = (
'ratelimitbackend.middleware.RateLimitMiddleware',
)
############# XBlock Configuration ##########
# 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, CmsBlockMixin, InheritanceMixin)
############################ SIGNAL HANDLERS ################################
# This is imported to register the exception signal handling that logs exceptions
import monitoring.exceptions # noqa
......
"""
Module with code executed during Studio startup
"""
import logging
from django.conf import settings
# Force settings to run so that the python path is modified
......@@ -8,6 +9,8 @@ settings.INSTALLED_APPS # pylint: disable=W0104
from django_startup import autostartup
log = logging.getLogger(__name__)
# TODO: Remove this code once Studio/CMS runs via wsgi in all environments
INITIALIZED = False
......@@ -22,4 +25,3 @@ def run():
INITIALIZED = True
autostartup()
<li class="field-group course-advanced-policy-list-item">
<div class="field is-not-editable text key" id="<%= key %>">
<label for="<%= keyUniqueId %>">Policy Key:</label>
<input readonly title="This field is disabled: policy keys cannot be edited." type="text" class="short policy-key" id="<%= keyUniqueId %>" value="<%= key %>" />
</div>
<div class="field text value">
<label for="<%= valueUniqueId %>">Policy Value:</label>
<textarea class="json text" id="<%= valueUniqueId %>"><%= value %></textarea>
</div>
</li>
\ No newline at end of file
<!-- In order to enable better debugging of templates, put them in
the script tag section.
TODO add lazy load fn to load templates as needed (called
from backbone view initialize to set this.template of the view)
-->
<%block name="jsextra">
<script type="text/javascript" charset="utf-8">
// How do I load an html file server side so I can
// Precompiling your templates can be a big help when debugging errors you can't reproduce. This is because precompiled templates can provide line numbers and a stack trace, something that is not possible when compiling templates on the client. The source property is available on the compiled template function for easy precompilation.
// <script>CMS.course_info_update = <%= _.template(jstText).source %>;</script>
</script>
</%block>
\ No newline at end of file
......@@ -26,7 +26,6 @@ describe "Test Metadata Editor", ->
explicitly_set: true,
field_name: "display_name",
help: "Specifies the name for this component.",
inheritable: false,
options: [],
type: CMS.Models.Metadata.GENERIC_TYPE,
value: "Word cloud"
......@@ -38,7 +37,6 @@ describe "Test Metadata Editor", ->
explicitly_set: false,
field_name: "show_answer",
help: "When should you show the answer",
inheritable: true,
options: [
{"display_name": "Always", "value": "always"},
{"display_name": "Answered", "value": "answered"},
......@@ -54,7 +52,6 @@ describe "Test Metadata Editor", ->
explicitly_set: false,
field_name: "num_inputs",
help: "Number of text boxes for student to input words/sentences.",
inheritable: false,
options: {min: 1},
type: CMS.Models.Metadata.INTEGER_TYPE,
value: 5
......@@ -66,7 +63,6 @@ describe "Test Metadata Editor", ->
explicitly_set: true,
field_name: "weight",
help: "Weight for this problem",
inheritable: true,
options: {min: 1.3, max:100.2, step:0.1},
type: CMS.Models.Metadata.FLOAT_TYPE,
value: 10.2
......@@ -78,7 +74,6 @@ describe "Test Metadata Editor", ->
explicitly_set: false,
field_name: "list",
help: "A list of things.",
inheritable: false,
options: [],
type: CMS.Models.Metadata.LIST_TYPE,
value: ["the first display value", "the second"]
......@@ -99,7 +94,6 @@ describe "Test Metadata Editor", ->
explicitly_set: true,
field_name: "unknown_type",
help: "Mystery property.",
inheritable: false,
options: [
{"display_name": "Always", "value": "always"},
{"display_name": "Answered", "value": "answered"},
......@@ -145,7 +139,6 @@ describe "Test Metadata Editor", ->
explicitly_set: false,
field_name: "display_name",
help: "",
inheritable: false,
options: [],
type: CMS.Models.Metadata.GENERIC_TYPE,
value: null
......
// <!-- from https://github.com/Gazler/Underscore-Template-Loader/blob/master/index.html -->
// TODO Figure out how to initialize w/ static views from server (don't call .load but instead inject in django as strings)
// so this only loads the lazily loaded ones.
(function () {
if (typeof window.templateLoader == 'function') return;
var templateLoader = {
templateVersion: "0.0.15",
templates: {},
// Control whether template caching in local memory occurs. Caching screws up development but may
// be a good optimization in production (it works fairly well).
cacheTemplates: false,
loadRemoteTemplate: function (templateName, filename, callback) {
if (!this.templates[templateName]) {
var self = this;
jQuery.ajax({url: filename,
success: function (data) {
self.addTemplate(templateName, data);
self.saveLocalTemplates();
callback(data);
},
error: function (xhdr, textStatus, errorThrown) {
console.log(textStatus);
},
dataType: "html"
})
}
else {
callback(this.templates[templateName]);
}
},
addTemplate: function (templateName, data) {
// is there a reason this doesn't go ahead and compile the template? _.template(data)
// I suppose localstorage use would still req raw string rather than compiled version, but that sd work
// if it maintains a separate cache of uncompiled ones
this.templates[templateName] = data;
},
localStorageAvailable: function () {
try {
return this.cacheTemplates && 'localStorage' in window && window['localStorage'] !== null;
} catch (e) {
return false;
}
},
saveLocalTemplates: function () {
if (this.localStorageAvailable()) {
localStorage.setItem("templates", JSON.stringify(this.templates));
localStorage.setItem("templateVersion", this.templateVersion);
}
},
loadLocalTemplates: function () {
if (this.localStorageAvailable()) {
var templateVersion = localStorage.getItem("templateVersion");
if (templateVersion && templateVersion == this.templateVersion) {
var templates = localStorage.getItem("templates");
if (templates) {
templates = JSON.parse(templates);
for (var x in templates) {
if (!this.templates[x]) {
this.addTemplate(x, templates[x]);
}
}
}
}
else {
localStorage.removeItem("templates");
localStorage.removeItem("templateVersion");
}
}
}
};
templateLoader.loadLocalTemplates();
window.templateLoader = templateLoader;
})();
......@@ -52,7 +52,29 @@ function removeAsset(e){
function showUploadModal(e) {
e.preventDefault();
resetUploadModal();
$modal = $('.upload-modal').show();
$('.upload-modal .file-chooser').fileupload({
dataType: 'json',
type: 'POST',
maxChunkSize: 100 * 1000 * 1000, // 100 MB
autoUpload: true,
progressall: function(e, data) {
var percentComplete = parseInt((100 * data.loaded) / data.total, 10);
showUploadFeedback(e, percentComplete);
},
maxFileSize: 100 * 1000 * 1000, // 100 MB
maxNumberofFiles: 100,
add: function(e, data) {
data.process().done(function () {
data.submit();
});
},
done: function(e, data) {
displayFinishedUpload(data.result);
}
});
$('.file-input').bind('change', startUpload);
$modalCover.show();
}
......@@ -69,11 +91,6 @@ function startUpload(e) {
$('.upload-modal h1').html(gettext('Uploading…'));
$('.upload-modal .file-name').html(files[0].name);
$('.upload-modal .file-chooser').ajaxSubmit({
beforeSend: resetUploadBar,
uploadProgress: showUploadFeedback,
complete: displayFinishedUpload
});
$('.upload-modal .choose-file-button').hide();
$('.upload-modal .progress-bar').removeClass('loaded').show();
}
......@@ -84,18 +101,28 @@ function resetUploadBar() {
$('.upload-modal .progress-fill').html(percentVal);
}
function showUploadFeedback(event, position, total, percentComplete) {
function resetUploadModal() {
// Reset modal so it no longer displays information about previously
// completed uploads.
resetUploadBar();
$('.upload-modal .file-name').html('');
$('.upload-modal h1').html(gettext('Upload New File'));
$('.upload-modal .choose-file-button').html(gettext('Choose File'));
$('.upload-modal .embeddable-xml-input').val('');
$('.upload-modal .embeddable').hide();
}
function showUploadFeedback(event, percentComplete) {
var percentVal = percentComplete + '%';
$('.upload-modal .progress-fill').width(percentVal);
$('.upload-modal .progress-fill').html(percentVal);
}
function displayFinishedUpload(xhr) {
if (xhr.status == 200) {
function displayFinishedUpload(resp) {
if (resp.status == 200) {
markAsLoaded();
}
var resp = JSON.parse(xhr.responseText);
$('.upload-modal .embeddable-xml-input').val(resp.portable_url);
$('.upload-modal .embeddable').show();
$('.upload-modal .file-name').hide();
......
......@@ -10,27 +10,12 @@ CMS.Views.Checklists = Backbone.View.extend({
},
initialize : function() {
var self = this;
this.collection.fetch({
complete: function () {
window.templateLoader.loadRemoteTemplate("checklist",
"/static/client_templates/checklist.html",
function (raw_template) {
self.template = _.template(raw_template);
self.render();
}
);
},
reset: true
}
);
this.template = _.template($("#checklist-tpl").text());
this.listenTo(this.collection, 'reset', this.render);
this.render();
},
render: function() {
// catch potential outside call before template loaded
if (!this.template) return this;
this.$el.empty();
var self = this;
......
......@@ -184,8 +184,16 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({
if (forcedTarget) {
thisTarget = forcedTarget;
thisTarget.id = $(thisTarget).attr('id');
} else {
} else if (e !== null) {
thisTarget = e.currentTarget;
} else
{
// e and forcedTarget can be null so don't deference it
// This is because in cases where we have a marketing site
// we don't display the codeMirrors for editing the marketing
// materials, except we do need to show the 'set course image'
// workflow. So in this case e = forcedTarget = null.
return;
}
if (!this.codeMirrors[thisTarget.id]) {
......
......@@ -20,6 +20,7 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
initialize : function() {
// load template for grading view
var self = this;
this.template = _.template($("#course_grade_policy-tpl").text());
this.gradeCutoffTemplate = _.template('<li class="grade-specific-bar" style="width:<%= width %>%"><span class="letter-grade" contenteditable="true">' +
'<%= descriptor %>' +
'</span><span class="range"></span>' +
......@@ -27,27 +28,15 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
'</li>');
this.setupCutoffs();
// instantiates an editor template for each update in the collection
// Because this calls render, put it after everything which render may depend upon to prevent race condition.
window.templateLoader.loadRemoteTemplate("course_grade_policy",
"/static/client_templates/course_grade_policy.html",
function (raw_template) {
self.template = _.template(raw_template);
self.render();
}
);
this.listenTo(this.model, 'invalid', this.handleValidationError);
this.listenTo(this.model, 'change', this.showNotificationBar);
this.model.get('graders').on('reset', this.render, this);
this.model.get('graders').on('add', this.render, this);
this.selectorToField = _.invert(this.fieldToSelectorMap);
this.render();
},
render: function() {
// prevent bootstrap race condition by event dispatch
if (!this.template) return;
this.clearValidationErrors();
this.renderGracePeriod();
......
......@@ -8,6 +8,8 @@
<%block name="jsextra">
<script src="${static.url('js/vendor/mustache.js')}"></script>
<script src="${static.url('js/vendor/jQuery-File-Upload/js/jquery.iframe-transport.js')}"> </script>
<script src="${static.url('js/vendor/jQuery-File-Upload/js/jquery.fileupload.js')}"> </script>
</%block>
<%block name="content">
......@@ -40,12 +42,12 @@
<div class="wrapper-mast wrapper">
<header class="mast has-actions has-subtitle">
<h1 class="page-header">
<small class="subtitle">Content</small>
<span class="sr">&gt; </span>Files &amp; Uploads
<small class="subtitle">${_("Content")}</small>
<span class="sr">&gt; </span>${_("Files &amp; Uploads")}
</h1>
<nav class="nav-actions">
<h3 class="sr">Page Actions</h3>
<h3 class="sr">${_("Page Actions")}</h3>
<ul>
<li class="nav-item">
<a href="#" class="button upload-button new-button"><i class="icon-plus"></i> ${_("Upload New File")}</a>
......@@ -116,7 +118,7 @@
<div class="upload-modal modal">
<a href="#" class="close-button"><span class="close-icon"></span></a>
<div class="modal-body">
<h1>Upload New File</h1>
<h1>${_("Upload New File")}</h1>
<p class="file-name"></a>
<div class="progress-bar">
<div class="progress-fill"></div>
......@@ -127,8 +129,8 @@
</div>
<form class="file-chooser" action="${upload_asset_callback_url}"
method="post" enctype="multipart/form-data">
<a href="#" class="choose-file-button">Choose File</a>
<input type="file" class="file-input" name="file">
<a href="#" class="choose-file-button">${_("Choose File")}</a>
<input type="file" class="file-input" name="files[]" multiple>
</form>
</div>
</div>
......
......@@ -5,9 +5,17 @@
<%block name="bodyclass">is-signedin course uxdesign checklists</%block>
<%namespace name='static' file='static_content.html'/>
<%block name="header_extras">
% for template_name in ["checklist"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="jsextra">
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/checklists_view.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/checklists.js')}"></script>
......@@ -20,7 +28,7 @@
el: $('.course-checklists'),
collection: checklistCollection
});
checklistCollection.fetch({reset: true});
});
</script>
......
......@@ -37,21 +37,21 @@
<div class="field field-start-date">
<label for="start_date">${_("Release Day")}</label>
<input type="text" id="start_date" name="start_date"
value="${subsection.lms.start.strftime('%m/%d/%Y') if subsection.lms.start else ''}"
value="${subsection.start.strftime('%m/%d/%Y') if subsection.start else ''}"
placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
</div>
<div class="field field-start-time">
<label for="start_time">${_("Release Time")} (<abbr title="${_("Coordinated Universal Time")}">${_("UTC")}</abbr>)</label>
<input type="text" id="start_time" name="start_time"
value="${subsection.lms.start.strftime('%H:%M') if subsection.lms.start else ''}"
value="${subsection.start.strftime('%H:%M') if subsection.start else ''}"
placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
</div>
</div>
% if subsection.lms.start and not almost_same_datetime(subsection.lms.start, parent_item.lms.start):
% if parent_item.lms.start is None:
% if subsection.start and not almost_same_datetime(subsection.start, parent_item.start):
% if parent_item.start is None:
<p class="notice">${_("The date above differs from the release date of {name}, which is unset.").format(name=parent_item.display_name_with_default)}
% else:
<p class="notice">${_("The date above differs from the release date of {name} - {start_time}").format(name=parent_item.display_name_with_default, start_time=get_default_time_display(parent_item.lms.start))}.
<p class="notice">${_("The date above differs from the release date of {name} - {start_time}").format(name=parent_item.display_name_with_default, start_time=get_default_time_display(parent_item.start))}.
% endif
<a href="#" class="sync-date no-spinner">${_("Sync to {name}.").format(name=parent_item.display_name_with_default)}</a></p>
% endif
......@@ -60,7 +60,7 @@
<div class="row gradable">
<label>${_("Graded as:")}</label>
<div class="gradable-status" data-initial-status="${subsection.lms.format if subsection.lms.format is not None else _('Not Graded')}">
<div class="gradable-status" data-initial-status="${subsection.format if subsection.format is not None else _('Not Graded')}">
</div>
<div class="due-date-input row">
......@@ -69,13 +69,13 @@
<div class="field field-start-date">
<label for="due_date">${_("Due Day")}</label>
<input type="text" id="due_date" name="due_date"
value="${subsection.lms.due.strftime('%m/%d/%Y') if subsection.lms.due else ''}"
value="${subsection.due.strftime('%m/%d/%Y') if subsection.due else ''}"
placeholder="MM/DD/YYYY" class="date" size='15' autocomplete="off"/>
</div>
<div class="field field-start-time">
<label for="due_time">${_("Due Time")} (<abbr title="${_('Coordinated Universal Time')}">UTC</abbr>)</label>
<input type="text" id="due_time" name="due_time"
value="${subsection.lms.due.strftime('%H:%M') if subsection.lms.due else ''}"
value="${subsection.due.strftime('%H:%M') if subsection.due else ''}"
placeholder="HH:MM" class="time" size='10' autocomplete="off"/>
</div>
<a href="#" class="remove-date">${_("Remove due date")}</a>
......
......@@ -72,7 +72,7 @@ $('#fileupload').fileupload({
add: function(e, data) {
submitBtn.unbind('click');
var file = data.files[0];
if (file.type == "application/x-gzip") {
if (file.name.match(/tar\.gz$/)) {
submitBtn.click(function(e){
e.preventDefault();
submitBtn.hide();
......
......@@ -157,19 +157,19 @@
<h3 class="section-name" data-name="${section.display_name_with_default | h}"></h3>
<div class="section-published-date">
<%
if section.lms.start is not None:
start_date_str = section.lms.start.strftime('%m/%d/%Y')
start_time_str = section.lms.start.strftime('%H:%M')
if section.start is not None:
start_date_str = section.start.strftime('%m/%d/%Y')
start_time_str = section.start.strftime('%H:%M')
else:
start_date_str = ''
start_time_str = ''
%>
%if section.lms.start is None:
%if section.start is None:
<span class="published-status">${_("This section has not been released.")}</span>
<a href="#" class="schedule-button" data-date="" data-time="" data-id="${section.location}">${_("Schedule")}</a>
%else:
<span class="published-status"><strong>${_("Will Release:")}</strong>
${date_utils.get_default_time_display(section.lms.start)}</span>
${date_utils.get_default_time_display(section.start)}</span>
<a href="#" class="edit-button" data-date="${start_date_str}"
data-time="${start_time_str}" data-id="${section.location}">${_("Edit")}</a>
%endif
......@@ -199,7 +199,7 @@
</a>
</div>
<div class="gradable-status" data-initial-status="${subsection.lms.format if subsection.lms.format is not None else _('Not Graded')}">
<div class="gradable-status" data-initial-status="${subsection.format if subsection.format is not None else _('Not Graded')}">
</div>
<div class="item-actions">
......
......@@ -16,7 +16,6 @@ from contentstore import utils
<script src="${static.url('js/vendor/timepicker/datepair.js')}"></script>
<script src="${static.url('js/vendor/date.js')}"></script>
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/course_relative.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/validating_view.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/settings/main_settings_view.js')}"></script>
......@@ -91,6 +90,7 @@ from contentstore import utils
</li>
</ol>
% if about_page_editable:
<div class="note note-promotion note-promotion-courseURL has-actions">
<h3 class="title">${_("Course Summary Page")} <span class="tip">${_("(for student enrollment and access)")}</span></h3>
<div class="copy">
......@@ -103,6 +103,7 @@ from contentstore import utils
</li>
</ul>
</div>
% endif
% if not about_page_editable:
<div class="notice notice-incontext notice-workflow">
......@@ -152,7 +153,6 @@ from contentstore import utils
</li>
</ol>
% if about_page_editable:
<ol class="list-input">
<li class="field-group field-group-enrollment-start" id="enrollment-start">
<div class="field date" id="field-enrollment-start-date">
......@@ -182,7 +182,6 @@ from contentstore import utils
</div>
</li>
</ol>
% endif
% if not about_page_editable:
<div class="notice notice-incontext notice-workflow">
......@@ -194,14 +193,13 @@ from contentstore import utils
% endif
</section>
<hr class="divide" />
% if about_page_editable:
<section class="group-settings marketing">
<header>
<h2 class="title-2">${_("Introducing Your Course")}</h2>
<span class="tip">${_("Information for prospective students")}</span>
</header>
<ol class="list-input">
% if about_page_editable:
<li class="field text" id="field-course-overview">
<label for="course-overview">${_("Course Overview")}</label>
<textarea class="tinymce text-editor" id="course-overview"></textarea>
......@@ -213,6 +211,7 @@ from contentstore import utils
%>${text}</%def>
<span class="tip tip-stacked">${overview_text()}</span>
</li>
% endif
<li class="field image" id="field-course-image">
<label>${_("Course Image")}</label>
......@@ -242,6 +241,7 @@ from contentstore import utils
</div>
</li>
% if about_page_editable:
<li class="field video" id="field-course-introduction-video">
<label for="course-overview">${_("Course Introduction Video")}</label>
<div class="input input-existing">
......@@ -258,9 +258,11 @@ from contentstore import utils
<span class="tip tip-stacked">${_("Enter your YouTube video's ID (along with any restriction parameters)")}</span>
</div>
</li>
% endif
</ol>
</section>
% if about_page_editable:
<hr class="divide" />
<section class="group-settings requirements">
......
......@@ -13,7 +13,6 @@
</script>
% endfor
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/validating_view.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/advanced.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/settings/advanced_view.js')}"></script>
......
......@@ -12,7 +12,6 @@ from contentstore import utils
<%block name="jsextra">
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/course_relative.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/validating_view.js')}"></script>
......
<%! from django.utils.translation import ugettext as _ %>
<%inherit file="base.html" />
<%block name="title">${_("Grading Settings")}</%block>
<%block name="bodyclass">is-signedin course grading settings</%block>
<%namespace name='static' file='static_content.html'/>
<%!
from contentstore import utils
from contentstore import utils
from django.utils.translation import ugettext as _
%>
<%block name="header_extras">
% for template_name in ["course_grade_policy"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="js/${template_name}.underscore" />
</script>
% endfor
</%block>
<%block name="jsextra">
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
<script src="${static.url('js/vendor/timepicker/jquery.timepicker.js')}"></script>
<script type="text/javascript" src="${static.url('js/template_loader.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/course_relative.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/validating_view.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/settings/course_grading_policy.js')}"></script>
......
......@@ -35,7 +35,7 @@
<li>
<ol id="sortable">
% for child in module.get_children():
<li class="${module.category}">
<li class="${module.scope_ids.block_type}">
<a href="#" class="module-edit"
data-id="${child.location.url()}"
data-type="${child.js_module_name}"
......
......@@ -21,7 +21,7 @@ This def will enumerate through a passed in subsection and list all of the units
%>
<div class="section-item ${selected_class}">
<a href="${reverse('edit_unit', args=[unit.location])}" class="${unit_state}-item">
<span class="${unit.category}-icon"></span>
<span class="${unit.scope_ids.block_type}-icon"></span>
<span class="unit-name">${unit.display_name_with_default}</span>
</a>
% if actions:
......
......@@ -4,12 +4,12 @@ Namespace defining common fields used by Studio for all blocks
import datetime
from xblock.core import Namespace, Scope, ModelType, String
from xblock.fields import Scope, Field, Integer, XBlockMixin
class DateTuple(ModelType):
class DateTuple(Field):
"""
ModelType that stores datetime objects as time tuples
Field that stores datetime objects as time tuples
"""
def from_json(self, value):
return datetime.datetime(*value[0:6])
......@@ -21,9 +21,9 @@ class DateTuple(ModelType):
return list(value.timetuple())
class CmsNamespace(Namespace):
class CmsBlockMixin(XBlockMixin):
"""
Namespace with fields common to all blocks in Studio
Mixin with fields common to all blocks in Studio
"""
published_date = DateTuple(help="Date when the module was published", scope=Scope.settings)
published_by = String(help="Id of the user who published this module", scope=Scope.settings)
published_by = Integer(help="Id of the user who published this module", scope=Scope.settings)
......@@ -17,7 +17,10 @@ from django.core.urlresolvers import reverse
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from student.models import TestCenterUser, TestCenterRegistration
if settings.MITX_FEATURES.get('AUTH_USE_CAS'):
from django_cas.views import login as django_cas_login
from student.models import UserProfile, TestCenterUser, TestCenterRegistration
from django.http import HttpResponse, HttpResponseRedirect, HttpRequest, HttpResponseForbidden
from django.utils.http import urlquote
......@@ -44,7 +47,7 @@ from ratelimitbackend.exceptions import RateLimitException
import student.views as student_views
# Required for Pearson
from courseware.views import get_module_for_descriptor, jump_to
from courseware.model_data import ModelDataCache
from courseware.model_data import FieldDataCache
from xmodule.modulestore.django import modulestore
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore import Location
......@@ -382,6 +385,32 @@ def ssl_login(request):
# -----------------------------------------------------------------------------
# CAS (Central Authentication Service)
# -----------------------------------------------------------------------------
def cas_login(request, next_page=None, required=False):
"""
Uses django_cas for authentication.
CAS is a common authentcation method pioneered by Yale.
See http://en.wikipedia.org/wiki/Central_Authentication_Service
Does normal CAS login then generates user_profile if nonexistent,
and if login was successful. We assume that user details are
maintained by the central service, and thus an empty user profile
is appropriate.
"""
ret = django_cas_login(request, next_page, required)
if request.user.is_authenticated():
user = request.user
if not UserProfile.objects.filter(user=user):
user_profile = UserProfile(name=user.username, user=user)
user_profile.save()
return ret
# -----------------------------------------------------------------------------
# Shibboleth (Stanford and others. Uses *Apache* environment variables)
# -----------------------------------------------------------------------------
def shib_login(request):
......@@ -915,7 +944,7 @@ def test_center_login(request):
log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location))
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"))
timelimit_module_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user,
timelimit_module_cache = FieldDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user,
timelimit_descriptor, depth=None)
timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor,
timelimit_module_cache, course_id, position=None)
......
......@@ -415,6 +415,8 @@ def change_enrollment(request):
@ensure_csrf_cookie
def accounts_login(request, error=""):
if settings.MITX_FEATURES.get('AUTH_USE_CAS'):
return redirect(reverse('cas-login'))
return render_to_response('login.html', {'error': error})
# Need different levels of logging
......@@ -511,7 +513,11 @@ def logout_user(request):
# We do not log here, because we have a handler registered
# to perform logging on successful logouts.
logout(request)
response = redirect('/')
if settings.MITX_FEATURES.get('AUTH_USE_CAS'):
target = reverse('cas-logout')
else:
target = '/'
response = redirect(target)
response.delete_cookie(settings.EDXMKTG_COOKIE_NAME,
path='/',
domain=settings.SESSION_COOKIE_DOMAIN)
......
......@@ -29,11 +29,13 @@ from xmodule.contentstore.django import _CONTENTSTORE
# to use staticfiles.
try:
import staticfiles
import staticfiles.handlers
except ImportError:
pass
else:
import sys
sys.modules['django.contrib.staticfiles'] = staticfiles
sys.modules['django.contrib.staticfiles.handlers'] = staticfiles.handlers
LOGGER = getLogger(__name__)
LOGGER.info("Loading the lettuce acceptance testing terrain file...")
......
......@@ -32,7 +32,7 @@ class TestXmoduleModfiers(ModuleStoreTestCase):
late_problem = ItemFactory.create(
parent_location=section.location, display_name='problem hist 2',
category='problem')
late_problem.lms.start = datetime.datetime.now(UTC) + datetime.timedelta(days=32)
late_problem.start = datetime.datetime.now(UTC) + datetime.timedelta(days=32)
late_problem.has_score = False
......
......@@ -29,12 +29,16 @@ def wrap_xmodule(get_html, module, template, context=None):
if context is None:
context = {}
# If XBlock generated this class, then use the first baseclass
# as the name (since that's the original, unmixed class)
class_name = getattr(module, 'unmixed_class', module.__class__).__name__
@wraps(get_html)
def _get_html():
context.update({
'content': get_html(),
'display_name': module.display_name,
'class_': module.__class__.__name__,
'class_': class_name,
'module_name': module.js_module_name
})
......@@ -157,7 +161,7 @@ def add_histogram(get_html, module, user):
# doesn't like symlinks)
filepath = filename
data_dir = osfs.root_path.rsplit('/')[-1]
giturl = module.lms.giturl or 'https://github.com/MITx'
giturl = module.giturl or 'https://github.com/MITx'
edit_link = "%s/%s/tree/master/%s" % (giturl, data_dir, filepath)
else:
edit_link = False
......@@ -165,22 +169,21 @@ def add_histogram(get_html, module, user):
giturl = ""
data_dir = ""
source_file = module.lms.source_file # source used to generate the problem XML, eg latex or word
source_file = module.source_file # source used to generate the problem XML, eg latex or word
# useful to indicate to staff if problem has been released or not
# TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here
now = datetime.datetime.now(UTC())
is_released = "unknown"
mstart = module.descriptor.lms.start
mstart = module.descriptor.start
if mstart is not None:
is_released = "<font color='red'>Yes!</font>" if (now > mstart) else "<font color='green'>Not yet</font>"
staff_context = {'fields': [(field.name, getattr(module, field.name)) for field in module.fields],
'lms_fields': [(field.name, getattr(module.lms, field.name)) for field in module.lms.fields],
'xml_attributes' : getattr(module.descriptor, 'xml_attributes', {}),
staff_context = {'fields': [(name, field.read_from(module)) for name, field in module.fields.items()],
'xml_attributes': getattr(module.descriptor, 'xml_attributes', {}),
'location': module.location,
'xqa_key': module.lms.xqa_key,
'xqa_key': module.xqa_key,
'source_file': source_file,
'source_url': '%s/%s/tree/master/%s' % (giturl, data_dir, source_file),
'category': str(module.__class__.__name__),
......
......@@ -208,10 +208,10 @@ class InputTypeBase(object):
# end up in a partially-initialized state.
loaded = {}
to_render = set()
for a in self.get_attributes():
loaded[a.name] = a.parse_from_xml(self.xml)
if a.render:
to_render.add(a.name)
for attribute in self.get_attributes():
loaded[attribute.name] = attribute.parse_from_xml(self.xml)
if attribute.render:
to_render.add(attribute.name)
self.loaded_attributes = loaded
self.to_render = to_render
......@@ -325,7 +325,7 @@ class OptionInput(InputTypeBase):
Convert options to a convenient format.
"""
return [Attribute('options', transform=cls.parse_options),
Attribute('inline', '')]
Attribute('inline', False)]
registry.register(OptionInput)
......@@ -493,7 +493,8 @@ class JSInput(InputTypeBase):
"""
Register the attributes.
"""
return [Attribute('params', None), # extra iframe params
return [
Attribute('params', None), # extra iframe params
Attribute('html_file', None),
Attribute('gradefn', "gradefn"),
Attribute('get_statefn', None), # Function to call in iframe
......@@ -501,9 +502,8 @@ class JSInput(InputTypeBase):
Attribute('set_statefn', None), # Function to call iframe to
# set state
Attribute('width', "400"), # iframe width
Attribute('height', "300")] # iframe height
Attribute('height', "300") # iframe height
]
def _extra_context(self):
context = {
......@@ -514,7 +514,6 @@ class JSInput(InputTypeBase):
return context
registry.register(JSInput)
#-----------------------------------------------------------------------------
......@@ -1048,8 +1047,8 @@ class ChemicalEquationInput(InputTypeBase):
try:
result['preview'] = chemcalc.render_to_html(formula)
except pyparsing.ParseException as p:
result['error'] = u"Couldn't parse formula: {0}".format(p.msg)
except pyparsing.ParseException as err:
result['error'] = u"Couldn't parse formula: {0}".format(err.msg)
except Exception:
# this is unexpected, so log
log.warning(
......@@ -1189,15 +1188,19 @@ class DragAndDropInput(InputTypeBase):
'can_reuse': smth}.
"""
tag_attrs = dict()
tag_attrs['draggable'] = {'id': Attribute._sentinel,
tag_attrs['draggable'] = {
'id': Attribute._sentinel,
'label': "", 'icon': "",
'can_reuse': ""}
'can_reuse': ""
}
tag_attrs['target'] = {'id': Attribute._sentinel,
tag_attrs['target'] = {
'id': Attribute._sentinel,
'x': Attribute._sentinel,
'y': Attribute._sentinel,
'w': Attribute._sentinel,
'h': Attribute._sentinel}
'h': Attribute._sentinel
}
dic = dict()
......
<form class="inputtype option-input">
<% doinline = "inline" if inline else "" %>
<form class="inputtype option-input ${doinline}">
<select name="input_${id}" id="input_${id}" aria-describedby="answer_${id}">
<option value="option_${id}_dummy_default"> </option>
% for option_id, option_description in options:
......
......@@ -55,7 +55,7 @@ class OptionInputTest(unittest.TestCase):
'options': [('Up', 'Up'), ('Down', 'Down')],
'status': 'answered',
'msg': '',
'inline': '',
'inline': False,
'id': 'sky_input'}
self.assertEqual(context, expected)
......
......@@ -6,7 +6,7 @@ from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.xml_module import XmlDescriptor
from xmodule.exceptions import InvalidDefinitionError
from xblock.core import String, Scope, Dict
from xblock.fields import String, Scope, Dict
DEFAULT = "_DEFAULT_GROUP"
......
......@@ -5,7 +5,7 @@ from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xblock.core import Scope, String
from xblock.fields import Scope, String
import textwrap
log = logging.getLogger(__name__)
......
......@@ -18,7 +18,7 @@ from .progress import Progress
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.exceptions import NotFoundError, ProcessingError
from xblock.core import Scope, String, Boolean, Dict, Integer, Float
from xblock.fields import Scope, String, Boolean, Dict, Integer, Float
from .fields import Timedelta, Date
from django.utils.timezone import UTC
......
......@@ -5,7 +5,7 @@ from pkg_resources import resource_string
from xmodule.raw_module import RawDescriptor
from .x_module import XModule
from xblock.core import Integer, Scope, String, List, Float, Boolean
from xblock.fields import Integer, Scope, String, List, Float, Boolean
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
from collections import namedtuple
from .fields import Date, Timedelta
......
......@@ -10,7 +10,7 @@ from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor
from xblock.core import Scope, List
from xblock.fields import Scope, List
from xmodule.modulestore.exceptions import ItemNotFoundError
......
......@@ -13,7 +13,7 @@ from xmodule.util.decorators import lazyproperty
from xmodule.graders import grader_from_conf
import json
from xblock.core import Scope, List, String, Dict, Boolean
from xblock.fields import Scope, List, String, Dict, Boolean
from .fields import Date
from xmodule.modulestore.locator import CourseLocator
from django.utils.timezone import UTC
......@@ -118,6 +118,13 @@ class Textbook(object):
return table_of_contents
def __eq__(self, other):
return (self.title == other.title and
self.book_url == other.book_url)
def __ne__(self, other):
return not self == other
class TextbookList(List):
def from_json(self, values):
......@@ -737,7 +744,7 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
all_descriptors - This contains a list of all xmodules that can
effect grading a student. This is used to efficiently fetch
all the xmodule state for a ModelDataCache without walking
all the xmodule state for a FieldDataCache without walking
the descriptor tree again.
......@@ -754,14 +761,14 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
for c in self.get_children():
for s in c.get_children():
if s.lms.graded:
if s.graded:
xmoduledescriptors = list(yield_descriptor_descendents(s))
xmoduledescriptors.append(s)
# The xmoduledescriptors included here are only the ones that have scores.
section_description = {'section_descriptor': s, 'xmoduledescriptors': filter(lambda child: child.has_score, xmoduledescriptors)}
section_format = s.lms.format if s.lms.format is not None else ''
section_format = s.format if s.format is not None else ''
graded_sections[section_format] = graded_sections.get(section_format, []) + [section_description]
all_descriptors.extend(xmoduledescriptors)
......
......@@ -15,7 +15,7 @@ from lxml import etree
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xblock.core import Scope, String, Integer, Boolean, Dict, List
from xblock.fields import Scope, String, Integer, Boolean, Dict, List
from capa.responsetypes import FormulaResponse
......
......@@ -964,3 +964,11 @@ section.peer-grading-container{
}
}
}
div.staff-info{
background-color: #eee;
border-radius: 10px;
border-bottom: 1px solid lightgray;
padding: 10px;
margin: 10px 0px 10px 0px;
}
......@@ -40,6 +40,12 @@ div.video {
padding-bottom: 56.25%;
position: relative;
div {
&.hidden {
display: none;
}
}
object, iframe {
border: none;
height: 100%;
......@@ -48,6 +54,15 @@ div.video {
top: 0;
width: 100%;
}
h3 {
text-align: center;
color: white;
&.hidden {
display: none;
}
}
}
section.video-controls {
......@@ -516,6 +531,12 @@ div.video {
height: 0px;
}
article.video-wrapper section.video-player {
h3 {
color: black;
}
}
ol.subtitles {
width: 0;
height: 0;
......@@ -563,6 +584,12 @@ div.video {
position: static;
}
article.video-wrapper section.video-player {
h3 {
color: white;
}
}
div.tc-wrapper {
@include clearfix;
display: table;
......
......@@ -3,7 +3,7 @@ from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.editing_module import MetadataOnlyEditingDescriptor
from xblock.core import String, Scope
from xblock.fields import String, Scope
from uuid import uuid4
......
......@@ -2,7 +2,7 @@
from pkg_resources import resource_string
from xmodule.mako_module import MakoModuleDescriptor
from xblock.core import Scope, String
from xblock.fields import Scope, String
import logging
log = logging.getLogger(__name__)
......
......@@ -12,7 +12,8 @@ from lxml import etree
from xmodule.x_module import XModule, XModuleDescriptor
from xmodule.errortracker import exc_info_to_str
from xmodule.modulestore import Location
from xblock.core import String, Scope
from xblock.fields import String, Scope, ScopeIds
from xblock.field_data import DictFieldData
log = logging.getLogger(__name__)
......@@ -95,16 +96,19 @@ class ErrorDescriptor(ErrorFields, XModuleDescriptor):
)
# real metadata stays in the content, but add a display name
model_data = {
field_data = DictFieldData({
'error_msg': str(error_msg),
'contents': contents,
'display_name': 'Error: ' + location.url(),
'location': location,
'category': 'error'
}
return cls(
system,
model_data,
})
return system.construct_xblock_from_class(
cls,
field_data,
# The error module doesn't use scoped data, and thus doesn't need
# real scope keys
ScopeIds('error', None, location, location)
)
def get_context(self):
......
......@@ -2,7 +2,7 @@ import time
import logging
import re
from xblock.core import ModelType
from xblock.fields import Field
import datetime
import dateutil.parser
......@@ -11,7 +11,7 @@ from pytz import UTC
log = logging.getLogger(__name__)
class Date(ModelType):
class Date(Field):
'''
Date fields know how to parse and produce json (iso) compatible formats. Converts to tz aware datetimes.
'''
......@@ -20,6 +20,8 @@ class Date(ModelType):
PREVENT_DEFAULT_DAY_MON_SEED1 = datetime.datetime(CURRENT_YEAR, 1, 1, tzinfo=UTC)
PREVENT_DEFAULT_DAY_MON_SEED2 = datetime.datetime(CURRENT_YEAR, 2, 2, tzinfo=UTC)
MUTABLE = False
def _parse_date_wo_default_month_day(self, field):
"""
Parse the field as an iso string but prevent dateutils from defaulting the day or month while
......@@ -76,12 +78,12 @@ class Date(ModelType):
else:
return value.isoformat()
else:
raise TypeError("Cannot convert {} to json".format(value))
raise TypeError("Cannot convert {!r} to json".format(value))
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
class Timedelta(ModelType):
class Timedelta(Field):
# Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types
MUTABLE = False
......
......@@ -6,7 +6,7 @@ from pkg_resources import resource_string
from xmodule.editing_module import EditingDescriptor
from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor
from xblock.core import Scope, Integer, String
from xblock.fields import Scope, Integer, String
from .fields import Date
......
......@@ -14,7 +14,7 @@ from xmodule.xml_module import XmlDescriptor
from xmodule.x_module import XModule
from xmodule.stringify import stringify_children
from pkg_resources import resource_string
from xblock.core import String, Scope
from xblock.fields import String, Scope
log = logging.getLogger(__name__)
......
......@@ -7,7 +7,7 @@ from lxml import etree
from path import path
from pkg_resources import resource_string
from xblock.core import Scope, String
from xblock.fields import Scope, String
from xmodule.editing_module import EditingDescriptor
from xmodule.html_checker import check_html
from xmodule.stringify import stringify_children
......
<div class="base_wrapper">
<div class="component-editor">
<div class="base_wrapper">
<section class="editor-with-tabs">
<div class="wrapper-comp-editor" id="editor-tab-id" data-html_id='test_id'>
<div class="edit-header">
......@@ -29,5 +30,6 @@
</section>
<div class="component-edit-header" style="display: block"/>
</div>
</div>
......@@ -10,6 +10,8 @@
data-end=""
data-caption-asset-path="/static/subs/"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/"
>
<div class="tc-wrapper">
<article class="video-wrapper">
......
......@@ -13,6 +13,8 @@
data-webm-source="xmodule/include/fixtures/test.webm"
data-ogg-source="xmodule/include/fixtures/test.ogv"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/"
>
<div class="tc-wrapper">
<article class="video-wrapper">
......
......@@ -13,6 +13,8 @@
data-webm-source="xmodule/include/fixtures/test.webm"
data-ogg-source="xmodule/include/fixtures/test.ogv"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/"
>
<div class="tc-wrapper">
<article class="video-wrapper">
......
......@@ -10,6 +10,8 @@
data-end=""
data-caption-asset-path="/static/subs/"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/"
>
<div class="tc-wrapper">
<article class="video-wrapper">
......
<div class="course-content">
<div id="video_example1">
<div id="example1">
<div
id="video_id1"
class="video"
data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM"
data-show-captions="true"
data-start=""
data-end=""
data-caption-asset-path="/static/subs/"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/"
>
<div class="tc-wrapper">
<article class="video-wrapper">
<div class="video-player-pre"></div>
<section class="video-player">
<div id="id1"></div>
</section>
<div class="video-player-post"></div>
<section class="video-controls">
<div class="slider"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#" title="Play"></a></li>
<li><div class="vidtime">0:00 / 0:00</div></li>
</ul>
<div class="secondary-controls">
<div class="speeds">
<a href="#">
<h3>Speed</h3>
<p class="active"></p>
</a>
<ol class="video_speeds"></ol>
</div>
<div class="volume">
<a href="#"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
<a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a>
<a href="#" class="quality_control" title="HD">HD</a>
<a href="#" class="hide-subtitles" title="Turn off captions">Captions</a>
</div>
</div>
</section>
</article>
<ol class="subtitles"><li></li></ol>
</div>
</div>
</div>
</div>
</div>
<div class="course-content">
<div id="video_example2">
<div id="example2">
<div
id="video_id2"
class="video"
data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM"
data-show-captions="true"
data-start=""
data-end=""
data-caption-asset-path="/static/subs/"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/"
>
<div class="tc-wrapper">
<article class="video-wrapper">
<div class="video-player-pre"></div>
<section class="video-player">
<div id="id2"></div>
</section>
<div class="video-player-post"></div>
<section class="video-controls">
<div class="slider"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#" title="Play"></a></li>
<li><div class="vidtime">0:00 / 0:00</div></li>
</ul>
<div class="secondary-controls">
<div class="speeds">
<a href="#">
<h3>Speed</h3>
<p class="active"></p>
</a>
<ol class="video_speeds"></ol>
</div>
<div class="volume">
<a href="#"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
<a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a>
<a href="#" class="quality_control" title="HD">HD</a>
<a href="#" class="hide-subtitles" title="Turn off captions">Captions</a>
</div>
</div>
</section>
</article>
<ol class="subtitles"><li></li></ol>
</div>
</div>
</div>
</div>
</div>
<div class="course-content">
<div id="video_example3">
<div id="example3">
<div
id="video_id3"
class="video"
data-streams="0.75:7tqY6eQzVhE,1.0:cogebirgzzM"
data-show-captions="true"
data-start=""
data-end=""
data-caption-asset-path="/static/subs/"
data-autoplay="False"
data-yt-test-timeout="1500"
data-yt-test-url="https://gdata.youtube.com/feeds/api/videos/"
>
<div class="tc-wrapper">
<article class="video-wrapper">
<div class="video-player-pre"></div>
<section class="video-player">
<div id="id3"></div>
</section>
<div class="video-player-post"></div>
<section class="video-controls">
<div class="slider"></div>
<div>
<ul class="vcr">
<li><a class="video_control" href="#" title="Play"></a></li>
<li><div class="vidtime">0:00 / 0:00</div></li>
</ul>
<div class="secondary-controls">
<div class="speeds">
<a href="#">
<h3>Speed</h3>
<p class="active"></p>
</a>
<ol class="video_speeds"></ol>
</div>
<div class="volume">
<a href="#"></a>
<div class="volume-slider-container">
<div class="volume-slider"></div>
</div>
</div>
<a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a>
<a href="#" class="quality_control" title="HD">HD</a>
<a href="#" class="hide-subtitles" title="Turn off captions">Captions</a>
</div>
</div>
</section>
</article>
<ol class="subtitles"><li></li></ol>
</div>
</div>
</div>
</div>
</div>
......@@ -90,12 +90,24 @@ jasmine.stubbedHtml5Speeds = ['0.75', '1.0', '1.25', '1.50']
jasmine.stubRequests = ->
spyOn($, 'ajax').andCallFake (settings) ->
if match = settings.url.match /youtube\.com\/.+\/videos\/(.+)\?v=2&alt=jsonc/
if settings.success
status = match[1].split('_')
if status and status[0] is 'status'
{
always: (callback) ->
callback.call(window, {}, status[1])
error: (callback) ->
callback.call(window, {}, status[1])
done: (callback) ->
callback.call(window, {}, status[1])
}
else if settings.success
# match[1] - it's video ID
settings.success data: jasmine.stubbedMetadata[match[1]]
else {
always: (callback) ->
callback.call(window, {}, 'success');
callback.call(window, {}, 'success')
done: (callback) ->
callback.call(window, {}, 'success')
}
else if match = settings.url.match /static(\/.*)?\/subs\/(.+)\.srt\.sjson/
settings.success jasmine.stubbedCaption
......
......@@ -65,7 +65,7 @@ describe "TabsEditingDescriptor", ->
describe "editor/settings header", ->
it "is hidden", ->
expect(@descriptor.element.find(".component-edit-header").css('display')).toEqual('none')
expect(@descriptor.element.closest(".component-editor").find(".component-edit-header")).toBeHidden()
describe "TabsEditingDescriptor special save cases", ->
beforeEach ->
......
......@@ -55,46 +55,6 @@
expect(this.state.speed).toEqual('0.75');
});
});
describe('Check Youtube link existence', function () {
var statusList = {
error: 'html5',
timeout: 'html5',
abort: 'html5',
parsererror: 'html5',
success: 'youtube',
notmodified: 'youtube'
};
function stubDeffered(data, status) {
return {
always: function(callback) {
callback.call(window, data, status);
}
}
}
function checkPlayer(videoType, data, status) {
this.state = new window.Video('#example');
spyOn(this.state , 'getVideoMetadata')
.andReturn(stubDeffered(data, status));
this.state.initialize('#example');
expect(this.state.videoType).toEqual(videoType);
}
it('if video id is incorrect', function () {
checkPlayer('html5', { error: {} }, 'success');
});
$.each(statusList, function(status, mode){
it('Status:' + status + ', mode:' + mode, function () {
checkPlayer(mode, {}, status);
});
});
});
});
describe('HTML5', function () {
......@@ -154,10 +114,22 @@
it('parse Html5 sources', function () {
var html5Sources = {
mp4: 'xmodule/include/fixtures/test.mp4',
webm: 'xmodule/include/fixtures/test.webm',
ogg: 'xmodule/include/fixtures/test.ogv'
};
mp4: null,
webm: null,
ogg: null
}, v = document.createElement('video');
if (!!(v.canPlayType && v.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/no/, ''))) {
html5Sources['webm'] = 'xmodule/include/fixtures/test.webm';
}
if (!!(v.canPlayType && v.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, ''))) {
html5Sources['mp4'] = 'xmodule/include/fixtures/test.mp4';
}
if (!!(v.canPlayType && v.canPlayType('video/ogg; codecs="theora"').replace(/no/, ''))) {
html5Sources['ogg'] = 'xmodule/include/fixtures/test.ogv';
}
expect(state.html5Sources).toEqual(html5Sources);
});
......@@ -214,6 +186,46 @@
});
});
describe('multiple YT on page', function () {
var state1, state2, state3;
beforeEach(function () {
loadFixtures('video_yt_multiple.html');
spyOn($, 'ajaxWithPrefix');
$.ajax.calls.length = 0;
$.ajaxWithPrefix.calls.length = 0;
// Because several other tests have run, the variable
// that stores the value of the first ajax request must be
// cleared so that we test a pristine state of the video
// module.
Video.clearYoutubeXhr();
state1 = new Video('#example1');
state2 = new Video('#example2');
state3 = new Video('#example3');
});
it('check for YT availability is performed only once', function () {
var numAjaxCalls = 0;
// Total ajax calls made.
numAjaxCalls = $.ajax.calls.length;
// Subtract ajax calls to get captions.
numAjaxCalls -= $.ajaxWithPrefix.calls.length;
// Subtract ajax calls to get metadata for each video.
numAjaxCalls -= 3;
// This should leave just one call. It was made to check
// for YT availability.
expect(numAjaxCalls).toBe(1);
});
});
describe('setSpeed', function () {
describe('YT', function () {
beforeEach(function () {
......
......@@ -82,6 +82,38 @@
$('.speeds').mouseenter().click();
expect($('.speeds')).not.toHaveClass('open');
});
// Tabbing depends on the following order:
// 1. Play anchor
// 2. Speed anchor
// 3. A number of speed entry anchors
// 4. Volume anchor
// If an other focusable element is inserted or if the order is changed, things will
// malfunction as a flag, state.previousFocus, is set in the 1,3,4 elements and is
// used to determine the behavior of foucus() and blur() for the speed anchor.
it('checks for a certain order in focusable elements in video controls', function() {
var playIndex, speedIndex, firstSpeedEntry, lastSpeedEntry, volumeIndex, foundFirst = false;
$('.video-controls').find('a, :focusable').each(function(index) {
if ($(this).hasClass('video_control')) {
playIndex = index;
}
else if ($(this).parent().hasClass('speeds')) {
speedIndex = index;
}
else if ($(this).hasClass('speed_link')) {
if (!foundFirst) {
firstSpeedEntry = index;
foundFirst = true;
}
lastSpeedEntry = index;
}
else if ($(this).parent().hasClass('volume')) {
volumeIndex = index;
}
});
expect(playIndex+1).toEqual(speedIndex);
expect(speedIndex+1).toEqual(firstSpeedEntry);
expect(lastSpeedEntry+1).toEqual(volumeIndex);
});
});
});
......
......@@ -9,7 +9,7 @@ class @TabsEditingDescriptor
###
# hide editor/settings bar
$('.component-edit-header').hide()
@element.closest('.component-editor').find('.component-edit-header').hide()
@$tabs = $(".tab", @element)
@$content = $(".component-tab", @element)
......
......@@ -77,6 +77,11 @@ function () {
state.el.on('mousemove', state.videoControl.showControls);
state.el.on('keydown', state.videoControl.showControls);
}
// The state.previousFocus is used in video_speed_control to track
// the element that had the focus before it.
state.videoControl.playPauseEl.on('blur', function () {
state.previousFocus = 'playPause';
});
}
// ***************************************************************
......
......@@ -125,6 +125,9 @@ function () {
// We store the fact that previous element that lost focus was
// the volume clontrol.
state.volumeBlur = true;
// The following field is used in video_speed_control to track
// the element that had the focus before it.
state.previousFocus = 'volume';
});
}
......
......@@ -10,21 +10,35 @@ function () {
return function (state) {
state.videoSpeedControl = {};
if (state.videoType === 'html5') {
_initialize(state);
} else if (state.videoType === 'youtube' && state.youtubeXhr) {
state.youtubeXhr.done(function () {
_initialize(state);
});
}
if (state.videoType === 'html5' && !(_checkPlaybackRates())) {
console.log(
'[Video info]: HTML5 mode - playbackRate is not supported.'
);
_hideSpeedControl(state);
return;
}
_makeFunctionsPublic(state);
_renderElements(state);
_bindHandlers(state);
};
// ***************************************************************
// Private functions start here.
// ***************************************************************
function _initialize(state) {
_makeFunctionsPublic(state);
_renderElements(state);
_bindHandlers(state);
}
// function _makeFunctionsPublic(state)
//
// Functions which will be accessible via 'state' object. When called,
......@@ -55,7 +69,7 @@ function () {
state.videoSpeedControl.el
);
$.each(state.videoSpeedControl.speeds, function(index, speed) {
$.each(state.videoSpeedControl.speeds, function (index, speed) {
var link = '<a class="speed_link" href="#">' + speed + 'x</a>';
state.videoSpeedControl.videoSpeedsEl
......@@ -86,8 +100,9 @@ function () {
function _checkPlaybackRates() {
var video = document.createElement('video');
// If browser supports, 1.0 should be returned by playbackRate property.
// In this case, function return True. Otherwise, False will be returned.
// If browser supports, 1.0 should be returned by playbackRate
// property. In this case, function return True. Otherwise, False will
// be returned.
return Boolean(video.playbackRate);
}
......@@ -118,7 +133,7 @@ function () {
.on('click', state.videoSpeedControl.changeVideoSpeed);
if (onTouchBasedDevice()) {
state.videoSpeedControl.el.on('click', function(event) {
state.videoSpeedControl.el.on('click', function (event) {
// So that you can't highlight this control via a drag
// operation, we disable the default browser actions on a
// click event.
......@@ -144,68 +159,92 @@ function () {
});
// ******************************
// Attach 'focus', and 'blur' events to the speed button which
// The tabbing will cycle through the elements in the following
// order:
// 1. Play control
// 2. Speed control
// 3. Fastest speed called firstSpeed
// 4. Intermediary speed called otherSpeed
// 5. Slowest speed called lastSpeed
// 6. Volume control
// This field will keep track of where the focus is coming from.
state.previousFocus = '';
// ******************************
// Attach 'focus', and 'blur' events to the speed control which
// either brings up the speed dialog with individual speed entries,
// or closes it.
state.videoSpeedControl.el.children('a')
.on('focus', function () {
// If the focus is comming from the first speed entry, this
// means we are tabbing backwards. In this case we have to
// hide the speed entries which will allow us to change the
// focus further backwards.
if (state.firstSpeedBlur === true) {
// If the focus is coming from the first speed entry
// (tabbing backwards) or last speed entry (tabbing forward)
// hide the speed entries dialog.
if (state.previousFocus === 'firstSpeed' ||
state.previousFocus === 'lastSpeed') {
state.videoSpeedControl.el.removeClass('open');
state.firstSpeedBlur = false;
}
// If the focus is comming from some other element, show
// the drop down with the speed entries.
else {
state.videoSpeedControl.el.addClass('open');
}
})
.on('blur', function () {
// When the focus leaves this element, if the speed entries
// dialog is shown (tabbing forwards), then we will set
// focus to the first speed entry.
//
// If the selector does not select anything, then this
// means that the speed entries dialog is closed, and we
// are tabbing backwads. The browser will select the
// previous element to tab to by itself.
// When the focus leaves this element, the speed entries
// dialog will be shown.
// If we are tabbing forward (previous focus is play
// control), we open the dialog and set focus on the first
// speed entry.
if (state.previousFocus === 'playPause') {
state.videoSpeedControl.el.addClass('open');
state.videoSpeedControl.videoSpeedsEl
.find('a.speed_link:first')
.focus();
});
}
// If we are tabbing backwards (previous focus is volume
// control), we open the dialog and set focus on the
// last speed entry.
if (state.previousFocus === 'volume') {
state.videoSpeedControl.el.addClass('open');
state.videoSpeedControl.videoSpeedsEl
.find('a.speed_link:last')
.focus();
}
});
// ******************************
// Attach 'focus', and 'blur' events to elements which represent
// individual speed entries.
// Attach 'blur' event to elements which represent individual speed
// entries and use it to track the origin of the focus.
speedLinks = state.videoSpeedControl.videoSpeedsEl
.find('a.speed_link');
speedLinks.last().on('blur', function () {
// If we have reached the last speed entry, and the focus
// changes to the next element, we need to hide the speeds
// control drop-down.
state.videoSpeedControl.el.removeClass('open');
});
speedLinks.first().on('blur', function () {
// This flag will indicate that the focus to the next
// element that will receive it is comming from the first
// speed entry.
//
// This flag will be used to correctly handle scenario of
// tabbing backwards.
state.firstSpeedBlur = true;
// The previous focus is a speed entry (we are tabbing
// backwards), the dialog will close, set focus on the speed
// control and track the focus on first speed.
if (state.previousFocus === 'otherSpeed') {
state.previousFocus = 'firstSpeed';
state.videoSpeedControl.el.children('a').focus();
}
});
speedLinks.on('focus', function () {
// Clear the flag which is only set when we are un-focusing
// (the blur event) from the first speed entry.
state.firstSpeedBlur = false;
// Track the focus on intermediary speeds.
speedLinks
.filter(function (index) {
return index === 1 || index === 2
})
.on('blur', function () {
state.previousFocus = 'otherSpeed';
});
speedLinks.last().on('blur', function () {
// The previous focus is a speed entry (we are tabbing forward),
// the dialog will close, set focus on the speed control and
// track the focus on last speed.
if (state.previousFocus === 'otherSpeed') {
state.previousFocus = 'lastSpeed';
state.videoSpeedControl.el.children('a').focus();
}
});
}
}
......@@ -253,7 +292,7 @@ function () {
this.videoSpeedControl.videoSpeedsEl.find('li').removeClass('active');
this.videoSpeedControl.speeds = params.newSpeeds;
$.each(this.videoSpeedControl.speeds, function(index, speed) {
$.each(this.videoSpeedControl.speeds, function (index, speed) {
var link, listItem;
link = '<a class="speed_link" href="#">' + speed + 'x</a>';
......
......@@ -20,7 +20,8 @@ function (
VideoSpeedControl,
VideoCaption
) {
var previousState;
var previousState,
youtubeXhr = null;
// Because this constructor can be called multiple times on a single page (when
// the user switches verticals, the page doesn't reload, but the content changes), we must
......@@ -53,7 +54,11 @@ function (
state = {};
previousState = state;
state.youtubeXhr = youtubeXhr;
Initialize(state, element);
if (!youtubeXhr) {
youtubeXhr = state.youtubeXhr;
}
VideoControl(state);
VideoQualityControl(state);
......@@ -67,6 +72,10 @@ function (
// Video with Jasmine.
return state;
};
window.Video.clearYoutubeXhr = function () {
youtubeXhr = null;
};
});
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));
......@@ -2,10 +2,8 @@ from .x_module import XModuleDescriptor, DescriptorSystem
class MakoDescriptorSystem(DescriptorSystem):
def __init__(self, load_item, resources_fs, error_tracker,
render_template, **kwargs):
super(MakoDescriptorSystem, self).__init__(
load_item, resources_fs, error_tracker, **kwargs)
def __init__(self, render_template, **kwargs):
super(MakoDescriptorSystem, self).__init__(**kwargs)
self.render_template = render_template
......
......@@ -398,7 +398,7 @@ class ModuleStoreBase(ModuleStore):
'''
Implement interface functionality that can be shared.
'''
def __init__(self, metadata_inheritance_cache_subsystem=None, request_cache=None, modulestore_update_signal=None):
def __init__(self, metadata_inheritance_cache_subsystem=None, request_cache=None, modulestore_update_signal=None, xblock_mixins=()):
'''
Set up the error-tracking logic.
'''
......@@ -406,6 +406,7 @@ class ModuleStoreBase(ModuleStore):
self.metadata_inheritance_cache_subsystem = metadata_inheritance_cache_subsystem
self.modulestore_update_signal = modulestore_update_signal
self.request_cache = request_cache
self.xblock_mixins = xblock_mixins
def _get_errorlog(self, location):
"""
......
......@@ -62,6 +62,7 @@ def create_modulestore_instance(engine, options):
metadata_inheritance_cache_subsystem=metadata_inheritance_cache,
request_cache=request_cache,
modulestore_update_signal=Signal(providing_args=['modulestore', 'course_id', 'location']),
xblock_mixins=getattr(settings, 'XBLOCK_MIXINS', ()),
**_options
)
......
from xblock.core import Scope
# A list of metadata that this module can inherit from its parent module
INHERITABLE_METADATA = (
'graded', 'start', 'due', 'graceperiod', 'showanswer', 'rerandomize',
# TODO (ichuang): used for Fall 2012 xqa server access
'xqa_key',
# How many days early to show a course element to beta testers (float)
# intended to be set per-course, but can be overridden in for specific
# elements. Can be a float.
'days_early_for_beta',
'giturl', # for git edit link
'static_asset_path', # for static assets placed outside xcontent contentstore
)
from datetime import datetime
from pytz import UTC
from xblock.fields import Scope, Boolean, String, Float, XBlockMixin
from xmodule.fields import Date, Timedelta
from xblock.runtime import KeyValueStore
class InheritanceMixin(XBlockMixin):
"""Field definitions for inheritable fields"""
graded = Boolean(
help="Whether this module contributes to the final course grade",
default=False,
scope=Scope.settings
)
start = Date(
help="Start time when this module is visible",
default=datetime.fromtimestamp(0, UTC),
scope=Scope.settings
)
due = Date(help="Date that this problem is due by", scope=Scope.settings)
giturl = String(help="url root for course data git repository", scope=Scope.settings)
xqa_key = String(help="DO NOT USE", scope=Scope.settings)
graceperiod = Timedelta(
help="Amount of time after the due date that submissions will be accepted",
scope=Scope.settings
)
showanswer = String(
help="When to show the problem answer to the student",
scope=Scope.settings,
default="finished"
)
rerandomize = String(
help="When to rerandomize the problem",
default="never",
scope=Scope.settings
)
days_early_for_beta = Float(
help="Number of days early to show content to beta users",
default=None,
scope=Scope.settings
)
static_asset_path = String(help="Path to use for static assets - overrides Studio c4x://", scope=Scope.settings, default='')
def compute_inherited_metadata(descriptor):
......@@ -21,59 +52,69 @@ def compute_inherited_metadata(descriptor):
NOTE: This means that there is no such thing as lazy loading at the
moment--this accesses all the children."""
if descriptor.has_children:
parent_metadata = descriptor.xblock_kvs.inherited_settings.copy()
# add any of descriptor's explicitly set fields to the inheriting list
for field in InheritanceMixin.fields.values():
# pylint: disable = W0212
if descriptor._field_data.has(descriptor, field.name):
# inherited_settings values are json repr
parent_metadata[field.name] = field.read_json(descriptor)
for child in descriptor.get_children():
inherit_metadata(child, descriptor._model_data)
inherit_metadata(child, parent_metadata)
compute_inherited_metadata(child)
def inherit_metadata(descriptor, model_data):
def inherit_metadata(descriptor, inherited_data):
"""
Updates this module with metadata inherited from a containing module.
Only metadata specified in self.inheritable_metadata will
be inherited
"""
# The inherited values that are actually being used.
if not hasattr(descriptor, '_inherited_metadata'):
setattr(descriptor, '_inherited_metadata', {})
# All inheritable metadata values (for which a value exists in model_data).
if not hasattr(descriptor, '_inheritable_metadata'):
setattr(descriptor, '_inheritable_metadata', {})
# Set all inheritable metadata from kwargs that are
# in self.inheritable_metadata and aren't already set in metadata
for attr in INHERITABLE_METADATA:
if attr in model_data:
descriptor._inheritable_metadata[attr] = model_data[attr]
if attr not in descriptor._model_data:
descriptor._inherited_metadata[attr] = model_data[attr]
descriptor._model_data[attr] = model_data[attr]
`inherited_data`: A dictionary mapping field names to the values that
they should inherit
"""
try:
descriptor.xblock_kvs.inherited_settings = inherited_data
except AttributeError: # the kvs doesn't have inherited_settings probably b/c it's an error module
pass
def own_metadata(module):
# IN SPLIT MONGO this is just ['metadata'] as it keeps ['_inherited_metadata'] separate!
# FIXME move into kvs? will that work for xml mongo?
"""
Return a dictionary that contains only non-inherited field keys,
mapped to their values
mapped to their serialized values
"""
inherited_metadata = getattr(module, '_inherited_metadata', {})
metadata = {}
for field in module.fields + module.lms.fields:
# Only save metadata that wasn't inherited
if field.scope != Scope.settings:
continue
return module.get_explicitly_set_fields_by_scope(Scope.settings)
if field.name in inherited_metadata and module._model_data.get(field.name) == inherited_metadata.get(field.name):
continue
class InheritanceKeyValueStore(KeyValueStore):
"""
Common superclass for kvs's which know about inheritance of settings. Offers simple
dict-based storage of fields and lookup of inherited values.
if field.name not in module._model_data:
continue
Note: inherited_settings is a dict of key to json values (internal xblock field repr)
"""
def __init__(self, initial_values=None, inherited_settings=None):
super(InheritanceKeyValueStore, self).__init__()
self.inherited_settings = inherited_settings or {}
self._fields = initial_values or {}
try:
metadata[field.name] = module._model_data[field.name]
except KeyError:
# Ignore any missing keys in _model_data
pass
def get(self, key):
return self._fields[key.field_name]
return metadata
def set(self, key, value):
# xml backed courses are read-only, but they do have some computed fields
self._fields[key.field_name] = value
def delete(self, key):
del self._fields[key.field_name]
def has(self, key):
return key.field_name in self._fields
def default(self, key):
"""
Check to see if the default should be from inheritance rather than from the field's global default
"""
return self.inherited_settings[key.field_name]
......@@ -6,7 +6,6 @@ from __future__ import absolute_import
import logging
import inspect
from abc import ABCMeta, abstractmethod
from urllib import quote
from bson.objectid import ObjectId
from bson.errors import InvalidId
......@@ -19,6 +18,15 @@ from .parsers import BRANCH_PREFIX, BLOCK_PREFIX, URL_VERSION_PREFIX
log = logging.getLogger(__name__)
class LocalId(object):
"""
Class for local ids for non-persisted xblocks
Should be hashable and distinguishable, but nothing else
"""
pass
class Locator(object):
"""
A locator is like a URL, it refers to a course resource.
......@@ -386,6 +394,9 @@ class BlockUsageLocator(CourseLocator):
self.set_property('usage_id', new)
def init_block_ref(self, block_ref):
if isinstance(block_ref, LocalId):
self.set_usage_id(block_ref)
else:
parse = parse_block_ref(block_ref)
assert parse, 'Could not parse "%s" as a block_ref' % block_ref
self.set_usage_id(parse['block'])
......@@ -409,12 +420,8 @@ class BlockUsageLocator(CourseLocator):
"""
Return a string representing this location.
"""
rep = CourseLocator.__unicode__(self)
if self.usage_id is None:
# usage_id has not been initialized
return rep + BLOCK_PREFIX + 'NONE'
else:
return rep + BLOCK_PREFIX + self.usage_id
rep = super(BlockUsageLocator, self).__unicode__()
return rep + BLOCK_PREFIX + unicode(self.usage_id)
class DescriptionLocator(Locator):
......
......@@ -29,9 +29,9 @@ class MixedModuleStore(ModuleStoreBase):
if 'default' not in stores:
raise Exception('Missing a default modulestore in the MixedModuleStore __init__ method.')
for key in stores:
self.modulestores[key] = create_modulestore_instance(stores[key]['ENGINE'],
stores[key]['OPTIONS'])
for key, store in stores.items():
self.modulestores[key] = create_modulestore_instance(store['ENGINE'],
store['OPTIONS'])
def _get_modulestore_for_courseid(self, course_id):
"""
......
......@@ -2,7 +2,7 @@
Provide names as exported by older mongo.py module
"""
from xmodule.modulestore.mongo.base import MongoModuleStore, MongoKeyValueStore, MongoUsage
from xmodule.modulestore.mongo.base import MongoModuleStore, MongoKeyValueStore
# Backwards compatibility for prod systems that refererence
# xmodule.modulestore.mongo.DraftMongoModuleStore
......
......@@ -42,7 +42,7 @@ def wrap_draft(item):
non-draft location in either case
"""
setattr(item, 'is_draft', item.location.revision == DRAFT)
item.location = item.location.replace(revision=None)
item.scope_ids = item.scope_ids._replace(usage_id=item.location.replace(revision=None))
return item
......@@ -235,10 +235,10 @@ class DraftModuleStore(MongoModuleStore):
"""
draft = self.get_item(location)
draft.cms.published_date = datetime.now(UTC)
draft.cms.published_by = published_by_id
super(DraftModuleStore, self).update_item(location, draft._model_data._kvs._data)
super(DraftModuleStore, self).update_children(location, draft._model_data._kvs._children)
draft.published_date = datetime.now(UTC)
draft.published_by = published_by_id
super(DraftModuleStore, self).update_item(location, draft._field_data._kvs._data)
super(DraftModuleStore, self).update_children(location, draft._field_data._kvs._children)
super(DraftModuleStore, self).update_metadata(location, own_metadata(draft))
self.delete_item(location)
......
......@@ -2,15 +2,17 @@ import sys
import logging
from xmodule.mako_module import MakoDescriptorSystem
from xmodule.x_module import XModuleDescriptor
from xmodule.modulestore.locator import BlockUsageLocator
from xmodule.modulestore.locator import BlockUsageLocator, LocalId
from xmodule.error_module import ErrorDescriptor
from xmodule.errortracker import exc_info_to_str
from xblock.runtime import DbModel
from ..exceptions import ItemNotFoundError
from .split_mongo_kvs import SplitMongoKVS, SplitMongoKVSid
from .split_mongo_kvs import SplitMongoKVS
from xblock.fields import ScopeIds
log = logging.getLogger(__name__)
class CachingDescriptorSystem(MakoDescriptorSystem):
"""
A system that has a cache of a course version's json that it will use to load modules
......@@ -18,8 +20,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
Computes the settings (nee 'metadata') inheritance upon creation.
"""
def __init__(self, modulestore, course_entry, module_data, lazy,
default_class, error_tracker, render_template):
def __init__(self, modulestore, course_entry, default_class, module_data, lazy, **kwargs):
"""
Computes the settings inheritance and sets up the cache.
......@@ -28,34 +29,31 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
module_data: a dict mapping Location -> json that was cached from the
underlying modulestore
default_class: The default_class to use when loading an
XModuleDescriptor from the module_data
resources_fs: a filesystem, as per MakoDescriptorSystem
error_tracker: a function that logs errors for later display to users
render_template: a function for rendering templates, as per
MakoDescriptorSystem
"""
# TODO find all references to resources_fs and make handle None
super(CachingDescriptorSystem, self).__init__(
self._load_item, None, error_tracker, render_template)
super(CachingDescriptorSystem, self).__init__(load_item=self._load_item, **kwargs)
self.modulestore = modulestore
self.course_entry = course_entry
self.lazy = lazy
self.module_data = module_data
self.default_class = default_class
# TODO see if self.course_id is needed: is already in course_entry but could be > 1 value
# Compute inheritance
modulestore.inherit_settings(
course_entry.get('blocks', {}),
course_entry.get('blocks', {}).get(course_entry.get('root'))
)
self.default_class = default_class
self.local_modules = {}
def _load_item(self, usage_id, course_entry_override=None):
# TODO ensure all callers of system.load_item pass just the id
if isinstance(usage_id, BlockUsageLocator) and isinstance(usage_id.usage_id, LocalId):
try:
return self.local_modules[usage_id]
except KeyError:
raise ItemNotFoundError
json_data = self.module_data.get(usage_id)
if json_data is None:
# deeper than initial descendant fetch or doesn't exist
......@@ -75,6 +73,11 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
course_entry_override = self.course_entry
# most likely a lazy loader or the id directly
definition = json_data.get('definition', {})
definition_id = self.modulestore.definition_locator(definition)
# If no usage id is provided, generate an in-memory id
if usage_id is None:
usage_id = LocalId()
block_locator = BlockUsageLocator(
version_guid=course_entry_override['_id'],
......@@ -87,25 +90,24 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
definition,
json_data.get('fields', {}),
json_data.get('_inherited_settings'),
block_locator,
json_data.get('category'))
model_data = DbModel(kvs, class_, None,
SplitMongoKVSid(
# DbModel req's that these support .url()
block_locator,
self.modulestore.definition_locator(definition)))
)
field_data = DbModel(kvs)
try:
module = class_(self, model_data)
module = self.construct_xblock_from_class(
class_,
field_data,
ScopeIds(None, json_data.get('category'), definition_id, block_locator)
)
except Exception:
log.warning("Failed to load descriptor", exc_info=True)
if usage_id is None:
usage_id = "MISSING"
return ErrorDescriptor.from_json(
json_data,
self,
BlockUsageLocator(version_guid=course_entry_override['_id'],
usage_id=usage_id),
BlockUsageLocator(
version_guid=course_entry_override['_id'],
usage_id=usage_id
),
error_msg=exc_info_to_str(sys.exc_info())
)
......@@ -117,4 +119,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
module.definition_locator = self.modulestore.definition_locator(definition)
# decache any pending field settings
module.save()
# If this is an in-memory block, store it in this system
if isinstance(block_locator.usage_id, LocalId):
self.local_modules[block_locator] = module
return module
......@@ -8,14 +8,15 @@ from path import path
from xmodule.errortracker import null_error_tracker
from xmodule.x_module import XModuleDescriptor
from xmodule.modulestore.locator import BlockUsageLocator, DescriptionLocator, CourseLocator, VersionTree
from xmodule.modulestore.locator import BlockUsageLocator, DescriptionLocator, CourseLocator, VersionTree, LocalId
from xmodule.modulestore.exceptions import InsufficientSpecificationError, VersionConflictError
from xmodule.modulestore import inheritance, ModuleStoreBase
from ..exceptions import ItemNotFoundError
from .definition_lazy_loader import DefinitionLazyLoader
from .caching_descriptor_system import CachingDescriptorSystem
from xblock.core import Scope
from xblock.fields import Scope
from xblock.runtime import Mixologist
from pytz import UTC
import collections
......@@ -41,6 +42,8 @@ log = logging.getLogger(__name__)
#==============================================================================
class SplitMongoModuleStore(ModuleStoreBase):
"""
A Mongodb backed ModuleStore supporting versions, inheritance,
......@@ -53,7 +56,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
mongo_options=None,
**kwargs):
ModuleStoreBase.__init__(self)
super(SplitMongoModuleStore, self).__init__(**kwargs)
if mongo_options is None:
mongo_options = {}
......@@ -93,6 +96,11 @@ class SplitMongoModuleStore(ModuleStoreBase):
self.error_tracker = error_tracker
self.render_template = render_template
# TODO: Don't have a runtime just to generate the appropriate mixin classes (cpennington)
# This is only used by _partition_fields_by_scope, which is only needed because
# the split mongo store is used for item creation as well as item persistence
self.mixologist = Mixologist(self.xblock_mixins)
def cache_items(self, system, base_usage_ids, depth=0, lazy=True):
'''
Handles caching of items once inheritance and any other one time
......@@ -144,13 +152,15 @@ class SplitMongoModuleStore(ModuleStoreBase):
system = self._get_cache(course_entry['_id'])
if system is None:
system = CachingDescriptorSystem(
self,
course_entry,
{},
lazy,
self.default_class,
self.error_tracker,
self.render_template
modulestore=self,
course_entry=course_entry,
module_data={},
lazy=lazy,
default_class=self.default_class,
error_tracker=self.error_tracker,
render_template=self.render_template,
resources_fs=None,
mixins=self.xblock_mixins
)
self._add_cache(course_entry['_id'], system)
self.cache_items(system, usage_ids, depth, lazy)
......@@ -943,12 +953,12 @@ class SplitMongoModuleStore(ModuleStoreBase):
xblock.definition_locator, is_updated = self.update_definition_from_data(
xblock.definition_locator, new_def_data, user_id)
if xblock.location.usage_id is None:
if isinstance(xblock.scope_ids.usage_id.usage_id, LocalId):
# generate an id
is_new = True
is_updated = True
usage_id = self._generate_usage_id(structure_blocks, xblock.category)
xblock.location.usage_id = usage_id
xblock.scope_ids.usage_id.usage_id = usage_id
else:
is_new = False
usage_id = xblock.location.usage_id
......@@ -960,9 +970,10 @@ class SplitMongoModuleStore(ModuleStoreBase):
updated_blocks = []
if xblock.has_children:
for child in xblock.children:
if isinstance(child, XModuleDescriptor):
updated_blocks += self._persist_subdag(child, user_id, structure_blocks)
children.append(child.location.usage_id)
if isinstance(child.usage_id, LocalId):
child_block = xblock.system.get_block(child)
updated_blocks += self._persist_subdag(child_block, user_id, structure_blocks)
children.append(child_block.location.usage_id)
else:
children.append(child)
......@@ -1118,11 +1129,11 @@ class SplitMongoModuleStore(ModuleStoreBase):
"""
return {}
def inherit_settings(self, block_map, block, inheriting_settings=None):
def inherit_settings(self, block_map, block_json, inheriting_settings=None):
"""
Updates block with any inheritable setting set by an ancestor and recurses to children.
Updates block_json with any inheritable setting set by an ancestor and recurses to children.
"""
if block is None:
if block_json is None:
return
if inheriting_settings is None:
......@@ -1132,14 +1143,14 @@ class SplitMongoModuleStore(ModuleStoreBase):
# NOTE: this should show the values which all fields would have if inherited: i.e.,
# not set to the locally defined value but to value set by nearest ancestor who sets it
# ALSO NOTE: no xblock should ever define a _inherited_settings field as it will collide w/ this logic.
block.setdefault('_inherited_settings', {}).update(inheriting_settings)
block_json.setdefault('_inherited_settings', {}).update(inheriting_settings)
# update the inheriting w/ what should pass to children
inheriting_settings = block['_inherited_settings'].copy()
block_fields = block['fields']
for field in inheritance.INHERITABLE_METADATA:
if field in block_fields:
inheriting_settings[field] = block_fields[field]
inheriting_settings = block_json['_inherited_settings'].copy()
block_fields = block_json['fields']
for field_name in inheritance.InheritanceMixin.fields:
if field_name in block_fields:
inheriting_settings[field_name] = block_fields[field_name]
for child in block_fields.get('children', []):
self.inherit_settings(block_map, block_map[child], inheriting_settings)
......@@ -1308,7 +1319,7 @@ class SplitMongoModuleStore(ModuleStoreBase):
"""
if fields is None:
return {}
cls = XModuleDescriptor.load_class(category)
cls = self.mixologist.mix(XModuleDescriptor.load_class(category))
result = collections.defaultdict(dict)
for field_name, value in fields.iteritems():
field = getattr(cls, field_name)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment