Merge remote-tracking branch 'origin/feature/cale/cms-master' into feature/btalbot/cms-settings
Conflicts: cms/djangoapps/contentstore/views.py
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ cms/static/sass/*.css
|
||||
lms/lib/comment_client/python
|
||||
nosetests.xml
|
||||
cover_html/
|
||||
.idea/
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -0,0 +1,3 @@
|
||||
[submodule "common/test/phantom-jasmine"]
|
||||
path = common/test/phantom-jasmine
|
||||
url = https://github.com/jcarver989/phantom-jasmine.git
|
||||
|
||||
2
Gemfile
2
Gemfile
@@ -3,3 +3,5 @@ ruby "1.9.3"
|
||||
gem 'rake'
|
||||
gem 'sass', '3.1.15'
|
||||
gem 'bourbon', '~> 1.3.6'
|
||||
gem 'colorize'
|
||||
gem 'launchy'
|
||||
|
||||
@@ -7,3 +7,4 @@ python
|
||||
yuicompressor
|
||||
node
|
||||
graphviz
|
||||
mysql
|
||||
|
||||
13
cms/.coveragerc
Normal file
13
cms/.coveragerc
Normal file
@@ -0,0 +1,13 @@
|
||||
# .coveragerc for cms
|
||||
[run]
|
||||
data_file = reports/cms/.coverage
|
||||
source = cms
|
||||
|
||||
[report]
|
||||
ignore_errors = True
|
||||
|
||||
[html]
|
||||
directory = reports/cms/cover
|
||||
|
||||
[xml]
|
||||
output = reports/cms/coverage.xml
|
||||
21
cms/CHANGELOG.md
Normal file
21
cms/CHANGELOG.md
Normal file
@@ -0,0 +1,21 @@
|
||||
Instructions
|
||||
============
|
||||
For each pull request, add one or more lines to the bottom of the change list. When
|
||||
code is released to production, change the `Upcoming` entry to todays date, and add
|
||||
a new block at the bottom of the file.
|
||||
|
||||
Upcoming
|
||||
--------
|
||||
|
||||
Change log entries should be targeted at end users. A good place to start is the
|
||||
user story that instigated the pull request.
|
||||
|
||||
|
||||
Changes
|
||||
=======
|
||||
|
||||
Upcoming
|
||||
--------
|
||||
* Fix: Deleting last component in a unit does not work
|
||||
* Fix: Unit name is editable when a unit is public
|
||||
* Fix: Visual feedback inconsistent when saving a unit name change
|
||||
@@ -182,17 +182,131 @@ TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data'
|
||||
TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data')
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MODULESTORE)
|
||||
class EditTestCase(ContentStoreTestCase):
|
||||
"""Check that editing functionality works on example courses"""
|
||||
class ContentStoreTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
email = 'edit@test.com'
|
||||
uname = 'testuser'
|
||||
email = 'test+courses@edx.org'
|
||||
password = 'foo'
|
||||
self.create_account('edittest', email, password)
|
||||
self.activate_user(email)
|
||||
self.login(email, password)
|
||||
|
||||
# Create the use so we can log them in.
|
||||
self.user = User.objects.create_user(uname, email, password)
|
||||
|
||||
# Note that we do not actually need to do anything
|
||||
# for registration if we directly mark them active.
|
||||
self.user.is_active = True
|
||||
# Staff has access to view all courses
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
|
||||
# Flush and initialize the module store
|
||||
# It needs the templates because it creates new records
|
||||
# by cloning from the template.
|
||||
# Note that if your test module gets in some weird state
|
||||
# (though it shouldn't), do this manually
|
||||
# from the bash shell to drop it:
|
||||
# $ mongo test_xmodule --eval "db.dropDatabase()"
|
||||
xmodule.modulestore.django._MODULESTORES = {}
|
||||
xmodule.modulestore.django.modulestore().collection.drop()
|
||||
xmodule.templates.update_templates()
|
||||
|
||||
self.client = Client()
|
||||
self.client.login(username=uname, password=password)
|
||||
|
||||
self.course_data = {
|
||||
'template': 'i4x://edx/templates/course/Empty',
|
||||
'org': 'MITx',
|
||||
'number': '999',
|
||||
'display_name': 'Robot Super Course',
|
||||
}
|
||||
|
||||
self.section_data = {
|
||||
'parent_location' : 'i4x://MITx/999/course/Robot_Super_Course',
|
||||
'template' : 'i4x://edx/templates/chapter/Empty',
|
||||
'display_name': 'Section One',
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
# Make sure you flush out the test modulestore after the end
|
||||
# of the last test because otherwise on the next run
|
||||
# cms/djangoapps/contentstore/__init__.py
|
||||
# update_templates() will try to update the templates
|
||||
# via upsert and it sometimes seems to be messing things up.
|
||||
xmodule.modulestore.django._MODULESTORES = {}
|
||||
xmodule.modulestore.django.modulestore().collection.drop()
|
||||
|
||||
def test_create_course(self):
|
||||
"""Test new course creation - happy path"""
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course')
|
||||
|
||||
def test_create_course_duplicate_course(self):
|
||||
"""Test new course creation - error path"""
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
data = parse_json(resp)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(data['ErrMsg'], 'There is already a course defined with this name.')
|
||||
|
||||
def test_create_course_duplicate_number(self):
|
||||
"""Test new course creation - error path"""
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
self.course_data['display_name'] = 'Robot Super Course Two'
|
||||
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
data = parse_json(resp)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(data['ErrMsg'],
|
||||
'There is already a course defined with the same organization and course number.')
|
||||
|
||||
def test_course_index_view_with_no_courses(self):
|
||||
"""Test viewing the index page with no courses"""
|
||||
# Create a course so there is something to view
|
||||
resp = self.client.get(reverse('index'))
|
||||
self.assertContains(resp,
|
||||
'<h1>My Courses</h1>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
|
||||
def test_course_index_view_with_course(self):
|
||||
"""Test viewing the index page with an existing course"""
|
||||
# Create a course so there is something to view
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
resp = self.client.get(reverse('index'))
|
||||
self.assertContains(resp,
|
||||
'<span class="class-name">Robot Super Course</span>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
|
||||
def test_course_overview_view_with_course(self):
|
||||
"""Test viewing the course overview page with an existing course"""
|
||||
# Create a course so there is something to view
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
|
||||
data = {
|
||||
'org': 'MITx',
|
||||
'course': '999',
|
||||
'name': Location.clean('Robot Super Course'),
|
||||
}
|
||||
|
||||
resp = self.client.get(reverse('course_index', kwargs=data))
|
||||
self.assertContains(resp,
|
||||
'<a href="/MITx/999/course/Robot_Super_Course" class="class-name">Robot Super Course</a>',
|
||||
status_code=200,
|
||||
html=True)
|
||||
|
||||
def test_clone_item(self):
|
||||
"""Test cloning an item. E.g. creating a new section"""
|
||||
resp = self.client.post(reverse('create_new_course'), self.course_data)
|
||||
resp = self.client.post(reverse('clone_item'), self.section_data)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = parse_json(resp)
|
||||
self.assertRegexpMatches(data['id'],
|
||||
'^i4x:\/\/MITx\/999\/chapter\/([0-9]|[a-f]){32}$')
|
||||
|
||||
def check_edit_unit(self, test_course_name):
|
||||
import_from_xml(modulestore(), 'common/test/data/', [test_course_name])
|
||||
@@ -208,3 +322,4 @@ class EditTestCase(ContentStoreTestCase):
|
||||
|
||||
def test_edit_unit_full(self):
|
||||
self.check_edit_unit('full')
|
||||
|
||||
|
||||
@@ -66,7 +66,10 @@ log = logging.getLogger(__name__)
|
||||
|
||||
COMPONENT_TYPES = ['customtag', 'discussion', 'html', 'problem', 'video']
|
||||
|
||||
DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential']
|
||||
DIRECT_ONLY_CATEGORIES = ['course', 'chapter', 'sequential', 'about', 'static_tab', 'course_info']
|
||||
|
||||
# cdodge: these are categories which should not be parented, they are detached from the hierarchy
|
||||
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']
|
||||
|
||||
|
||||
def _modulestore(location):
|
||||
@@ -287,6 +290,7 @@ def edit_unit(request, location):
|
||||
# TODO (cpennington): If we share units between courses,
|
||||
# this will need to change to check permissions correctly so as
|
||||
# to pick the correct parent subsection
|
||||
|
||||
containing_subsection_locs = modulestore().get_parent_locations(location)
|
||||
containing_subsection = modulestore().get_item(containing_subsection_locs[0])
|
||||
|
||||
@@ -499,7 +503,8 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_
|
||||
)
|
||||
module.get_html = replace_static_urls(
|
||||
module.get_html,
|
||||
module.metadata.get('data_dir', module.location.course)
|
||||
module.metadata.get('data_dir', module.location.course),
|
||||
course_namespace = Location([module.location.tag, module.location.org, module.location.course, None, None])
|
||||
)
|
||||
save_preview_state(request, preview_id, descriptor.location.url(),
|
||||
module.get_instance_state(), module.get_shared_state())
|
||||
@@ -580,8 +585,11 @@ def save_item(request):
|
||||
if request.POST.get('data') is not None:
|
||||
data = request.POST['data']
|
||||
store.update_item(item_location, data)
|
||||
|
||||
if request.POST.get('children') is not None:
|
||||
|
||||
# cdodge: note calling request.POST.get('children') will return None if children is an empty array
|
||||
# so it lead to a bug whereby the last component to be deleted in the UI was not actually
|
||||
# deleting the children object from the children collection
|
||||
if 'children' in request.POST and request.POST['children'] is not None:
|
||||
children = request.POST['children']
|
||||
store.update_children(item_location, children)
|
||||
|
||||
@@ -688,7 +696,9 @@ def clone_item(request):
|
||||
new_item.metadata['display_name'] = display_name
|
||||
|
||||
_modulestore(template).update_metadata(new_item.location.url(), new_item.own_metadata)
|
||||
_modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
|
||||
|
||||
if new_item.location.category not in DETACHED_CATEGORIES:
|
||||
_modulestore(parent.location).update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
|
||||
|
||||
return HttpResponse(json.dumps({'id': dest_location.url()}))
|
||||
|
||||
@@ -872,6 +882,24 @@ def edit_static(request, org, course, coursename):
|
||||
def settings(request, org, course, coursename):
|
||||
return render_to_response('settings.html', {})
|
||||
|
||||
def edit_tabs(request, org, course, coursename):
|
||||
location = ['i4x', org, course, 'course', coursename]
|
||||
course_item = modulestore().get_item(location)
|
||||
static_tabs_loc = Location('i4x', org, course, 'static_tab', None)
|
||||
|
||||
static_tabs = modulestore('direct').get_items(static_tabs_loc)
|
||||
|
||||
components = [
|
||||
static_tab.location.url()
|
||||
for static_tab
|
||||
in static_tabs
|
||||
]
|
||||
|
||||
return render_to_response('edit-tabs.html', {
|
||||
'active_tab': 'pages',
|
||||
'context_course':course_item,
|
||||
'components': components
|
||||
})
|
||||
|
||||
def not_found(request):
|
||||
return render_to_response('error.html', {'error': '404'})
|
||||
@@ -977,6 +1005,17 @@ def create_new_course(request):
|
||||
# set a default start date to now
|
||||
new_course.metadata['start'] = stringify_time(time.gmtime())
|
||||
|
||||
# set up the default tabs
|
||||
# I've added this because when we add static tabs, the LMS either expects a None for the tabs list or
|
||||
# at least a list populated with the minimal times
|
||||
# @TODO: I don't like the fact that the presentation tier is away of these data related constraints, let's find a better
|
||||
# place for this. Also rather than using a simple list of dictionaries a nice class model would be helpful here
|
||||
new_course.tabs = [{"type": "courseware"},
|
||||
{"type": "course_info", "name": "Course Info"},
|
||||
{"type": "discussion", "name": "Discussion"},
|
||||
{"type": "wiki", "name": "Wiki"},
|
||||
{"type": "progress", "name": "Progress"}]
|
||||
|
||||
modulestore('direct').update_metadata(new_course.location.url(), new_course.own_metadata)
|
||||
|
||||
create_all_course_groups(request.user, new_course.location)
|
||||
@@ -1001,7 +1040,8 @@ def import_course(request, org, course, name):
|
||||
|
||||
data_root = path(settings.GITHUB_REPO_ROOT)
|
||||
|
||||
course_dir = data_root / "{0}-{1}-{2}".format(org, course, name)
|
||||
course_subdir = "{0}-{1}-{2}".format(org, course, name)
|
||||
course_dir = data_root / course_subdir
|
||||
if not course_dir.isdir():
|
||||
os.mkdir(course_dir)
|
||||
|
||||
@@ -1036,18 +1076,8 @@ def import_course(request, org, course, name):
|
||||
for fname in os.listdir(r):
|
||||
shutil.move(r/fname, course_dir)
|
||||
|
||||
with open(course_dir / 'course.xml', 'r') as course_file:
|
||||
course_data = etree.parse(course_file, parser=edx_xml_parser)
|
||||
course_data_root = course_data.getroot()
|
||||
course_data_root.set('org', org)
|
||||
course_data_root.set('course', course)
|
||||
course_data_root.set('url_name', name)
|
||||
|
||||
with open(course_dir / 'course.xml', 'w') as course_file:
|
||||
course_data.write(course_file)
|
||||
|
||||
module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT,
|
||||
[course_dir], load_error_modules=False, static_content_store=contentstore())
|
||||
[course_subdir], load_error_modules=False, static_content_store=contentstore(), target_location_namespace = Location(location))
|
||||
|
||||
# we can blow this away when we're done importing.
|
||||
shutil.rmtree(course_dir)
|
||||
|
||||
@@ -35,6 +35,7 @@ MITX_FEATURES = {
|
||||
'ENABLE_DISCUSSION_SERVICE': False,
|
||||
'AUTH_USE_MIT_CERTIFICATES' : False,
|
||||
}
|
||||
ENABLE_JASMINE = False
|
||||
|
||||
# needed to use lms student app
|
||||
GENERATE_RANDOM_USER_CREDENTIALS = False
|
||||
@@ -68,9 +69,7 @@ MAKO_TEMPLATES['main'] = [
|
||||
for namespace, template_dirs in lms.envs.common.MAKO_TEMPLATES.iteritems():
|
||||
MAKO_TEMPLATES['lms.' + namespace] = template_dirs
|
||||
|
||||
TEMPLATE_DIRS = (
|
||||
PROJECT_ROOT / "templates",
|
||||
)
|
||||
TEMPLATE_DIRS = MAKO_TEMPLATES['main']
|
||||
|
||||
MITX_ROOT_URL = ''
|
||||
|
||||
@@ -88,10 +87,6 @@ TEMPLATE_CONTEXT_PROCESSORS = (
|
||||
|
||||
LMS_BASE = None
|
||||
|
||||
################################# Jasmine ###################################
|
||||
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
|
||||
|
||||
|
||||
#################### CAPA External Code Evaluation #############################
|
||||
XQUEUE_INTERFACE = {
|
||||
'url': 'http://localhost:8888',
|
||||
@@ -289,7 +284,4 @@ INSTALLED_APPS = (
|
||||
# For asset pipelining
|
||||
'pipeline',
|
||||
'staticfiles',
|
||||
|
||||
# For testing
|
||||
'django_jasmine',
|
||||
)
|
||||
|
||||
36
cms/envs/jasmine.py
Normal file
36
cms/envs/jasmine.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
This configuration is used for running jasmine tests
|
||||
"""
|
||||
|
||||
from .test import *
|
||||
from logsettings import get_logger_config
|
||||
|
||||
ENABLE_JASMINE = True
|
||||
DEBUG = True
|
||||
|
||||
LOGGING = get_logger_config(TEST_ROOT / "log",
|
||||
logging_env="dev",
|
||||
tracking_filename="tracking.log",
|
||||
dev_env=True,
|
||||
debug=True)
|
||||
|
||||
PIPELINE_JS['js-test-source'] = {
|
||||
'source_filenames': sum([
|
||||
pipeline_group['source_filenames']
|
||||
for group_name, pipeline_group
|
||||
in PIPELINE_JS.items()
|
||||
if group_name != 'spec'
|
||||
], []),
|
||||
'output_filename': 'js/cms-test-source.js'
|
||||
}
|
||||
|
||||
PIPELINE_JS['spec'] = {
|
||||
'source_filenames': sorted(rooted_glob(PROJECT_ROOT / 'static/', 'coffee/spec/**/*.coffee')),
|
||||
'output_filename': 'js/cms-spec.js'
|
||||
}
|
||||
|
||||
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
|
||||
|
||||
STATICFILES_DIRS.append(COMMON_ROOT / 'test' / 'phantom-jasmine' / 'lib')
|
||||
|
||||
INSTALLED_APPS += ('django_jasmine', )
|
||||
@@ -14,13 +14,14 @@ from path import path
|
||||
|
||||
# Nose Test Runner
|
||||
INSTALLED_APPS += ('django_nose',)
|
||||
NOSE_ARGS = ['--cover-erase', '--with-xunit', '--with-xcoverage', '--cover-html', '--cover-inclusive']
|
||||
for app in os.listdir(PROJECT_ROOT / 'djangoapps'):
|
||||
NOSE_ARGS += ['--cover-package', app]
|
||||
NOSE_ARGS = ['--with-xunit']
|
||||
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
|
||||
|
||||
TEST_ROOT = path('test_root')
|
||||
|
||||
# Makes the tests run much faster...
|
||||
SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
|
||||
|
||||
# Want static files in the same dir for running on jenkins.
|
||||
STATIC_ROOT = TEST_ROOT / "staticfiles"
|
||||
|
||||
@@ -98,3 +99,10 @@ CACHES = {
|
||||
'KEY_FUNCTION': 'util.memcache.safe_key',
|
||||
}
|
||||
}
|
||||
|
||||
################### Make tests faster
|
||||
#http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/
|
||||
PASSWORD_HASHERS = (
|
||||
'django.contrib.auth.hashers.SHA1PasswordHasher',
|
||||
'django.contrib.auth.hashers.MD5PasswordHasher',
|
||||
)
|
||||
@@ -8,72 +8,6 @@ describe "CMS", ->
|
||||
it "should initialize Views", ->
|
||||
expect(CMS.Views).toBeDefined()
|
||||
|
||||
describe "start", ->
|
||||
beforeEach ->
|
||||
@element = $("<div>")
|
||||
spyOn(CMS.Views, "Course").andReturn(jasmine.createSpyObj("Course", ["render"]))
|
||||
CMS.start(@element)
|
||||
|
||||
it "create the Course", ->
|
||||
expect(CMS.Views.Course).toHaveBeenCalledWith(el: @element)
|
||||
expect(CMS.Views.Course().render).toHaveBeenCalled()
|
||||
|
||||
describe "view stack", ->
|
||||
beforeEach ->
|
||||
@currentView = jasmine.createSpy("currentView")
|
||||
CMS.viewStack = [@currentView]
|
||||
|
||||
describe "replaceView", ->
|
||||
beforeEach ->
|
||||
@newView = jasmine.createSpy("newView")
|
||||
CMS.on("content.show", (@expectedView) =>)
|
||||
CMS.replaceView(@newView)
|
||||
|
||||
it "replace the views on the viewStack", ->
|
||||
expect(CMS.viewStack).toEqual([@newView])
|
||||
|
||||
it "trigger content.show on CMS", ->
|
||||
expect(@expectedView).toEqual(@newView)
|
||||
|
||||
describe "pushView", ->
|
||||
beforeEach ->
|
||||
@newView = jasmine.createSpy("newView")
|
||||
CMS.on("content.show", (@expectedView) =>)
|
||||
CMS.pushView(@newView)
|
||||
|
||||
it "push new view onto viewStack", ->
|
||||
expect(CMS.viewStack).toEqual([@currentView, @newView])
|
||||
|
||||
it "trigger content.show on CMS", ->
|
||||
expect(@expectedView).toEqual(@newView)
|
||||
|
||||
describe "popView", ->
|
||||
it "remove the current view from the viewStack", ->
|
||||
CMS.popView()
|
||||
expect(CMS.viewStack).toEqual([])
|
||||
|
||||
describe "when there's no view on the viewStack", ->
|
||||
beforeEach ->
|
||||
CMS.viewStack = [@currentView]
|
||||
CMS.on("content.hide", => @eventTriggered = true)
|
||||
CMS.popView()
|
||||
|
||||
it "trigger content.hide on CMS", ->
|
||||
expect(@eventTriggered).toBeTruthy
|
||||
|
||||
describe "when there's previous view on the viewStack", ->
|
||||
beforeEach ->
|
||||
@parentView = jasmine.createSpyObj("parentView", ["delegateEvents"])
|
||||
CMS.viewStack = [@parentView, @currentView]
|
||||
CMS.on("content.show", (@expectedView) =>)
|
||||
CMS.popView()
|
||||
|
||||
it "trigger content.show with the previous view on CMS", ->
|
||||
expect(@expectedView).toEqual @parentView
|
||||
|
||||
it "re-bind events on the view", ->
|
||||
expect(@parentView.delegateEvents).toHaveBeenCalled()
|
||||
|
||||
describe "main helper", ->
|
||||
beforeEach ->
|
||||
@previousAjaxSettings = $.extend(true, {}, $.ajaxSettings)
|
||||
|
||||
@@ -3,75 +3,4 @@ describe "CMS.Models.Module", ->
|
||||
expect(new CMS.Models.Module().url).toEqual("/save_item")
|
||||
|
||||
it "set the correct default", ->
|
||||
expect(new CMS.Models.Module().defaults).toEqual({data: ""})
|
||||
|
||||
describe "loadModule", ->
|
||||
describe "when the module exists", ->
|
||||
beforeEach ->
|
||||
@fakeModule = jasmine.createSpy("fakeModuleObject")
|
||||
window.FakeModule = jasmine.createSpy("FakeModule").andReturn(@fakeModule)
|
||||
@module = new CMS.Models.Module(type: "FakeModule")
|
||||
@stubDiv = $('<div />')
|
||||
@stubElement = $('<div class="xmodule_edit" />')
|
||||
@stubElement.data('type', "FakeModule")
|
||||
|
||||
@stubDiv.append(@stubElement)
|
||||
@module.loadModule(@stubDiv)
|
||||
|
||||
afterEach ->
|
||||
window.FakeModule = undefined
|
||||
|
||||
it "initialize the module", ->
|
||||
expect(window.FakeModule).toHaveBeenCalled()
|
||||
# Need to compare underlying nodes, because jquery selectors
|
||||
# aren't equal even when they point to the same node.
|
||||
# http://stackoverflow.com/questions/9505437/how-to-test-jquery-with-jasmine-for-element-id-if-used-as-this
|
||||
expectedNode = @stubElement[0]
|
||||
actualNode = window.FakeModule.mostRecentCall.args[0][0]
|
||||
|
||||
expect(actualNode).toEqual(expectedNode)
|
||||
expect(@module.module).toEqual(@fakeModule)
|
||||
|
||||
describe "when the module does not exists", ->
|
||||
beforeEach ->
|
||||
@previousConsole = window.console
|
||||
window.console = jasmine.createSpyObj("fakeConsole", ["error"])
|
||||
@module = new CMS.Models.Module(type: "HTML")
|
||||
@module.loadModule($("<div>"))
|
||||
|
||||
afterEach ->
|
||||
window.console = @previousConsole
|
||||
|
||||
it "print out error to log", ->
|
||||
expect(window.console.error).toHaveBeenCalled()
|
||||
expect(window.console.error.mostRecentCall.args[0]).toMatch("^Unable to load")
|
||||
|
||||
|
||||
describe "editUrl", ->
|
||||
it "construct the correct URL based on id", ->
|
||||
expect(new CMS.Models.Module(id: "i4x://mit.edu/module/html_123").editUrl())
|
||||
.toEqual("/edit_item?id=i4x%3A%2F%2Fmit.edu%2Fmodule%2Fhtml_123")
|
||||
|
||||
describe "save", ->
|
||||
beforeEach ->
|
||||
spyOn(Backbone.Model.prototype, "save")
|
||||
@module = new CMS.Models.Module()
|
||||
|
||||
describe "when the module exists", ->
|
||||
beforeEach ->
|
||||
@module.module = jasmine.createSpyObj("FakeModule", ["save"])
|
||||
@module.module.save.andReturn("module data")
|
||||
@module.save()
|
||||
|
||||
it "set the data and call save on the module", ->
|
||||
expect(@module.get("data")).toEqual("\"module data\"")
|
||||
|
||||
it "call save on the backbone model", ->
|
||||
expect(Backbone.Model.prototype.save).toHaveBeenCalled()
|
||||
|
||||
describe "when the module does not exists", ->
|
||||
beforeEach ->
|
||||
@module.save()
|
||||
|
||||
it "call save on the backbone model", ->
|
||||
expect(Backbone.Model.prototype.save).toHaveBeenCalled()
|
||||
expect(new CMS.Models.Module().defaults).toEqual(undefined)
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
describe "CMS.Views.Course", ->
|
||||
beforeEach ->
|
||||
setFixtures """
|
||||
<section id="main-section">
|
||||
<section class="main-content"></section>
|
||||
<ol id="weeks">
|
||||
<li class="cal week-one" style="height: 50px"></li>
|
||||
<li class="cal week-two" style="height: 100px"></li>
|
||||
</ol>
|
||||
</section>
|
||||
"""
|
||||
CMS.unbind()
|
||||
|
||||
describe "render", ->
|
||||
beforeEach ->
|
||||
spyOn(CMS.Views, "Week").andReturn(jasmine.createSpyObj("Week", ["render"]))
|
||||
new CMS.Views.Course(el: $("#main-section")).render()
|
||||
|
||||
it "create week view for each week",->
|
||||
expect(CMS.Views.Week.calls[0].args[0])
|
||||
.toEqual({ el: $(".week-one").get(0), height: 101 })
|
||||
expect(CMS.Views.Week.calls[1].args[0])
|
||||
.toEqual({ el: $(".week-two").get(0), height: 101 })
|
||||
|
||||
describe "on content.show", ->
|
||||
beforeEach ->
|
||||
@view = new CMS.Views.Course(el: $("#main-section"))
|
||||
@subView = jasmine.createSpyObj("subView", ["render"])
|
||||
@subView.render.andReturn(el: "Subview Content")
|
||||
spyOn(@view, "contentHeight").andReturn(100)
|
||||
CMS.trigger("content.show", @subView)
|
||||
|
||||
afterEach ->
|
||||
$("body").removeClass("content")
|
||||
|
||||
it "add content class to body", ->
|
||||
expect($("body").attr("class")).toEqual("content")
|
||||
|
||||
it "replace content in .main-content", ->
|
||||
expect($(".main-content")).toHaveHtml("Subview Content")
|
||||
|
||||
it "set height on calendar", ->
|
||||
expect($(".cal")).toHaveCss(height: "100px")
|
||||
|
||||
it "set minimum height on all sections", ->
|
||||
expect($("#main-section>section")).toHaveCss(minHeight: "100px")
|
||||
|
||||
describe "on content.hide", ->
|
||||
beforeEach ->
|
||||
$("body").addClass("content")
|
||||
@view = new CMS.Views.Course(el: $("#main-section"))
|
||||
$(".cal").css(height: 100)
|
||||
$("#main-section>section").css(minHeight: 100)
|
||||
CMS.trigger("content.hide")
|
||||
|
||||
afterEach ->
|
||||
$("body").removeClass("content")
|
||||
|
||||
it "remove content class from body", ->
|
||||
expect($("body").attr("class")).toEqual("")
|
||||
|
||||
it "remove content from .main-content", ->
|
||||
expect($(".main-content")).toHaveHtml("")
|
||||
|
||||
it "reset height on calendar", ->
|
||||
expect($(".cal")).not.toHaveCss(height: "100px")
|
||||
|
||||
it "reset minimum height on all sections", ->
|
||||
expect($("#main-section>section")).not.toHaveCss(minHeight: "100px")
|
||||
|
||||
describe "maxWeekHeight", ->
|
||||
it "return maximum height of the week element", ->
|
||||
@view = new CMS.Views.Course(el: $("#main-section"))
|
||||
expect(@view.maxWeekHeight()).toEqual(101)
|
||||
|
||||
describe "contentHeight", ->
|
||||
beforeEach ->
|
||||
$("body").append($('<header id="test">').height(100).hide())
|
||||
|
||||
afterEach ->
|
||||
$("body>header#test").remove()
|
||||
|
||||
it "return the window height minus the header bar", ->
|
||||
@view = new CMS.Views.Course(el: $("#main-section"))
|
||||
expect(@view.contentHeight()).toEqual($(window).height() - 100)
|
||||
@@ -1,81 +1,74 @@
|
||||
describe "CMS.Views.ModuleEdit", ->
|
||||
beforeEach ->
|
||||
@stubModule = jasmine.createSpyObj("Module", ["editUrl", "loadModule"])
|
||||
spyOn($.fn, "load")
|
||||
@stubModule = jasmine.createSpy("CMS.Models.Module")
|
||||
@stubModule.id = 'stub-id'
|
||||
|
||||
|
||||
setFixtures """
|
||||
<div id="module-edit">
|
||||
<a href="#" class="save-update">save</a>
|
||||
<a href="#" class="cancel">cancel</a>
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#" class="module-edit" data-id="i4x://mitx/course/html/module" data-type="html">submodule</a>
|
||||
</li>
|
||||
</ol>
|
||||
<li class="component" id="stub-id">
|
||||
<div class="component-editor">
|
||||
<div class="module-editor">
|
||||
${editor}
|
||||
</div>
|
||||
<a href="#" class="save-button">Save</a>
|
||||
<a href="#" class="cancel-button">Cancel</a>
|
||||
</div>
|
||||
""" #"
|
||||
<div class="component-actions">
|
||||
<a href="#" class="edit-button"><span class="edit-icon white"></span>Edit</a>
|
||||
<a href="#" class="delete-button"><span class="delete-icon white"></span>Delete</a>
|
||||
</div>
|
||||
<a href="#" class="drag-handle"></a>
|
||||
<section class="xmodule_display xmodule_stub" data-type="StubModule">
|
||||
<div id="stub-module-content"/>
|
||||
</section>
|
||||
</li>
|
||||
"""
|
||||
spyOn($.fn, 'load').andReturn(@moduleData)
|
||||
|
||||
@moduleEdit = new CMS.Views.ModuleEdit(
|
||||
el: $(".component")
|
||||
model: @stubModule
|
||||
onDelete: jasmine.createSpy()
|
||||
)
|
||||
CMS.unbind()
|
||||
|
||||
describe "defaults", ->
|
||||
it "set the correct tagName", ->
|
||||
expect(new CMS.Views.ModuleEdit(model: @stubModule).tagName).toEqual("section")
|
||||
describe "class definition", ->
|
||||
it "sets the correct tagName", ->
|
||||
expect(@moduleEdit.tagName).toEqual("li")
|
||||
|
||||
it "set the correct className", ->
|
||||
expect(new CMS.Views.ModuleEdit(model: @stubModule).className).toEqual("edit-pane")
|
||||
it "sets the correct className", ->
|
||||
expect(@moduleEdit.className).toEqual("component")
|
||||
|
||||
describe "view creation", ->
|
||||
beforeEach ->
|
||||
@stubModule.editUrl.andReturn("/edit_item?id=stub_module")
|
||||
new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule)
|
||||
describe "methods", ->
|
||||
describe "initialize", ->
|
||||
beforeEach ->
|
||||
spyOn(CMS.Views.ModuleEdit.prototype, 'render')
|
||||
@moduleEdit = new CMS.Views.ModuleEdit(
|
||||
el: $(".component")
|
||||
model: @stubModule
|
||||
onDelete: jasmine.createSpy()
|
||||
)
|
||||
|
||||
it "load the edit via ajax and pass to the model", ->
|
||||
expect($.fn.load).toHaveBeenCalledWith("/edit_item?id=stub_module", jasmine.any(Function))
|
||||
if $.fn.load.mostRecentCall
|
||||
$.fn.load.mostRecentCall.args[1]()
|
||||
expect(@stubModule.loadModule).toHaveBeenCalledWith($("#module-edit").get(0))
|
||||
it "renders the module editor", ->
|
||||
expect(@moduleEdit.render).toHaveBeenCalled()
|
||||
|
||||
describe "save", ->
|
||||
beforeEach ->
|
||||
@stubJqXHR = jasmine.createSpy("stubJqXHR")
|
||||
@stubJqXHR.success = jasmine.createSpy("stubJqXHR.success").andReturn(@stubJqXHR)
|
||||
@stubJqXHR.error = jasmine.createSpy("stubJqXHR.error").andReturn(@stubJqXHR)
|
||||
@stubModule.save = jasmine.createSpy("stubModule.save").andReturn(@stubJqXHR)
|
||||
new CMS.Views.ModuleEdit(el: $(".module-edit"), model: @stubModule)
|
||||
spyOn(window, "alert")
|
||||
$(".save-update").click()
|
||||
describe "render", ->
|
||||
beforeEach ->
|
||||
spyOn(@moduleEdit, 'loadDisplay')
|
||||
spyOn(@moduleEdit, 'delegateEvents')
|
||||
@moduleEdit.render()
|
||||
|
||||
it "call save on the model", ->
|
||||
expect(@stubModule.save).toHaveBeenCalled()
|
||||
it "loads the module preview and editor via ajax on the view element", ->
|
||||
expect(@moduleEdit.$el.load).toHaveBeenCalledWith("/preview_component/#{@moduleEdit.model.id}", jasmine.any(Function))
|
||||
@moduleEdit.$el.load.mostRecentCall.args[1]()
|
||||
expect(@moduleEdit.loadDisplay).toHaveBeenCalled()
|
||||
expect(@moduleEdit.delegateEvents).toHaveBeenCalled()
|
||||
|
||||
it "alert user on success", ->
|
||||
@stubJqXHR.success.mostRecentCall.args[0]()
|
||||
expect(window.alert).toHaveBeenCalledWith("Your changes have been saved.")
|
||||
describe "loadDisplay", ->
|
||||
beforeEach ->
|
||||
spyOn(XModule, 'loadModule')
|
||||
@moduleEdit.loadDisplay()
|
||||
|
||||
it "alert user on error", ->
|
||||
@stubJqXHR.error.mostRecentCall.args[0]()
|
||||
expect(window.alert).toHaveBeenCalledWith("There was an error saving your changes. Please try again.")
|
||||
|
||||
describe "cancel", ->
|
||||
beforeEach ->
|
||||
spyOn(CMS, "popView")
|
||||
@view = new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule)
|
||||
$(".cancel").click()
|
||||
|
||||
it "pop current view from viewStack", ->
|
||||
expect(CMS.popView).toHaveBeenCalled()
|
||||
|
||||
describe "editSubmodule", ->
|
||||
beforeEach ->
|
||||
@view = new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule)
|
||||
spyOn(CMS, "pushView")
|
||||
spyOn(CMS.Views, "ModuleEdit")
|
||||
.andReturn(@view = jasmine.createSpy("Views.ModuleEdit"))
|
||||
spyOn(CMS.Models, "Module")
|
||||
.andReturn(@model = jasmine.createSpy("Models.Module"))
|
||||
$(".module-edit").click()
|
||||
|
||||
it "push another module editing view into viewStack", ->
|
||||
expect(CMS.pushView).toHaveBeenCalledWith @view
|
||||
expect(CMS.Views.ModuleEdit).toHaveBeenCalledWith model: @model
|
||||
expect(CMS.Models.Module).toHaveBeenCalledWith
|
||||
id: "i4x://mitx/course/html/module"
|
||||
type: "html"
|
||||
it "loads the .xmodule-display inside the module editor", ->
|
||||
expect(XModule.loadModule).toHaveBeenCalled()
|
||||
expect(XModule.loadModule.mostRecentCall.args[0]).toBe($('.xmodule_display'))
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
describe "CMS.Views.Module", ->
|
||||
beforeEach ->
|
||||
setFixtures """
|
||||
<div id="module" data-id="i4x://mitx/course/html/module" data-type="html">
|
||||
<a href="#" class="module-edit">edit</a>
|
||||
</div>
|
||||
"""
|
||||
|
||||
describe "edit", ->
|
||||
beforeEach ->
|
||||
@view = new CMS.Views.Module(el: $("#module"))
|
||||
spyOn(CMS, "replaceView")
|
||||
spyOn(CMS.Views, "ModuleEdit")
|
||||
.andReturn(@view = jasmine.createSpy("Views.ModuleEdit"))
|
||||
spyOn(CMS.Models, "Module")
|
||||
.andReturn(@model = jasmine.createSpy("Models.Module"))
|
||||
$(".module-edit").click()
|
||||
|
||||
it "replace the main view with ModuleEdit view", ->
|
||||
expect(CMS.replaceView).toHaveBeenCalledWith @view
|
||||
expect(CMS.Views.ModuleEdit).toHaveBeenCalledWith model: @model
|
||||
expect(CMS.Models.Module).toHaveBeenCalledWith
|
||||
id: "i4x://mitx/course/html/module"
|
||||
type: "html"
|
||||
@@ -1,7 +0,0 @@
|
||||
describe "CMS.Views.WeekEdit", ->
|
||||
describe "defaults", ->
|
||||
it "set the correct tagName", ->
|
||||
expect(new CMS.Views.WeekEdit().tagName).toEqual("section")
|
||||
|
||||
it "set the correct className", ->
|
||||
expect(new CMS.Views.WeekEdit().className).toEqual("edit-pane")
|
||||
@@ -1,67 +0,0 @@
|
||||
describe "CMS.Views.Week", ->
|
||||
beforeEach ->
|
||||
setFixtures """
|
||||
<div id="week" data-id="i4x://mitx/course/chapter/week">
|
||||
<div class="editable"></div>
|
||||
<textarea class="editable-textarea"></textarea>
|
||||
<a href="#" class="week-edit" >edit</a>
|
||||
<ul class="modules">
|
||||
<li id="module-one" class="module"></li>
|
||||
<li id="module-two" class="module"></li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
CMS.unbind()
|
||||
|
||||
describe "render", ->
|
||||
beforeEach ->
|
||||
spyOn(CMS.Views, "Module").andReturn(jasmine.createSpyObj("Module", ["render"]))
|
||||
$.fn.inlineEdit = jasmine.createSpy("$.fn.inlineEdit")
|
||||
@view = new CMS.Views.Week(el: $("#week"), height: 100).render()
|
||||
|
||||
it "set the height of the element", ->
|
||||
expect(@view.el).toHaveCss(height: "100px")
|
||||
|
||||
it "make .editable as inline editor", ->
|
||||
expect($.fn.inlineEdit.calls[0].object.get(0))
|
||||
.toEqual($(".editable").get(0))
|
||||
|
||||
it "make .editable-test as inline editor", ->
|
||||
expect($.fn.inlineEdit.calls[1].object.get(0))
|
||||
.toEqual($(".editable-textarea").get(0))
|
||||
|
||||
it "create module subview for each module", ->
|
||||
expect(CMS.Views.Module.calls[0].args[0])
|
||||
.toEqual({ el: $("#module-one").get(0) })
|
||||
expect(CMS.Views.Module.calls[1].args[0])
|
||||
.toEqual({ el: $("#module-two").get(0) })
|
||||
|
||||
describe "edit", ->
|
||||
beforeEach ->
|
||||
new CMS.Views.Week(el: $("#week"), height: 100).render()
|
||||
spyOn(CMS, "replaceView")
|
||||
spyOn(CMS.Views, "WeekEdit")
|
||||
.andReturn(@view = jasmine.createSpy("Views.WeekEdit"))
|
||||
$(".week-edit").click()
|
||||
|
||||
it "replace the content with edit week view", ->
|
||||
expect(CMS.replaceView).toHaveBeenCalledWith @view
|
||||
expect(CMS.Views.WeekEdit).toHaveBeenCalled()
|
||||
|
||||
describe "on content.show", ->
|
||||
beforeEach ->
|
||||
@view = new CMS.Views.Week(el: $("#week"), height: 100).render()
|
||||
@view.$el.height("")
|
||||
@view.setHeight()
|
||||
|
||||
it "set the correct height", ->
|
||||
expect(@view.el).toHaveCss(height: "100px")
|
||||
|
||||
describe "on content.hide", ->
|
||||
beforeEach ->
|
||||
@view = new CMS.Views.Week(el: $("#week"), height: 100).render()
|
||||
@view.$el.height("100px")
|
||||
@view.resetHeight()
|
||||
|
||||
it "remove height from the element", ->
|
||||
expect(@view.el).not.toHaveCss(height: "100px")
|
||||
@@ -6,28 +6,6 @@ AjaxPrefix.addAjaxPrefix(jQuery, -> CMS.prefix)
|
||||
|
||||
prefix: $("meta[name='path_prefix']").attr('content')
|
||||
|
||||
viewStack: []
|
||||
|
||||
start: (el) ->
|
||||
new CMS.Views.Course(el: el).render()
|
||||
|
||||
replaceView: (view) ->
|
||||
@viewStack = [view]
|
||||
CMS.trigger('content.show', view)
|
||||
|
||||
pushView: (view) ->
|
||||
@viewStack.push(view)
|
||||
CMS.trigger('content.show', view)
|
||||
|
||||
popView: ->
|
||||
@viewStack.pop()
|
||||
if _.isEmpty(@viewStack)
|
||||
CMS.trigger('content.hide')
|
||||
else
|
||||
view = _.last(@viewStack)
|
||||
CMS.trigger('content.show', view)
|
||||
view.delegateEvents()
|
||||
|
||||
_.extend CMS, Backbone.Events
|
||||
|
||||
$ ->
|
||||
@@ -41,7 +19,3 @@ $ ->
|
||||
navigator.userAgent.match /iPhone|iPod|iPad/i
|
||||
|
||||
$('body').addClass 'touch-based-device' if onTouchBasedDevice()
|
||||
|
||||
|
||||
CMS.start($('section.main-container'))
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class CMS.Models.NewModule extends Backbone.Model
|
||||
url: '/clone_item'
|
||||
|
||||
newUrl: ->
|
||||
"/new_item?#{$.param(parent_location: @get('parent_location'))}"
|
||||
@@ -1,28 +0,0 @@
|
||||
class CMS.Views.Course extends Backbone.View
|
||||
initialize: ->
|
||||
CMS.on('content.show', @showContent)
|
||||
CMS.on('content.hide', @hideContent)
|
||||
|
||||
render: ->
|
||||
@$('#weeks > li').each (index, week) =>
|
||||
new CMS.Views.Week(el: week, height: @maxWeekHeight()).render()
|
||||
return @
|
||||
|
||||
showContent: (subview) =>
|
||||
$('body').addClass('content')
|
||||
@$('.main-content').html(subview.render().el)
|
||||
@$('.cal').css height: @contentHeight()
|
||||
@$('>section').css minHeight: @contentHeight()
|
||||
|
||||
hideContent: =>
|
||||
$('body').removeClass('content')
|
||||
@$('.main-content').empty()
|
||||
@$('.cal').css height: ''
|
||||
@$('>section').css minHeight: ''
|
||||
|
||||
maxWeekHeight: ->
|
||||
weekElementBorderSize = 1
|
||||
_.max($('#weeks > li').map -> $(this).height()) + weekElementBorderSize
|
||||
|
||||
contentHeight: ->
|
||||
$(window).height() - $('body>header').outerHeight()
|
||||
@@ -1,14 +0,0 @@
|
||||
class CMS.Views.Module extends Backbone.View
|
||||
events:
|
||||
"click .module-edit": "edit"
|
||||
|
||||
edit: (event) =>
|
||||
event.preventDefault()
|
||||
previewType = @$el.data('preview-type')
|
||||
moduleType = @$el.data('type')
|
||||
CMS.replaceView new CMS.Views.ModuleEdit
|
||||
model: new CMS.Models.Module
|
||||
id: @$el.data('id')
|
||||
type: if moduleType == 'None' then null else moduleType
|
||||
previewType: if previewType == 'None' then null else previewType
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
class CMS.Views.ModuleAdd extends Backbone.View
|
||||
tagName: 'section'
|
||||
className: 'add-pane'
|
||||
|
||||
events:
|
||||
'click .cancel': 'cancel'
|
||||
'click .save': 'save'
|
||||
|
||||
initialize: ->
|
||||
@$el.load @model.newUrl()
|
||||
|
||||
save: (event) ->
|
||||
event.preventDefault()
|
||||
@model.save({
|
||||
name: @$el.find('.name').val()
|
||||
template: $(event.target).data('template-id')
|
||||
}, {
|
||||
success: -> CMS.popView()
|
||||
error: -> alert('Create failed')
|
||||
})
|
||||
|
||||
cancel: (event) ->
|
||||
event.preventDefault()
|
||||
CMS.popView()
|
||||
|
||||
|
||||
54
cms/static/coffee/src/views/tabs.coffee
Normal file
54
cms/static/coffee/src/views/tabs.coffee
Normal file
@@ -0,0 +1,54 @@
|
||||
class CMS.Views.TabsEdit extends Backbone.View
|
||||
events:
|
||||
'click .new-tab': 'addNewTab'
|
||||
|
||||
initialize: =>
|
||||
@$('.component').each((idx, element) =>
|
||||
new CMS.Views.ModuleEdit(
|
||||
el: element,
|
||||
onDelete: @deleteTab,
|
||||
model: new CMS.Models.Module(
|
||||
id: $(element).data('id'),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@$('.components').sortable(
|
||||
handle: '.drag-handle'
|
||||
update: (event, ui) => alert 'not yet implemented!'
|
||||
helper: 'clone'
|
||||
opacity: '0.5'
|
||||
placeholder: 'component-placeholder'
|
||||
forcePlaceholderSize: true
|
||||
axis: 'y'
|
||||
items: '> .component'
|
||||
)
|
||||
|
||||
addNewTab: (event) =>
|
||||
event.preventDefault()
|
||||
|
||||
editor = new CMS.Views.ModuleEdit(
|
||||
onDelete: @deleteTab
|
||||
model: new CMS.Models.Module()
|
||||
)
|
||||
|
||||
$('.new-component-item').before(editor.$el)
|
||||
|
||||
editor.cloneTemplate(
|
||||
@model.get('id'),
|
||||
'i4x://edx/templates/static_tab/Empty'
|
||||
)
|
||||
|
||||
deleteTab: (event) =>
|
||||
if not confirm 'Are you sure you want to delete this component? This action cannot be undone.'
|
||||
return
|
||||
$component = $(event.currentTarget).parents('.component')
|
||||
$.post('/delete_item', {
|
||||
id: $component.data('id')
|
||||
}, =>
|
||||
$component.remove()
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -169,12 +169,21 @@ class CMS.Views.UnitEdit.NameEdit extends Backbone.View
|
||||
|
||||
initialize: =>
|
||||
@model.on('change:metadata', @render)
|
||||
@model.on('change:state', @setEnabled)
|
||||
@setEnabled()
|
||||
@saveName
|
||||
@$spinner = $('<span class="spinner-in-field-icon"></span>');
|
||||
|
||||
render: =>
|
||||
@$('.unit-display-name-input').val(@model.get('metadata').display_name)
|
||||
|
||||
setEnabled: =>
|
||||
disabled = @model.get('state') == 'public'
|
||||
if disabled
|
||||
@$('.unit-display-name-input').attr('disabled', true)
|
||||
else
|
||||
@$('.unit-display-name-input').removeAttr('disabled')
|
||||
|
||||
saveName: =>
|
||||
# Treat the metadata dictionary as immutable
|
||||
metadata = $.extend({}, @model.get('metadata'))
|
||||
@@ -191,6 +200,7 @@ class CMS.Views.UnitEdit.NameEdit extends Backbone.View
|
||||
'margin-top': '-10px'
|
||||
});
|
||||
inputField.after(@$spinner);
|
||||
@$spinner.fadeIn(10)
|
||||
|
||||
# save the name after a slight delay
|
||||
if @timer
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
class CMS.Views.Week extends Backbone.View
|
||||
events:
|
||||
'click .week-edit': 'edit'
|
||||
'click .new-module': 'new'
|
||||
|
||||
initialize: ->
|
||||
CMS.on('content.show', @resetHeight)
|
||||
CMS.on('content.hide', @setHeight)
|
||||
|
||||
render: ->
|
||||
@setHeight()
|
||||
@$('.editable').inlineEdit()
|
||||
@$('.editable-textarea').inlineEdit(control: 'textarea')
|
||||
@$('.modules .module').each ->
|
||||
new CMS.Views.Module(el: this).render()
|
||||
return @
|
||||
|
||||
edit: (event) ->
|
||||
event.preventDefault()
|
||||
CMS.replaceView(new CMS.Views.WeekEdit())
|
||||
|
||||
setHeight: =>
|
||||
@$el.height(@options.height)
|
||||
|
||||
resetHeight: =>
|
||||
@$el.height('')
|
||||
|
||||
new: (event) =>
|
||||
event.preventDefault()
|
||||
CMS.replaceView new CMS.Views.ModuleAdd
|
||||
model: new CMS.Models.NewModule
|
||||
parent_location: @$el.data('id')
|
||||
@@ -1,3 +0,0 @@
|
||||
class CMS.Views.WeekEdit extends Backbone.View
|
||||
tagName: 'section'
|
||||
className: 'edit-pane'
|
||||
@@ -28,7 +28,7 @@
|
||||
{{uploadDate}}
|
||||
</td>
|
||||
<td class="embed-col">
|
||||
<input type="text" class="embeddable-xml-input" value='<img src="{{url}}"/>'>
|
||||
<input type="text" class="embeddable-xml-input" value='{{url}}'>
|
||||
</td>
|
||||
</tr>
|
||||
</script>
|
||||
@@ -47,7 +47,7 @@
|
||||
<th class="thumb-col"></th>
|
||||
<th class="name-col">Name</th>
|
||||
<th class="date-col">Date Added</th>
|
||||
<th class="embed-col">Embed</th>
|
||||
<th class="embed-col">URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="asset_table_body">
|
||||
@@ -68,7 +68,7 @@
|
||||
${asset['uploadDate']}
|
||||
</td>
|
||||
<td class="embed-col">
|
||||
<input type="text" class="embeddable-xml-input" value='<img src="${asset['url']}"/>'>
|
||||
<input type="text" class="embeddable-xml-input" value='${asset['url']}'>
|
||||
</td>
|
||||
</tr>
|
||||
% endfor
|
||||
|
||||
42
cms/templates/edit-tabs.html
Normal file
42
cms/templates/edit-tabs.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<%inherit file="base.html" />
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%block name="title">Tabs</%block>
|
||||
<%block name="bodyclass">static-pages</%block>
|
||||
|
||||
<%block name="jsextra">
|
||||
<script type='text/javascript'>
|
||||
new CMS.Views.TabsEdit({
|
||||
el: $('.main-wrapper'),
|
||||
model: new CMS.Models.Module({
|
||||
id: '${context_course.location}'
|
||||
})
|
||||
});
|
||||
</script>
|
||||
</%block>
|
||||
|
||||
<%block name="content">
|
||||
<div class="main-wrapper">
|
||||
<div class="inner-wrapper">
|
||||
<div>
|
||||
<h1>Static Tabs</h1>
|
||||
</div>
|
||||
<div class="main-column">
|
||||
<article class="unit-body window">
|
||||
<div class="tab-list">
|
||||
<ol class='components'>
|
||||
% for id in components:
|
||||
<li class="component" data-id="${id}"/>
|
||||
% endfor
|
||||
|
||||
<li class="new-component-item">
|
||||
<a href="#" class="new-component-button new-tab">
|
||||
<span class="plus-icon"></span>New Tab
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</%block>
|
||||
@@ -61,7 +61,4 @@
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</%block>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<a href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" class="class-name">${context_course.display_name}</a>
|
||||
<ul class="class-nav">
|
||||
<li><a href="${reverse('course_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='courseware-tab'>Courseware</a></li>
|
||||
<li><a href="${reverse('static_pages', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}" id='pages-tab' style="display:none">Pages</a></li>
|
||||
<li><a href="${reverse('edit_tabs', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, coursename=ctx_loc.name))}" id='pages-tab'>Tabs</a></li>
|
||||
<li><a href="${reverse('asset_index', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='assets-tab'>Assets</a></li>
|
||||
<li><a href="${reverse('manage_users', kwargs=dict(location=ctx_loc))}" id='users-tab'>Users</a></li>
|
||||
<li><a href="${reverse('import_course', kwargs=dict(org=ctx_loc.org, course=ctx_loc.course, name=ctx_loc.name))}" id='import-tab'>Import</a></li>
|
||||
@@ -24,6 +24,6 @@
|
||||
% else:
|
||||
<a href="${reverse('login')}">Log in</a>
|
||||
% endif
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -36,6 +36,7 @@ urlpatterns = ('',
|
||||
'contentstore.views.remove_user', name='remove_user'),
|
||||
url(r'^pages/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.static_pages', name='static_pages'),
|
||||
url(r'^edit_static/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.edit_static', name='edit_static'),
|
||||
url(r'^edit_tabs/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.edit_tabs', name='edit_tabs'),
|
||||
url(r'^(?P<org>[^/]+)/(?P<course>[^/]+)/assets/(?P<name>[^/]+)$', 'contentstore.views.asset_index', name='asset_index'),
|
||||
url(r'^settings/(?P<org>[^/]+)/(?P<course>[^/]+)/course/(?P<coursename>[^/]+)$', 'contentstore.views.settings', name='settings'),
|
||||
|
||||
@@ -69,7 +70,7 @@ urlpatterns += (
|
||||
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
if settings.ENABLE_JASMINE:
|
||||
## Jasmine
|
||||
urlpatterns = urlpatterns + (url(r'^_jasmine/', include('django_jasmine.urls')),)
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ def render_to_string(template_name, dictionary, context=None, namespace='main'):
|
||||
context_dictionary.update(context)
|
||||
# fetch and render template
|
||||
template = middleware.lookup[namespace].get_template(template_name)
|
||||
return template.render(**context_dictionary)
|
||||
return template.render_unicode(**context_dictionary)
|
||||
|
||||
|
||||
def render_to_response(template_name, dictionary, context_instance=None, namespace='main', **kwargs):
|
||||
|
||||
@@ -3,8 +3,7 @@ from staticfiles.storage import staticfiles_storage
|
||||
from pipeline_mako import compressed_css, compressed_js
|
||||
%>
|
||||
|
||||
<%def name='url(file)'>
|
||||
<%
|
||||
<%def name='url(file)'><%
|
||||
try:
|
||||
url = staticfiles_storage.url(file)
|
||||
except:
|
||||
|
||||
@@ -5,6 +5,10 @@ from staticfiles.storage import staticfiles_storage
|
||||
from staticfiles import finders
|
||||
from django.conf import settings
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def try_staticfiles_lookup(path):
|
||||
@@ -22,7 +26,7 @@ def try_staticfiles_lookup(path):
|
||||
return url
|
||||
|
||||
|
||||
def replace(static_url, prefix=None):
|
||||
def replace(static_url, prefix=None, course_namespace=None):
|
||||
if prefix is None:
|
||||
prefix = ''
|
||||
else:
|
||||
@@ -41,13 +45,23 @@ def replace(static_url, prefix=None):
|
||||
return static_url.group(0)
|
||||
else:
|
||||
# don't error if file can't be found
|
||||
url = try_staticfiles_lookup(prefix + static_url.group('rest'))
|
||||
return "".join([quote, url, quote])
|
||||
# cdodge: to support the change over to Mongo backed content stores, lets
|
||||
# use the utility functions in StaticContent.py
|
||||
if static_url.group('prefix') == '/static/' and not isinstance(modulestore(), XMLModuleStore):
|
||||
if course_namespace is None:
|
||||
raise BaseException('You must pass in course_namespace when remapping static content urls with MongoDB stores')
|
||||
url = StaticContent.convert_legacy_static_url(static_url.group('rest'), course_namespace)
|
||||
else:
|
||||
url = try_staticfiles_lookup(prefix + static_url.group('rest'))
|
||||
|
||||
new_link = "".join([quote, url, quote])
|
||||
return new_link
|
||||
|
||||
|
||||
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/'):
|
||||
|
||||
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None):
|
||||
def replace_url(static_url):
|
||||
return replace(static_url, staticfiles_prefix)
|
||||
return replace(static_url, staticfiles_prefix, course_namespace = course_namespace)
|
||||
|
||||
return re.sub(r"""
|
||||
(?x) # flags=re.VERBOSE
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from optparse import make_option
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.contrib.auth.models import User, Group
|
||||
|
||||
class Command(BaseCommand):
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--list',
|
||||
action='store_true',
|
||||
dest='list',
|
||||
default=False,
|
||||
help='List available groups'),
|
||||
make_option('--create',
|
||||
action='store_true',
|
||||
dest='create',
|
||||
default=False,
|
||||
help='Create the group if it does not exist'),
|
||||
make_option('--remove',
|
||||
action='store_true',
|
||||
dest='remove',
|
||||
default=False,
|
||||
help='Remove the user from the group instead of adding it'),
|
||||
)
|
||||
|
||||
args = '<user|email> <group>'
|
||||
help = 'Add a user to a group'
|
||||
|
||||
def print_groups(self):
|
||||
print 'Groups available:'
|
||||
for group in Group.objects.all().distinct():
|
||||
print ' ', group.name
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options['list']:
|
||||
self.print_groups()
|
||||
return
|
||||
|
||||
if len(args) != 2:
|
||||
raise CommandError('Usage is add_to_group {0}'.format(self.args))
|
||||
|
||||
name_or_email, group_name = args
|
||||
|
||||
if '@' in name_or_email:
|
||||
user = User.objects.get(email=name_or_email)
|
||||
else:
|
||||
user = User.objects.get(username=name_or_email)
|
||||
|
||||
try:
|
||||
group = Group.objects.get(name=group_name)
|
||||
except Group.DoesNotExist:
|
||||
if options['create']:
|
||||
group = Group(name=group_name)
|
||||
group.save()
|
||||
else:
|
||||
raise CommandError('Group {} does not exist'.format(group_name))
|
||||
|
||||
if options['remove']:
|
||||
user.groups.remove(group)
|
||||
else:
|
||||
user.groups.add(group)
|
||||
|
||||
print 'Success!'
|
||||
@@ -0,0 +1,156 @@
|
||||
import csv
|
||||
import uuid
|
||||
from collections import defaultdict, OrderedDict
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterUser
|
||||
|
||||
class Command(BaseCommand):
|
||||
CSV_TO_MODEL_FIELDS = OrderedDict([
|
||||
("ClientCandidateID", "client_candidate_id"),
|
||||
("FirstName", "first_name"),
|
||||
("LastName", "last_name"),
|
||||
("MiddleName", "middle_name"),
|
||||
("Suffix", "suffix"),
|
||||
("Salutation", "salutation"),
|
||||
("Email", "email"),
|
||||
# Skipping optional fields Username and Password
|
||||
("Address1", "address_1"),
|
||||
("Address2", "address_2"),
|
||||
("Address3", "address_3"),
|
||||
("City", "city"),
|
||||
("State", "state"),
|
||||
("PostalCode", "postal_code"),
|
||||
("Country", "country"),
|
||||
("Phone", "phone"),
|
||||
("Extension", "extension"),
|
||||
("PhoneCountryCode", "phone_country_code"),
|
||||
("FAX", "fax"),
|
||||
("FAXCountryCode", "fax_country_code"),
|
||||
("CompanyName", "company_name"),
|
||||
# Skipping optional field CustomQuestion
|
||||
("LastUpdate", "user_updated_at"), # in UTC, so same as what we store
|
||||
])
|
||||
|
||||
args = '<output_file>'
|
||||
help = """
|
||||
Export user information from TestCenterUser model into a tab delimited
|
||||
text file with a format that Pearson expects.
|
||||
"""
|
||||
def handle(self, *args, **kwargs):
|
||||
if len(args) < 1:
|
||||
print Command.help
|
||||
return
|
||||
|
||||
self.reset_sample_data()
|
||||
|
||||
with open(args[0], "wb") as outfile:
|
||||
writer = csv.DictWriter(outfile,
|
||||
Command.CSV_TO_MODEL_FIELDS,
|
||||
delimiter="\t",
|
||||
quoting=csv.QUOTE_MINIMAL,
|
||||
extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
for tcu in TestCenterUser.objects.order_by('id'):
|
||||
record = dict((csv_field, getattr(tcu, model_field))
|
||||
for csv_field, model_field
|
||||
in Command.CSV_TO_MODEL_FIELDS.items())
|
||||
record["LastUpdate"] = record["LastUpdate"].strftime("%Y/%m/%d %H:%M:%S")
|
||||
writer.writerow(record)
|
||||
|
||||
def reset_sample_data(self):
|
||||
def make_sample(**kwargs):
|
||||
data = dict((model_field, kwargs.get(model_field, ""))
|
||||
for model_field in Command.CSV_TO_MODEL_FIELDS.values())
|
||||
return TestCenterUser(**data)
|
||||
|
||||
def generate_id():
|
||||
return "edX{:012}".format(uuid.uuid4().int % (10**12))
|
||||
|
||||
# TestCenterUser.objects.all().delete()
|
||||
|
||||
samples = [
|
||||
make_sample(
|
||||
client_candidate_id=generate_id(),
|
||||
first_name="Jack",
|
||||
last_name="Doe",
|
||||
middle_name="C",
|
||||
address_1="11 Cambridge Center",
|
||||
address_2="Suite 101",
|
||||
city="Cambridge",
|
||||
state="MA",
|
||||
postal_code="02140",
|
||||
country="USA",
|
||||
phone="(617)555-5555",
|
||||
phone_country_code="1",
|
||||
user_updated_at=datetime.utcnow()
|
||||
),
|
||||
make_sample(
|
||||
client_candidate_id=generate_id(),
|
||||
first_name="Clyde",
|
||||
last_name="Smith",
|
||||
middle_name="J",
|
||||
suffix="Jr.",
|
||||
salutation="Mr.",
|
||||
address_1="1 Penny Lane",
|
||||
city="Honolulu",
|
||||
state="HI",
|
||||
postal_code="96792",
|
||||
country="USA",
|
||||
phone="555-555-5555",
|
||||
phone_country_code="1",
|
||||
user_updated_at=datetime.utcnow()
|
||||
),
|
||||
make_sample(
|
||||
client_candidate_id=generate_id(),
|
||||
first_name="Patty",
|
||||
last_name="Lee",
|
||||
salutation="Dr.",
|
||||
address_1="P.O. Box 555",
|
||||
city="Honolulu",
|
||||
state="HI",
|
||||
postal_code="96792",
|
||||
country="USA",
|
||||
phone="808-555-5555",
|
||||
phone_country_code="1",
|
||||
user_updated_at=datetime.utcnow()
|
||||
),
|
||||
make_sample(
|
||||
client_candidate_id=generate_id(),
|
||||
first_name="Jimmy",
|
||||
last_name="James",
|
||||
address_1="2020 Palmer Blvd.",
|
||||
city="Springfield",
|
||||
state="MA",
|
||||
postal_code="96792",
|
||||
country="USA",
|
||||
phone="917-555-5555",
|
||||
phone_country_code="1",
|
||||
extension="2039",
|
||||
fax="917-555-5556",
|
||||
fax_country_code="1",
|
||||
company_name="ACME Traps",
|
||||
user_updated_at=datetime.utcnow()
|
||||
),
|
||||
make_sample(
|
||||
client_candidate_id=generate_id(),
|
||||
first_name="Yeong-Un",
|
||||
last_name="Seo",
|
||||
address_1="Duryu, Lotte 101",
|
||||
address_2="Apt 55",
|
||||
city="Daegu",
|
||||
country="KOR",
|
||||
phone="917-555-5555",
|
||||
phone_country_code="011",
|
||||
user_updated_at=datetime.utcnow()
|
||||
),
|
||||
|
||||
]
|
||||
|
||||
for tcu in samples:
|
||||
tcu.save()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import csv
|
||||
import uuid
|
||||
from collections import defaultdict, OrderedDict
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterUser
|
||||
|
||||
def generate_id():
|
||||
return "{:012}".format(uuid.uuid4().int % (10**12))
|
||||
|
||||
class Command(BaseCommand):
|
||||
args = '<output_file>'
|
||||
help = """
|
||||
Export user information from TestCenterUser model into a tab delimited
|
||||
text file with a format that Pearson expects.
|
||||
"""
|
||||
FIELDS = [
|
||||
'AuthorizationTransactionType',
|
||||
'AuthorizationID',
|
||||
'ClientAuthorizationID',
|
||||
'ClientCandidateID',
|
||||
'ExamAuthorizationCount',
|
||||
'ExamSeriesCode',
|
||||
'EligibilityApptDateFirst',
|
||||
'EligibilityApptDateLast',
|
||||
'LastUpdate',
|
||||
]
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
if len(args) < 1:
|
||||
print Command.help
|
||||
return
|
||||
|
||||
# self.reset_sample_data()
|
||||
|
||||
with open(args[0], "wb") as outfile:
|
||||
writer = csv.DictWriter(outfile,
|
||||
Command.FIELDS,
|
||||
delimiter="\t",
|
||||
quoting=csv.QUOTE_MINIMAL,
|
||||
extrasaction='ignore')
|
||||
writer.writeheader()
|
||||
for tcu in TestCenterUser.objects.order_by('id')[:5]:
|
||||
record = defaultdict(
|
||||
lambda: "",
|
||||
AuthorizationTransactionType="Add",
|
||||
ClientAuthorizationID=generate_id(),
|
||||
ClientCandidateID=tcu.client_candidate_id,
|
||||
ExamAuthorizationCount="1",
|
||||
ExamSeriesCode="6002x001",
|
||||
EligibilityApptDateFirst="2012/12/15",
|
||||
EligibilityApptDateLast="2012/12/30",
|
||||
LastUpdate=datetime.utcnow().strftime("%Y/%m/%d %H:%M:%S")
|
||||
)
|
||||
writer.writerow(record)
|
||||
|
||||
|
||||
def reset_sample_data(self):
|
||||
def make_sample(**kwargs):
|
||||
data = dict((model_field, kwargs.get(model_field, ""))
|
||||
for model_field in Command.CSV_TO_MODEL_FIELDS.values())
|
||||
return TestCenterUser(**data)
|
||||
|
||||
# TestCenterUser.objects.all().delete()
|
||||
|
||||
samples = [
|
||||
make_sample(
|
||||
client_candidate_id=generate_id(),
|
||||
first_name="Jack",
|
||||
last_name="Doe",
|
||||
middle_name="C",
|
||||
address_1="11 Cambridge Center",
|
||||
address_2="Suite 101",
|
||||
city="Cambridge",
|
||||
state="MA",
|
||||
postal_code="02140",
|
||||
country="USA",
|
||||
phone="(617)555-5555",
|
||||
phone_country_code="1",
|
||||
user_updated_at=datetime.utcnow()
|
||||
),
|
||||
make_sample(
|
||||
client_candidate_id=generate_id(),
|
||||
first_name="Clyde",
|
||||
last_name="Smith",
|
||||
middle_name="J",
|
||||
suffix="Jr.",
|
||||
salutation="Mr.",
|
||||
address_1="1 Penny Lane",
|
||||
city="Honolulu",
|
||||
state="HI",
|
||||
postal_code="96792",
|
||||
country="USA",
|
||||
phone="555-555-5555",
|
||||
phone_country_code="1",
|
||||
user_updated_at=datetime.utcnow()
|
||||
),
|
||||
make_sample(
|
||||
client_candidate_id=generate_id(),
|
||||
first_name="Patty",
|
||||
last_name="Lee",
|
||||
salutation="Dr.",
|
||||
address_1="P.O. Box 555",
|
||||
city="Honolulu",
|
||||
state="HI",
|
||||
postal_code="96792",
|
||||
country="USA",
|
||||
phone="808-555-5555",
|
||||
phone_country_code="1",
|
||||
user_updated_at=datetime.utcnow()
|
||||
),
|
||||
make_sample(
|
||||
client_candidate_id=generate_id(),
|
||||
first_name="Jimmy",
|
||||
last_name="James",
|
||||
address_1="2020 Palmer Blvd.",
|
||||
city="Springfield",
|
||||
state="MA",
|
||||
postal_code="96792",
|
||||
country="USA",
|
||||
phone="917-555-5555",
|
||||
phone_country_code="1",
|
||||
extension="2039",
|
||||
fax="917-555-5556",
|
||||
fax_country_code="1",
|
||||
company_name="ACME Traps",
|
||||
user_updated_at=datetime.utcnow()
|
||||
),
|
||||
make_sample(
|
||||
client_candidate_id=generate_id(),
|
||||
first_name="Yeong-Un",
|
||||
last_name="Seo",
|
||||
address_1="Duryu, Lotte 101",
|
||||
address_2="Apt 55",
|
||||
city="Daegu",
|
||||
country="KOR",
|
||||
phone="917-555-5555",
|
||||
phone_country_code="011",
|
||||
user_updated_at=datetime.utcnow()
|
||||
),
|
||||
|
||||
]
|
||||
|
||||
for tcu in samples:
|
||||
tcu.save()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from optparse import make_option
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from student.models import TestCenterUser
|
||||
|
||||
class Command(BaseCommand):
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option(
|
||||
'--client_candidate_id',
|
||||
action='store',
|
||||
dest='client_candidate_id',
|
||||
help='ID we assign a user to identify them to Pearson'
|
||||
),
|
||||
make_option(
|
||||
'--first_name',
|
||||
action='store',
|
||||
dest='first_name',
|
||||
),
|
||||
make_option(
|
||||
'--last_name',
|
||||
action='store',
|
||||
dest='last_name',
|
||||
),
|
||||
make_option(
|
||||
'--address_1',
|
||||
action='store',
|
||||
dest='address_1',
|
||||
),
|
||||
make_option(
|
||||
'--city',
|
||||
action='store',
|
||||
dest='city',
|
||||
),
|
||||
make_option(
|
||||
'--state',
|
||||
action='store',
|
||||
dest='state',
|
||||
help='Two letter code (e.g. MA)'
|
||||
),
|
||||
make_option(
|
||||
'--postal_code',
|
||||
action='store',
|
||||
dest='postal_code',
|
||||
),
|
||||
make_option(
|
||||
'--country',
|
||||
action='store',
|
||||
dest='country',
|
||||
help='Three letter country code (ISO 3166-1 alpha-3), like USA'
|
||||
),
|
||||
make_option(
|
||||
'--phone',
|
||||
action='store',
|
||||
dest='phone',
|
||||
help='Pretty free-form (parens, spaces, dashes), but no country code'
|
||||
),
|
||||
make_option(
|
||||
'--phone_country_code',
|
||||
action='store',
|
||||
dest='phone_country_code',
|
||||
help='Phone country code, just "1" for the USA'
|
||||
),
|
||||
)
|
||||
args = "<student_username>"
|
||||
help = "Create a TestCenterUser entry for a given Student"
|
||||
|
||||
@staticmethod
|
||||
def is_valid_option(option_name):
|
||||
base_options = set(option.dest for option in BaseCommand.option_list)
|
||||
return option_name not in base_options
|
||||
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = args[0]
|
||||
print username
|
||||
|
||||
our_options = dict((k, v) for k, v in options.items()
|
||||
if Command.is_valid_option(k))
|
||||
student = User.objects.get(username=username)
|
||||
student.test_center_user = TestCenterUser(**our_options)
|
||||
student.test_center_user.save()
|
||||
@@ -1,37 +1,47 @@
|
||||
from optparse import make_option
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
import re
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
option_list = BaseCommand.option_list + (
|
||||
make_option('--unset',
|
||||
action='store_true',
|
||||
dest='unset',
|
||||
default=False,
|
||||
help='Set is_staff to False instead of True'),
|
||||
)
|
||||
|
||||
args = '<user/email user/email ...>'
|
||||
args = '<user|email> [user|email ...]>'
|
||||
help = """
|
||||
This command will set isstaff to true for one or more users.
|
||||
This command will set is_staff to true for one or more users.
|
||||
Lookup by username or email address, assumes usernames
|
||||
do not look like email addresses.
|
||||
"""
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) < 1:
|
||||
print Command.help
|
||||
return
|
||||
raise CommandError('Usage is set_staff {0}'.format(self.args))
|
||||
|
||||
for user in args:
|
||||
|
||||
if re.match('[^@]+@[^@]+\.[^@]+', user):
|
||||
try:
|
||||
v = User.objects.get(email=user)
|
||||
except:
|
||||
raise CommandError("User {0} does not exist".format(
|
||||
user))
|
||||
raise CommandError("User {0} does not exist".format(user))
|
||||
else:
|
||||
try:
|
||||
v = User.objects.get(username=user)
|
||||
except:
|
||||
raise CommandError("User {0} does not exist".format(
|
||||
user))
|
||||
raise CommandError("User {0} does not exist".format(user))
|
||||
|
||||
if options['unset']:
|
||||
v.is_staff = False
|
||||
else:
|
||||
v.is_staff = True
|
||||
|
||||
v.is_staff = True
|
||||
v.save()
|
||||
|
||||
print 'Success!'
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'TestCenterUser'
|
||||
db.create_table('student_testcenteruser', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('user', self.gf('django.db.models.fields.related.ForeignKey')(default=None, to=orm['auth.User'], unique=True)),
|
||||
('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)),
|
||||
('updated_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)),
|
||||
('user_updated_at', self.gf('django.db.models.fields.DateTimeField')(db_index=True)),
|
||||
('candidate_id', self.gf('django.db.models.fields.IntegerField')(null=True, db_index=True)),
|
||||
('client_candidate_id', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
|
||||
('first_name', self.gf('django.db.models.fields.CharField')(max_length=30, db_index=True)),
|
||||
('last_name', self.gf('django.db.models.fields.CharField')(max_length=50, db_index=True)),
|
||||
('middle_name', self.gf('django.db.models.fields.CharField')(max_length=30, blank=True)),
|
||||
('suffix', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)),
|
||||
('salutation', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)),
|
||||
('address_1', self.gf('django.db.models.fields.CharField')(max_length=40)),
|
||||
('address_2', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)),
|
||||
('address_3', self.gf('django.db.models.fields.CharField')(max_length=40, blank=True)),
|
||||
('city', self.gf('django.db.models.fields.CharField')(max_length=32, db_index=True)),
|
||||
('state', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=20, blank=True)),
|
||||
('postal_code', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=16, blank=True)),
|
||||
('country', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)),
|
||||
('phone', self.gf('django.db.models.fields.CharField')(max_length=35)),
|
||||
('extension', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=8, blank=True)),
|
||||
('phone_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, db_index=True)),
|
||||
('fax', self.gf('django.db.models.fields.CharField')(max_length=35, blank=True)),
|
||||
('fax_country_code', self.gf('django.db.models.fields.CharField')(max_length=3, blank=True)),
|
||||
('company_name', self.gf('django.db.models.fields.CharField')(max_length=50, blank=True)),
|
||||
))
|
||||
db.send_create_signal('student', ['TestCenterUser'])
|
||||
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'TestCenterUser'
|
||||
db.delete_table('student_testcenteruser')
|
||||
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'about': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'avatar_type': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '1'}),
|
||||
'bronze': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
|
||||
'consecutive_days_visit_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}),
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'date_of_birth': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'display_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}),
|
||||
'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
|
||||
'gravatar': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'ignored_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'interesting_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'last_seen': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'location': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
|
||||
'new_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'questions_per_page': ('django.db.models.fields.SmallIntegerField', [], {'default': '10'}),
|
||||
'real_name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
|
||||
'reputation': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1'}),
|
||||
'seen_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'show_country': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
|
||||
'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}),
|
||||
'website': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'student.courseenrollment': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'},
|
||||
'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'student.pendingemailchange': {
|
||||
'Meta': {'object_name': 'PendingEmailChange'},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.pendingnamechange': {
|
||||
'Meta': {'object_name': 'PendingNameChange'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.registration': {
|
||||
'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"},
|
||||
'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'})
|
||||
},
|
||||
'student.testcenteruser': {
|
||||
'Meta': {'object_name': 'TestCenterUser'},
|
||||
'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
|
||||
'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}),
|
||||
'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}),
|
||||
'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'client_candidate_id': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'company_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
|
||||
'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}),
|
||||
'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}),
|
||||
'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
|
||||
'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}),
|
||||
'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}),
|
||||
'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}),
|
||||
'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}),
|
||||
'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}),
|
||||
'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}),
|
||||
'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}),
|
||||
'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'})
|
||||
},
|
||||
'student.userprofile': {
|
||||
'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"},
|
||||
'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}),
|
||||
'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}),
|
||||
'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}),
|
||||
'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'student.usertestgroup': {
|
||||
'Meta': {'object_name': 'UserTestGroup'},
|
||||
'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}),
|
||||
'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['student']
|
||||
@@ -8,7 +8,7 @@ Portal servers that hold the canoncial user information and that user
|
||||
information is replicated to slave Course server pools. Each Course has a set of
|
||||
servers that serves only its content and has users that are relevant only to it.
|
||||
|
||||
We replicate the following tables into the Course DBs where the user is
|
||||
We replicate the following tables into the Course DBs where the user is
|
||||
enrolled. Only the Portal servers should ever write to these models.
|
||||
* UserProfile
|
||||
* CourseEnrollment
|
||||
@@ -41,33 +41,22 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django_countries import CountryField
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from functools import partial
|
||||
|
||||
import comment_client as cc
|
||||
from django_comment_client.models import Role, Permission
|
||||
from django_comment_client.models import Role
|
||||
|
||||
import logging
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
#from cache_toolbox import cache_model, cache_relation
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserProfile(models.Model):
|
||||
"""This is where we store all the user demographic fields. We have a
|
||||
"""This is where we store all the user demographic fields. We have a
|
||||
separate table for this rather than extending the built-in Django auth_user.
|
||||
|
||||
Notes:
|
||||
* Some fields are legacy ones from the first run of 6.002, from which
|
||||
* Some fields are legacy ones from the first run of 6.002, from which
|
||||
we imported many users.
|
||||
* Fields like name and address are intentionally open ended, to account
|
||||
for international variations. An unfortunate side-effect is that we
|
||||
@@ -133,6 +122,72 @@ class UserProfile(models.Model):
|
||||
def set_meta(self, js):
|
||||
self.meta = json.dumps(js)
|
||||
|
||||
class TestCenterUser(models.Model):
|
||||
"""This is our representation of the User for in-person testing, and
|
||||
specifically for Pearson at this point. A few things to note:
|
||||
|
||||
* Pearson only supports Latin-1, so we have to make sure that the data we
|
||||
capture here will work with that encoding.
|
||||
* While we have a lot of this demographic data in UserProfile, it's much
|
||||
more free-structured there. We'll try to pre-pop the form with data from
|
||||
UserProfile, but we'll need to have a step where people who are signing
|
||||
up re-enter their demographic data into the fields we specify.
|
||||
* Users are only created here if they register to take an exam in person.
|
||||
|
||||
The field names and lengths are modeled on the conventions and constraints
|
||||
of Pearson's data import system, including oddities such as suffix having
|
||||
a limit of 255 while last_name only gets 50.
|
||||
"""
|
||||
# Our own record keeping...
|
||||
user = models.ForeignKey(User, unique=True, default=None)
|
||||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, db_index=True)
|
||||
# user_updated_at happens only when the user makes a change to their data,
|
||||
# and is something Pearson needs to know to manage updates. Unlike
|
||||
# updated_at, this will not get incremented when we do a batch data import.
|
||||
user_updated_at = models.DateTimeField(db_index=True)
|
||||
|
||||
# Unique ID given to us for this User by the Testing Center. It's null when
|
||||
# we first create the User entry, and is assigned by Pearson later.
|
||||
candidate_id = models.IntegerField(null=True, db_index=True)
|
||||
|
||||
# Unique ID we assign our user for a the Test Center.
|
||||
client_candidate_id = models.CharField(max_length=50, db_index=True)
|
||||
|
||||
# Name
|
||||
first_name = models.CharField(max_length=30, db_index=True)
|
||||
last_name = models.CharField(max_length=50, db_index=True)
|
||||
middle_name = models.CharField(max_length=30, blank=True)
|
||||
suffix = models.CharField(max_length=255, blank=True)
|
||||
salutation = models.CharField(max_length=50, blank=True)
|
||||
|
||||
# Address
|
||||
address_1 = models.CharField(max_length=40)
|
||||
address_2 = models.CharField(max_length=40, blank=True)
|
||||
address_3 = models.CharField(max_length=40, blank=True)
|
||||
city = models.CharField(max_length=32, db_index=True)
|
||||
# state example: HI -- they have an acceptable list that we'll just plug in
|
||||
# state is required if you're in the US or Canada, but otherwise not.
|
||||
state = models.CharField(max_length=20, blank=True, db_index=True)
|
||||
# postal_code required if you're in the US or Canada
|
||||
postal_code = models.CharField(max_length=16, blank=True, db_index=True)
|
||||
# country is a ISO 3166-1 alpha-3 country code (e.g. "USA", "CAN", "MNG")
|
||||
country = models.CharField(max_length=3, db_index=True)
|
||||
|
||||
# Phone
|
||||
phone = models.CharField(max_length=35)
|
||||
extension = models.CharField(max_length=8, blank=True, db_index=True)
|
||||
phone_country_code = models.CharField(max_length=3, db_index=True)
|
||||
fax = models.CharField(max_length=35, blank=True)
|
||||
# fax_country_code required *if* fax is present.
|
||||
fax_country_code = models.CharField(max_length=3, blank=True)
|
||||
|
||||
# Company
|
||||
company_name = models.CharField(max_length=50, blank=True)
|
||||
|
||||
@property
|
||||
def email(self):
|
||||
return self.user.email
|
||||
|
||||
## TODO: Should be renamed to generic UserGroup, and possibly
|
||||
# Given an optional field for type of group
|
||||
|
||||
@@ -19,16 +19,19 @@ from django.core.context_processors import csrf
|
||||
from django.core.mail import send_mail
|
||||
from django.core.validators import validate_email, validate_slug, ValidationError
|
||||
from django.db import IntegrityError
|
||||
from django.http import HttpResponse, Http404
|
||||
from django.http import HttpResponse, HttpResponseForbidden, Http404
|
||||
from django.shortcuts import redirect
|
||||
from mitxmako.shortcuts import render_to_response, render_to_string
|
||||
from bs4 import BeautifulSoup
|
||||
from django.core.cache import cache
|
||||
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django_future.csrf import ensure_csrf_cookie, csrf_exempt
|
||||
from student.models import (Registration, UserProfile,
|
||||
PendingNameChange, PendingEmailChange,
|
||||
CourseEnrollment)
|
||||
|
||||
from certificates.models import CertificateStatuses, certificate_status_for_student
|
||||
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
@@ -39,6 +42,8 @@ from collections import namedtuple
|
||||
from courseware.courses import get_courses_by_university
|
||||
from courseware.access import has_access
|
||||
|
||||
from statsd import statsd
|
||||
|
||||
log = logging.getLogger("mitx.student")
|
||||
Article = namedtuple('Article', 'title url author image deck publication publish_date')
|
||||
|
||||
@@ -141,11 +146,20 @@ def dashboard(request):
|
||||
show_courseware_links_for = frozenset(course.id for course in courses
|
||||
if has_access(request.user, course, 'load'))
|
||||
|
||||
# TODO: workaround to not have to zip courses and certificates in the template
|
||||
# since before there is a migration to certificates
|
||||
if settings.MITX_FEATURES.get('CERTIFICATES_ENABLED'):
|
||||
cert_statuses = { course.id: certificate_status_for_student(request.user, course.id) for course in courses}
|
||||
else:
|
||||
cert_statuses = {}
|
||||
|
||||
context = {'courses': courses,
|
||||
'message': message,
|
||||
'staff_access': staff_access,
|
||||
'errored_courses': errored_courses,
|
||||
'show_courseware_links_for' : show_courseware_links_for}
|
||||
'show_courseware_links_for' : show_courseware_links_for,
|
||||
'cert_statuses': cert_statuses,
|
||||
}
|
||||
|
||||
return render_to_response('dashboard.html', context)
|
||||
|
||||
@@ -205,6 +219,12 @@ def change_enrollment(request):
|
||||
'error': 'enrollment in {} not allowed at this time'
|
||||
.format(course.display_name)}
|
||||
|
||||
org, course_num, run=course_id.split("/")
|
||||
statsd.increment("common.student.enrollment",
|
||||
tags=["org:{0}".format(org),
|
||||
"course:{0}".format(course_num),
|
||||
"run:{0}".format(run)])
|
||||
|
||||
enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id)
|
||||
return {'success': True}
|
||||
|
||||
@@ -212,6 +232,13 @@ def change_enrollment(request):
|
||||
try:
|
||||
enrollment = CourseEnrollment.objects.get(user=user, course_id=course_id)
|
||||
enrollment.delete()
|
||||
|
||||
org, course_num, run=course_id.split("/")
|
||||
statsd.increment("common.student.unenrollment",
|
||||
tags=["org:{0}".format(org),
|
||||
"course:{0}".format(course_num),
|
||||
"run:{0}".format(run)])
|
||||
|
||||
return {'success': True}
|
||||
except CourseEnrollment.DoesNotExist:
|
||||
return {'success': False, 'error': 'You are not enrolled for this course.'}
|
||||
@@ -261,10 +288,12 @@ def login_user(request, error=""):
|
||||
|
||||
try_change_enrollment(request)
|
||||
|
||||
statsd.increment("common.student.successful_login")
|
||||
|
||||
return HttpResponse(json.dumps({'success': True}))
|
||||
|
||||
|
||||
log.warning("Login failed - Account not active for user {0}, resending activation".format(username))
|
||||
|
||||
|
||||
reactivation_email_for_user(user)
|
||||
not_activated_msg = "This account has not been activated. We have " + \
|
||||
"sent another activation message. Please check your " + \
|
||||
@@ -467,6 +496,8 @@ def create_account(request, post_override=None):
|
||||
login_user.is_active = True
|
||||
login_user.save()
|
||||
|
||||
statsd.increment("common.student.account_created")
|
||||
|
||||
js = {'success': True}
|
||||
return HttpResponse(json.dumps(js), mimetype="application/json")
|
||||
|
||||
@@ -522,9 +553,9 @@ def password_reset(request):
|
||||
''' Attempts to send a password reset e-mail. '''
|
||||
if request.method != "POST":
|
||||
raise Http404
|
||||
|
||||
|
||||
# By default, Django doesn't allow Users with is_active = False to reset their passwords,
|
||||
# but this bites people who signed up a long time ago, never activated, and forgot their
|
||||
# but this bites people who signed up a long time ago, never activated, and forgot their
|
||||
# password. So for their sake, we'll auto-activate a user for whome password_reset is called.
|
||||
try:
|
||||
user = User.objects.get(email=request.POST['email'])
|
||||
@@ -532,7 +563,7 @@ def password_reset(request):
|
||||
user.save()
|
||||
except:
|
||||
log.exception("Tried to auto-activate user to enable password reset, but failed.")
|
||||
|
||||
|
||||
form = PasswordResetForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save(use_https = request.is_secure(),
|
||||
@@ -570,7 +601,7 @@ def reactivation_email_for_user(user):
|
||||
res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
|
||||
|
||||
return HttpResponse(json.dumps({'success': True}))
|
||||
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def change_email_request(request):
|
||||
@@ -745,8 +776,8 @@ def accept_name_change_by_id(id):
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def accept_name_change(request):
|
||||
''' JSON: Name change process. Course staff clicks 'accept' on a given name change
|
||||
|
||||
''' JSON: Name change process. Course staff clicks 'accept' on a given name change
|
||||
|
||||
We used this during the prototype but now we simply record name changes instead
|
||||
of manually approving them. Still keeping this around in case we want to go
|
||||
back to this approval method.
|
||||
@@ -755,3 +786,23 @@ def accept_name_change(request):
|
||||
raise Http404
|
||||
|
||||
return accept_name_change_by_id(int(request.POST['id']))
|
||||
|
||||
# TODO: This is a giant kludge to give Pearson something to test against ASAP.
|
||||
# Will need to get replaced by something that actually ties into TestCenterUser
|
||||
@csrf_exempt
|
||||
def test_center_login(request):
|
||||
if not settings.MITX_FEATURES.get('ENABLE_PEARSON_HACK_TEST'):
|
||||
raise Http404
|
||||
|
||||
client_candidate_id = request.POST.get("clientCandidateID")
|
||||
# registration_id = request.POST.get("registrationID")
|
||||
exit_url = request.POST.get("exitURL")
|
||||
error_url = request.POST.get("errorURL")
|
||||
|
||||
if client_candidate_id == "edX003671291147":
|
||||
user = authenticate(username=settings.PEARSON_TEST_USER,
|
||||
password=settings.PEARSON_TEST_PASSWORD)
|
||||
login(request, user)
|
||||
return redirect('/courses/MITx/6.002x/2012_Fall/courseware/Final_Exam/Final_Exam_Fall_2012/')
|
||||
else:
|
||||
return HttpResponseForbidden()
|
||||
|
||||
@@ -50,7 +50,7 @@ def replace_course_urls(get_html, course_id):
|
||||
return replace_urls(get_html(), staticfiles_prefix='/courses/'+course_id, replace_prefix='/course/')
|
||||
return _get_html
|
||||
|
||||
def replace_static_urls(get_html, prefix):
|
||||
def replace_static_urls(get_html, prefix, course_namespace=None):
|
||||
"""
|
||||
Updates the supplied module with a new get_html function that wraps
|
||||
the old get_html function and substitutes urls of the form /static/...
|
||||
@@ -59,7 +59,7 @@ def replace_static_urls(get_html, prefix):
|
||||
|
||||
@wraps(get_html)
|
||||
def _get_html():
|
||||
return replace_urls(get_html(), staticfiles_prefix=prefix)
|
||||
return replace_urls(get_html(), staticfiles_prefix=prefix, course_namespace = course_namespace)
|
||||
return _get_html
|
||||
|
||||
|
||||
|
||||
13
common/lib/capa/.coveragerc
Normal file
13
common/lib/capa/.coveragerc
Normal file
@@ -0,0 +1,13 @@
|
||||
# .coveragerc for common/lib/capa
|
||||
[run]
|
||||
data_file = reports/common/lib/capa/.coverage
|
||||
source = common/lib/capa
|
||||
|
||||
[report]
|
||||
ignore_errors = True
|
||||
|
||||
[html]
|
||||
directory = reports/common/lib/capa/cover
|
||||
|
||||
[xml]
|
||||
output = reports/common/lib/capa/coverage.xml
|
||||
@@ -32,10 +32,13 @@ from xml.sax.saxutils import unescape
|
||||
|
||||
import chem
|
||||
import chem.chemcalc
|
||||
import chem.chemtools
|
||||
|
||||
import calc
|
||||
from correctmap import CorrectMap
|
||||
import eia
|
||||
import inputtypes
|
||||
import customrender
|
||||
from util import contextualize_text, convert_files_to_filenames
|
||||
import xqueue_interface
|
||||
|
||||
@@ -45,22 +48,8 @@ import responsetypes
|
||||
# dict of tagname, Response Class -- this should come from auto-registering
|
||||
response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__])
|
||||
|
||||
# Different ways students can input code
|
||||
entry_types = ['textline',
|
||||
'schematic',
|
||||
'textbox',
|
||||
'imageinput',
|
||||
'optioninput',
|
||||
'choicegroup',
|
||||
'radiogroup',
|
||||
'checkboxgroup',
|
||||
'filesubmission',
|
||||
'javascriptinput',
|
||||
'crystallography',
|
||||
'chemicalequationinput',]
|
||||
|
||||
# extra things displayed after "show answers" is pressed
|
||||
solution_types = ['solution']
|
||||
solution_tags = ['solution']
|
||||
|
||||
# these get captured as student responses
|
||||
response_properties = ["codeparam", "responseparam", "answer"]
|
||||
@@ -77,7 +66,8 @@ global_context = {'random': random,
|
||||
'scipy': scipy,
|
||||
'calc': calc,
|
||||
'eia': eia,
|
||||
'chemcalc': chem.chemcalc}
|
||||
'chemcalc': chem.chemcalc,
|
||||
'chemtools': chem.chemtools}
|
||||
|
||||
# These should be removed from HTML output, including all subelements
|
||||
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup"]
|
||||
@@ -305,7 +295,7 @@ class LoncapaProblem(object):
|
||||
answer_map.update(results)
|
||||
|
||||
# include solutions from <solution>...</solution> stanzas
|
||||
for entry in self.tree.xpath("//" + "|//".join(solution_types)):
|
||||
for entry in self.tree.xpath("//" + "|//".join(solution_tags)):
|
||||
answer = etree.tostring(entry)
|
||||
if answer:
|
||||
answer_map[entry.get('id')] = contextualize_text(answer, self.context)
|
||||
@@ -483,7 +473,7 @@ class LoncapaProblem(object):
|
||||
|
||||
problemid = problemtree.get('id') # my ID
|
||||
|
||||
if problemtree.tag in inputtypes.registered_input_tags():
|
||||
if problemtree.tag in inputtypes.registry.registered_tags():
|
||||
# If this is an inputtype subtree, let it render itself.
|
||||
status = "unsubmitted"
|
||||
msg = ''
|
||||
@@ -509,7 +499,7 @@ class LoncapaProblem(object):
|
||||
'hint': hint,
|
||||
'hintmode': hintmode,}}
|
||||
|
||||
input_type_cls = inputtypes.get_class_for_tag(problemtree.tag)
|
||||
input_type_cls = inputtypes.registry.get_class_for_tag(problemtree.tag)
|
||||
the_input = input_type_cls(self.system, problemtree, state)
|
||||
return the_input.get_html()
|
||||
|
||||
@@ -517,9 +507,15 @@ class LoncapaProblem(object):
|
||||
if problemtree in self.responders:
|
||||
return self.responders[problemtree].render_html(self._extract_html)
|
||||
|
||||
# let each custom renderer render itself:
|
||||
if problemtree.tag in customrender.registry.registered_tags():
|
||||
renderer_class = customrender.registry.get_class_for_tag(problemtree.tag)
|
||||
renderer = renderer_class(self.system, problemtree)
|
||||
return renderer.get_html()
|
||||
|
||||
# otherwise, render children recursively, and copy over attributes
|
||||
tree = etree.Element(problemtree.tag)
|
||||
for item in problemtree:
|
||||
# render child recursively
|
||||
item_xhtml = self._extract_html(item)
|
||||
if item_xhtml is not None:
|
||||
tree.append(item_xhtml)
|
||||
@@ -556,11 +552,12 @@ class LoncapaProblem(object):
|
||||
response_id += 1
|
||||
|
||||
answer_id = 1
|
||||
input_tags = inputtypes.registry.registered_tags()
|
||||
inputfields = tree.xpath("|".join(['//' + response.tag + '[@id=$id]//' + x
|
||||
for x in (entry_types + solution_types)]),
|
||||
for x in (input_tags + solution_tags)]),
|
||||
id=response_id_str)
|
||||
|
||||
# assign one answer_id for each entry_type or solution_type
|
||||
# assign one answer_id for each input type or solution type
|
||||
for entry in inputfields:
|
||||
entry.attrib['response_id'] = str(response_id)
|
||||
entry.attrib['answer_id'] = str(answer_id)
|
||||
|
||||
206
common/lib/capa/capa/chem/chemtools.py
Normal file
206
common/lib/capa/capa/chem/chemtools.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""This module originally includes functions for grading Vsepr problems.
|
||||
|
||||
Also, may be this module is the place for other chemistry-related grade functions. TODO: discuss it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
import itertools
|
||||
|
||||
|
||||
def vsepr_parse_user_answer(user_input):
|
||||
"""
|
||||
user_input is json generated by vsepr.js from dictionary.
|
||||
There are must be only two keys in original user_input dictionary: "geometry" and "atoms".
|
||||
Format: u'{"geometry": "AX3E0","atoms":{"c0": "B","p0": "F","p1": "B","p2": "F"}}'
|
||||
Order of elements inside "atoms" subdict does not matters.
|
||||
Return dict from parsed json.
|
||||
|
||||
"Atoms" subdict stores positions of atoms in molecule.
|
||||
General types of positions:
|
||||
c0 - central atom
|
||||
p0..pN - peripheral atoms
|
||||
a0..aN - axial atoms
|
||||
e0..eN - equatorial atoms
|
||||
|
||||
Each position is dictionary key, i.e. user_input["atoms"]["c0"] is central atom, user_input["atoms"]["a0"] is one of axial atoms.
|
||||
|
||||
Special position only for AX6 (Octahedral) geometry:
|
||||
e10, e12 - atom pairs opposite the central atom,
|
||||
e20, e22 - atom pairs opposite the central atom,
|
||||
e1 and e2 pairs lying crosswise in equatorial plane.
|
||||
|
||||
In user_input["atoms"] may be only 3 set of keys:
|
||||
(c0,p0..pN),
|
||||
(c0, a0..aN, e0..eN),
|
||||
(c0, a0, a1, e10,e11,e20,e21) - if geometry is AX6.
|
||||
"""
|
||||
return json.loads(user_input)
|
||||
|
||||
|
||||
def vsepr_build_correct_answer(geometry, atoms):
|
||||
"""
|
||||
geometry is string.
|
||||
atoms is dict of atoms with proper positions.
|
||||
Example:
|
||||
|
||||
correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"})
|
||||
|
||||
returns a dictionary composed from input values:
|
||||
{'geometry': geometry, 'atoms': atoms}
|
||||
"""
|
||||
return {'geometry': geometry, 'atoms': atoms}
|
||||
|
||||
|
||||
def vsepr_grade(user_input, correct_answer, convert_to_peripheral=False):
|
||||
"""
|
||||
This function does comparison between user_input and correct_answer.
|
||||
|
||||
Comparison is successful if all steps are successful:
|
||||
|
||||
1) geometries are equal
|
||||
2) central atoms (index in dictionary 'c0') are equal
|
||||
3):
|
||||
In next steps there is comparing of corresponding subsets of atom positions: equatorial (e0..eN), axial (a0..aN) or peripheral (p0..pN)
|
||||
|
||||
If convert_to_peripheral is True, then axial and equatorial positions are converted to peripheral.
|
||||
This means that user_input from:
|
||||
"atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}' after parsing to json
|
||||
is converted to:
|
||||
{"c0": "Br", "p0": "(ep)", "p1": "test", "p2": "H", "p3": "H", "p4": "(ep)", "p6": "(ep)"}
|
||||
i.e. aX and eX -> pX
|
||||
|
||||
So if converted, p subsets are compared,
|
||||
if not a and e subsets are compared
|
||||
If all subsets are equal, grade succeeds.
|
||||
|
||||
There is also one special case for AX6 geometry.
|
||||
In this case user_input["atoms"] contains special 3 symbol keys: e10, e12, e20, and e21.
|
||||
Correct answer for this geometry can be of 3 types:
|
||||
1) c0 and peripheral
|
||||
2) c0 and axial and equatorial
|
||||
3) c0 and axial and equatorial-subset-1 (e1X) and equatorial-subset-2 (e2X)
|
||||
|
||||
If correct answer is type 1 or 2, then user_input is converted from type 3 to type 2 (or to type 1 if convert_to_peripheral is True)
|
||||
|
||||
If correct_answer is type 3, then we done special case comparison. We have 3 sets of atoms positions both in user_input and correct_answer: axial, eq-1 and eq-2.
|
||||
Answer will be correct if these sets are equals for one of permutations. For example, if :
|
||||
user_axial = correct_eq-1
|
||||
user_eq-1 = correct-axial
|
||||
user_eq-2 = correct-eq-2
|
||||
|
||||
"""
|
||||
if user_input['geometry'] != correct_answer['geometry']:
|
||||
return False
|
||||
|
||||
if user_input['atoms']['c0'] != correct_answer['atoms']['c0']:
|
||||
return False
|
||||
|
||||
if convert_to_peripheral:
|
||||
# convert user_input from (a,e,e1,e2) to (p)
|
||||
# correct_answer must be set in (p) using this flag
|
||||
c0 = user_input['atoms'].pop('c0')
|
||||
user_input['atoms'] = {'p' + str(i): v for i, v in enumerate(user_input['atoms'].values())}
|
||||
user_input['atoms']['c0'] = c0
|
||||
|
||||
# special case for AX6
|
||||
if 'e10' in correct_answer['atoms']: # need check e1x, e2x symmetry for AX6..
|
||||
a_user = {}
|
||||
a_correct = {}
|
||||
for ea_position in ['a', 'e1', 'e2']: # collecting positions:
|
||||
a_user[ea_position] = [v for k, v in user_input['atoms'].items() if k.startswith(ea_position)]
|
||||
a_correct[ea_position] = [v for k, v in correct_answer['atoms'].items() if k.startswith(ea_position)]
|
||||
|
||||
correct = [sorted(a_correct['a'])] + [sorted(a_correct['e1'])] + [sorted(a_correct['e2'])]
|
||||
for permutation in itertools.permutations(['a', 'e1', 'e2']):
|
||||
if correct == [sorted(a_user[permutation[0]])] + [sorted(a_user[permutation[1]])] + [sorted(a_user[permutation[2]])]:
|
||||
return True
|
||||
return False
|
||||
|
||||
else: # no need to check e1x,e2x symmetry - convert them to ex
|
||||
if 'e10' in user_input['atoms']: # e1x exists, it is AX6.. case
|
||||
e_index = 0
|
||||
for k, v in user_input['atoms'].items():
|
||||
if len(k) == 3: # e1x
|
||||
del user_input['atoms'][k]
|
||||
user_input['atoms']['e' + str(e_index)] = v
|
||||
e_index += 1
|
||||
|
||||
# common case
|
||||
for ea_position in ['p', 'a', 'e']:
|
||||
# collecting atoms:
|
||||
a_user = [v for k, v in user_input['atoms'].items() if k.startswith(ea_position)]
|
||||
a_correct = [v for k, v in correct_answer['atoms'].items() if k.startswith(ea_position)]
|
||||
# print a_user, a_correct
|
||||
if len(a_user) != len(a_correct):
|
||||
return False
|
||||
if sorted(a_user) != sorted(a_correct):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class Test_Grade(unittest.TestCase):
|
||||
''' test grade function '''
|
||||
|
||||
def test_incorrect_geometry(self):
|
||||
correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"})
|
||||
user_answer = vsepr_parse_user_answer(u'{"geometry": "AX3E0","atoms":{"c0": "B","p0": "F","p1": "B","p2": "F"}}')
|
||||
self.assertFalse(vsepr_grade(user_answer, correct_answer))
|
||||
|
||||
def test_correct_answer_p(self):
|
||||
correct_answer = vsepr_build_correct_answer(geometry="AX4E0", atoms={"c0": "N", "p0": "H", "p1": "(ep)", "p2": "H", "p3": "H"})
|
||||
user_answer = vsepr_parse_user_answer(u'{"geometry": "AX4E0","atoms":{"c0": "N","p0": "H","p1": "(ep)","p2": "H", "p3": "H"}}')
|
||||
self.assertTrue(vsepr_grade(user_answer, correct_answer))
|
||||
|
||||
def test_correct_answer_ae(self):
|
||||
correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "test", "a1": "(ep)", "e0": "H", "e1": "H", "e2": "(ep)", "e3": "(ep)"})
|
||||
user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "H","e20": "(ep)","e21": "(ep)"}}')
|
||||
self.assertTrue(vsepr_grade(user_answer, correct_answer))
|
||||
|
||||
def test_correct_answer_ae_convert_to_p_but_input_not_in_p(self):
|
||||
correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "(ep)", "e3": "(ep)"})
|
||||
user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}')
|
||||
self.assertFalse(vsepr_grade(user_answer, correct_answer, convert_to_peripheral=True))
|
||||
|
||||
def test_correct_answer_ae_convert_to_p(self):
|
||||
correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "p0": "(ep)", "p1": "test", "p2": "H", "p3": "H", "p4": "(ep)", "p6": "(ep)"})
|
||||
user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "test","a1": "(ep)","e10": "H","e11": "(ep)","e20": "H","e21": "(ep)"}}')
|
||||
self.assertTrue(vsepr_grade(user_answer, correct_answer, convert_to_peripheral=True))
|
||||
|
||||
def test_correct_answer_e1e2_in_a(self):
|
||||
correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"})
|
||||
user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "(ep)","a1": "(ep)","e10": "H","e11": "H","e20": "H","e21": "H"}}')
|
||||
self.assertTrue(vsepr_grade(user_answer, correct_answer))
|
||||
|
||||
def test_correct_answer_e1e2_in_e1(self):
|
||||
correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"})
|
||||
user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "(ep)","e11": "(ep)","e20": "H","e21": "H"}}')
|
||||
self.assertTrue(vsepr_grade(user_answer, correct_answer))
|
||||
|
||||
def test_correct_answer_e1e2_in_e2(self):
|
||||
correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"})
|
||||
user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "H","e11": "H","e20": "(ep)","e21": "(ep)"}}')
|
||||
self.assertTrue(vsepr_grade(user_answer, correct_answer))
|
||||
|
||||
def test_incorrect_answer_e1e2(self):
|
||||
correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "(ep)", "e10": "H", "e11": "H", "e20": "H", "e21": "H"})
|
||||
user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "Br","a0": "H","a1": "H","e10": "(ep)","e11": "H","e20": "H","e21": "(ep)"}}')
|
||||
self.assertFalse(vsepr_grade(user_answer, correct_answer))
|
||||
|
||||
def test_incorrect_c0(self):
|
||||
correct_answer = vsepr_build_correct_answer(geometry="AX6E0", atoms={"c0": "Br", "a0": "(ep)", "a1": "test", "e0": "H", "e1": "H", "e2": "H", "e3": "(ep)"})
|
||||
user_answer = vsepr_parse_user_answer(u'{"geometry": "AX6E0","atoms":{"c0": "H","a0": "test","a1": "(ep)","e0": "H","e1": "H","e2": "(ep)","e3": "H"}}')
|
||||
self.assertFalse(vsepr_grade(user_answer, correct_answer))
|
||||
|
||||
|
||||
def suite():
|
||||
|
||||
testcases = [Test_Grade]
|
||||
suites = []
|
||||
for testcase in testcases:
|
||||
suites.append(unittest.TestLoader().loadTestsFromTestCase(testcase))
|
||||
return unittest.TestSuite(suites)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.TextTestRunner(verbosity=2).run(suite())
|
||||
@@ -3,7 +3,6 @@
|
||||
#
|
||||
# Used by responsetypes and capa_problem
|
||||
|
||||
|
||||
class CorrectMap(object):
|
||||
"""
|
||||
Stores map between answer_id and response evaluation result for each question
|
||||
@@ -69,7 +68,7 @@ class CorrectMap(object):
|
||||
|
||||
correct_map is saved by LMS as a plaintext JSON dump of the correctmap dict. This
|
||||
means that when the definition of CorrectMap (e.g. its properties) are altered,
|
||||
an existing correct_map dict not coincide with the newest CorrectMap format as
|
||||
an existing correct_map dict will not coincide with the newest CorrectMap format as
|
||||
defined by self.set.
|
||||
|
||||
For graceful migration, feed the contents of each correct map to self.set, rather than
|
||||
|
||||
100
common/lib/capa/capa/customrender.py
Normal file
100
common/lib/capa/capa/customrender.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
This has custom renderers: classes that know how to render certain problem tags (e.g. <math> and
|
||||
<solution>) to html.
|
||||
|
||||
These tags do not have state, so they just get passed the system (for access to render_template),
|
||||
and the xml element.
|
||||
"""
|
||||
|
||||
from registry import TagRegistry
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shlex # for splitting quoted strings
|
||||
import json
|
||||
|
||||
from lxml import etree
|
||||
import xml.sax.saxutils as saxutils
|
||||
from registry import TagRegistry
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
registry = TagRegistry()
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
class MathRenderer(object):
|
||||
tags = ['math']
|
||||
|
||||
def __init__(self, system, xml):
|
||||
'''
|
||||
Render math using latex-like formatting.
|
||||
|
||||
Examples:
|
||||
|
||||
<math>$\displaystyle U(r)=4 U_0 $</math>
|
||||
<math>$r_0$</math>
|
||||
|
||||
We convert these to [mathjax]...[/mathjax] and [mathjaxinline]...[/mathjaxinline]
|
||||
|
||||
TODO: use shorter tags (but this will require converting problem XML files!)
|
||||
'''
|
||||
self.system = system
|
||||
self.xml = xml
|
||||
|
||||
mathstr = re.sub('\$(.*)\$', r'[mathjaxinline]\1[/mathjaxinline]', xml.text)
|
||||
mtag = 'mathjax'
|
||||
if not r'\displaystyle' in mathstr:
|
||||
mtag += 'inline'
|
||||
else:
|
||||
mathstr = mathstr.replace(r'\displaystyle', '')
|
||||
self.mathstr = mathstr.replace('mathjaxinline]', '%s]' % mtag)
|
||||
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Return the contents of this tag, rendered to html, as an etree element.
|
||||
"""
|
||||
# TODO: why are there nested html tags here?? Why are there html tags at all, in fact?
|
||||
html = '<html><html>%s</html><html>%s</html></html>' % (
|
||||
self.mathstr, saxutils.escape(self.xml.tail))
|
||||
try:
|
||||
xhtml = etree.XML(html)
|
||||
except Exception as err:
|
||||
if self.system.DEBUG:
|
||||
msg = '<html><div class="inline-error"><p>Error %s</p>' % (
|
||||
str(err).replace('<', '<'))
|
||||
msg += ('<p>Failed to construct math expression from <pre>%s</pre></p>' %
|
||||
html.replace('<', '<'))
|
||||
msg += "</div></html>"
|
||||
log.error(msg)
|
||||
return etree.XML(msg)
|
||||
else:
|
||||
raise
|
||||
return xhtml
|
||||
|
||||
|
||||
registry.register(MathRenderer)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class SolutionRenderer(object):
|
||||
'''
|
||||
A solution is just a <span>...</span> which is given an ID, that is used for displaying an
|
||||
extended answer (a problem "solution") after "show answers" is pressed.
|
||||
|
||||
Note that the solution content is NOT rendered and returned in the HTML. It is obtained by an
|
||||
ajax call.
|
||||
'''
|
||||
tags = ['solution']
|
||||
|
||||
def __init__(self, system, xml):
|
||||
self.system = system
|
||||
self.id = xml.get('id')
|
||||
|
||||
def get_html(self):
|
||||
context = {'id': self.id}
|
||||
html = self.system.render_template("solutionspan.html", context)
|
||||
return etree.XML(html)
|
||||
|
||||
registry.register(SolutionRenderer)
|
||||
|
||||
@@ -6,11 +6,9 @@
|
||||
Module containing the problem elements which render into input objects
|
||||
|
||||
- textline
|
||||
- textbox (change this to textarea?)
|
||||
- schemmatic
|
||||
- choicegroup
|
||||
- radiogroup
|
||||
- checkboxgroup
|
||||
- textbox (aka codeinput)
|
||||
- schematic
|
||||
- choicegroup (aka radiogroup, checkboxgroup)
|
||||
- javascriptinput
|
||||
- imageinput (for clickable image)
|
||||
- optioninput (for option list)
|
||||
@@ -23,63 +21,88 @@ Each input type takes the xml tree as 'element', the previous answer as 'value',
|
||||
graded status as'status'
|
||||
"""
|
||||
|
||||
# TODO: rename "state" to "status" for all below. status is currently the answer for the
|
||||
# problem ID for the input element, but it will turn into a dict containing both the
|
||||
# answer and any associated message for the problem ID for the input element.
|
||||
# TODO: make hints do something
|
||||
|
||||
# TODO: make all inputtypes actually render msg
|
||||
|
||||
# TODO: remove unused fields (e.g. 'hidden' in a few places)
|
||||
|
||||
# TODO: add validators so that content folks get better error messages.
|
||||
|
||||
|
||||
# Possible todo: make inline the default for textlines and other "one-line" inputs. It probably
|
||||
# makes sense, but a bunch of problems have markup that assumes block. Bigger TODO: figure out a
|
||||
# general css and layout strategy for capa, document it, then implement it.
|
||||
|
||||
from collections import namedtuple
|
||||
import json
|
||||
import logging
|
||||
from lxml import etree
|
||||
import re
|
||||
import shlex # for splitting quoted strings
|
||||
import json
|
||||
import sys
|
||||
|
||||
from lxml import etree
|
||||
import xml.sax.saxutils as saxutils
|
||||
from registry import TagRegistry
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
#########################################################################
|
||||
|
||||
_TAGS_TO_CLASSES = {}
|
||||
registry = TagRegistry()
|
||||
|
||||
def register_input_class(cls):
|
||||
class Attribute(object):
|
||||
"""
|
||||
Register cls as a supported input type. It is expected to have the same constructor as
|
||||
InputTypeBase, and to define cls.tags as a list of tags that it implements.
|
||||
|
||||
If an already-registered input type has claimed one of those tags, will raise ValueError.
|
||||
|
||||
If there are no tags in cls.tags, will also raise ValueError.
|
||||
Allows specifying required and optional attributes for input types.
|
||||
"""
|
||||
|
||||
# Do all checks and complain before changing any state.
|
||||
if len(cls.tags) == 0:
|
||||
raise ValueError("No supported tags for class {0}".format(cls.__name__))
|
||||
# want to allow default to be None, but also allow required objects
|
||||
_sentinel = object()
|
||||
|
||||
for t in cls.tags:
|
||||
if t in _TAGS_TO_CLASSES:
|
||||
other_cls = _TAGS_TO_CLASSES[t]
|
||||
if cls == other_cls:
|
||||
# registering the same class multiple times seems silly, but ok
|
||||
continue
|
||||
raise ValueError("Tag {0} already registered by class {1}. Can't register for class {2}"
|
||||
.format(t, other_cls.__name__, cls.__name__))
|
||||
def __init__(self, name, default=_sentinel, transform=None, validate=None, render=True):
|
||||
"""
|
||||
Define an attribute
|
||||
|
||||
# Ok, should be good to change state now.
|
||||
for t in cls.tags:
|
||||
_TAGS_TO_CLASSES[t] = cls
|
||||
name (str): then name of the attribute--should be alphanumeric (valid for an XML attribute)
|
||||
|
||||
def registered_input_tags():
|
||||
"""
|
||||
Get a list of all the xml tags that map to known input types.
|
||||
"""
|
||||
return _TAGS_TO_CLASSES.keys()
|
||||
default (any type): If not specified, this attribute is required. If specified, use this as the default value
|
||||
if the attribute is not specified. Note that this value will not be transformed or validated.
|
||||
|
||||
transform (function str -> any type): If not None, will be called to transform the parsed value into an internal
|
||||
representation.
|
||||
|
||||
def get_class_for_tag(tag):
|
||||
"""
|
||||
For any tag in registered_input_tags(), return the corresponding class. Otherwise, will raise KeyError.
|
||||
"""
|
||||
return _TAGS_TO_CLASSES[tag]
|
||||
validate (function str-or-return-type-of-tranform -> unit or exception): If not None, called to validate the
|
||||
(possibly transformed) value of the attribute. Should raise ValueError with a helpful message if
|
||||
the value is invalid.
|
||||
|
||||
render (bool): if False, don't include this attribute in the template context.
|
||||
"""
|
||||
self.name = name
|
||||
self.default = default
|
||||
self.validate = validate
|
||||
self.transform = transform
|
||||
self.render = render
|
||||
|
||||
def parse_from_xml(self, element):
|
||||
"""
|
||||
Given an etree xml element that should have this attribute, do the obvious thing:
|
||||
- look for it. raise ValueError if not found and required.
|
||||
- transform and validate. pass through any exceptions from transform or validate.
|
||||
"""
|
||||
val = element.get(self.name)
|
||||
if self.default == self._sentinel and val is None:
|
||||
raise ValueError('Missing required attribute {0}.'.format(self.name))
|
||||
|
||||
if val is None:
|
||||
# not required, so return default
|
||||
return self.default
|
||||
|
||||
if self.transform is not None:
|
||||
val = self.transform(val)
|
||||
|
||||
if self.validate is not None:
|
||||
self.validate(val)
|
||||
|
||||
return val
|
||||
|
||||
|
||||
class InputTypeBase(object):
|
||||
@@ -93,16 +116,18 @@ class InputTypeBase(object):
|
||||
"""
|
||||
Instantiate an InputType class. Arguments:
|
||||
|
||||
- system : ModuleSystem instance which provides OS, rendering, and user context. Specifically, must
|
||||
have a render_template function.
|
||||
- system : ModuleSystem instance which provides OS, rendering, and user context.
|
||||
Specifically, must have a render_template function.
|
||||
- xml : Element tree of this Input element
|
||||
- state : a dictionary with optional keys:
|
||||
* 'value'
|
||||
* 'id'
|
||||
* 'value' -- the current value of this input
|
||||
(what the student entered last time)
|
||||
* 'id' -- the id of this input, typically
|
||||
"{problem-location}_{response-num}_{input-num}"
|
||||
* 'status' (answered, unanswered, unsubmitted)
|
||||
* 'feedback' (dictionary containing keys for hints, errors, or other
|
||||
feedback from previous attempt. Specifically 'message', 'hint', 'hintmode'. If 'hintmode'
|
||||
is 'always', the hint is always displayed.)
|
||||
feedback from previous attempt. Specifically 'message', 'hint',
|
||||
'hintmode'. If 'hintmode' is 'always', the hint is always displayed.)
|
||||
"""
|
||||
|
||||
self.xml = xml
|
||||
@@ -132,54 +157,104 @@ class InputTypeBase(object):
|
||||
|
||||
self.status = state.get('status', 'unanswered')
|
||||
|
||||
try:
|
||||
# Pre-parse and propcess all the declared requirements.
|
||||
self.process_requirements()
|
||||
|
||||
# Call subclass "constructor" -- means they don't have to worry about calling
|
||||
# super().__init__, and are isolated from changes to the input constructor interface.
|
||||
self.setup()
|
||||
except Exception as err:
|
||||
# Something went wrong: add xml to message, but keep the traceback
|
||||
msg = "Error in xml '{x}': {err} ".format(x=etree.tostring(xml), err=str(err))
|
||||
raise Exception, msg, sys.exc_info()[2]
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Should return a list of Attribute objects (see docstring there for details). Subclasses should override. e.g.
|
||||
|
||||
return [Attribute('unicorn', True), Attribute('num_dragons', 12, transform=int), ...]
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
def process_requirements(self):
|
||||
"""
|
||||
Subclasses can declare lists of required and optional attributes. This
|
||||
function parses the input xml and pulls out those attributes. This
|
||||
isolates most simple input types from needing to deal with xml parsing at all.
|
||||
|
||||
Processes attributes, putting the results in the self.loaded_attributes dictionary. Also creates a set
|
||||
self.to_render, containing the names of attributes that should be included in the context by default.
|
||||
"""
|
||||
# Use local dicts and sets so that if there are exceptions, we don't 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)
|
||||
|
||||
self.loaded_attributes = loaded
|
||||
self.to_render = to_render
|
||||
|
||||
def setup(self):
|
||||
"""
|
||||
InputTypes should override this to do any needed initialization. It is called after the
|
||||
constructor, so all base attributes will be set.
|
||||
|
||||
If this method raises an exception, it will be wrapped with a message that includes the
|
||||
problem xml.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def _get_render_context(self):
|
||||
"""
|
||||
Abstract method. Subclasses should implement to return the dictionary
|
||||
of keys needed to render their template.
|
||||
Should return a dictionary of keys needed to render the template for the input type.
|
||||
|
||||
(Separate from get_html to faciliate testing of logic separately from the rendering)
|
||||
|
||||
The default implementation gets the following rendering context: basic things like value, id, status, and msg,
|
||||
as well as everything in self.loaded_attributes, and everything returned by self._extra_context().
|
||||
|
||||
This means that input types that only parse attributes and pass them to the template get everything they need,
|
||||
and don't need to override this method.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
context = {
|
||||
'id': self.id,
|
||||
'value': self.value,
|
||||
'status': self.status,
|
||||
'msg': self.msg,
|
||||
}
|
||||
context.update((a, v) for (a, v) in self.loaded_attributes.iteritems() if a in self.to_render)
|
||||
context.update(self._extra_context())
|
||||
return context
|
||||
|
||||
def _extra_context(self):
|
||||
"""
|
||||
Subclasses can override this to return extra context that should be passed to their templates for rendering.
|
||||
|
||||
This is useful when the input type requires computing new template variables from the parsed attributes.
|
||||
"""
|
||||
return {}
|
||||
|
||||
def get_html(self):
|
||||
"""
|
||||
Return the html for this input, as an etree element.
|
||||
"""
|
||||
if self.template is None:
|
||||
raise NotImplementedError("no rendering template specified for class {0}".format(self.__class__))
|
||||
raise NotImplementedError("no rendering template specified for class {0}"
|
||||
.format(self.__class__))
|
||||
|
||||
html = self.system.render_template(self.template, self._get_render_context())
|
||||
context = self._get_render_context()
|
||||
|
||||
html = self.system.render_template(self.template, context)
|
||||
return etree.XML(html)
|
||||
|
||||
|
||||
## TODO: Remove once refactor is complete
|
||||
def make_class_for_render_function(fn):
|
||||
"""
|
||||
Take an old-style render function, return a new-style input class.
|
||||
"""
|
||||
|
||||
class Impl(InputTypeBase):
|
||||
"""
|
||||
Inherit all the constructor logic from InputTypeBase...
|
||||
"""
|
||||
tags = [fn.__name__]
|
||||
def get_html(self):
|
||||
"""...delegate to the render function to do the work"""
|
||||
return fn(self.xml, self.value, self.status, self.system.render_template, self.msg)
|
||||
|
||||
# don't want all the classes to be called Impl (confuses register_input_class).
|
||||
Impl.__name__ = fn.__name__.capitalize()
|
||||
return Impl
|
||||
|
||||
|
||||
def _reg(fn):
|
||||
"""
|
||||
Register an old-style inputtype render function as a new-style subclass of InputTypeBase.
|
||||
This will go away once converting all input types to the new format is complete. (TODO)
|
||||
"""
|
||||
register_input_class(make_class_for_render_function(fn))
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -190,487 +265,353 @@ class OptionInput(InputTypeBase):
|
||||
Example:
|
||||
|
||||
<optioninput options="('Up','Down')" correct="Up"/><text>The location of the sky</text>
|
||||
|
||||
# TODO: allow ordering to be randomized
|
||||
"""
|
||||
|
||||
template = "optioninput.html"
|
||||
tags = ['optioninput']
|
||||
|
||||
def _get_render_context(self):
|
||||
return _optioninput(self.xml, self.value, self.status, self.system.render_template, self.msg)
|
||||
@staticmethod
|
||||
def parse_options(options):
|
||||
"""
|
||||
Given options string, convert it into an ordered list of (option_id, option_description) tuples, where
|
||||
id==description for now. TODO: make it possible to specify different id and descriptions.
|
||||
"""
|
||||
# parse the set of possible options
|
||||
lexer = shlex.shlex(options[1:-1])
|
||||
lexer.quotes = "'"
|
||||
# Allow options to be separated by whitespace as well as commas
|
||||
lexer.whitespace = ", "
|
||||
|
||||
# remove quotes
|
||||
tokens = [x[1:-1] for x in list(lexer)]
|
||||
|
||||
# make list of (option_id, option_description), with description=id
|
||||
return [(t, t) for t in tokens]
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Convert options to a convenient format.
|
||||
"""
|
||||
return [Attribute('options', transform=cls.parse_options),
|
||||
Attribute('inline', '')]
|
||||
|
||||
registry.register(OptionInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def optioninput(element, value, status, render_template, msg=''):
|
||||
context = _optioninput(element, value, status, render_template, msg)
|
||||
html = render_template("optioninput.html", context)
|
||||
return etree.XML(html)
|
||||
# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of
|
||||
# desired semantics.
|
||||
|
||||
def _optioninput(element, value, status, render_template, msg=''):
|
||||
class ChoiceGroup(InputTypeBase):
|
||||
"""
|
||||
Select option input type.
|
||||
Radio button or checkbox inputs: multiple choice or true/false
|
||||
|
||||
TODO: allow order of choices to be randomized, following lon-capa spec. Use
|
||||
"location" attribute, ie random, top, bottom.
|
||||
|
||||
Example:
|
||||
|
||||
<optioninput options="('Up','Down')" correct="Up"/><text>The location of the sky</text>
|
||||
<choicegroup>
|
||||
<choice correct="false" name="foil1">
|
||||
<text>This is foil One.</text>
|
||||
</choice>
|
||||
<choice correct="false" name="foil2">
|
||||
<text>This is foil Two.</text>
|
||||
</choice>
|
||||
<choice correct="true" name="foil3">
|
||||
<text>This is foil Three.</text>
|
||||
</choice>
|
||||
</choicegroup>
|
||||
"""
|
||||
eid = element.get('id')
|
||||
options = element.get('options')
|
||||
if not options:
|
||||
raise Exception(
|
||||
"[courseware.capa.inputtypes.optioninput] Missing options specification in "
|
||||
+ etree.tostring(element))
|
||||
template = "choicegroup.html"
|
||||
tags = ['choicegroup', 'radiogroup', 'checkboxgroup']
|
||||
|
||||
# parse the set of possible options
|
||||
oset = shlex.shlex(options[1:-1])
|
||||
oset.quotes = "'"
|
||||
oset.whitespace = ","
|
||||
oset = [x[1:-1] for x in list(oset)]
|
||||
def setup(self):
|
||||
# suffix is '' or [] to change the way the input is handled in --as a scalar or vector
|
||||
# value. (VS: would be nice to make this less hackish).
|
||||
if self.tag == 'choicegroup':
|
||||
self.suffix = ''
|
||||
self.html_input_type = "radio"
|
||||
elif self.tag == 'radiogroup':
|
||||
self.html_input_type = "radio"
|
||||
self.suffix = '[]'
|
||||
elif self.tag == 'checkboxgroup':
|
||||
self.html_input_type = "checkbox"
|
||||
self.suffix = '[]'
|
||||
else:
|
||||
raise Exception("ChoiceGroup: unexpected tag {0}".format(self.tag))
|
||||
|
||||
# make ordered list with (key, value) same
|
||||
osetdict = [(oset[x], oset[x]) for x in range(len(oset))]
|
||||
# TODO: allow ordering to be randomized
|
||||
self.choices = self.extract_choices(self.xml)
|
||||
|
||||
context = {'id': eid,
|
||||
'value': value,
|
||||
'state': status,
|
||||
'msg': msg,
|
||||
'options': osetdict,
|
||||
'inline': element.get('inline',''),
|
||||
}
|
||||
return context
|
||||
def _extra_context(self):
|
||||
return {'input_type': self.html_input_type,
|
||||
'choices': self.choices,
|
||||
'name_array_suffix': self.suffix}
|
||||
|
||||
@staticmethod
|
||||
def extract_choices(element):
|
||||
'''
|
||||
Extracts choices for a few input types, such as ChoiceGroup, RadioGroup and
|
||||
CheckboxGroup.
|
||||
|
||||
returns list of (choice_name, choice_text) tuples
|
||||
|
||||
TODO: allow order of choices to be randomized, following lon-capa spec. Use
|
||||
"location" attribute, ie random, top, bottom.
|
||||
'''
|
||||
|
||||
choices = []
|
||||
|
||||
for choice in element:
|
||||
if choice.tag != 'choice':
|
||||
raise Exception(
|
||||
"[capa.inputtypes.extract_choices] Expected a <choice> tag; got %s instead"
|
||||
% choice.tag)
|
||||
choice_text = ''.join([etree.tostring(x) for x in choice])
|
||||
if choice.text is not None:
|
||||
# TODO: fix order?
|
||||
choice_text += choice.text
|
||||
|
||||
choices.append((choice.get("name"), choice_text))
|
||||
|
||||
return choices
|
||||
|
||||
|
||||
registry.register(ChoiceGroup)
|
||||
|
||||
register_input_class(OptionInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of
|
||||
# desired semantics.
|
||||
# @register_render_function
|
||||
def choicegroup(element, value, status, render_template, msg=''):
|
||||
'''
|
||||
Radio button inputs: multiple choice or true/false
|
||||
|
||||
TODO: allow order of choices to be randomized, following lon-capa spec. Use
|
||||
"location" attribute, ie random, top, bottom.
|
||||
'''
|
||||
eid = element.get('id')
|
||||
if element.get('type') == "MultipleChoice":
|
||||
element_type = "radio"
|
||||
elif element.get('type') == "TrueFalse":
|
||||
element_type = "checkbox"
|
||||
else:
|
||||
element_type = "radio"
|
||||
choices = []
|
||||
for choice in element:
|
||||
if not choice.tag == 'choice':
|
||||
raise Exception("[courseware.capa.inputtypes.choicegroup] "
|
||||
"Error: only <choice> tags should be immediate children "
|
||||
"of a <choicegroup>, found %s instead" % choice.tag)
|
||||
ctext = ""
|
||||
# TODO: what if choice[0] has math tags in it?
|
||||
ctext += ''.join([etree.tostring(x) for x in choice])
|
||||
if choice.text is not None:
|
||||
# TODO: fix order?
|
||||
ctext += choice.text
|
||||
choices.append((choice.get("name"), ctext))
|
||||
context = {'id': eid,
|
||||
'value': value,
|
||||
'state': status,
|
||||
'input_type': element_type,
|
||||
'choices': choices,
|
||||
'name_array_suffix': ''}
|
||||
html = render_template("choicegroup.html", context)
|
||||
return etree.XML(html)
|
||||
|
||||
_reg(choicegroup)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
def extract_choices(element):
|
||||
'''
|
||||
Extracts choices for a few input types, such as radiogroup and
|
||||
checkboxgroup.
|
||||
|
||||
TODO: allow order of choices to be randomized, following lon-capa spec. Use
|
||||
"location" attribute, ie random, top, bottom.
|
||||
'''
|
||||
|
||||
choices = []
|
||||
|
||||
for choice in element:
|
||||
if not choice.tag == 'choice':
|
||||
raise Exception("[courseware.capa.inputtypes.extract_choices] \
|
||||
Expected a <choice> tag; got %s instead"
|
||||
% choice.tag)
|
||||
choice_text = ''.join([etree.tostring(x) for x in choice])
|
||||
|
||||
choices.append((choice.get("name"), choice_text))
|
||||
|
||||
return choices
|
||||
|
||||
|
||||
# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of
|
||||
# desired semantics.
|
||||
def radiogroup(element, value, status, render_template, msg=''):
|
||||
'''
|
||||
Radio button inputs: (multiple choice)
|
||||
'''
|
||||
|
||||
eid = element.get('id')
|
||||
|
||||
choices = extract_choices(element)
|
||||
|
||||
context = {'id': eid,
|
||||
'value': value,
|
||||
'state': status,
|
||||
'input_type': 'radio',
|
||||
'choices': choices,
|
||||
'name_array_suffix': '[]'}
|
||||
|
||||
html = render_template("choicegroup.html", context)
|
||||
return etree.XML(html)
|
||||
|
||||
|
||||
_reg(radiogroup)
|
||||
|
||||
# TODO: consolidate choicegroup, radiogroup, checkboxgroup after discussion of
|
||||
# desired semantics.
|
||||
def checkboxgroup(element, value, status, render_template, msg=''):
|
||||
'''
|
||||
Checkbox inputs: (select one or more choices)
|
||||
'''
|
||||
|
||||
eid = element.get('id')
|
||||
|
||||
choices = extract_choices(element)
|
||||
|
||||
context = {'id': eid,
|
||||
'value': value,
|
||||
'state': status,
|
||||
'input_type': 'checkbox',
|
||||
'choices': choices,
|
||||
'name_array_suffix': '[]'}
|
||||
|
||||
html = render_template("choicegroup.html", context)
|
||||
return etree.XML(html)
|
||||
|
||||
_reg(checkboxgroup)
|
||||
|
||||
def javascriptinput(element, value, status, render_template, msg='null'):
|
||||
'''
|
||||
class JavascriptInput(InputTypeBase):
|
||||
"""
|
||||
Hidden field for javascript to communicate via; also loads the required
|
||||
scripts for rendering the problem and passes data to the problem.
|
||||
'''
|
||||
eid = element.get('id')
|
||||
params = element.get('params')
|
||||
problem_state = element.get('problem_state')
|
||||
display_class = element.get('display_class')
|
||||
display_file = element.get('display_file')
|
||||
|
||||
# Need to provide a value that JSON can parse if there is no
|
||||
# student-supplied value yet.
|
||||
if value == "":
|
||||
value = 'null'
|
||||
TODO (arjun?): document this in detail. Initial notes:
|
||||
- display_class is a subclass of XProblemClassDisplay (see
|
||||
xmodule/xmodule/js/src/capa/display.coffee),
|
||||
- display_file is the js script to be in /static/js/ where display_class is defined.
|
||||
"""
|
||||
|
||||
escapedict = {'"': '"'}
|
||||
value = saxutils.escape(value, escapedict)
|
||||
msg = saxutils.escape(msg, escapedict)
|
||||
context = {'id': eid,
|
||||
'params': params,
|
||||
'display_file': display_file,
|
||||
'display_class': display_class,
|
||||
'problem_state': problem_state,
|
||||
'value': value,
|
||||
'evaluation': msg,
|
||||
}
|
||||
html = render_template("javascriptinput.html", context)
|
||||
return etree.XML(html)
|
||||
template = "javascriptinput.html"
|
||||
tags = ['javascriptinput']
|
||||
|
||||
_reg(javascriptinput)
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Register the attributes.
|
||||
"""
|
||||
return [Attribute('params', None),
|
||||
Attribute('problem_state', None),
|
||||
Attribute('display_class', None),
|
||||
Attribute('display_file', None),]
|
||||
|
||||
|
||||
def textline(element, value, status, render_template, msg=""):
|
||||
'''
|
||||
Simple text line input, with optional size specification.
|
||||
'''
|
||||
# TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x
|
||||
if element.get('math') or element.get('dojs'):
|
||||
return textline_dynamath(element, value, status, render_template, msg)
|
||||
eid = element.get('id')
|
||||
if eid is None:
|
||||
msg = 'textline has no id: it probably appears outside of a known response type'
|
||||
msg += "\nSee problem XML source line %s" % getattr(element, 'sourceline', '<unavailable>')
|
||||
raise Exception(msg)
|
||||
def setup(self):
|
||||
# Need to provide a value that JSON can parse if there is no
|
||||
# student-supplied value yet.
|
||||
if self.value == "":
|
||||
self.value = 'null'
|
||||
|
||||
count = int(eid.split('_')[-2]) - 1 # HACK
|
||||
size = element.get('size')
|
||||
# if specified, then textline is hidden and id is stored in div of name given by hidden
|
||||
hidden = element.get('hidden', '')
|
||||
|
||||
# Escape answers with quotes, so they don't crash the system!
|
||||
escapedict = {'"': '"'}
|
||||
value = saxutils.escape(value, escapedict)
|
||||
|
||||
context = {'id': eid,
|
||||
'value': value,
|
||||
'state': status,
|
||||
'count': count,
|
||||
'size': size,
|
||||
'msg': msg,
|
||||
'hidden': hidden,
|
||||
'inline': element.get('inline',''),
|
||||
}
|
||||
|
||||
html = render_template("textinput.html", context)
|
||||
try:
|
||||
xhtml = etree.XML(html)
|
||||
except Exception as err:
|
||||
# TODO: needs to be self.system.DEBUG - but can't access system
|
||||
if True:
|
||||
log.debug('[inputtypes.textline] failed to parse XML for:\n%s' % html)
|
||||
raise
|
||||
return xhtml
|
||||
|
||||
_reg(textline)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def textline_dynamath(element, value, status, render_template, msg=''):
|
||||
'''
|
||||
Text line input with dynamic math display (equation rendered on client in real time
|
||||
during input).
|
||||
'''
|
||||
# TODO: Make a wrapper for <formulainput>
|
||||
# TODO: Make an AJAX loop to confirm equation is okay in real-time as user types
|
||||
'''
|
||||
textline is used for simple one-line inputs, like formularesponse and symbolicresponse.
|
||||
uses a <span id=display_eid>`{::}`</span>
|
||||
and a hidden textarea with id=input_eid_fromjs for the mathjax rendering and return.
|
||||
'''
|
||||
eid = element.get('id')
|
||||
count = int(eid.split('_')[-2]) - 1 # HACK
|
||||
size = element.get('size')
|
||||
# if specified, then textline is hidden and id is stored in div of name given by hidden
|
||||
hidden = element.get('hidden', '')
|
||||
|
||||
# Preprocessor to insert between raw input and Mathjax
|
||||
preprocessor = {'class_name': element.get('preprocessorClassName',''),
|
||||
'script_src': element.get('preprocessorSrc','')}
|
||||
if '' in preprocessor.values():
|
||||
preprocessor = None
|
||||
|
||||
# Escape characters in student input for safe XML parsing
|
||||
escapedict = {'"': '"'}
|
||||
value = saxutils.escape(value, escapedict)
|
||||
|
||||
context = {'id': eid,
|
||||
'value': value,
|
||||
'state': status,
|
||||
'count': count,
|
||||
'size': size,
|
||||
'msg': msg,
|
||||
'hidden': hidden,
|
||||
'preprocessor': preprocessor,}
|
||||
html = render_template("textinput_dynamath.html", context)
|
||||
return etree.XML(html)
|
||||
registry.register(JavascriptInput)
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
def filesubmission(element, value, status, render_template, msg=''):
|
||||
'''
|
||||
Upload a single file (e.g. for programming assignments)
|
||||
'''
|
||||
eid = element.get('id')
|
||||
escapedict = {'"': '"'}
|
||||
allowed_files = json.dumps(element.get('allowed_files', '').split())
|
||||
allowed_files = saxutils.escape(allowed_files, escapedict)
|
||||
required_files = json.dumps(element.get('required_files', '').split())
|
||||
required_files = saxutils.escape(required_files, escapedict)
|
||||
|
||||
# Check if problem has been queued
|
||||
queue_len = 0
|
||||
# Flag indicating that the problem has been queued, 'msg' is length of queue
|
||||
if status == 'incomplete':
|
||||
status = 'queued'
|
||||
queue_len = msg
|
||||
msg = 'Submitted to grader.'
|
||||
class TextLine(InputTypeBase):
|
||||
"""
|
||||
A text line input. Can do math preview if "math"="1" is specified.
|
||||
|
||||
context = { 'id': eid,
|
||||
'state': status,
|
||||
'msg': msg,
|
||||
'value': value,
|
||||
'queue_len': queue_len,
|
||||
'allowed_files': allowed_files,
|
||||
'required_files': required_files,}
|
||||
html = render_template("filesubmission.html", context)
|
||||
return etree.XML(html)
|
||||
If the hidden attribute is specified, the textline is hidden and the input id is stored in a div with name equal
|
||||
to the value of the hidden attribute. This is used e.g. for embedding simulations turned into questions.
|
||||
"""
|
||||
|
||||
_reg(filesubmission)
|
||||
template = "textline.html"
|
||||
tags = ['textline']
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
## TODO: Make a wrapper for <codeinput>
|
||||
def textbox(element, value, status, render_template, msg=''):
|
||||
'''
|
||||
The textbox is used for code input. The message is the return HTML string from
|
||||
evaluating the code, eg error messages, and output from the code tests.
|
||||
|
||||
'''
|
||||
eid = element.get('id')
|
||||
count = int(eid.split('_')[-2]) - 1 # HACK
|
||||
size = element.get('size')
|
||||
rows = element.get('rows') or '30'
|
||||
cols = element.get('cols') or '80'
|
||||
# if specified, then textline is hidden and id is stored in div of name given by hidden
|
||||
hidden = element.get('hidden', '')
|
||||
|
||||
# if no student input yet, then use the default input given by the problem
|
||||
if not value:
|
||||
value = element.text
|
||||
|
||||
# Check if problem has been queued
|
||||
queue_len = 0
|
||||
# Flag indicating that the problem has been queued, 'msg' is length of queue
|
||||
if status == 'incomplete':
|
||||
status = 'queued'
|
||||
queue_len = msg
|
||||
msg = 'Submitted to grader.'
|
||||
|
||||
# For CodeMirror
|
||||
mode = element.get('mode','python')
|
||||
linenumbers = element.get('linenumbers','true')
|
||||
tabsize = element.get('tabsize','4')
|
||||
tabsize = int(tabsize)
|
||||
|
||||
context = {'id': eid,
|
||||
'value': value,
|
||||
'state': status,
|
||||
'count': count,
|
||||
'size': size,
|
||||
'msg': msg,
|
||||
'mode': mode,
|
||||
'linenumbers': linenumbers,
|
||||
'rows': rows,
|
||||
'cols': cols,
|
||||
'hidden': hidden,
|
||||
'tabsize': tabsize,
|
||||
'queue_len': queue_len,
|
||||
}
|
||||
html = render_template("textbox.html", context)
|
||||
try:
|
||||
xhtml = etree.XML(html)
|
||||
except Exception as err:
|
||||
newmsg = 'error %s in rendering message' % (str(err).replace('<', '<'))
|
||||
newmsg += '<br/>Original message: %s' % msg.replace('<', '<')
|
||||
context['msg'] = newmsg
|
||||
html = render_template("textbox.html", context)
|
||||
xhtml = etree.XML(html)
|
||||
return xhtml
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Register the attributes.
|
||||
"""
|
||||
return [
|
||||
Attribute('size', None),
|
||||
|
||||
|
||||
_reg(textbox)
|
||||
Attribute('hidden', False),
|
||||
Attribute('inline', False),
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
def schematic(element, value, status, render_template, msg=''):
|
||||
eid = element.get('id')
|
||||
height = element.get('height')
|
||||
width = element.get('width')
|
||||
parts = element.get('parts')
|
||||
analyses = element.get('analyses')
|
||||
initial_value = element.get('initial_value')
|
||||
submit_analyses = element.get('submit_analyses')
|
||||
context = {
|
||||
'id': eid,
|
||||
'value': value,
|
||||
'initial_value': initial_value,
|
||||
'state': status,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'parts': parts,
|
||||
'analyses': analyses,
|
||||
'submit_analyses': submit_analyses,
|
||||
}
|
||||
html = render_template("schematicinput.html", context)
|
||||
return etree.XML(html)
|
||||
# Attributes below used in setup(), not rendered directly.
|
||||
Attribute('math', None, render=False),
|
||||
# TODO: 'dojs' flag is temporary, for backwards compatibility with 8.02x
|
||||
Attribute('dojs', None, render=False),
|
||||
Attribute('preprocessorClassName', None, render=False),
|
||||
Attribute('preprocessorSrc', None, render=False),
|
||||
]
|
||||
|
||||
_reg(schematic)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
### TODO: Move out of inputtypes
|
||||
def math(element, value, status, render_template, msg=''):
|
||||
'''
|
||||
This is not really an input type. It is a convention from Lon-CAPA, used for
|
||||
displaying a math equation.
|
||||
def setup(self):
|
||||
self.do_math = bool(self.loaded_attributes['math'] or
|
||||
self.loaded_attributes['dojs'])
|
||||
|
||||
Examples:
|
||||
# TODO: do math checking using ajax instead of using js, so
|
||||
# that we only have one math parser.
|
||||
self.preprocessor = None
|
||||
if self.do_math:
|
||||
# Preprocessor to insert between raw input and Mathjax
|
||||
self.preprocessor = {'class_name': self.loaded_attributes['preprocessorClassName'],
|
||||
'script_src': self.loaded_attributes['preprocessorSrc']}
|
||||
if None in self.preprocessor.values():
|
||||
self.preprocessor = None
|
||||
|
||||
<m display="jsmath">$\displaystyle U(r)=4 U_0 </m>
|
||||
<m>$r_0$</m>
|
||||
|
||||
We convert these to [mathjax]...[/mathjax] and [mathjaxinline]...[/mathjaxinline]
|
||||
def _extra_context(self):
|
||||
return {'do_math': self.do_math,
|
||||
'preprocessor': self.preprocessor,}
|
||||
|
||||
TODO: use shorter tags (but this will require converting problem XML files!)
|
||||
'''
|
||||
mathstr = re.sub('\$(.*)\$', '[mathjaxinline]\\1[/mathjaxinline]', element.text)
|
||||
mtag = 'mathjax'
|
||||
if not '\\displaystyle' in mathstr: mtag += 'inline'
|
||||
else: mathstr = mathstr.replace('\\displaystyle', '')
|
||||
mathstr = mathstr.replace('mathjaxinline]', '%s]' % mtag)
|
||||
|
||||
#if '\\displaystyle' in mathstr:
|
||||
# isinline = False
|
||||
# mathstr = mathstr.replace('\\displaystyle','')
|
||||
#else:
|
||||
# isinline = True
|
||||
# html = render_template("mathstring.html", {'mathstr':mathstr,
|
||||
# 'isinline':isinline,'tail':element.tail})
|
||||
|
||||
html = '<html><html>%s</html><html>%s</html></html>' % (mathstr, saxutils.escape(element.tail))
|
||||
try:
|
||||
xhtml = etree.XML(html)
|
||||
except Exception as err:
|
||||
if False: # TODO needs to be self.system.DEBUG - but can't access system
|
||||
msg = '<html><div class="inline-error"><p>Error %s</p>' % str(err).replace('<', '<')
|
||||
msg += ('<p>Failed to construct math expression from <pre>%s</pre></p>' %
|
||||
html.replace('<', '<'))
|
||||
msg += "</div></html>"
|
||||
log.error(msg)
|
||||
return etree.XML(msg)
|
||||
else:
|
||||
raise
|
||||
# xhtml.tail = element.tail # don't forget to include the tail!
|
||||
return xhtml
|
||||
|
||||
_reg(math)
|
||||
registry.register(TextLine)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class FileSubmission(InputTypeBase):
|
||||
"""
|
||||
Upload some files (e.g. for programming assignments)
|
||||
"""
|
||||
|
||||
def solution(element, value, status, render_template, msg=''):
|
||||
'''
|
||||
This is not really an input type. It is just a <span>...</span> which is given an ID,
|
||||
that is used for displaying an extended answer (a problem "solution") after "show answers"
|
||||
is pressed. Note that the solution content is NOT sent with the HTML. It is obtained
|
||||
by an ajax call.
|
||||
'''
|
||||
eid = element.get('id')
|
||||
size = element.get('size')
|
||||
context = {'id': eid,
|
||||
'value': value,
|
||||
'state': status,
|
||||
'size': size,
|
||||
'msg': msg,
|
||||
}
|
||||
html = render_template("solutionspan.html", context)
|
||||
return etree.XML(html)
|
||||
template = "filesubmission.html"
|
||||
tags = ['filesubmission']
|
||||
|
||||
# pulled out for testing
|
||||
submitted_msg = ("Your file(s) have been submitted; as soon as your submission is"
|
||||
" graded, this message will be replaced with the grader's feedback.")
|
||||
|
||||
@staticmethod
|
||||
def parse_files(files):
|
||||
"""
|
||||
Given a string like 'a.py b.py c.out', split on whitespace and return as a json list.
|
||||
"""
|
||||
return json.dumps(files.split())
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Convert the list of allowed files to a convenient format.
|
||||
"""
|
||||
return [Attribute('allowed_files', '[]', transform=cls.parse_files),
|
||||
Attribute('required_files', '[]', transform=cls.parse_files),]
|
||||
|
||||
def setup(self):
|
||||
"""
|
||||
Do some magic to handle queueing status (render as "queued" instead of "incomplete"),
|
||||
pull queue_len from the msg field. (TODO: get rid of the queue_len hack).
|
||||
"""
|
||||
# Check if problem has been queued
|
||||
self.queue_len = 0
|
||||
# Flag indicating that the problem has been queued, 'msg' is length of queue
|
||||
if self.status == 'incomplete':
|
||||
self.status = 'queued'
|
||||
self.queue_len = self.msg
|
||||
self.msg = FileSubmission.submitted_msg
|
||||
|
||||
def _extra_context(self):
|
||||
return {'queue_len': self.queue_len,}
|
||||
return context
|
||||
|
||||
registry.register(FileSubmission)
|
||||
|
||||
_reg(solution)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class CodeInput(InputTypeBase):
|
||||
"""
|
||||
A text area input for code--uses codemirror, does syntax highlighting, special tab handling,
|
||||
etc.
|
||||
"""
|
||||
|
||||
def imageinput(element, value, status, render_template, msg=''):
|
||||
'''
|
||||
template = "codeinput.html"
|
||||
tags = ['codeinput',
|
||||
'textbox', # Another (older) name--at some point we may want to make it use a
|
||||
# non-codemirror editor.
|
||||
]
|
||||
|
||||
# pulled out for testing
|
||||
submitted_msg = ("Submitted. As soon as your submission is"
|
||||
" graded, this message will be replaced with the grader's feedback.")
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Convert options to a convenient format.
|
||||
"""
|
||||
return [Attribute('rows', '30'),
|
||||
Attribute('cols', '80'),
|
||||
Attribute('hidden', ''),
|
||||
|
||||
# For CodeMirror
|
||||
Attribute('mode', 'python'),
|
||||
Attribute('linenumbers', 'true'),
|
||||
# Template expects tabsize to be an int it can do math with
|
||||
Attribute('tabsize', 4, transform=int),
|
||||
]
|
||||
|
||||
def setup(self):
|
||||
"""
|
||||
Implement special logic: handle queueing state, and default input.
|
||||
"""
|
||||
# if no student input yet, then use the default input given by the problem
|
||||
if not self.value:
|
||||
self.value = self.xml.text
|
||||
|
||||
# Check if problem has been queued
|
||||
self.queue_len = 0
|
||||
# Flag indicating that the problem has been queued, 'msg' is length of queue
|
||||
if self.status == 'incomplete':
|
||||
self.status = 'queued'
|
||||
self.queue_len = self.msg
|
||||
self.msg = self.submitted_msg
|
||||
|
||||
def _extra_context(self):
|
||||
"""Defined queue_len, add it """
|
||||
return {'queue_len': self.queue_len,}
|
||||
|
||||
registry.register(CodeInput)
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
class Schematic(InputTypeBase):
|
||||
"""
|
||||
"""
|
||||
|
||||
template = "schematicinput.html"
|
||||
tags = ['schematic']
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Convert options to a convenient format.
|
||||
"""
|
||||
return [
|
||||
Attribute('height', None),
|
||||
Attribute('width', None),
|
||||
Attribute('parts', None),
|
||||
Attribute('analyses', None),
|
||||
Attribute('initial_value', None),
|
||||
Attribute('submit_analyses', None),]
|
||||
|
||||
return context
|
||||
|
||||
registry.register(Schematic)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class ImageInput(InputTypeBase):
|
||||
"""
|
||||
Clickable image as an input field. Element should specify the image source, height,
|
||||
and width, e.g.
|
||||
|
||||
@@ -678,79 +619,91 @@ def imageinput(element, value, status, render_template, msg=''):
|
||||
|
||||
TODO: showanswer for imageimput does not work yet - need javascript to put rectangle
|
||||
over acceptable area of image.
|
||||
'''
|
||||
eid = element.get('id')
|
||||
src = element.get('src')
|
||||
height = element.get('height')
|
||||
width = element.get('width')
|
||||
"""
|
||||
|
||||
# if value is of the form [x,y] then parse it and send along coordinates of previous answer
|
||||
m = re.match('\[([0-9]+),([0-9]+)]', value.strip().replace(' ', ''))
|
||||
if m:
|
||||
(gx, gy) = [int(x) - 15 for x in m.groups()]
|
||||
else:
|
||||
(gx, gy) = (0, 0)
|
||||
template = "imageinput.html"
|
||||
tags = ['imageinput']
|
||||
|
||||
context = {
|
||||
'id': eid,
|
||||
'value': value,
|
||||
'height': height,
|
||||
'width': width,
|
||||
'src': src,
|
||||
'gx': gx,
|
||||
'gy': gy,
|
||||
'state': status, # to change
|
||||
'msg': msg, # to change
|
||||
}
|
||||
html = render_template("imageinput.html", context)
|
||||
return etree.XML(html)
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Note: src, height, and width are all required.
|
||||
"""
|
||||
return [Attribute('src'),
|
||||
Attribute('height'),
|
||||
Attribute('width'),]
|
||||
|
||||
_reg(imageinput)
|
||||
|
||||
def setup(self):
|
||||
"""
|
||||
if value is of the form [x,y] then parse it and send along coordinates of previous answer
|
||||
"""
|
||||
m = re.match('\[([0-9]+),([0-9]+)]', self.value.strip().replace(' ', ''))
|
||||
if m:
|
||||
# Note: we subtract 15 to compensate for the size of the dot on the screen.
|
||||
# (is a 30x30 image--lms/static/green-pointer.png).
|
||||
(self.gx, self.gy) = [int(x) - 15 for x in m.groups()]
|
||||
else:
|
||||
(self.gx, self.gy) = (0, 0)
|
||||
|
||||
|
||||
def _extra_context(self):
|
||||
|
||||
return {'gx': self.gx,
|
||||
'gy': self.gy}
|
||||
|
||||
registry.register(ImageInput)
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
def crystallography(element, value, status, render_template, msg=''):
|
||||
eid = element.get('id')
|
||||
if eid is None:
|
||||
msg = 'cryst has no id: it probably appears outside of a known response type'
|
||||
msg += "\nSee problem XML source line %s" % getattr(element, 'sourceline', '<unavailable>')
|
||||
raise Exception(msg)
|
||||
height = element.get('height')
|
||||
width = element.get('width')
|
||||
display_file = element.get('display_file')
|
||||
|
||||
count = int(eid.split('_')[-2]) - 1 # HACK
|
||||
size = element.get('size')
|
||||
# if specified, then textline is hidden and id is stored in div of name given by hidden
|
||||
hidden = element.get('hidden', '')
|
||||
# Escape answers with quotes, so they don't crash the system!
|
||||
escapedict = {'"': '"'}
|
||||
value = saxutils.escape(value, escapedict)
|
||||
class Crystallography(InputTypeBase):
|
||||
"""
|
||||
An input for crystallography -- user selects 3 points on the axes, and we get a plane.
|
||||
|
||||
context = {'id': eid,
|
||||
'value': value,
|
||||
'state': status,
|
||||
'count': count,
|
||||
'size': size,
|
||||
'msg': msg,
|
||||
'hidden': hidden,
|
||||
'inline': element.get('inline', ''),
|
||||
'width': width,
|
||||
'height': height,
|
||||
'display_file': display_file,
|
||||
}
|
||||
TODO: what's the actual value format?
|
||||
"""
|
||||
|
||||
html = render_template("crystallography.html", context)
|
||||
try:
|
||||
xhtml = etree.XML(html)
|
||||
except Exception as err:
|
||||
# TODO: needs to be self.system.DEBUG - but can't access system
|
||||
if True:
|
||||
log.debug('[inputtypes.textline] failed to parse XML for:\n%s' % html)
|
||||
raise
|
||||
return xhtml
|
||||
template = "crystallography.html"
|
||||
tags = ['crystallography']
|
||||
|
||||
_reg(crystallography)
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Note: height, width are required.
|
||||
"""
|
||||
return [Attribute('size', None),
|
||||
Attribute('height'),
|
||||
Attribute('width'),
|
||||
|
||||
# can probably be removed (textline should prob be always-hidden)
|
||||
Attribute('hidden', ''),
|
||||
]
|
||||
|
||||
registry.register(Crystallography)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
class VseprInput(InputTypeBase):
|
||||
"""
|
||||
Input for molecular geometry--show possible structures, let student
|
||||
pick structure and label positions with atoms or electron pairs.
|
||||
"""
|
||||
|
||||
template = 'vsepr_input.html'
|
||||
tags = ['vsepr_input']
|
||||
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Note: height, width are required.
|
||||
"""
|
||||
return [Attribute('height'),
|
||||
Attribute('width'),
|
||||
Attribute('molecules'),
|
||||
Attribute('geometries'),
|
||||
]
|
||||
|
||||
registry.register(VseprInput)
|
||||
|
||||
#--------------------------------------------------------------------------------
|
||||
|
||||
@@ -769,15 +722,17 @@ class ChemicalEquationInput(InputTypeBase):
|
||||
template = "chemicalequationinput.html"
|
||||
tags = ['chemicalequationinput']
|
||||
|
||||
def _get_render_context(self):
|
||||
size = self.xml.get('size', '20')
|
||||
context = {
|
||||
'id': self.id,
|
||||
'value': self.value,
|
||||
'status': self.status,
|
||||
'size': size,
|
||||
'previewer': '/static/js/capa/chemical_equation_preview.js',
|
||||
}
|
||||
return context
|
||||
@classmethod
|
||||
def get_attributes(cls):
|
||||
"""
|
||||
Can set size of text field.
|
||||
"""
|
||||
return [Attribute('size', '20'),]
|
||||
|
||||
register_input_class(ChemicalEquationInput)
|
||||
def _extra_context(self):
|
||||
"""
|
||||
TODO (vshnayder): Get rid of this once we have a standard way of requiring js to be loaded.
|
||||
"""
|
||||
return {'previewer': '/static/js/capa/chemical_equation_preview.js',}
|
||||
|
||||
registry.register(ChemicalEquationInput)
|
||||
|
||||
49
common/lib/capa/capa/registry.py
Normal file
49
common/lib/capa/capa/registry.py
Normal file
@@ -0,0 +1,49 @@
|
||||
class TagRegistry(object):
|
||||
"""
|
||||
A registry mapping tags to handlers.
|
||||
|
||||
(A dictionary with some extra error checking.)
|
||||
"""
|
||||
def __init__(self):
|
||||
self._mapping = {}
|
||||
|
||||
def register(self, cls):
|
||||
"""
|
||||
Register cls as a supported tag type. It is expected to define cls.tags as a list of tags
|
||||
that it implements.
|
||||
|
||||
If an already-registered type has registered one of those tags, will raise ValueError.
|
||||
|
||||
If there are no tags in cls.tags, will also raise ValueError.
|
||||
"""
|
||||
|
||||
# Do all checks and complain before changing any state.
|
||||
if len(cls.tags) == 0:
|
||||
raise ValueError("No tags specified for class {0}".format(cls.__name__))
|
||||
|
||||
for t in cls.tags:
|
||||
if t in self._mapping:
|
||||
other_cls = self._mapping[t]
|
||||
if cls == other_cls:
|
||||
# registering the same class multiple times seems silly, but ok
|
||||
continue
|
||||
raise ValueError("Tag {0} already registered by class {1}."
|
||||
" Can't register for class {2}"
|
||||
.format(t, other_cls.__name__, cls.__name__))
|
||||
|
||||
# Ok, should be good to change state now.
|
||||
for t in cls.tags:
|
||||
self._mapping[t] = cls
|
||||
|
||||
def registered_tags(self):
|
||||
"""
|
||||
Get a list of all the tags that have been registered.
|
||||
"""
|
||||
return self._mapping.keys()
|
||||
|
||||
def get_class_for_tag(self, tag):
|
||||
"""
|
||||
For any tag in registered_tags(), returns the corresponding class. Otherwise, will raise
|
||||
KeyError.
|
||||
"""
|
||||
return self._mapping[tag]
|
||||
@@ -81,7 +81,7 @@ class LoncapaResponse(object):
|
||||
by __init__
|
||||
|
||||
- check_hint_condition : check to see if the student's answers satisfy a particular
|
||||
condition for a hint to be displayed
|
||||
condition for a hint to be displayed
|
||||
|
||||
- render_html : render this Response as HTML (must return XHTML-compliant string)
|
||||
- __unicode__ : unicode representation of this Response
|
||||
@@ -148,6 +148,7 @@ class LoncapaResponse(object):
|
||||
# for convenience
|
||||
self.answer_id = self.answer_ids[0]
|
||||
|
||||
# map input_id -> maxpoints
|
||||
self.maxpoints = dict()
|
||||
for inputfield in self.inputfields:
|
||||
# By default, each answerfield is worth 1 point
|
||||
@@ -284,17 +285,14 @@ class LoncapaResponse(object):
|
||||
(correctness, npoints, msg) for each answer_id.
|
||||
|
||||
Arguments:
|
||||
- student_answers : dict of (answer_id,answer) where answer = student input (string)
|
||||
|
||||
- old_cmap : previous CorrectMap (may be empty); useful for analyzing or
|
||||
recording history of responses
|
||||
- student_answers : dict of (answer_id, answer) where answer = student input (string)
|
||||
'''
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_answers(self):
|
||||
'''
|
||||
Return a dict of (answer_id,answer_text) for each answer for this question.
|
||||
Return a dict of (answer_id, answer_text) for each answer for this question.
|
||||
'''
|
||||
pass
|
||||
|
||||
@@ -871,7 +869,8 @@ def sympy_check2():
|
||||
</customresponse>"""}]
|
||||
|
||||
response_tag = 'customresponse'
|
||||
allowed_inputfields = ['textline', 'textbox', 'crystallography', 'chemicalequationinput']
|
||||
|
||||
allowed_inputfields = ['textline', 'textbox', 'crystallography', 'chemicalequationinput', 'vsepr_input']
|
||||
|
||||
def setup_response(self):
|
||||
xml = self.xml
|
||||
@@ -1720,7 +1719,7 @@ class ImageResponse(LoncapaResponse):
|
||||
"""
|
||||
Handle student response for image input: the input is a click on an image,
|
||||
which produces an [x,y] coordinate pair. The click is correct if it falls
|
||||
within a region specified. This region is nominally a rectangle.
|
||||
within a region specified. This region is a union of rectangles.
|
||||
|
||||
Lon-CAPA requires that each <imageresponse> has a <foilgroup> inside it. That
|
||||
doesn't make sense to me (Ike). Instead, let's have it such that <imageresponse>
|
||||
@@ -1730,6 +1729,7 @@ class ImageResponse(LoncapaResponse):
|
||||
snippets = [{'snippet': '''<imageresponse>
|
||||
<imageinput src="image1.jpg" width="200" height="100" rectangle="(10,10)-(20,30)" />
|
||||
<imageinput src="image2.jpg" width="210" height="130" rectangle="(12,12)-(40,60)" />
|
||||
<imageinput src="image2.jpg" width="210" height="130" rectangle="(10,10)-(20,30);(12,12)-(40,60)" />
|
||||
</imageresponse>'''}]
|
||||
|
||||
response_tag = 'imageresponse'
|
||||
@@ -1746,20 +1746,10 @@ class ImageResponse(LoncapaResponse):
|
||||
for aid in self.answer_ids: # loop through IDs of <imageinput> fields in our stanza
|
||||
given = student_answers[aid] # this should be a string of the form '[x,y]'
|
||||
|
||||
correct_map.set(aid, 'incorrect')
|
||||
if not given: # No answer to parse. Mark as incorrect and move on
|
||||
correct_map.set(aid, 'incorrect')
|
||||
continue
|
||||
|
||||
# parse expected answer
|
||||
# TODO: Compile regexp on file load
|
||||
m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
|
||||
expectedset[aid].strip().replace(' ', ''))
|
||||
if not m:
|
||||
msg = 'Error in problem specification! cannot parse rectangle in %s' % (
|
||||
etree.tostring(self.ielements[aid], pretty_print=True))
|
||||
raise Exception('[capamodule.capa.responsetypes.imageinput] ' + msg)
|
||||
(llx, lly, urx, ury) = [int(x) for x in m.groups()]
|
||||
|
||||
# parse given answer
|
||||
m = re.match('\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
|
||||
if not m:
|
||||
@@ -1767,11 +1757,24 @@ class ImageResponse(LoncapaResponse):
|
||||
'error grading %s (input=%s)' % (aid, given))
|
||||
(gx, gy) = [int(x) for x in m.groups()]
|
||||
|
||||
# answer is correct if (x,y) is within the specified rectangle
|
||||
if (llx <= gx <= urx) and (lly <= gy <= ury):
|
||||
correct_map.set(aid, 'correct')
|
||||
else:
|
||||
correct_map.set(aid, 'incorrect')
|
||||
# Check whether given point lies in any of the solution rectangles
|
||||
solution_rectangles = expectedset[aid].split(';')
|
||||
for solution_rectangle in solution_rectangles:
|
||||
# parse expected answer
|
||||
# TODO: Compile regexp on file load
|
||||
m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]',
|
||||
solution_rectangle.strip().replace(' ', ''))
|
||||
if not m:
|
||||
msg = 'Error in problem specification! cannot parse rectangle in %s' % (
|
||||
etree.tostring(self.ielements[aid], pretty_print=True))
|
||||
raise Exception('[capamodule.capa.responsetypes.imageinput] ' + msg)
|
||||
(llx, lly, urx, ury) = [int(x) for x in m.groups()]
|
||||
|
||||
# answer is correct if (x,y) is within the specified rectangle
|
||||
if (llx <= gx <= urx) and (lly <= gy <= ury):
|
||||
correct_map.set(aid, 'correct')
|
||||
break
|
||||
|
||||
return correct_map
|
||||
|
||||
def get_answers(self):
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<form class="choicegroup capa_inputtype" id="inputtype_${id}">
|
||||
<div class="indicator_container">
|
||||
% if state == 'unsubmitted':
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif state == 'correct':
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
% elif state == 'incorrect':
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% elif state == 'incomplete':
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% endif
|
||||
</div>
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
>${value|h}</textarea>
|
||||
|
||||
<div class="grader-status">
|
||||
% if state == 'unsubmitted':
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
|
||||
% elif state == 'correct':
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}">Correct</span>
|
||||
% elif state == 'incorrect':
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}">Incorrect</span>
|
||||
% elif state == 'queued':
|
||||
% elif status == 'queued':
|
||||
<span class="processing" id="status_${id}">Queued</span>
|
||||
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
|
||||
% endif
|
||||
@@ -21,7 +21,7 @@
|
||||
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
|
||||
% endif
|
||||
|
||||
<p class="debug">${state}</p>
|
||||
<p class="debug">${status}</p>
|
||||
</div>
|
||||
|
||||
<span id="answer_${id}"></span>
|
||||
@@ -1,25 +1,25 @@
|
||||
<% doinline = "inline" if inline else "" %>
|
||||
|
||||
<section id="textinput_${id}" class="textinput ${doinline}" >
|
||||
<section id="inputtype_${id}" class="capa_inputtype" >
|
||||
<div id="holder" style="width:${width};height:${height}"></div>
|
||||
<div class="script_placeholder" data-src="/static/js/raphael.js"></div><div class="script_placeholder" data-src="/static/js/sylvester.js"></div><div class="script_placeholder" data-src="/static/js/underscore-min.js"></div>
|
||||
<div class="script_placeholder" data-src="/static/js/raphael.js"></div>
|
||||
<div class="script_placeholder" data-src="/static/js/sylvester.js"></div>
|
||||
<div class="script_placeholder" data-src="/static/js/underscore-min.js"></div>
|
||||
<div class="script_placeholder" data-src="/static/js/crystallography.js"></div>
|
||||
|
||||
|
||||
% if state == 'unsubmitted':
|
||||
<div class="unanswered ${doinline}" id="status_${id}">
|
||||
% elif state == 'correct':
|
||||
<div class="correct ${doinline}" id="status_${id}">
|
||||
% elif state == 'incorrect':
|
||||
<div class="incorrect ${doinline}" id="status_${id}">
|
||||
% elif state == 'incomplete':
|
||||
<div class="incorrect ${doinline}" id="status_${id}">
|
||||
% if status == 'unsubmitted':
|
||||
<div class="unanswered" id="status_${id}">
|
||||
% elif status == 'correct':
|
||||
<div class="correct" id="status_${id}">
|
||||
% elif status == 'incorrect':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% elif status == 'incomplete':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
% if hidden:
|
||||
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value}"
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
% if size:
|
||||
size="${size}"
|
||||
% endif
|
||||
@@ -29,13 +29,13 @@
|
||||
/>
|
||||
|
||||
<p class="status">
|
||||
% if state == 'unsubmitted':
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif state == 'correct':
|
||||
% elif status == 'correct':
|
||||
correct
|
||||
% elif state == 'incorrect':
|
||||
% elif status == 'incorrect':
|
||||
incorrect
|
||||
% elif state == 'incomplete':
|
||||
% elif status == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
@@ -45,7 +45,7 @@
|
||||
% if msg:
|
||||
<span class="message">${msg|n}</span>
|
||||
% endif
|
||||
% if state in ['unsubmitted', 'correct', 'incorrect', 'incomplete'] or hidden:
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<section id="filesubmission_${id}" class="filesubmission">
|
||||
<div class="grader-status file">
|
||||
% if state == 'unsubmitted':
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
|
||||
% elif state == 'correct':
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}">Correct</span>
|
||||
% elif state == 'incorrect':
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}">Incorrect</span>
|
||||
% elif state == 'queued':
|
||||
% elif status == 'queued':
|
||||
<span class="processing" id="status_${id}">Queued</span>
|
||||
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
|
||||
% endif
|
||||
<p class="debug">${state}</p>
|
||||
<p class="debug">${status}</p>
|
||||
|
||||
<input type="file" name="input_${id}" id="input_${id}" value="${value}" multiple="multiple" data-required_files="${required_files}" data-allowed_files="${allowed_files}"/>
|
||||
<input type="file" name="input_${id}" id="input_${id}" value="${value}" multiple="multiple" data-required_files="${required_files|h}" data-allowed_files="${allowed_files|h}"/>
|
||||
</div>
|
||||
<div class="message">${msg|n}</div>
|
||||
</section>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<img src="/static/green-pointer.png" id="cross_${id}" style="position: absolute;top: ${gy}px;left: ${gx}px;" />
|
||||
</div>
|
||||
|
||||
% if state == 'unsubmitted':
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif state == 'correct':
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
% elif state == 'incorrect':
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% elif state == 'incomplete':
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% endif
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<input type="hidden" name="input_${id}" id="input_${id}" class="javascriptinput_input"/>
|
||||
<div class="javascriptinput_data" data-display_class="${display_class}"
|
||||
data-problem_state="${problem_state}" data-params="${params}"
|
||||
data-submission="${value}" data-evaluation="${evaluation}">
|
||||
data-submission="${value|h}" data-evaluation="${msg|h}">
|
||||
</div>
|
||||
<div class="script_placeholder" data-src="/static/js/${display_file}"></div>
|
||||
<div class="javascriptinput_container"></div>
|
||||
|
||||
@@ -18,13 +18,13 @@
|
||||
<textarea style="display:none" id="input_${id}_fromjs" name="input_${id}_fromjs"></textarea>
|
||||
% endif
|
||||
|
||||
% if state == 'unsubmitted':
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif state == 'correct':
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
% elif state == 'incorrect':
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% elif state == 'incomplete':
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% endif
|
||||
% if msg:
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
|
||||
<span id="answer_${id}"></span>
|
||||
|
||||
% if state == 'unsubmitted':
|
||||
% if status == 'unsubmitted':
|
||||
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif state == 'correct':
|
||||
% elif status == 'correct':
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
% elif state == 'incorrect':
|
||||
% elif status == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% elif state == 'incomplete':
|
||||
% elif status == 'incomplete':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% endif
|
||||
</form>
|
||||
|
||||
@@ -12,13 +12,13 @@
|
||||
</script>
|
||||
|
||||
<span id="answer_${id}"></span>
|
||||
% if state == 'unsubmitted':
|
||||
% if status == 'unsubmitted':
|
||||
<span class="ui-icon ui-icon-bullet" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif state == 'correct':
|
||||
% elif status == 'correct':
|
||||
<span class="ui-icon ui-icon-check" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif state == 'incorrect':
|
||||
% elif status == 'incorrect':
|
||||
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}"></span>
|
||||
% elif state == 'incomplete':
|
||||
% elif status == 'incomplete':
|
||||
<span class="ui-icon ui-icon-close" style="display:inline-block;" id="status_${id}"></span>
|
||||
% endif
|
||||
</span>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<% doinline = "inline" if inline else "" %>
|
||||
|
||||
<section id="textinput_${id}" class="textinput ${doinline}" >
|
||||
% if state == 'unsubmitted':
|
||||
<div class="unanswered ${doinline}" id="status_${id}">
|
||||
% elif state == 'correct':
|
||||
<div class="correct ${doinline}" id="status_${id}">
|
||||
% elif state == 'incorrect':
|
||||
<div class="incorrect ${doinline}" id="status_${id}">
|
||||
% elif state == 'incomplete':
|
||||
<div class="incorrect ${doinline}" id="status_${id}">
|
||||
% endif
|
||||
% if hidden:
|
||||
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value}"
|
||||
% if size:
|
||||
size="${size}"
|
||||
% endif
|
||||
% if hidden:
|
||||
style="display:none;"
|
||||
% endif
|
||||
/>
|
||||
|
||||
<p class="status">
|
||||
% if state == 'unsubmitted':
|
||||
unanswered
|
||||
% elif state == 'correct':
|
||||
correct
|
||||
% elif state == 'incorrect':
|
||||
incorrect
|
||||
% elif state == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
% if msg:
|
||||
<span class="message">${msg|n}</span>
|
||||
% endif
|
||||
% if state in ['unsubmitted', 'correct', 'incorrect', 'incomplete'] or hidden:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
@@ -1,50 +0,0 @@
|
||||
###
|
||||
### version of textline.html which does dynamic math
|
||||
###
|
||||
<section class="text-input-dynamath capa_inputtype" id="inputtype_${id}">
|
||||
|
||||
% if preprocessor is not None:
|
||||
<div class="text-input-dynamath_data" data-preprocessor="${preprocessor['class_name']}"/>
|
||||
<div class="script_placeholder" data-src="${preprocessor['script_src']}"/>
|
||||
% endif
|
||||
|
||||
% if state == 'unsubmitted':
|
||||
<div class="unanswered" id="status_${id}">
|
||||
% elif state == 'correct':
|
||||
<div class="correct" id="status_${id}">
|
||||
% elif state == 'incorrect':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% elif state == 'incomplete':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
% if hidden:
|
||||
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value}" class="math" size="${size if size else ''}"
|
||||
% if hidden:
|
||||
style="display:none;"
|
||||
% endif
|
||||
/>
|
||||
<p class="status">
|
||||
% if state == 'unsubmitted':
|
||||
unanswered
|
||||
% elif state == 'correct':
|
||||
correct
|
||||
% elif state == 'incorrect':
|
||||
incorrect
|
||||
% elif state == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
<div id="display_${id}" class="equation">`{::}`</div>
|
||||
|
||||
</div>
|
||||
<textarea style="display:none" id="input_${id}_dynamath" name="input_${id}_dynamath"> </textarea>
|
||||
% if msg:
|
||||
<span class="message">${msg|n}</span>
|
||||
% endif
|
||||
</section>
|
||||
64
common/lib/capa/capa/templates/textline.html
Normal file
64
common/lib/capa/capa/templates/textline.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<% doinline = "inline" if inline else "" %>
|
||||
|
||||
<section id="inputtype_${id}" class="${'text-input-dynamath' if do_math else ''} capa_inputtype ${doinline}" >
|
||||
|
||||
% if preprocessor is not None:
|
||||
<div class="text-input-dynamath_data" data-preprocessor="${preprocessor['class_name']}"/>
|
||||
<div class="script_placeholder" data-src="${preprocessor['script_src']}"/>
|
||||
% endif
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<div class="unanswered ${doinline}" id="status_${id}">
|
||||
% elif status == 'correct':
|
||||
<div class="correct ${doinline}" id="status_${id}">
|
||||
% elif status == 'incorrect':
|
||||
<div class="incorrect ${doinline}" id="status_${id}">
|
||||
% elif status == 'incomplete':
|
||||
<div class="incorrect ${doinline}" id="status_${id}">
|
||||
% endif
|
||||
% if hidden:
|
||||
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
% if do_math:
|
||||
class="math"
|
||||
% endif
|
||||
% if size:
|
||||
size="${size}"
|
||||
% endif
|
||||
% if hidden:
|
||||
style="display:none;"
|
||||
% endif
|
||||
/>
|
||||
|
||||
<p class="status">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
correct
|
||||
% elif status == 'incorrect':
|
||||
incorrect
|
||||
% elif status == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
% if do_math:
|
||||
<div id="display_${id}" class="equation">`{::}`</div>
|
||||
<textarea style="display:none" id="input_${id}_dynamath" name="input_${id}_dynamath">
|
||||
</textarea>
|
||||
|
||||
% endif
|
||||
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
|
||||
% if msg:
|
||||
<span class="message">${msg|n}</span>
|
||||
% endif
|
||||
|
||||
</section>
|
||||
48
common/lib/capa/capa/templates/vsepr_input.html
Normal file
48
common/lib/capa/capa/templates/vsepr_input.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<section id="inputtype_${id}" class="capa_inputtype" >
|
||||
<table><tr><td height='600'>
|
||||
<div id="vsepr_div_${id}" style="position:relative;" data-molecules="${molecules}" data-geometries="${geometries}">
|
||||
<canvas id="vsepr_canvas_${id}" width="${width}" height="${height}">
|
||||
</canvas>
|
||||
</div>
|
||||
</td><td valign ='top'>
|
||||
<select class="molecule_select" id="molecule_select_${id}" size="18">
|
||||
</select>
|
||||
</td></tr></table>
|
||||
|
||||
<div class="script_placeholder" data-src="/static/js/vsepr/vsepr.js"></div>
|
||||
|
||||
% if status == 'unsubmitted':
|
||||
<div class="unanswered" id="status_${id}">
|
||||
% elif status == 'correct':
|
||||
<div class="correct" id="status_${id}">
|
||||
% elif status == 'incorrect':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% elif status == 'incomplete':
|
||||
<div class="incorrect" id="status_${id}">
|
||||
% endif
|
||||
|
||||
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
|
||||
style="display:none;"
|
||||
/>
|
||||
|
||||
<p class="status">
|
||||
% if status == 'unsubmitted':
|
||||
unanswered
|
||||
% elif status == 'correct':
|
||||
correct
|
||||
% elif status == 'incorrect':
|
||||
incorrect
|
||||
% elif status == 'incomplete':
|
||||
incomplete
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<p id="answer_${id}" class="answer"></p>
|
||||
|
||||
% if msg:
|
||||
<span class="message">${msg|n}</span>
|
||||
% endif
|
||||
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
|
||||
</div>
|
||||
% endif
|
||||
</section>
|
||||
@@ -4,13 +4,23 @@ import os
|
||||
|
||||
from mock import Mock
|
||||
|
||||
import xml.sax.saxutils as saxutils
|
||||
|
||||
TEST_DIR = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
def tst_render_template(template, context):
|
||||
"""
|
||||
A test version of render to template. Renders to the repr of the context, completely ignoring
|
||||
the template name. To make the output valid xml, quotes the content, and wraps it in a <div>
|
||||
"""
|
||||
return '<div>{0}</div>'.format(saxutils.escape(repr(context)))
|
||||
|
||||
|
||||
test_system = Mock(
|
||||
ajax_url='courses/course_id/modx/a_location',
|
||||
track_function=Mock(),
|
||||
get_module=Mock(),
|
||||
render_template=Mock(),
|
||||
render_template=tst_render_template,
|
||||
replace_urls=Mock(),
|
||||
user=Mock(),
|
||||
filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")),
|
||||
|
||||
76
common/lib/capa/capa/tests/test_customrender.py
Normal file
76
common/lib/capa/capa/tests/test_customrender.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from lxml import etree
|
||||
import unittest
|
||||
import xml.sax.saxutils as saxutils
|
||||
|
||||
from . import test_system
|
||||
from capa import customrender
|
||||
|
||||
# just a handy shortcut
|
||||
lookup_tag = customrender.registry.get_class_for_tag
|
||||
|
||||
def extract_context(xml):
|
||||
"""
|
||||
Given an xml element corresponding to the output of test_system.render_template, get back the
|
||||
original context
|
||||
"""
|
||||
return eval(xml.text)
|
||||
|
||||
def quote_attr(s):
|
||||
return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes
|
||||
|
||||
class HelperTest(unittest.TestCase):
|
||||
'''
|
||||
Make sure that our helper function works!
|
||||
'''
|
||||
def check(self, d):
|
||||
xml = etree.XML(test_system.render_template('blah', d))
|
||||
self.assertEqual(d, extract_context(xml))
|
||||
|
||||
def test_extract_context(self):
|
||||
self.check({})
|
||||
self.check({1, 2})
|
||||
self.check({'id', 'an id'})
|
||||
self.check({'with"quote', 'also"quote'})
|
||||
|
||||
|
||||
class SolutionRenderTest(unittest.TestCase):
|
||||
'''
|
||||
Make sure solutions render properly.
|
||||
'''
|
||||
|
||||
def test_rendering(self):
|
||||
solution = 'To compute unicorns, count them.'
|
||||
xml_str = """<solution id="solution_12">{s}</solution>""".format(s=solution)
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
renderer = lookup_tag('solution')(test_system, element)
|
||||
|
||||
self.assertEqual(renderer.id, 'solution_12')
|
||||
|
||||
# our test_system "renders" templates to a div with the repr of the context
|
||||
xml = renderer.get_html()
|
||||
context = extract_context(xml)
|
||||
self.assertEqual(context, {'id' : 'solution_12'})
|
||||
|
||||
|
||||
class MathRenderTest(unittest.TestCase):
|
||||
'''
|
||||
Make sure math renders properly.
|
||||
'''
|
||||
|
||||
def check_parse(self, latex_in, mathjax_out):
|
||||
xml_str = """<math>{tex}</math>""".format(tex=latex_in)
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
renderer = lookup_tag('math')(test_system, element)
|
||||
|
||||
self.assertEqual(renderer.mathstr, mathjax_out)
|
||||
|
||||
def test_parsing(self):
|
||||
self.check_parse('$abc$', '[mathjaxinline]abc[/mathjaxinline]')
|
||||
self.check_parse('$abc', '$abc')
|
||||
self.check_parse(r'$\displaystyle 2+2$', '[mathjax] 2+2[/mathjax]')
|
||||
|
||||
|
||||
# NOTE: not testing get_html yet because I don't understand why it's doing what it's doing.
|
||||
|
||||
@@ -8,8 +8,14 @@ Hello</p></text>
|
||||
<text>Click on the image where the top skier will stop momentarily if the top skier starts from rest.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(242,202)-(296,276)"/>
|
||||
<text>Click on the image where the lower skier will stop momentarily if the lower skier starts from rest.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98);(242,202)-(296,276)"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98);(242,202)-(296,276)"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98);(242,202)-(296,276)"/>
|
||||
<text>Click on either of the two positions as discussed previously.</text>
|
||||
<hintgroup showoncorrect="no">
|
||||
<text><p>Use conservation of energy.</p></text>
|
||||
</hintgroup>
|
||||
</imageresponse>
|
||||
</problem>
|
||||
</problem>
|
||||
|
||||
@@ -1,50 +1,39 @@
|
||||
"""
|
||||
Tests of input types (and actually responsetypes too)
|
||||
Tests of input types.
|
||||
|
||||
TODO:
|
||||
- refactor: so much repetive code (have factory methods that build xml elements directly, etc)
|
||||
|
||||
- test error cases
|
||||
|
||||
- check rendering -- e.g. msg should appear in the rendered output. If possible, test that
|
||||
templates are escaping things properly.
|
||||
|
||||
|
||||
- test unicode in values, parameters, etc.
|
||||
- test various html escapes
|
||||
- test funny xml chars -- should never get xml parse error if things are escaped properly.
|
||||
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
from mock import Mock
|
||||
from nose.plugins.skip import SkipTest
|
||||
import os
|
||||
from lxml import etree
|
||||
import unittest
|
||||
import xml.sax.saxutils as saxutils
|
||||
|
||||
from . import test_system
|
||||
from capa import inputtypes
|
||||
|
||||
from lxml import etree
|
||||
|
||||
def tst_render_template(template, context):
|
||||
"""
|
||||
A test version of render to template. Renders to the repr of the context, completely ignoring the template name.
|
||||
"""
|
||||
return repr(context)
|
||||
# just a handy shortcut
|
||||
lookup_tag = inputtypes.registry.get_class_for_tag
|
||||
|
||||
|
||||
system = Mock(render_template=tst_render_template)
|
||||
def quote_attr(s):
|
||||
return saxutils.quoteattr(s)[1:-1] # don't want the outer quotes
|
||||
|
||||
class OptionInputTest(unittest.TestCase):
|
||||
'''
|
||||
Make sure option inputs work
|
||||
'''
|
||||
def test_rendering_new(self):
|
||||
xml = """<optioninput options="('Up','Down')" id="sky_input" correct="Up"/>"""
|
||||
element = etree.fromstring(xml)
|
||||
|
||||
value = 'Down'
|
||||
status = 'answered'
|
||||
context = inputtypes._optioninput(element, value, status, test_system.render_template)
|
||||
print 'context: ', context
|
||||
|
||||
expected = {'value': 'Down',
|
||||
'options': [('Up', 'Up'), ('Down', 'Down')],
|
||||
'state': 'answered',
|
||||
'msg': '',
|
||||
'inline': '',
|
||||
'id': 'sky_input'}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
def test_rendering(self):
|
||||
xml_str = """<optioninput options="('Up','Down')" id="sky_input" correct="Up"/>"""
|
||||
@@ -53,16 +42,466 @@ class OptionInputTest(unittest.TestCase):
|
||||
state = {'value': 'Down',
|
||||
'id': 'sky_input',
|
||||
'status': 'answered'}
|
||||
option_input = inputtypes.OptionInput(system, element, state)
|
||||
option_input = lookup_tag('optioninput')(test_system, element, state)
|
||||
|
||||
context = option_input._get_render_context()
|
||||
|
||||
expected = {'value': 'Down',
|
||||
'options': [('Up', 'Up'), ('Down', 'Down')],
|
||||
'state': 'answered',
|
||||
'status': 'answered',
|
||||
'msg': '',
|
||||
'inline': '',
|
||||
'id': 'sky_input'}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
def test_option_parsing(self):
|
||||
f = inputtypes.OptionInput.parse_options
|
||||
def check(input, options):
|
||||
"""Take list of options, confirm that output is in the silly doubled format"""
|
||||
expected = [(o, o) for o in options]
|
||||
self.assertEqual(f(input), expected)
|
||||
|
||||
check("('a','b')", ['a', 'b'])
|
||||
check("('a', 'b')", ['a', 'b'])
|
||||
check("('a b','b')", ['a b', 'b'])
|
||||
check("('My \"quoted\"place','b')", ['My \"quoted\"place', 'b'])
|
||||
|
||||
|
||||
class ChoiceGroupTest(unittest.TestCase):
|
||||
'''
|
||||
Test choice groups, radio groups, and checkbox groups
|
||||
'''
|
||||
|
||||
def check_group(self, tag, expected_input_type, expected_suffix):
|
||||
xml_str = """
|
||||
<{tag}>
|
||||
<choice correct="false" name="foil1"><text>This is foil One.</text></choice>
|
||||
<choice correct="false" name="foil2"><text>This is foil Two.</text></choice>
|
||||
<choice correct="true" name="foil3">This is foil Three.</choice>
|
||||
</{tag}>
|
||||
""".format(tag=tag)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'foil3',
|
||||
'id': 'sky_input',
|
||||
'status': 'answered'}
|
||||
|
||||
the_input = lookup_tag(tag)(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'sky_input',
|
||||
'value': 'foil3',
|
||||
'status': 'answered',
|
||||
'msg': '',
|
||||
'input_type': expected_input_type,
|
||||
'choices': [('foil1', '<text>This is foil One.</text>'),
|
||||
('foil2', '<text>This is foil Two.</text>'),
|
||||
('foil3', 'This is foil Three.'),],
|
||||
'name_array_suffix': expected_suffix, # what is this for??
|
||||
}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
def test_choicegroup(self):
|
||||
self.check_group('choicegroup', 'radio', '')
|
||||
|
||||
def test_radiogroup(self):
|
||||
self.check_group('radiogroup', 'radio', '[]')
|
||||
|
||||
def test_checkboxgroup(self):
|
||||
self.check_group('checkboxgroup', 'checkbox', '[]')
|
||||
|
||||
|
||||
|
||||
class JavascriptInputTest(unittest.TestCase):
|
||||
'''
|
||||
The javascript input is a pretty straightforward pass-thru, but test it anyway
|
||||
'''
|
||||
|
||||
def test_rendering(self):
|
||||
params = "(1,2,3)"
|
||||
|
||||
problem_state = "abc12',12&hi<there>"
|
||||
display_class = "a_class"
|
||||
display_file = "my_files/hi.js"
|
||||
|
||||
xml_str = """<javascriptinput id="prob_1_2" params="{params}" problem_state="{ps}"
|
||||
display_class="{dc}" display_file="{df}"/>""".format(
|
||||
params=params,
|
||||
ps=quote_attr(problem_state),
|
||||
dc=display_class, df=display_file)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': '3',}
|
||||
the_input = lookup_tag('javascriptinput')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'status': 'unanswered',
|
||||
'msg': '',
|
||||
'value': '3',
|
||||
'params': params,
|
||||
'display_file': display_file,
|
||||
'display_class': display_class,
|
||||
'problem_state': problem_state,}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
class TextLineTest(unittest.TestCase):
|
||||
'''
|
||||
Check that textline inputs work, with and without math.
|
||||
'''
|
||||
|
||||
def test_rendering(self):
|
||||
size = "42"
|
||||
xml_str = """<textline id="prob_1_2" size="{size}"/>""".format(size=size)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'BumbleBee',}
|
||||
the_input = lookup_tag('textline')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': 'BumbleBee',
|
||||
'status': 'unanswered',
|
||||
'size': size,
|
||||
'msg': '',
|
||||
'hidden': False,
|
||||
'inline': False,
|
||||
'do_math': False,
|
||||
'preprocessor': None}
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
def test_math_rendering(self):
|
||||
size = "42"
|
||||
preprocessorClass = "preParty"
|
||||
script = "foo/party.js"
|
||||
|
||||
xml_str = """<textline math="True" id="prob_1_2" size="{size}"
|
||||
preprocessorClassName="{pp}"
|
||||
preprocessorSrc="{sc}"/>""".format(size=size, pp=preprocessorClass, sc=script)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'BumbleBee',}
|
||||
the_input = lookup_tag('textline')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': 'BumbleBee',
|
||||
'status': 'unanswered',
|
||||
'size': size,
|
||||
'msg': '',
|
||||
'hidden': False,
|
||||
'inline': False,
|
||||
'do_math': True,
|
||||
'preprocessor': {'class_name': preprocessorClass,
|
||||
'script_src': script}}
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
class FileSubmissionTest(unittest.TestCase):
|
||||
'''
|
||||
Check that file submission inputs work
|
||||
'''
|
||||
|
||||
def test_rendering(self):
|
||||
allowed_files = "runme.py nooooo.rb ohai.java"
|
||||
required_files = "cookies.py"
|
||||
|
||||
xml_str = """<filesubmission id="prob_1_2"
|
||||
allowed_files="{af}"
|
||||
required_files="{rf}"
|
||||
/>""".format(af=allowed_files,
|
||||
rf=required_files,)
|
||||
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'BumbleBee.py',
|
||||
'status': 'incomplete',
|
||||
'feedback' : {'message': '3'}, }
|
||||
input_class = lookup_tag('filesubmission')
|
||||
the_input = input_class(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'status': 'queued',
|
||||
'msg': input_class.submitted_msg,
|
||||
'value': 'BumbleBee.py',
|
||||
'queue_len': '3',
|
||||
'allowed_files': '["runme.py", "nooooo.rb", "ohai.java"]',
|
||||
'required_files': '["cookies.py"]'}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
class CodeInputTest(unittest.TestCase):
|
||||
'''
|
||||
Check that codeinput inputs work
|
||||
'''
|
||||
|
||||
def test_rendering(self):
|
||||
mode = "parrot"
|
||||
linenumbers = 'false'
|
||||
rows = '37'
|
||||
cols = '11'
|
||||
tabsize = '7'
|
||||
|
||||
xml_str = """<codeinput id="prob_1_2"
|
||||
mode="{m}"
|
||||
cols="{c}"
|
||||
rows="{r}"
|
||||
linenumbers="{ln}"
|
||||
tabsize="{ts}"
|
||||
/>""".format(m=mode, c=cols, r=rows, ln=linenumbers, ts=tabsize)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
escapedict = {'"': '"'}
|
||||
esc = lambda s: saxutils.escape(s, escapedict)
|
||||
|
||||
state = {'value': 'print "good evening"',
|
||||
'status': 'incomplete',
|
||||
'feedback' : {'message': '3'}, }
|
||||
|
||||
input_class = lookup_tag('codeinput')
|
||||
the_input = input_class(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': 'print "good evening"',
|
||||
'status': 'queued',
|
||||
'msg': input_class.submitted_msg,
|
||||
'mode': mode,
|
||||
'linenumbers': linenumbers,
|
||||
'rows': rows,
|
||||
'cols': cols,
|
||||
'hidden': '',
|
||||
'tabsize': int(tabsize),
|
||||
'queue_len': '3',
|
||||
}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
class SchematicTest(unittest.TestCase):
|
||||
'''
|
||||
Check that schematic inputs work
|
||||
'''
|
||||
|
||||
def test_rendering(self):
|
||||
height = '12'
|
||||
width = '33'
|
||||
parts = 'resistors, capacitors, and flowers'
|
||||
analyses = 'fast, slow, and pink'
|
||||
initial_value = 'two large batteries'
|
||||
submit_analyses = 'maybe'
|
||||
|
||||
|
||||
xml_str = """<schematic id="prob_1_2"
|
||||
height="{h}"
|
||||
width="{w}"
|
||||
parts="{p}"
|
||||
analyses="{a}"
|
||||
initial_value="{iv}"
|
||||
submit_analyses="{sa}"
|
||||
/>""".format(h=height, w=width, p=parts, a=analyses,
|
||||
iv=initial_value, sa=submit_analyses)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
value = 'three resistors and an oscilating pendulum'
|
||||
state = {'value': value,
|
||||
'status': 'unsubmitted'}
|
||||
|
||||
the_input = lookup_tag('schematic')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': value,
|
||||
'status': 'unsubmitted',
|
||||
'msg': '',
|
||||
'initial_value': initial_value,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'parts': parts,
|
||||
'analyses': analyses,
|
||||
'submit_analyses': submit_analyses,
|
||||
}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
class ImageInputTest(unittest.TestCase):
|
||||
'''
|
||||
Check that image inputs work
|
||||
'''
|
||||
|
||||
def check(self, value, egx, egy):
|
||||
height = '78'
|
||||
width = '427'
|
||||
src = 'http://www.edx.org/cowclicker.jpg'
|
||||
|
||||
xml_str = """<imageinput id="prob_1_2"
|
||||
src="{s}"
|
||||
height="{h}"
|
||||
width="{w}"
|
||||
/>""".format(s=src, h=height, w=width)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': value,
|
||||
'status': 'unsubmitted'}
|
||||
|
||||
the_input = lookup_tag('imageinput')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': value,
|
||||
'status': 'unsubmitted',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'src': src,
|
||||
'gx': egx,
|
||||
'gy': egy,
|
||||
'msg': ''}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
def test_with_value(self):
|
||||
# Check that compensating for the dot size works properly.
|
||||
self.check('[50,40]', 35, 25)
|
||||
|
||||
def test_without_value(self):
|
||||
self.check('', 0, 0)
|
||||
|
||||
def test_corrupt_values(self):
|
||||
self.check('[12', 0, 0)
|
||||
self.check('[12, a]', 0, 0)
|
||||
self.check('[12 10]', 0, 0)
|
||||
self.check('[12]', 0, 0)
|
||||
self.check('[12 13 14]', 0, 0)
|
||||
|
||||
|
||||
|
||||
class CrystallographyTest(unittest.TestCase):
|
||||
'''
|
||||
Check that crystallography inputs work
|
||||
'''
|
||||
|
||||
def test_rendering(self):
|
||||
height = '12'
|
||||
width = '33'
|
||||
size = '10'
|
||||
|
||||
xml_str = """<crystallography id="prob_1_2"
|
||||
height="{h}"
|
||||
width="{w}"
|
||||
size="{s}"
|
||||
/>""".format(h=height, w=width, s=size)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
value = 'abc'
|
||||
state = {'value': value,
|
||||
'status': 'unsubmitted'}
|
||||
|
||||
the_input = lookup_tag('crystallography')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': value,
|
||||
'status': 'unsubmitted',
|
||||
'size': size,
|
||||
'msg': '',
|
||||
'hidden': '',
|
||||
'width': width,
|
||||
'height': height,
|
||||
}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
class VseprTest(unittest.TestCase):
|
||||
'''
|
||||
Check that vsepr inputs work
|
||||
'''
|
||||
|
||||
def test_rendering(self):
|
||||
height = '12'
|
||||
width = '33'
|
||||
molecules = "H2O, C2O"
|
||||
geometries = "AX12,TK421"
|
||||
|
||||
xml_str = """<vsepr id="prob_1_2"
|
||||
height="{h}"
|
||||
width="{w}"
|
||||
molecules="{m}"
|
||||
geometries="{g}"
|
||||
/>""".format(h=height, w=width, m=molecules, g=geometries)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
value = 'abc'
|
||||
state = {'value': value,
|
||||
'status': 'unsubmitted'}
|
||||
|
||||
the_input = lookup_tag('vsepr_input')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': value,
|
||||
'status': 'unsubmitted',
|
||||
'msg': '',
|
||||
'width': width,
|
||||
'height': height,
|
||||
'molecules': molecules,
|
||||
'geometries': geometries,
|
||||
}
|
||||
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
|
||||
class ChemicalEquationTest(unittest.TestCase):
|
||||
'''
|
||||
Check that chemical equation inputs work.
|
||||
'''
|
||||
|
||||
def test_rendering(self):
|
||||
size = "42"
|
||||
xml_str = """<chemicalequationinput id="prob_1_2" size="{size}"/>""".format(size=size)
|
||||
|
||||
element = etree.fromstring(xml_str)
|
||||
|
||||
state = {'value': 'H2OYeah',}
|
||||
the_input = lookup_tag('chemicalequationinput')(test_system, element, state)
|
||||
|
||||
context = the_input._get_render_context()
|
||||
|
||||
expected = {'id': 'prob_1_2',
|
||||
'value': 'H2OYeah',
|
||||
'status': 'unanswered',
|
||||
'msg': '',
|
||||
'size': size,
|
||||
'previewer': '/static/js/capa/chemical_equation_preview.js',
|
||||
}
|
||||
self.assertEqual(context, expected)
|
||||
|
||||
|
||||
@@ -53,12 +53,22 @@ class ImageResponseTest(unittest.TestCase):
|
||||
imageresponse_file = os.path.dirname(__file__) + "/test_files/imageresponse.xml"
|
||||
test_lcp = lcp.LoncapaProblem(open(imageresponse_file).read(), '1', system=test_system)
|
||||
correct_answers = {'1_2_1': '(490,11)-(556,98)',
|
||||
'1_2_2': '(242,202)-(296,276)'}
|
||||
'1_2_2': '(242,202)-(296,276)',
|
||||
'1_2_3': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
'1_2_4': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
'1_2_5': '(490,11)-(556,98);(242,202)-(296,276)',
|
||||
}
|
||||
test_answers = {'1_2_1': '[500,20]',
|
||||
'1_2_2': '[250,300]',
|
||||
'1_2_3': '[500,20]',
|
||||
'1_2_4': '[250,250]',
|
||||
'1_2_5': '[10,10]',
|
||||
}
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_1'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_2'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_3'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_4'), 'correct')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_2_5'), 'incorrect')
|
||||
|
||||
|
||||
class SymbolicResponseTest(unittest.TestCase):
|
||||
|
||||
13
common/lib/xmodule/.coveragerc
Normal file
13
common/lib/xmodule/.coveragerc
Normal file
@@ -0,0 +1,13 @@
|
||||
# .coveragerc for common/lib/xmodule
|
||||
[run]
|
||||
data_file = reports/common/lib/xmodule/.coverage
|
||||
source = common/lib/xmodule
|
||||
|
||||
[report]
|
||||
ignore_errors = True
|
||||
|
||||
[html]
|
||||
directory = reports/common/lib/xmodule/cover
|
||||
|
||||
[xml]
|
||||
output = reports/common/lib/xmodule/coverage.xml
|
||||
@@ -26,8 +26,9 @@ setup(
|
||||
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"error = xmodule.error_module:ErrorDescriptor",
|
||||
"problem = xmodule.capa_module:CapaDescriptor",
|
||||
"problemset = xmodule.vertical_module:VerticalDescriptor",
|
||||
"problemset = xmodule.seq_module:SequenceDescriptor",
|
||||
"section = xmodule.backcompat_module:SemanticSectionDescriptor",
|
||||
"selfassessment = xmodule.self_assessment_module:SelfAssessmentDescriptor",
|
||||
"sequential = xmodule.seq_module:SequenceDescriptor",
|
||||
"slides = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"vertical = xmodule.vertical_module:VerticalDescriptor",
|
||||
@@ -35,6 +36,10 @@ setup(
|
||||
"videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor",
|
||||
"videosequence = xmodule.seq_module:SequenceDescriptor",
|
||||
"discussion = xmodule.discussion_module:DiscussionDescriptor",
|
||||
"course_info = xmodule.html_module:CourseInfoDescriptor",
|
||||
"static_tab = xmodule.html_module:StaticTabDescriptor",
|
||||
"custom_tag_template = xmodule.raw_module:RawDescriptor",
|
||||
"about = xmodule.html_module:AboutDescriptor"
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ import sys
|
||||
|
||||
from datetime import timedelta
|
||||
from lxml import etree
|
||||
from lxml.html import rewrite_links
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from capa.capa_problem import LoncapaProblem
|
||||
@@ -30,15 +29,17 @@ TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?)
|
||||
def only_one(lst, default="", process=lambda x: x):
|
||||
"""
|
||||
If lst is empty, returns default
|
||||
If lst has a single element, applies process to that element and returns it
|
||||
Otherwise, raises an exeception
|
||||
|
||||
If lst has a single element, applies process to that element and returns it.
|
||||
|
||||
Otherwise, raises an exception.
|
||||
"""
|
||||
if len(lst) == 0:
|
||||
return default
|
||||
elif len(lst) == 1:
|
||||
return process(lst[0])
|
||||
else:
|
||||
raise Exception('Malformed XML')
|
||||
raise Exception('Malformed XML: expected at most one element in list.')
|
||||
|
||||
|
||||
def parse_timedelta(time_str):
|
||||
@@ -120,6 +121,8 @@ class CapaModule(XModule):
|
||||
|
||||
self.show_answer = self.metadata.get('showanswer', 'closed')
|
||||
|
||||
self.force_save_button = self.metadata.get('force_save_button', 'false')
|
||||
|
||||
if self.show_answer == "":
|
||||
self.show_answer = "closed"
|
||||
|
||||
@@ -290,11 +293,11 @@ class CapaModule(XModule):
|
||||
# check button is context-specific.
|
||||
|
||||
# Put a "Check" button if unlimited attempts or still some left
|
||||
if self.max_attempts is None or self.attempts < self.max_attempts-1:
|
||||
if self.max_attempts is None or self.attempts < self.max_attempts-1:
|
||||
check_button = "Check"
|
||||
else:
|
||||
# Will be final check so let user know that
|
||||
check_button = "Final Check"
|
||||
check_button = "Final Check"
|
||||
|
||||
reset_button = True
|
||||
save_button = True
|
||||
@@ -320,9 +323,10 @@ class CapaModule(XModule):
|
||||
if not self.lcp.done:
|
||||
reset_button = False
|
||||
|
||||
# We don't need a "save" button if infinite number of attempts and
|
||||
# non-randomized
|
||||
if self.max_attempts is None and self.rerandomize != "always":
|
||||
# We may not need a "save" button if infinite number of attempts and
|
||||
# non-randomized. The problem author can force it. It's a bit weird for
|
||||
# randomization to control this; should perhaps be cleaned up.
|
||||
if (self.force_save_button == "false") and (self.max_attempts is None and self.rerandomize != "always"):
|
||||
save_button = False
|
||||
|
||||
context = {'problem': content,
|
||||
@@ -342,17 +346,6 @@ class CapaModule(XModule):
|
||||
html = '<div id="problem_{id}" class="problem" data-url="{ajax_url}">'.format(
|
||||
id=self.location.html_id(), ajax_url=self.system.ajax_url) + html + "</div>"
|
||||
|
||||
# cdodge: OK, we have to do two rounds of url reference subsitutions
|
||||
# one which uses the 'asset library' that is served by the contentstore and the
|
||||
# more global /static/ filesystem based static content.
|
||||
# NOTE: rewrite_content_links is defined in XModule
|
||||
# This is a bit unfortunate and I'm sure we'll try to considate this into
|
||||
# a one step process.
|
||||
try:
|
||||
html = rewrite_links(html, self.rewrite_content_links)
|
||||
except:
|
||||
logging.error('error rewriting links in {0}'.format(html))
|
||||
|
||||
# now do the substitutions which are filesystem based, e.g. '/static/' prefixes
|
||||
return self.system.replace_urls(html, self.metadata['data_dir'])
|
||||
|
||||
@@ -527,26 +520,20 @@ class CapaModule(XModule):
|
||||
# Problem queued. Students must wait a specified waittime before they are allowed to submit
|
||||
if self.lcp.is_queued():
|
||||
current_time = datetime.datetime.now()
|
||||
prev_submit_time = self.lcp.get_recentmost_queuetime()
|
||||
prev_submit_time = self.lcp.get_recentmost_queuetime()
|
||||
waittime_between_requests = self.system.xqueue['waittime']
|
||||
if (current_time-prev_submit_time).total_seconds() < waittime_between_requests:
|
||||
msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests
|
||||
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
|
||||
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
|
||||
|
||||
try:
|
||||
old_state = self.lcp.get_state()
|
||||
lcp_id = self.lcp.problem_id
|
||||
correct_map = self.lcp.grade_answers(answers)
|
||||
except StudentInputError as inst:
|
||||
# TODO (vshnayder): why is this line here?
|
||||
#self.lcp = LoncapaProblem(self.definition['data'],
|
||||
# id=lcp_id, state=old_state, system=self.system)
|
||||
log.exception("StudentInputError in capa_module:problem_check")
|
||||
return {'success': inst.message}
|
||||
except Exception, err:
|
||||
# TODO: why is this line here?
|
||||
#self.lcp = LoncapaProblem(self.definition['data'],
|
||||
# id=lcp_id, state=old_state, system=self.system)
|
||||
if self.system.DEBUG:
|
||||
msg = "Error checking problem: " + str(err)
|
||||
msg += '\nTraceback:\n' + traceback.format_exc()
|
||||
@@ -678,10 +665,10 @@ class CapaDescriptor(RawDescriptor):
|
||||
'problems/' + path[8:],
|
||||
path[8:],
|
||||
]
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(CapaDescriptor, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
weight_string = self.metadata.get('weight', None)
|
||||
if weight_string:
|
||||
self.weight = float(weight_string)
|
||||
|
||||
@@ -62,6 +62,13 @@ class StaticContent(object):
|
||||
@staticmethod
|
||||
def get_id_from_path(path):
|
||||
return get_id_from_location(get_location_from_path(path))
|
||||
|
||||
@staticmethod
|
||||
def convert_legacy_static_url(path, course_namespace):
|
||||
loc = StaticContent.compute_location(course_namespace.org, course_namespace.course, path)
|
||||
return StaticContent.get_url_path_from_location(loc)
|
||||
|
||||
|
||||
|
||||
|
||||
class ContentStore(object):
|
||||
|
||||
@@ -30,13 +30,13 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
self.book_url = book_url
|
||||
self.table_of_contents = self._get_toc_from_s3()
|
||||
self.start_page = int(self.table_of_contents[0].attrib['page'])
|
||||
|
||||
|
||||
# The last page should be the last element in the table of contents,
|
||||
# but it may be nested. So recurse all the way down the last element
|
||||
last_el = self.table_of_contents[-1]
|
||||
while last_el.getchildren():
|
||||
last_el = last_el[-1]
|
||||
|
||||
|
||||
self.end_page = int(last_el.attrib['page'])
|
||||
|
||||
@property
|
||||
@@ -94,6 +94,7 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
self.enrollment_start = self._try_parse_time("enrollment_start")
|
||||
self.enrollment_end = self._try_parse_time("enrollment_end")
|
||||
self.end = self._try_parse_time("end")
|
||||
|
||||
# NOTE: relies on the modulestore to call set_grading_policy() right after
|
||||
# init. (Modulestore is in charge of figuring out where to load the policy from)
|
||||
@@ -237,6 +238,16 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
return definition
|
||||
|
||||
def has_ended(self):
|
||||
"""
|
||||
Returns True if the current time is after the specified course end date.
|
||||
Returns False if there is no end date specified.
|
||||
"""
|
||||
if self.end is None:
|
||||
return False
|
||||
|
||||
return time.gmtime() > self.end
|
||||
|
||||
def has_started(self):
|
||||
return time.gmtime() > self.start
|
||||
|
||||
@@ -255,6 +266,10 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
"""
|
||||
return self.metadata.get('tabs')
|
||||
|
||||
@tabs.setter
|
||||
def tabs(self, value):
|
||||
self.metadata['tabs'] = value
|
||||
|
||||
@property
|
||||
def show_calculator(self):
|
||||
return self.metadata.get("show_calculator", None) == "Yes"
|
||||
@@ -346,7 +361,12 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
@property
|
||||
def start_date_text(self):
|
||||
return time.strftime("%b %d, %Y", self.start)
|
||||
displayed_start = self._try_parse_time('advertised_start') or self.start
|
||||
return time.strftime("%b %d, %Y", displayed_start)
|
||||
|
||||
@property
|
||||
def end_date_text(self):
|
||||
return time.strftime("%b %d, %Y", self.end)
|
||||
|
||||
# An extra property is used rather than the wiki_slug/number because
|
||||
# there are courses that change the number for different runs. This allows
|
||||
@@ -374,6 +394,21 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
more sensible framework later."""
|
||||
return self.metadata.get('discussion_link', None)
|
||||
|
||||
@property
|
||||
def forum_posts_allowed(self):
|
||||
try:
|
||||
blackout_periods = [(parse_time(start), parse_time(end))
|
||||
for start, end
|
||||
in self.metadata.get('discussion_blackouts', [])]
|
||||
now = time.gmtime()
|
||||
for start, end in blackout_periods:
|
||||
if start <= now <= end:
|
||||
return False
|
||||
except:
|
||||
log.exception("Error parsing discussion_blackouts for course {0}".format(self.id))
|
||||
|
||||
return True
|
||||
|
||||
@property
|
||||
def hide_progress_tab(self):
|
||||
"""TODO: same as above, intended to let internal CS50 hide the progress tab
|
||||
@@ -381,6 +416,16 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
# Explicit comparison to True because we always want to return a bool.
|
||||
return self.metadata.get('hide_progress_tab') == True
|
||||
|
||||
@property
|
||||
def end_of_course_survey_url(self):
|
||||
"""
|
||||
Pull from policy. Once we have our own survey module set up, can change this to point to an automatically
|
||||
created survey for each class.
|
||||
|
||||
Returns None if no url specified.
|
||||
"""
|
||||
return self.metadata.get('end_of_course_survey_url')
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
return self.display_name
|
||||
@@ -394,3 +439,4 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
return self.location.org
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -359,6 +359,34 @@ div.video {
|
||||
}
|
||||
}
|
||||
|
||||
a.quality_control {
|
||||
background: url(../images/hd.png) center no-repeat;
|
||||
border-right: 1px solid #000;
|
||||
@include box-shadow(1px 0 0 #555, inset 1px 0 0 #555);
|
||||
color: #797979;
|
||||
display: block;
|
||||
float: left;
|
||||
line-height: 46px; //height of play pause buttons
|
||||
margin-left: 0;
|
||||
padding: 0 lh(.5);
|
||||
text-indent: -9999px;
|
||||
@include transition();
|
||||
width: 30px;
|
||||
|
||||
&:hover {
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #F44;
|
||||
color: #0ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
a.hide-subtitles {
|
||||
background: url('../images/cc.png') center no-repeat;
|
||||
color: #797979;
|
||||
|
||||
@@ -4,7 +4,6 @@ import logging
|
||||
import os
|
||||
import sys
|
||||
from lxml import etree
|
||||
from lxml.html import rewrite_links
|
||||
from path import path
|
||||
|
||||
from .x_module import XModule
|
||||
@@ -29,14 +28,7 @@ class HtmlModule(XModule):
|
||||
js_module_name = "HTMLModule"
|
||||
|
||||
def get_html(self):
|
||||
# cdodge: perform link substitutions for any references to course static content (e.g. images)
|
||||
_html = self.html
|
||||
try:
|
||||
_html = rewrite_links(_html, self.rewrite_content_links)
|
||||
except:
|
||||
logging.error('error rewriting links on the following HTML content: {0}'.format(_html))
|
||||
|
||||
return _html
|
||||
return self.html
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
@@ -178,3 +170,25 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
elt = etree.Element('html')
|
||||
elt.set("filename", relname)
|
||||
return elt
|
||||
|
||||
|
||||
class AboutDescriptor(HtmlDescriptor):
|
||||
"""
|
||||
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
|
||||
in order to be able to create new ones
|
||||
"""
|
||||
template_dir_name = "about"
|
||||
|
||||
class StaticTabDescriptor(HtmlDescriptor):
|
||||
"""
|
||||
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
|
||||
in order to be able to create new ones
|
||||
"""
|
||||
template_dir_name = "statictab"
|
||||
|
||||
class CourseInfoDescriptor(HtmlDescriptor):
|
||||
"""
|
||||
These pieces of course content are treated as HtmlModules but we need to overload where the templates are located
|
||||
in order to be able to create new ones
|
||||
"""
|
||||
template_dir_name = "courseinfo"
|
||||
|
||||
@@ -216,7 +216,9 @@ class @Problem
|
||||
for choice in value
|
||||
@$("label[for='input_#{key}_#{choice}']").attr correct_answer: 'true'
|
||||
else
|
||||
@$("#answer_#{key}, #solution_#{key}").html(value)
|
||||
answer = @$("#answer_#{key}, #solution_#{key}")
|
||||
answer.html(value)
|
||||
Collapsible.setCollapsibles(answer)
|
||||
|
||||
# TODO remove the above once everything is extracted into its own
|
||||
# inputtype functions.
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
function image_input_click(id,event){
|
||||
iidiv = document.getElementById("imageinput_"+id);
|
||||
pos_x = event.offsetX?(event.offsetX):event.pageX-document.iidiv.offsetLeft;
|
||||
pos_y = event.offsetY?(event.offsetY):event.pageY-document.iidiv.offsetTop;
|
||||
pos_x = event.offsetX?(event.offsetX):event.pageX-iidiv.offsetLeft;
|
||||
pos_y = event.offsetY?(event.offsetY):event.pageY-iidiv.offsetTop;
|
||||
result = "[" + pos_x + "," + pos_y + "]";
|
||||
cx = (pos_x-15) +"px";
|
||||
cy = (pos_y-15) +"px" ;
|
||||
|
||||
@@ -1995,7 +1995,7 @@ cktsim = (function() {
|
||||
// set up each schematic entry widget
|
||||
function update_schematics() {
|
||||
// set up each schematic on the page
|
||||
var schematics = document.getElementsByClassName('schematic');
|
||||
var schematics = $('.schematic');
|
||||
for (var i = 0; i < schematics.length; ++i)
|
||||
if (schematics[i].getAttribute("loaded") != "true") {
|
||||
try {
|
||||
@@ -2036,7 +2036,7 @@ function add_schematic_handler(other_onload) {
|
||||
|
||||
// ask each schematic input widget to update its value field for submission
|
||||
function prepare_schematics() {
|
||||
var schematics = document.getElementsByClassName('schematic');
|
||||
var schematics = $('.schematic');
|
||||
for (var i = schematics.length - 1; i >= 0; i--)
|
||||
schematics[i].schematic.update_value();
|
||||
}
|
||||
@@ -3339,23 +3339,28 @@ schematic = (function() {
|
||||
}
|
||||
|
||||
// add method to canvas to compute relative coords for event
|
||||
HTMLCanvasElement.prototype.relMouseCoords = function(event){
|
||||
// run up the DOM tree to figure out coords for top,left of canvas
|
||||
var totalOffsetX = 0;
|
||||
var totalOffsetY = 0;
|
||||
var currentElement = this;
|
||||
do {
|
||||
totalOffsetX += currentElement.offsetLeft;
|
||||
totalOffsetY += currentElement.offsetTop;
|
||||
}
|
||||
while (currentElement = currentElement.offsetParent);
|
||||
|
||||
// now compute relative position of click within the canvas
|
||||
this.mouse_x = event.pageX - totalOffsetX;
|
||||
this.mouse_y = event.pageY - totalOffsetY;
|
||||
|
||||
this.page_x = event.pageX;
|
||||
this.page_y = event.pageY;
|
||||
try {
|
||||
if (HTMLCanvasElement)
|
||||
HTMLCanvasElement.prototype.relMouseCoords = function(event){
|
||||
// run up the DOM tree to figure out coords for top,left of canvas
|
||||
var totalOffsetX = 0;
|
||||
var totalOffsetY = 0;
|
||||
var currentElement = this;
|
||||
do {
|
||||
totalOffsetX += currentElement.offsetLeft;
|
||||
totalOffsetY += currentElement.offsetTop;
|
||||
}
|
||||
while (currentElement = currentElement.offsetParent);
|
||||
|
||||
// now compute relative position of click within the canvas
|
||||
this.mouse_x = event.pageX - totalOffsetX;
|
||||
this.mouse_y = event.pageY - totalOffsetY;
|
||||
|
||||
this.page_x = event.pageX;
|
||||
this.page_y = event.pageY;
|
||||
}
|
||||
}
|
||||
catch (err) { // ignore
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
@@ -4091,48 +4096,52 @@ schematic = (function() {
|
||||
|
||||
// add dashed lines!
|
||||
// from http://davidowens.wordpress.com/2010/09/07/html-5-canvas-and-dashed-lines/
|
||||
CanvasRenderingContext2D.prototype.dashedLineTo = function(fromX, fromY, toX, toY, pattern) {
|
||||
// Our growth rate for our line can be one of the following:
|
||||
// (+,+), (+,-), (-,+), (-,-)
|
||||
// Because of this, our algorithm needs to understand if the x-coord and
|
||||
// y-coord should be getting smaller or larger and properly cap the values
|
||||
// based on (x,y).
|
||||
var lt = function (a, b) { return a <= b; };
|
||||
var gt = function (a, b) { return a >= b; };
|
||||
var capmin = function (a, b) { return Math.min(a, b); };
|
||||
var capmax = function (a, b) { return Math.max(a, b); };
|
||||
|
||||
var checkX = { thereYet: gt, cap: capmin };
|
||||
var checkY = { thereYet: gt, cap: capmin };
|
||||
|
||||
if (fromY - toY > 0) {
|
||||
checkY.thereYet = lt;
|
||||
checkY.cap = capmax;
|
||||
}
|
||||
if (fromX - toX > 0) {
|
||||
checkX.thereYet = lt;
|
||||
checkX.cap = capmax;
|
||||
}
|
||||
|
||||
this.moveTo(fromX, fromY);
|
||||
var offsetX = fromX;
|
||||
var offsetY = fromY;
|
||||
var idx = 0, dash = true;
|
||||
while (!(checkX.thereYet(offsetX, toX) && checkY.thereYet(offsetY, toY))) {
|
||||
var ang = Math.atan2(toY - fromY, toX - fromX);
|
||||
var len = pattern[idx];
|
||||
|
||||
offsetX = checkX.cap(toX, offsetX + (Math.cos(ang) * len));
|
||||
offsetY = checkY.cap(toY, offsetY + (Math.sin(ang) * len));
|
||||
|
||||
if (dash) this.lineTo(offsetX, offsetY);
|
||||
else this.moveTo(offsetX, offsetY);
|
||||
|
||||
idx = (idx + 1) % pattern.length;
|
||||
dash = !dash;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (CanvasRenderingContext2D)
|
||||
CanvasRenderingContext2D.prototype.dashedLineTo = function(fromX, fromY, toX, toY, pattern) {
|
||||
// Our growth rate for our line can be one of the following:
|
||||
// (+,+), (+,-), (-,+), (-,-)
|
||||
// Because of this, our algorithm needs to understand if the x-coord and
|
||||
// y-coord should be getting smaller or larger and properly cap the values
|
||||
// based on (x,y).
|
||||
var lt = function (a, b) { return a <= b; };
|
||||
var gt = function (a, b) { return a >= b; };
|
||||
var capmin = function (a, b) { return Math.min(a, b); };
|
||||
var capmax = function (a, b) { return Math.max(a, b); };
|
||||
|
||||
var checkX = { thereYet: gt, cap: capmin };
|
||||
var checkY = { thereYet: gt, cap: capmin };
|
||||
|
||||
if (fromY - toY > 0) {
|
||||
checkY.thereYet = lt;
|
||||
checkY.cap = capmax;
|
||||
}
|
||||
if (fromX - toX > 0) {
|
||||
checkX.thereYet = lt;
|
||||
checkX.cap = capmax;
|
||||
}
|
||||
|
||||
this.moveTo(fromX, fromY);
|
||||
var offsetX = fromX;
|
||||
var offsetY = fromY;
|
||||
var idx = 0, dash = true;
|
||||
while (!(checkX.thereYet(offsetX, toX) && checkY.thereYet(offsetY, toY))) {
|
||||
var ang = Math.atan2(toY - fromY, toX - fromX);
|
||||
var len = pattern[idx];
|
||||
|
||||
offsetX = checkX.cap(toX, offsetX + (Math.cos(ang) * len));
|
||||
offsetY = checkY.cap(toY, offsetY + (Math.sin(ang) * len));
|
||||
|
||||
if (dash) this.lineTo(offsetX, offsetY);
|
||||
else this.moveTo(offsetX, offsetY);
|
||||
|
||||
idx = (idx + 1) % pattern.length;
|
||||
dash = !dash;
|
||||
}
|
||||
};
|
||||
}
|
||||
catch (err) { //noop
|
||||
}
|
||||
// given a range of values, return a new range [vmin',vmax'] where the limits
|
||||
// have been chosen "nicely". Taken from matplotlib.ticker.LinearLocator
|
||||
function view_limits(vmin,vmax) {
|
||||
|
||||
133
common/lib/xmodule/xmodule/js/src/selfassessment/display.coffee
Normal file
133
common/lib/xmodule/xmodule/js/src/selfassessment/display.coffee
Normal file
@@ -0,0 +1,133 @@
|
||||
class @SelfAssessment
|
||||
constructor: (element) ->
|
||||
@el = $(element).find('section.self-assessment')
|
||||
@id = @el.data('id')
|
||||
@ajax_url = @el.data('ajax-url')
|
||||
@state = @el.data('state')
|
||||
@allow_reset = @el.data('allow_reset')
|
||||
# valid states: 'initial', 'assessing', 'request_hint', 'done'
|
||||
|
||||
# Where to put the rubric once we load it
|
||||
@errors_area = @$('.error')
|
||||
@answer_area = @$('textarea.answer')
|
||||
|
||||
@rubric_wrapper = @$('.rubric-wrapper')
|
||||
@hint_wrapper = @$('.hint-wrapper')
|
||||
@message_wrapper = @$('.message-wrapper')
|
||||
@submit_button = @$('.submit-button')
|
||||
@reset_button = @$('.reset-button')
|
||||
@reset_button.click @reset
|
||||
|
||||
@find_assessment_elements()
|
||||
@find_hint_elements()
|
||||
|
||||
@rebind()
|
||||
|
||||
# locally scoped jquery.
|
||||
$: (selector) ->
|
||||
$(selector, @el)
|
||||
|
||||
rebind: () =>
|
||||
# rebind to the appropriate function for the current state
|
||||
@submit_button.unbind('click')
|
||||
@submit_button.show()
|
||||
@reset_button.hide()
|
||||
@hint_area.attr('disabled', false)
|
||||
if @state == 'initial'
|
||||
@answer_area.attr("disabled", false)
|
||||
@submit_button.prop('value', 'Submit')
|
||||
@submit_button.click @save_answer
|
||||
else if @state == 'assessing'
|
||||
@answer_area.attr("disabled", true)
|
||||
@submit_button.prop('value', 'Submit assessment')
|
||||
@submit_button.click @save_assessment
|
||||
else if @state == 'request_hint'
|
||||
@answer_area.attr("disabled", true)
|
||||
@submit_button.prop('value', 'Submit hint')
|
||||
@submit_button.click @save_hint
|
||||
else if @state == 'done'
|
||||
@answer_area.attr("disabled", true)
|
||||
@hint_area.attr('disabled', true)
|
||||
@submit_button.hide()
|
||||
if @allow_reset
|
||||
@reset_button.show()
|
||||
else
|
||||
@reset_button.hide()
|
||||
|
||||
|
||||
find_assessment_elements: ->
|
||||
@assessment = @$('select.assessment')
|
||||
|
||||
find_hint_elements: ->
|
||||
@hint_area = @$('textarea.hint')
|
||||
|
||||
save_answer: (event) =>
|
||||
event.preventDefault()
|
||||
if @state == 'initial'
|
||||
data = {'student_answer' : @answer_area.val()}
|
||||
$.postWithPrefix "#{@ajax_url}/save_answer", data, (response) =>
|
||||
if response.success
|
||||
@rubric_wrapper.html(response.rubric_html)
|
||||
@state = 'assessing'
|
||||
@find_assessment_elements()
|
||||
@rebind()
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
else
|
||||
@errors_area.html('Problem state got out of sync. Try reloading the page.')
|
||||
|
||||
save_assessment: (event) =>
|
||||
event.preventDefault()
|
||||
if @state == 'assessing'
|
||||
data = {'assessment' : @assessment.find(':selected').text()}
|
||||
$.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) =>
|
||||
if response.success
|
||||
@state = response.state
|
||||
|
||||
if @state == 'request_hint'
|
||||
@hint_wrapper.html(response.hint_html)
|
||||
@find_hint_elements()
|
||||
else if @state == 'done'
|
||||
@message_wrapper.html(response.message_html)
|
||||
@allow_reset = response.allow_reset
|
||||
|
||||
@rebind()
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
else
|
||||
@errors_area.html('Problem state got out of sync. Try reloading the page.')
|
||||
|
||||
|
||||
save_hint: (event) =>
|
||||
event.preventDefault()
|
||||
if @state == 'request_hint'
|
||||
data = {'hint' : @hint_area.val()}
|
||||
|
||||
$.postWithPrefix "#{@ajax_url}/save_hint", data, (response) =>
|
||||
if response.success
|
||||
@message_wrapper.html(response.message_html)
|
||||
@state = 'done'
|
||||
@allow_reset = response.allow_reset
|
||||
@rebind()
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
else
|
||||
@errors_area.html('Problem state got out of sync. Try reloading the page.')
|
||||
|
||||
|
||||
reset: (event) =>
|
||||
event.preventDefault()
|
||||
if @state == 'done'
|
||||
$.postWithPrefix "#{@ajax_url}/reset", {}, (response) =>
|
||||
if response.success
|
||||
@answer_area.html('')
|
||||
@rubric_wrapper.html('')
|
||||
@hint_wrapper.html('')
|
||||
@message_wrapper.html('')
|
||||
@state = 'initial'
|
||||
@rebind()
|
||||
@reset_button.hide()
|
||||
else
|
||||
@errors_area.html(response.error)
|
||||
else
|
||||
@errors_area.html('Problem state got out of sync. Try reloading the page.')
|
||||
@@ -22,7 +22,7 @@ class @VideoCaption extends Subview
|
||||
"""
|
||||
@$('.video-controls .secondary-controls').append """
|
||||
<a href="#" class="hide-subtitles" title="Turn off captions">Captions</a>
|
||||
"""
|
||||
"""#"
|
||||
@$('.subtitles').css maxHeight: @$('.video-wrapper').height() - 5
|
||||
@fetchCaption()
|
||||
|
||||
@@ -144,7 +144,7 @@ class @VideoCaption extends Subview
|
||||
@el.removeClass('closed')
|
||||
@scrollCaption()
|
||||
$.cookie('hide_captions', hide_captions, expires: 3650, path: '/')
|
||||
|
||||
|
||||
captionHeight: ->
|
||||
if @el.hasClass('fullscreen')
|
||||
$(window).height() - @$('.video-controls').height()
|
||||
|
||||
@@ -16,7 +16,7 @@ class @VideoControl extends Subview
|
||||
<a href="#" class="add-fullscreen" title="Fill browser">Fill Browser</a>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
"""#"
|
||||
|
||||
unless onTouchBasedDevice()
|
||||
@$('.video_control').addClass('play').html('Play')
|
||||
|
||||
@@ -9,6 +9,7 @@ class @VideoPlayer extends Subview
|
||||
bind: ->
|
||||
$(@control).bind('play', @play)
|
||||
.bind('pause', @pause)
|
||||
$(@qualityControl).bind('changeQuality', @handlePlaybackQualityChange)
|
||||
$(@caption).bind('seek', @onSeek)
|
||||
$(@speedControl).bind('speedChange', @onSpeedChange)
|
||||
$(@progressSlider).bind('seek', @onSeek)
|
||||
@@ -25,6 +26,7 @@ class @VideoPlayer extends Subview
|
||||
|
||||
render: ->
|
||||
@control = new VideoControl el: @$('.video-controls')
|
||||
@qualityControl = new VideoQualityControl el: @$('.secondary-controls')
|
||||
@caption = new VideoCaption
|
||||
el: @el
|
||||
youtubeId: @video.youtubeId('1.0')
|
||||
@@ -41,10 +43,12 @@ class @VideoPlayer extends Subview
|
||||
rel: 0
|
||||
showinfo: 0
|
||||
enablejsapi: 1
|
||||
modestbranding: 1
|
||||
videoId: @video.youtubeId()
|
||||
events:
|
||||
onReady: @onReady
|
||||
onStateChange: @onStateChange
|
||||
onPlaybackQualityChange: @onPlaybackQualityChange
|
||||
@caption.hideCaptions(@['video'].hide_captions)
|
||||
|
||||
addToolTip: ->
|
||||
@@ -53,7 +57,7 @@ class @VideoPlayer extends Subview
|
||||
my: 'top right'
|
||||
at: 'top center'
|
||||
|
||||
onReady: =>
|
||||
onReady: (event) =>
|
||||
unless onTouchBasedDevice()
|
||||
$('.video-load-complete:first').data('video').player.play()
|
||||
|
||||
@@ -68,6 +72,13 @@ class @VideoPlayer extends Subview
|
||||
when YT.PlayerState.ENDED
|
||||
@onEnded()
|
||||
|
||||
onPlaybackQualityChange: (event, value) =>
|
||||
quality = @player.getPlaybackQuality()
|
||||
@qualityControl.onQualityChange(quality)
|
||||
|
||||
handlePlaybackQualityChange: (event, value) =>
|
||||
@player.setPlaybackQuality(value)
|
||||
|
||||
onUnstarted: =>
|
||||
@control.pause()
|
||||
@caption.pause()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
class @VideoQualityControl extends Subview
|
||||
initialize: ->
|
||||
@quality = null;
|
||||
|
||||
bind: ->
|
||||
@$('.quality_control').click @toggleQuality
|
||||
|
||||
render: ->
|
||||
@el.append """
|
||||
<a href="#" class="quality_control" title="HD">HD</a>
|
||||
"""#"
|
||||
|
||||
onQualityChange: (value) ->
|
||||
@quality = value
|
||||
if @quality in ['hd720', 'hd1080', 'highres']
|
||||
@el.addClass('active')
|
||||
else
|
||||
@el.removeClass('active')
|
||||
|
||||
toggleQuality: (event) =>
|
||||
event.preventDefault()
|
||||
if @quality in ['hd720', 'hd1080', 'highres']
|
||||
newQuality = 'large'
|
||||
else
|
||||
newQuality = 'hd720'
|
||||
$(@).trigger('changeQuality', newQuality)
|
||||
@@ -17,7 +17,7 @@ class @VideoVolumeControl extends Subview
|
||||
<div class="volume-slider"></div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
"""#"
|
||||
@slider = @$('.volume-slider').slider
|
||||
orientation: "vertical"
|
||||
range: "min"
|
||||
|
||||
@@ -377,6 +377,7 @@ class ModuleStore(object):
|
||||
return courses
|
||||
|
||||
|
||||
|
||||
class ModuleStoreBase(ModuleStore):
|
||||
'''
|
||||
Implement interface functionality that can be shared.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import pymongo
|
||||
import sys
|
||||
import logging
|
||||
|
||||
from bson.son import SON
|
||||
from fs.osfs import OSFS
|
||||
@@ -275,10 +276,49 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
source_item = self.collection.find_one(location_to_query(source))
|
||||
source_item['_id'] = Location(location).dict()
|
||||
self.collection.insert(source_item)
|
||||
return self._load_items([source_item])[0]
|
||||
item = self._load_items([source_item])[0]
|
||||
|
||||
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
|
||||
# if we add one then we need to also add it to the policy information (i.e. metadata)
|
||||
# we should remove this once we can break this reference from the course to static tabs
|
||||
if location.category == 'static_tab':
|
||||
course = self.get_course_for_item(item.location)
|
||||
existing_tabs = course.tabs or []
|
||||
existing_tabs.append({'type':'static_tab', 'name' : item.metadata.get('display_name'), 'url_slug' : item.location.name})
|
||||
course.tabs = existing_tabs
|
||||
self.update_metadata(course.location, course.metadata)
|
||||
|
||||
return item
|
||||
except pymongo.errors.DuplicateKeyError:
|
||||
raise DuplicateItemError(location)
|
||||
|
||||
|
||||
def get_course_for_item(self, location):
|
||||
'''
|
||||
VS[compat]
|
||||
cdodge: for a given Xmodule, return the course that it belongs to
|
||||
NOTE: This makes a lot of assumptions about the format of the course location
|
||||
Also we have to assert that this module maps to only one course item - it'll throw an
|
||||
assert if not
|
||||
This is only used to support static_tabs as we need to be course module aware
|
||||
'''
|
||||
|
||||
# @hack! We need to find the course location however, we don't
|
||||
# know the 'name' parameter in this context, so we have
|
||||
# to assume there's only one item in this query even though we are not specifying a name
|
||||
course_search_location = ['i4x', location.org, location.course, 'course', None]
|
||||
courses = self.get_items(course_search_location)
|
||||
|
||||
# make sure we found exactly one match on this above course search
|
||||
found_cnt = len(courses)
|
||||
if found_cnt == 0:
|
||||
raise BaseException('Could not find course at {0}'.format(course_search_location))
|
||||
|
||||
if found_cnt > 1:
|
||||
raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
|
||||
|
||||
return courses[0]
|
||||
|
||||
def _update_single_item(self, location, update):
|
||||
"""
|
||||
Set update on the specified item, and raises ItemNotFoundError
|
||||
@@ -326,6 +366,19 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
location: Something that can be passed to Location
|
||||
metadata: A nested dictionary of module metadata
|
||||
"""
|
||||
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
|
||||
# if we add one then we need to also add it to the policy information (i.e. metadata)
|
||||
# we should remove this once we can break this reference from the course to static tabs
|
||||
loc = Location(location)
|
||||
if loc.category == 'static_tab':
|
||||
course = self.get_course_for_item(loc)
|
||||
existing_tabs = course.tabs or []
|
||||
for tab in existing_tabs:
|
||||
if tab.get('url_slug') == loc.name:
|
||||
tab['name'] = metadata.get('display_name')
|
||||
break
|
||||
course.tabs = existing_tabs
|
||||
self.update_metadata(course.location, course.metadata)
|
||||
|
||||
self._update_single_item(location, {'metadata': metadata})
|
||||
|
||||
@@ -335,6 +388,16 @@ class MongoModuleStore(ModuleStoreBase):
|
||||
|
||||
location: Something that can be passed to Location
|
||||
"""
|
||||
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
|
||||
# if we add one then we need to also add it to the policy information (i.e. metadata)
|
||||
# we should remove this once we can break this reference from the course to static tabs
|
||||
if location.category == 'static_tab':
|
||||
item = self.get_item(location)
|
||||
course = self.get_course_for_item(item.location)
|
||||
existing_tabs = course.tabs or []
|
||||
course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name]
|
||||
self.update_metadata(course.location, course.metadata)
|
||||
|
||||
self.collection.remove({'_id': Location(location).dict()})
|
||||
|
||||
def get_parent_locations(self, location):
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import glob
|
||||
|
||||
from collections import defaultdict
|
||||
from cStringIO import StringIO
|
||||
@@ -17,6 +18,8 @@ from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.mako_module import MakoDescriptorSystem
|
||||
from xmodule.x_module import XModuleDescriptor, XMLParsingSystem
|
||||
|
||||
from xmodule.html_module import HtmlDescriptor
|
||||
|
||||
from . import ModuleStoreBase, Location
|
||||
from .exceptions import ItemNotFoundError
|
||||
|
||||
@@ -331,7 +334,6 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
if not os.path.exists(policy_path):
|
||||
return {}
|
||||
try:
|
||||
log.debug("Loading policy from {0}".format(policy_path))
|
||||
with open(policy_path) as f:
|
||||
return json.load(f)
|
||||
except (IOError, ValueError) as err:
|
||||
@@ -386,6 +388,7 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
if url_name:
|
||||
policy_dir = self.data_dir / course_dir / 'policies' / url_name
|
||||
policy_path = policy_dir / 'policy.json'
|
||||
|
||||
policy = self.load_policy(policy_path, tracker)
|
||||
|
||||
# VS[compat]: remove once courses use the policy dirs.
|
||||
@@ -403,7 +406,6 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
raise ValueError("Can't load a course without a 'url_name' "
|
||||
"(or 'name') set. Set url_name.")
|
||||
|
||||
|
||||
course_id = CourseDescriptor.make_id(org, course, url_name)
|
||||
system = ImportSystem(
|
||||
self,
|
||||
@@ -423,9 +425,40 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
# after we have the course descriptor.
|
||||
XModuleDescriptor.compute_inherited_metadata(course_descriptor)
|
||||
|
||||
# now import all pieces of course_info which is expected to be stored
|
||||
# in <content_dir>/info or <content_dir>/info/<url_name>
|
||||
self.load_extra_content(system, course_descriptor, 'course_info', self.data_dir / course_dir / 'info', course_dir, url_name)
|
||||
|
||||
# now import all static tabs which are expected to be stored in
|
||||
# in <content_dir>/tabs or <content_dir>/tabs/<url_name>
|
||||
self.load_extra_content(system, course_descriptor, 'static_tab', self.data_dir / course_dir / 'tabs', course_dir, url_name)
|
||||
|
||||
self.load_extra_content(system, course_descriptor, 'custom_tag_template', self.data_dir / course_dir / 'custom_tags', course_dir, url_name)
|
||||
|
||||
self.load_extra_content(system, course_descriptor, 'about', self.data_dir / course_dir / 'about', course_dir, url_name)
|
||||
|
||||
log.debug('========> Done with course import from {0}'.format(course_dir))
|
||||
return course_descriptor
|
||||
|
||||
def load_extra_content(self, system, course_descriptor, category, base_dir, course_dir, url_name):
|
||||
if url_name:
|
||||
path = base_dir / url_name
|
||||
|
||||
if not os.path.exists(path):
|
||||
path = base_dir
|
||||
|
||||
for filepath in glob.glob(path/ '*'):
|
||||
with open(filepath) as f:
|
||||
try:
|
||||
html = f.read().decode('utf-8')
|
||||
# tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix
|
||||
slug = os.path.splitext(os.path.basename(filepath))[0]
|
||||
loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug)
|
||||
module = HtmlDescriptor(system, definition={'data' : html}, **{'location' : loc})
|
||||
module.metadata['data_dir'] = course_dir
|
||||
self.modules[course_descriptor.id][module.location] = module
|
||||
except Exception, e:
|
||||
logging.exception("Failed to load {0}. Skipping... Exception: {1}".format(filepath, str(e)))
|
||||
|
||||
def get_instance(self, course_id, location, depth=0):
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
import mimetypes
|
||||
from lxml.html import rewrite_links as lxml_rewrite_links
|
||||
from path import path
|
||||
|
||||
from .xml import XMLModuleStore
|
||||
from .exceptions import DuplicateItemError
|
||||
@@ -9,29 +11,12 @@ from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def import_static_content(modules, data_dir, static_content_store):
|
||||
def import_static_content(modules, course_loc, course_data_path, static_content_store, target_location_namespace):
|
||||
|
||||
remap_dict = {}
|
||||
|
||||
course_data_dir = None
|
||||
course_loc = None
|
||||
|
||||
# quick scan to find the course module and pull out the data_dir and location
|
||||
# maybe there an easier way to look this up?!?
|
||||
|
||||
for module in modules.itervalues():
|
||||
if module.category == 'course':
|
||||
course_loc = module.location
|
||||
course_data_dir = module.metadata['data_dir']
|
||||
|
||||
if course_data_dir is None or course_loc is None:
|
||||
return remap_dict
|
||||
|
||||
|
||||
# now import all static assets
|
||||
static_dir = '{0}/static/'.format(course_data_dir)
|
||||
|
||||
logging.debug("Importing static assets in {0}".format(static_dir))
|
||||
static_dir = course_data_path / 'static/'
|
||||
|
||||
for dirname, dirnames, filenames in os.walk(static_dir):
|
||||
for filename in filenames:
|
||||
@@ -39,12 +24,11 @@ def import_static_content(modules, data_dir, static_content_store):
|
||||
try:
|
||||
content_path = os.path.join(dirname, filename)
|
||||
fullname_with_subpath = content_path.replace(static_dir, '') # strip away leading path from the name
|
||||
content_loc = StaticContent.compute_location(course_loc.org, course_loc.course, fullname_with_subpath)
|
||||
content_loc = StaticContent.compute_location(target_location_namespace.org, target_location_namespace.course, fullname_with_subpath)
|
||||
mime_type = mimetypes.guess_type(filename)[0]
|
||||
|
||||
f = open(content_path, 'rb')
|
||||
data = f.read()
|
||||
f.close()
|
||||
with open(content_path, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
content = StaticContent(content_loc, filename, mime_type, data)
|
||||
|
||||
@@ -59,15 +43,52 @@ def import_static_content(modules, data_dir, static_content_store):
|
||||
|
||||
#store the remapping information which will be needed to subsitute in the module data
|
||||
remap_dict[fullname_with_subpath] = content_loc.name
|
||||
|
||||
except:
|
||||
raise
|
||||
raise
|
||||
|
||||
return remap_dict
|
||||
|
||||
def verify_content_links(module, base_dir, static_content_store, link, remap_dict = None):
|
||||
if link.startswith('/static/'):
|
||||
# yes, then parse out the name
|
||||
path = link[len('/static/'):]
|
||||
|
||||
static_pathname = base_dir / path
|
||||
|
||||
if os.path.exists(static_pathname):
|
||||
try:
|
||||
content_loc = StaticContent.compute_location(module.location.org, module.location.course, path)
|
||||
filename = os.path.basename(path)
|
||||
mime_type = mimetypes.guess_type(filename)[0]
|
||||
|
||||
with open(static_pathname, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
content = StaticContent(content_loc, filename, mime_type, data)
|
||||
|
||||
# first let's save a thumbnail so we can get back a thumbnail location
|
||||
thumbnail_content = static_content_store.generate_thumbnail(content)
|
||||
|
||||
if thumbnail_content is not None:
|
||||
content.thumbnail_location = thumbnail_content.location
|
||||
|
||||
#then commit the content
|
||||
static_content_store.save(content)
|
||||
|
||||
new_link = StaticContent.get_url_path_from_location(content_loc)
|
||||
|
||||
if remap_dict is not None:
|
||||
remap_dict[link] = new_link
|
||||
|
||||
return new_link
|
||||
except Exception, e:
|
||||
logging.exception('Skipping failed content load from {0}. Exception: {1}'.format(path, e))
|
||||
|
||||
return link
|
||||
|
||||
def import_from_xml(store, data_dir, course_dirs=None,
|
||||
default_class='xmodule.raw_module.RawDescriptor',
|
||||
load_error_modules=True, static_content_store=None):
|
||||
load_error_modules=True, static_content_store=None, target_location_namespace = None):
|
||||
"""
|
||||
Import the specified xml data_dir into the "store" modulestore,
|
||||
using org and course as the location org and course.
|
||||
@@ -75,6 +96,11 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
course_dirs: If specified, the list of course_dirs to load. Otherwise, load
|
||||
all course dirs
|
||||
|
||||
target_location_namespace is the namespace [passed as Location] (i.e. {tag},{org},{course}) that all modules in the should be remapped to
|
||||
after import off disk. We do this remapping as a post-processing step because there's logic in the importing which
|
||||
expects a 'url_name' as an identifier to where things are on disk e.g. ../policies/<url_name>/policy.json as well as metadata keys in
|
||||
the policy.json. so we need to keep the original url_name during import
|
||||
|
||||
"""
|
||||
|
||||
module_store = XMLModuleStore(
|
||||
@@ -89,31 +115,80 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
# method on XmlModuleStore.
|
||||
course_items = []
|
||||
for course_id in module_store.modules.keys():
|
||||
remap_dict = {}
|
||||
|
||||
course_data_path = None
|
||||
course_location = None
|
||||
# Quick scan to get course Location as well as the course_data_path
|
||||
for module in module_store.modules[course_id].itervalues():
|
||||
if module.category == 'course':
|
||||
course_data_path = path(data_dir) / module.metadata['data_dir']
|
||||
course_location = module.location
|
||||
|
||||
if static_content_store is not None:
|
||||
remap_dict = import_static_content(module_store.modules[course_id], data_dir, static_content_store)
|
||||
import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store,
|
||||
target_location_namespace if target_location_namespace is not None else module_store.modules[course_id].location)
|
||||
|
||||
for module in module_store.modules[course_id].itervalues():
|
||||
|
||||
# remap module to the new namespace
|
||||
if target_location_namespace is not None:
|
||||
# This looks a bit wonky as we need to also change the 'name' of the imported course to be what
|
||||
# the caller passed in
|
||||
if module.location.category != 'course':
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
else:
|
||||
module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course, name=target_location_namespace.name)
|
||||
|
||||
# then remap children pointers since they too will be re-namespaced
|
||||
children_locs = module.definition.get('children')
|
||||
if children_locs is not None:
|
||||
new_locs = []
|
||||
for child in children_locs:
|
||||
child_loc = Location(child)
|
||||
new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org,
|
||||
course=target_location_namespace.course)
|
||||
|
||||
new_locs.append(new_child_loc.url())
|
||||
|
||||
module.definition['children'] = new_locs
|
||||
|
||||
|
||||
if module.category == 'course':
|
||||
# HACK: for now we don't support progress tabs. There's a special metadata configuration setting for this.
|
||||
module.metadata['hide_progress_tab'] = True
|
||||
|
||||
# a bit of a hack, but typically the "course image" which is shown on marketing pages is hard coded to /images/course_image.jpg
|
||||
# so let's make sure we import in case there are no other references to it in the modules
|
||||
verify_content_links(module, course_data_path, static_content_store, '/static/images/course_image.jpg')
|
||||
|
||||
course_items.append(module)
|
||||
|
||||
if 'data' in module.definition:
|
||||
module_data = module.definition['data']
|
||||
|
||||
# cdodge: update any references to the static content paths
|
||||
# This is a bit brute force - simple search/replace - but it's unlikely that such references to '/static/....'
|
||||
# would occur naturally (in the wild)
|
||||
# @TODO, sorry a bit of technical debt here. There are some helper methods in xmodule_modifiers.py and static_replace.py which could
|
||||
# better do the url replace on the html rendering side rather than on the ingest side
|
||||
try:
|
||||
if '/static/' in module_data:
|
||||
for subkey in remap_dict.keys():
|
||||
module_data = module_data.replace('/static/' + subkey, 'xasset:' + remap_dict[subkey])
|
||||
except:
|
||||
pass # part of the techincal debt is that module_data might not be a string (e.g. ABTest)
|
||||
# cdodge: now go through any link references to '/static/' and make sure we've imported
|
||||
# it as a StaticContent asset
|
||||
try:
|
||||
remap_dict = {}
|
||||
|
||||
# use the rewrite_links as a utility means to enumerate through all links
|
||||
# in the module data. We use that to load that reference into our asset store
|
||||
# IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to
|
||||
# do the rewrites natively in that code.
|
||||
# For example, what I'm seeing is <img src='foo.jpg' /> -> <img src='bar.jpg'>
|
||||
# Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's
|
||||
# no good, so we have to do this kludge
|
||||
if isinstance(module_data, str) or isinstance(module_data, unicode): # some module 'data' fields are non strings which blows up the link traversal code
|
||||
lxml_rewrite_links(module_data, lambda link: verify_content_links(module, course_data_path,
|
||||
static_content_store, link, remap_dict))
|
||||
|
||||
for key in remap_dict.keys():
|
||||
module_data = module_data.replace(key, remap_dict[key])
|
||||
|
||||
except Exception, e:
|
||||
logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location))
|
||||
|
||||
store.update_item(module.location, module_data)
|
||||
|
||||
@@ -125,4 +200,6 @@ def import_from_xml(store, data_dir, course_dirs=None,
|
||||
# inherited metadata everywhere.
|
||||
store.update_metadata(module.location, dict(module.own_metadata))
|
||||
|
||||
|
||||
|
||||
return module_store, course_items
|
||||
|
||||
476
common/lib/xmodule/xmodule/self_assessment_module.py
Normal file
476
common/lib/xmodule/xmodule/self_assessment_module.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
A Self Assessment module that allows students to write open-ended responses,
|
||||
submit, then see a rubric and rate themselves. Persists student supplied
|
||||
hints, answers, and assessment judgment (currently only correct/incorrect).
|
||||
Parses xml definition file--see below for exact format.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from fs.errors import ResourceNotFoundError
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from lxml import etree
|
||||
from lxml.html import rewrite_links
|
||||
from path import path
|
||||
import json
|
||||
from progress import Progress
|
||||
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from .capa_module import only_one, ComplexEncoder
|
||||
from .editing_module import EditingDescriptor
|
||||
from .html_checker import check_html
|
||||
from .stringify import stringify_children
|
||||
from .x_module import XModule
|
||||
from .xml_module import XmlDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
# Set the default number of max attempts. Should be 1 for production
|
||||
# Set higher for debugging/testing
|
||||
# attempts specified in xml definition overrides this.
|
||||
MAX_ATTEMPTS = 1
|
||||
|
||||
# Set maximum available number of points.
|
||||
# Overriden by max_score specified in xml.
|
||||
MAX_SCORE = 1
|
||||
|
||||
class SelfAssessmentModule(XModule):
|
||||
"""
|
||||
States:
|
||||
|
||||
initial (prompt, textbox shown)
|
||||
|
|
||||
assessing (read-only textbox, rubric + assessment input shown)
|
||||
|
|
||||
request_hint (read-only textbox, read-only rubric and assessment, hint input box shown)
|
||||
|
|
||||
done (submitted msg, green checkmark, everything else read-only. If attempts < max, shows
|
||||
a reset button that goes back to initial state. Saves previous
|
||||
submissions too.)
|
||||
"""
|
||||
|
||||
# states
|
||||
INITIAL = 'initial'
|
||||
ASSESSING = 'assessing'
|
||||
REQUEST_HINT = 'request_hint'
|
||||
DONE = 'done'
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/selfassessment/display.coffee')]}
|
||||
js_module_name = "SelfAssessment"
|
||||
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
|
||||
"""
|
||||
Definition file should have 4 blocks -- prompt, rubric, submitmessage, hintprompt,
|
||||
and two optional attributes:
|
||||
attempts, which should be an integer that defaults to 1.
|
||||
If it's > 1, the student will be able to re-submit after they see
|
||||
the rubric.
|
||||
max_score, which should be an integer that defaults to 1.
|
||||
It defines the maximum number of points a student can get. Assumed to be integer scale
|
||||
from 0 to max_score, with an interval of 1.
|
||||
|
||||
Note: all the submissions are stored.
|
||||
|
||||
Sample file:
|
||||
|
||||
<selfassessment attempts="1" max_score="1">
|
||||
<prompt>
|
||||
Insert prompt text here. (arbitrary html)
|
||||
</prompt>
|
||||
<rubric>
|
||||
Insert grading rubric here. (arbitrary html)
|
||||
</rubric>
|
||||
<hintprompt>
|
||||
Please enter a hint below: (arbitrary html)
|
||||
</hintprompt>
|
||||
<submitmessage>
|
||||
Thanks for submitting! (arbitrary html)
|
||||
</submitmessage>
|
||||
</selfassessment>
|
||||
"""
|
||||
|
||||
# Load instance state
|
||||
if instance_state is not None:
|
||||
instance_state = json.loads(instance_state)
|
||||
else:
|
||||
instance_state = {}
|
||||
|
||||
# Note: score responses are on scale from 0 to max_score
|
||||
self.student_answers = instance_state.get('student_answers', [])
|
||||
self.scores = instance_state.get('scores', [])
|
||||
self.hints = instance_state.get('hints', [])
|
||||
|
||||
self.state = instance_state.get('state', 'initial')
|
||||
|
||||
# Used for progress / grading. Currently get credit just for
|
||||
# completion (doesn't matter if you self-assessed correct/incorrect).
|
||||
|
||||
self._max_score = int(self.metadata.get('max_score', MAX_SCORE))
|
||||
|
||||
self.attempts = instance_state.get('attempts', 0)
|
||||
|
||||
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
|
||||
|
||||
self.rubric = definition['rubric']
|
||||
self.prompt = definition['prompt']
|
||||
self.submit_message = definition['submitmessage']
|
||||
self.hint_prompt = definition['hintprompt']
|
||||
|
||||
def _allow_reset(self):
|
||||
"""Can the module be reset?"""
|
||||
return self.state == self.DONE and self.attempts < self.max_attempts
|
||||
|
||||
def get_html(self):
|
||||
#set context variables and render template
|
||||
if self.state != self.INITIAL and self.student_answers:
|
||||
previous_answer = self.student_answers[-1]
|
||||
else:
|
||||
previous_answer = ''
|
||||
|
||||
context = {
|
||||
'prompt': self.prompt,
|
||||
'previous_answer': previous_answer,
|
||||
'ajax_url': self.system.ajax_url,
|
||||
'initial_rubric': self.get_rubric_html(),
|
||||
'initial_hint': self.get_hint_html(),
|
||||
'initial_message': self.get_message_html(),
|
||||
'state': self.state,
|
||||
'allow_reset': self._allow_reset(),
|
||||
}
|
||||
html = self.system.render_template('self_assessment_prompt.html', context)
|
||||
|
||||
# cdodge: perform link substitutions for any references to course static content (e.g. images)
|
||||
return rewrite_links(html, self.rewrite_content_links)
|
||||
|
||||
def get_score(self):
|
||||
"""
|
||||
Returns dict with 'score' key
|
||||
"""
|
||||
return {'score': self.get_last_score()}
|
||||
|
||||
def max_score(self):
|
||||
"""
|
||||
Return max_score
|
||||
"""
|
||||
return self._max_score
|
||||
|
||||
def get_last_score(self):
|
||||
"""
|
||||
Returns the last score in the list
|
||||
"""
|
||||
last_score=0
|
||||
if(len(self.scores)>0):
|
||||
last_score=self.scores[len(self.scores)-1]
|
||||
return last_score
|
||||
|
||||
def get_progress(self):
|
||||
'''
|
||||
For now, just return last score / max_score
|
||||
'''
|
||||
if self._max_score > 0:
|
||||
try:
|
||||
return Progress(self.get_last_score(), self._max_score)
|
||||
except Exception as err:
|
||||
log.exception("Got bad progress")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
"""
|
||||
This is called by courseware.module_render, to handle an AJAX call.
|
||||
"get" is request.POST.
|
||||
|
||||
Returns a json dictionary:
|
||||
{ 'progress_changed' : True/False,
|
||||
'progress': 'none'/'in_progress'/'done',
|
||||
<other request-specific values here > }
|
||||
"""
|
||||
|
||||
handlers = {
|
||||
'save_answer': self.save_answer,
|
||||
'save_assessment': self.save_assessment,
|
||||
'save_hint': self.save_hint,
|
||||
'reset': self.reset,
|
||||
}
|
||||
|
||||
if dispatch not in handlers:
|
||||
return 'Error'
|
||||
|
||||
before = self.get_progress()
|
||||
d = handlers[dispatch](get)
|
||||
after = self.get_progress()
|
||||
d.update({
|
||||
'progress_changed': after != before,
|
||||
'progress_status': Progress.to_js_status_str(after),
|
||||
})
|
||||
return json.dumps(d, cls=ComplexEncoder)
|
||||
|
||||
def out_of_sync_error(self, get, msg=''):
|
||||
"""
|
||||
return dict out-of-sync error message, and also log.
|
||||
"""
|
||||
log.warning("Assessment module state out sync. state: %r, get: %r. %s",
|
||||
self.state, get, msg)
|
||||
return {'success': False,
|
||||
'error': 'The problem state got out-of-sync'}
|
||||
|
||||
def get_rubric_html(self):
|
||||
"""
|
||||
Return the appropriate version of the rubric, based on the state.
|
||||
"""
|
||||
if self.state == self.INITIAL:
|
||||
return ''
|
||||
|
||||
# we'll render it
|
||||
context = {'rubric': self.rubric,
|
||||
'max_score' : self._max_score,
|
||||
}
|
||||
|
||||
if self.state == self.ASSESSING:
|
||||
context['read_only'] = False
|
||||
elif self.state in (self.REQUEST_HINT, self.DONE):
|
||||
context['read_only'] = True
|
||||
else:
|
||||
raise ValueError("Illegal state '%r'" % self.state)
|
||||
|
||||
return self.system.render_template('self_assessment_rubric.html', context)
|
||||
|
||||
def get_hint_html(self):
|
||||
"""
|
||||
Return the appropriate version of the hint view, based on state.
|
||||
"""
|
||||
if self.state in (self.INITIAL, self.ASSESSING):
|
||||
return ''
|
||||
|
||||
if self.state == self.DONE and len(self.hints) > 0:
|
||||
# display the previous hint
|
||||
hint = self.hints[-1]
|
||||
else:
|
||||
hint = ''
|
||||
|
||||
context = {'hint_prompt': self.hint_prompt,
|
||||
'hint': hint}
|
||||
|
||||
if self.state == self.REQUEST_HINT:
|
||||
context['read_only'] = False
|
||||
elif self.state == self.DONE:
|
||||
context['read_only'] = True
|
||||
else:
|
||||
raise ValueError("Illegal state '%r'" % self.state)
|
||||
|
||||
return self.system.render_template('self_assessment_hint.html', context)
|
||||
|
||||
def get_message_html(self):
|
||||
"""
|
||||
Return the appropriate version of the message view, based on state.
|
||||
"""
|
||||
if self.state != self.DONE:
|
||||
return ""
|
||||
|
||||
return """<div class="save_message">{0}</div>""".format(self.submit_message)
|
||||
|
||||
|
||||
def save_answer(self, get):
|
||||
"""
|
||||
After the answer is submitted, show the rubric.
|
||||
"""
|
||||
# Check to see if attempts are less than max
|
||||
if self.attempts > self.max_attempts:
|
||||
# If too many attempts, prevent student from saving answer and
|
||||
# seeing rubric. In normal use, students shouldn't see this because
|
||||
# they won't see the reset button once they're out of attempts.
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
|
||||
if self.state != self.INITIAL:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
self.student_answers.append(get['student_answer'])
|
||||
self.state = self.ASSESSING
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'rubric_html': self.get_rubric_html()
|
||||
}
|
||||
|
||||
def save_assessment(self, get):
|
||||
"""
|
||||
Save the assessment. If the student said they're right, don't ask for a
|
||||
hint, and go straight to the done state. Otherwise, do ask for a hint.
|
||||
|
||||
Returns a dict { 'success': bool, 'state': state,
|
||||
|
||||
'hint_html': hint_html OR 'message_html': html and 'allow_reset',
|
||||
|
||||
'error': error-msg},
|
||||
|
||||
with 'error' only present if 'success' is False, and 'hint_html' or
|
||||
'message_html' only if success is true
|
||||
"""
|
||||
|
||||
n_answers = len(self.student_answers)
|
||||
n_scores = len(self.scores)
|
||||
if (self.state != self.ASSESSING or n_answers != n_scores + 1):
|
||||
msg = "%d answers, %d scores" % (n_answers, n_scores)
|
||||
return self.out_of_sync_error(get, msg)
|
||||
|
||||
try:
|
||||
score = int(get['assessment'])
|
||||
except:
|
||||
return {'success': False, 'error': "Non-integer score value"}
|
||||
|
||||
self.scores.append(score)
|
||||
|
||||
d = {'success': True,}
|
||||
|
||||
if score == self.max_score():
|
||||
self.state = self.DONE
|
||||
d['message_html'] = self.get_message_html()
|
||||
d['allow_reset'] = self._allow_reset()
|
||||
else:
|
||||
self.state = self.REQUEST_HINT
|
||||
d['hint_html'] = self.get_hint_html()
|
||||
|
||||
d['state'] = self.state
|
||||
return d
|
||||
|
||||
|
||||
def save_hint(self, get):
|
||||
'''
|
||||
Save the hint.
|
||||
Returns a dict { 'success': bool,
|
||||
'message_html': message_html,
|
||||
'error': error-msg,
|
||||
'allow_reset': bool},
|
||||
with the error key only present if success is False and message_html
|
||||
only if True.
|
||||
'''
|
||||
if self.state != self.REQUEST_HINT:
|
||||
# Note: because we only ask for hints on wrong answers, may not have
|
||||
# the same number of hints and answers.
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
self.hints.append(get['hint'].lower())
|
||||
self.state = self.DONE
|
||||
|
||||
# increment attempts
|
||||
self.attempts = self.attempts + 1
|
||||
|
||||
# To the tracking logs!
|
||||
event_info = {
|
||||
'selfassessment_id': self.location.url(),
|
||||
'state': {
|
||||
'student_answers': self.student_answers,
|
||||
'score': self.scores,
|
||||
'hints': self.hints,
|
||||
}
|
||||
}
|
||||
self.system.track_function('save_hint', event_info)
|
||||
|
||||
return {'success': True,
|
||||
'message_html': self.get_message_html(),
|
||||
'allow_reset': self._allow_reset()}
|
||||
|
||||
|
||||
def reset(self, get):
|
||||
"""
|
||||
If resetting is allowed, reset the state.
|
||||
|
||||
Returns {'success': bool, 'error': msg}
|
||||
(error only present if not success)
|
||||
"""
|
||||
if self.state != self.DONE:
|
||||
return self.out_of_sync_error(get)
|
||||
|
||||
if self.attempts > self.max_attempts:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Too many attempts.'
|
||||
}
|
||||
self.state = self.INITIAL
|
||||
return {'success': True}
|
||||
|
||||
|
||||
def get_instance_state(self):
|
||||
"""
|
||||
Get the current score and state
|
||||
"""
|
||||
|
||||
state = {
|
||||
'student_answers': self.student_answers,
|
||||
'hints': self.hints,
|
||||
'state': self.state,
|
||||
'scores': self.scores,
|
||||
'max_score': self._max_score,
|
||||
'attempts': self.attempts
|
||||
}
|
||||
return json.dumps(state)
|
||||
|
||||
|
||||
class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
"""
|
||||
Module for adding self assessment questions to courses
|
||||
"""
|
||||
mako_template = "widgets/html-edit.html"
|
||||
module_class = SelfAssessmentModule
|
||||
filename_extension = "xml"
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
template_dir_name = "selfassessment"
|
||||
|
||||
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
|
||||
js_module_name = "HTMLEditingDescriptor"
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
"""
|
||||
Pull out the rubric, prompt, and submitmessage into a dictionary.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'rubric': 'some-html',
|
||||
'prompt': 'some-html',
|
||||
'submitmessage': 'some-html'
|
||||
'hintprompt': 'some-html'
|
||||
}
|
||||
"""
|
||||
expected_children = ['rubric', 'prompt', 'submitmessage', 'hintprompt']
|
||||
for child in expected_children:
|
||||
if len(xml_object.xpath(child)) != 1:
|
||||
raise ValueError("Self assessment definition must include exactly one '{0}' tag".format(child))
|
||||
|
||||
def parse(k):
|
||||
"""Assumes that xml_object has child k"""
|
||||
return stringify_children(xml_object.xpath(k)[0])
|
||||
|
||||
return {'rubric': parse('rubric'),
|
||||
'prompt': parse('prompt'),
|
||||
'submitmessage': parse('submitmessage'),
|
||||
'hintprompt': parse('hintprompt'),
|
||||
}
|
||||
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
'''Return an xml element representing this definition.'''
|
||||
elt = etree.Element('selfassessment')
|
||||
|
||||
def add_child(k):
|
||||
child_str = '<{tag}>{body}</{tag}>'.format(tag=k, body=self.definition[k])
|
||||
child_node = etree.fromstring(child_str)
|
||||
elt.append(child_node)
|
||||
|
||||
for child in ['rubric', 'prompt', 'submitmessage', 'hintprompt']:
|
||||
add_child(child)
|
||||
|
||||
return elt
|
||||
@@ -2,6 +2,7 @@ from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from lxml import etree
|
||||
from mako.template import Template
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
class CustomTagModule(XModule):
|
||||
@@ -40,8 +41,7 @@ class CustomTagDescriptor(RawDescriptor):
|
||||
module_class = CustomTagModule
|
||||
template_dir_name = 'customtag'
|
||||
|
||||
@staticmethod
|
||||
def render_template(system, xml_data):
|
||||
def render_template(self, system, xml_data):
|
||||
'''Render the template, given the definition xml_data'''
|
||||
xmltree = etree.fromstring(xml_data)
|
||||
if 'impl' in xmltree.attrib:
|
||||
@@ -57,15 +57,23 @@ class CustomTagDescriptor(RawDescriptor):
|
||||
.format(location))
|
||||
|
||||
params = dict(xmltree.items())
|
||||
with system.resources_fs.open('custom_tags/{name}'
|
||||
.format(name=template_name)) as template:
|
||||
return Template(template.read()).render(**params)
|
||||
|
||||
# cdodge: look up the template as a module
|
||||
template_loc = self.location._replace(category='custom_tag_template', name=template_name)
|
||||
|
||||
template_module = modulestore().get_item(template_loc)
|
||||
template_module_data = template_module.definition['data']
|
||||
template = Template(template_module_data)
|
||||
return template.render(**params)
|
||||
|
||||
|
||||
def __init__(self, system, definition, **kwargs):
|
||||
'''Render and save the template for this descriptor instance'''
|
||||
super(CustomTagDescriptor, self).__init__(system, definition, **kwargs)
|
||||
self.rendered_html = self.render_template(system, definition['data'])
|
||||
|
||||
@property
|
||||
def rendered_html(self):
|
||||
return self.render_template(self.system, self.definition['data'])
|
||||
|
||||
def export_to_file(self):
|
||||
"""
|
||||
|
||||
5
common/lib/xmodule/xmodule/templates/about/empty.yaml
Normal file
5
common/lib/xmodule/xmodule/templates/about/empty.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
metadata:
|
||||
display_name: Empty
|
||||
data: "<p>This is where you can add additional information about your course.</p>"
|
||||
children: []
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
metadata:
|
||||
display_name: Empty
|
||||
data: "<p>This is where you can add additional information about your course.</p>"
|
||||
children: []
|
||||
@@ -36,7 +36,7 @@ metadata:
|
||||
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
|
||||
\subsection{Example "multiple choice" problem}
|
||||
|
||||
What color is a bannana?
|
||||
What color is a banana?
|
||||
|
||||
\edXabox{ type="multichoice" expect="Yellow" options="Red","Green","Yellow","Blue" }
|
||||
|
||||
@@ -129,7 +129,7 @@ data: |
|
||||
<h4>Example "multiple choice" problem</h4>
|
||||
</p>
|
||||
<p>
|
||||
What color is a bannana? </p>
|
||||
What color is a banana? </p>
|
||||
<p>
|
||||
<choiceresponse>
|
||||
<checkboxgroup>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
metadata:
|
||||
display_name: Empty
|
||||
data: "<p>This is where you can add additional pages to your courseware. Click the 'edit' button to begin editing.</p>"
|
||||
children: []
|
||||
@@ -113,3 +113,7 @@ class RoundTripTestCase(unittest.TestCase):
|
||||
|
||||
def test_full_roundtrip(self):
|
||||
self.check_export_roundtrip(DATA_DIR, "full")
|
||||
|
||||
def test_selfassessment_roundtrip(self):
|
||||
#Test selfassessment xmodule to see if it exports correctly
|
||||
self.check_export_roundtrip(DATA_DIR,"self_assessment")
|
||||
|
||||
@@ -176,6 +176,33 @@ class ImportTestCase(unittest.TestCase):
|
||||
self.assertEqual(chapter_xml.tag, 'chapter')
|
||||
self.assertFalse('graceperiod' in chapter_xml.attrib)
|
||||
|
||||
def test_is_pointer_tag(self):
|
||||
"""
|
||||
Check that is_pointer_tag works properly.
|
||||
"""
|
||||
|
||||
yes = ["""<html url_name="blah"/>""",
|
||||
"""<html url_name="blah"></html>""",
|
||||
"""<html url_name="blah"> </html>""",
|
||||
"""<problem url_name="blah"/>""",
|
||||
"""<course org="HogwartsX" course="Mathemagics" url_name="3.14159"/>"""]
|
||||
|
||||
no = ["""<html url_name="blah" also="this"/>""",
|
||||
"""<html url_name="blah">some text</html>""",
|
||||
"""<problem url_name="blah"><sub>tree</sub></problem>""",
|
||||
"""<course org="HogwartsX" course="Mathemagics" url_name="3.14159">
|
||||
<chapter>3</chapter>
|
||||
</course>
|
||||
"""]
|
||||
|
||||
for xml_str in yes:
|
||||
print "should be True for {0}".format(xml_str)
|
||||
self.assertTrue(is_pointer_tag(etree.fromstring(xml_str)))
|
||||
|
||||
for xml_str in no:
|
||||
print "should be False for {0}".format(xml_str)
|
||||
self.assertFalse(is_pointer_tag(etree.fromstring(xml_str)))
|
||||
|
||||
def test_metadata_inherit(self):
|
||||
"""Make sure that metadata is inherited properly"""
|
||||
|
||||
@@ -311,3 +338,17 @@ class ImportTestCase(unittest.TestCase):
|
||||
system = self.get_system(False)
|
||||
|
||||
self.assertRaises(etree.XMLSyntaxError, system.process_xml, bad_xml)
|
||||
|
||||
def test_selfassessment_import(self):
|
||||
'''
|
||||
Check to see if definition_from_xml in self_assessment_module.py
|
||||
works properly. Pulls data from the self_assessment directory in the test data directory.
|
||||
'''
|
||||
|
||||
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['self_assessment'])
|
||||
|
||||
sa_id = "edX/sa_test/2012_Fall"
|
||||
location = Location(["i4x", "edX", "sa_test", "selfassessment", "SampleQuestion"])
|
||||
sa_sample = modulestore.get_instance(sa_id, location)
|
||||
#10 attempts is hard coded into SampleQuestion, which is the url_name of a selfassessment xml tag
|
||||
self.assertEqual(sa_sample.metadata['attempts'], '10')
|
||||
|
||||
@@ -32,6 +32,7 @@ class VideoModule(XModule):
|
||||
self.position = 0
|
||||
self.show_captions = xmltree.get('show_captions', 'true')
|
||||
self.source = self._get_source(xmltree)
|
||||
self.track = self._get_track(xmltree)
|
||||
|
||||
if instance_state is not None:
|
||||
state = json.loads(instance_state)
|
||||
@@ -40,13 +41,25 @@ class VideoModule(XModule):
|
||||
|
||||
def _get_source(self, xmltree):
|
||||
# find the first valid source
|
||||
source = None
|
||||
for element in xmltree.findall('source'):
|
||||
return self._get_first_external(xmltree, 'source')
|
||||
|
||||
def _get_track(self, xmltree):
|
||||
# find the first valid track
|
||||
return self._get_first_external(xmltree, 'track')
|
||||
|
||||
def _get_first_external(self, xmltree, tag):
|
||||
"""
|
||||
Will return the first valid element
|
||||
of the given tag.
|
||||
'valid' means has a non-empty 'src' attribute
|
||||
"""
|
||||
result = None
|
||||
for element in xmltree.findall(tag):
|
||||
src = element.get('src')
|
||||
if src:
|
||||
source = src
|
||||
result = src
|
||||
break
|
||||
return source
|
||||
return result
|
||||
|
||||
def handle_ajax(self, dispatch, get):
|
||||
'''
|
||||
@@ -85,6 +98,7 @@ class VideoModule(XModule):
|
||||
'id': self.location.html_id(),
|
||||
'position': self.position,
|
||||
'source': self.source,
|
||||
'track' : self.track,
|
||||
'display_name': self.display_name,
|
||||
# TODO (cpennington): This won't work when we move to data that isn't on the filesystem
|
||||
'data_dir': self.metadata['data_dir'],
|
||||
|
||||
@@ -320,21 +320,6 @@ class XModule(HTMLSnippet):
|
||||
get is a dictionary-like object '''
|
||||
return ""
|
||||
|
||||
# cdodge: added to support dynamic substitutions of
|
||||
# links for courseware assets (e.g. images). <link> is passed through from lxml.html parser
|
||||
def rewrite_content_links(self, link):
|
||||
# see if we start with our format, e.g. 'xasset:<filename>'
|
||||
if link.startswith(XASSET_SRCREF_PREFIX):
|
||||
# yes, then parse out the name
|
||||
name = link[len(XASSET_SRCREF_PREFIX):]
|
||||
loc = Location(self.location)
|
||||
# resolve the reference to our internal 'filepath' which
|
||||
content_loc = StaticContent.compute_location(loc.org, loc.course, name)
|
||||
link = StaticContent.get_url_path_from_location(content_loc)
|
||||
|
||||
return link
|
||||
|
||||
|
||||
|
||||
def policy_key(location):
|
||||
"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user