Merge branch 'feature/cale/cms-master' into feature/christina/tiny-mce

This commit is contained in:
cahrens
2013-01-10 14:31:32 -05:00
313 changed files with 10834 additions and 3115 deletions

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ lms/lib/comment_client/python
nosetests.xml
cover_html/
.idea/
chromedriver.log

4
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "common/test/phantom-jasmine"]
path = common/test/phantom-jasmine
url = https://github.com/jcarver989/phantom-jasmine.git
path = common/test/phantom-jasmine
url = https://github.com/jcarver989/phantom-jasmine.git

25
apt-packages.txt Normal file
View File

@@ -0,0 +1,25 @@
python-software-properties
pkg-config
curl
git
python-virtualenv
build-essential
python-dev
gfortran
liblapack-dev
libfreetype6-dev
libpng12-dev
libxml2-dev
libxslt-dev
yui-compressor
graphviz
graphviz-dev
mysql-server
libmysqlclient-dev
libgeos-dev
libreadline6
libreadline6-dev
mongodb
nodejs
npm
coffeescript

3
apt-repos.txt Normal file
View File

@@ -0,0 +1,3 @@
ppa:chris-lea/node.js
ppa:chris-lea/node.js-libs
ppa:chris-lea/libjs-underscore

View File

@@ -1,10 +1,12 @@
readline
sqlite
gdbm
pkg-config
gfortran
python
yuicompressor
readline
sqlite
gdbm
pkg-config
gfortran
python
yuicompressor
node
graphviz
mysql
geos
mongodb

View File

@@ -2,11 +2,13 @@
[run]
data_file = reports/cms/.coverage
source = cms
omit = cms/envs/*, cms/manage.py
[report]
ignore_errors = True
[html]
title = CMS Python Test Coverage Report
directory = reports/cms/cover
[xml]

View File

@@ -1,7 +1,7 @@
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from lxml import etree
from lxml import html
import re
from django.http import HttpResponseBadRequest
import logging
@@ -24,9 +24,9 @@ def get_course_updates(location):
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = etree.fromstring(course_updates.definition['data'])
except etree.XMLSyntaxError:
course_html_parsed = etree.fromstring("<ol></ol>")
course_html_parsed = html.fromstring(course_updates.definition['data'])
except:
course_html_parsed = html.fromstring("<ol></ol>")
# Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val
course_upd_collection = []
@@ -39,7 +39,7 @@ def get_course_updates(location):
# could enforce that update[0].tag == 'h2'
content = update[0].tail
else:
content = "\n".join([etree.tostring(ele) for ele in update[1:]])
content = "\n".join([html.tostring(ele) for ele in update[1:]])
# make the id on the client be 1..len w/ 1 being the oldest and len being the newest
course_upd_collection.append({"id" : location_base + "/" + str(len(course_html_parsed) - idx),
@@ -61,17 +61,17 @@ def update_course_updates(location, update, passed_id=None):
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = etree.fromstring(course_updates.definition['data'])
except etree.XMLSyntaxError:
course_html_parsed = etree.fromstring("<ol></ol>")
course_html_parsed = html.fromstring(course_updates.definition['data'])
except:
course_html_parsed = html.fromstring("<ol></ol>")
# No try/catch b/c failure generates an error back to client
new_html_parsed = etree.fromstring('<li><h2>' + update['date'] + '</h2>' + update['content'] + '</li>')
new_html_parsed = html.fromstring('<li><h2>' + update['date'] + '</h2>' + update['content'] + '</li>')
# Confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val
if course_html_parsed.tag == 'ol':
# ??? Should this use the id in the json or in the url or does it matter?
if passed_id:
if passed_id is not None:
idx = get_idx(passed_id)
# idx is count from end of list
course_html_parsed[-idx] = new_html_parsed
@@ -82,7 +82,7 @@ def update_course_updates(location, update, passed_id=None):
passed_id = course_updates.location.url() + "/" + str(idx)
# update db record
course_updates.definition['data'] = etree.tostring(course_html_parsed)
course_updates.definition['data'] = html.tostring(course_html_parsed)
modulestore('direct').update_item(location, course_updates.definition['data'])
return {"id" : passed_id,
@@ -105,9 +105,9 @@ def delete_course_update(location, update, passed_id):
# TODO use delete_blank_text parser throughout and cache as a static var in a class
# purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
try:
course_html_parsed = etree.fromstring(course_updates.definition['data'])
except etree.XMLSyntaxError:
course_html_parsed = etree.fromstring("<ol></ol>")
course_html_parsed = html.fromstring(course_updates.definition['data'])
except:
course_html_parsed = html.fromstring("<ol></ol>")
if course_html_parsed.tag == 'ol':
# ??? Should this use the id in the json or in the url or does it matter?
@@ -118,7 +118,7 @@ def delete_course_update(location, update, passed_id):
course_html_parsed.remove(element_to_delete)
# update db record
course_updates.definition['data'] = etree.tostring(course_html_parsed)
course_updates.definition['data'] = html.tostring(course_html_parsed)
store = modulestore('direct')
store.update_item(location, course_updates.definition['data'])

View File

@@ -0,0 +1,127 @@
from lettuce import world, step
from factories import *
from django.core.management import call_command
from lettuce.django import django_url
from django.conf import settings
from django.core.management import call_command
from nose.tools import assert_true
from nose.tools import assert_equal
import xmodule.modulestore.django
from logging import getLogger
logger = getLogger(__name__)
########### STEP HELPERS ##############
@step('I (?:visit|access|open) the Studio homepage$')
def i_visit_the_studio_homepage(step):
# To make this go to port 8001, put
# LETTUCE_SERVER_PORT = 8001
# in your settings.py file.
world.browser.visit(django_url('/'))
assert world.browser.is_element_present_by_css('body.no-header', 10)
@step('I am logged into Studio$')
def i_am_logged_into_studio(step):
log_into_studio()
@step('I confirm the alert$')
def i_confirm_with_ok(step):
world.browser.get_alert().accept()
@step(u'I press the "([^"]*)" delete icon$')
def i_press_the_category_delete_icon(step, category):
if category == 'section':
css = 'a.delete-button.delete-section-button span.delete-icon'
elif category == 'subsection':
css='a.delete-button.delete-subsection-button span.delete-icon'
else:
assert False, 'Invalid category: %s' % category
css_click(css)
####### HELPER FUNCTIONS ##############
def create_studio_user(
uname='robot',
em='robot+studio@edx.org',
password='test'):
studio_user = UserFactory.build(
username=uname,
email=em)
studio_user.set_password(password)
studio_user.save()
registration = RegistrationFactory(user=studio_user)
registration.register(studio_user)
registration.activate()
user_profile = UserProfileFactory(user=studio_user)
def flush_xmodule_store():
# 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()
def assert_css_with_text(css,text):
assert_true(world.browser.is_element_present_by_css(css, 5))
assert_equal(world.browser.find_by_css(css).text, text)
def css_click(css):
world.browser.find_by_css(css).first.click()
def css_fill(css, value):
world.browser.find_by_css(css).first.fill(value)
def clear_courses():
flush_xmodule_store()
def fill_in_course_info(
name='Robot Super Course',
org='MITx',
num='101'):
css_fill('.new-course-name',name)
css_fill('.new-course-org',org)
css_fill('.new-course-number',num)
def log_into_studio(
uname='robot',
email='robot+studio@edx.org',
password='test'):
create_studio_user(uname, email)
world.browser.cookies.delete()
world.browser.visit(django_url('/'))
world.browser.is_element_present_by_css('body.no-header', 10)
login_form = world.browser.find_by_css('form#login_form')
login_form.find_by_name('email').fill(email)
login_form.find_by_name('password').fill(password)
login_form.find_by_name('submit').click()
assert_true(world.browser.is_element_present_by_css('.new-course-button', 5))
def create_a_course():
css_click('a.new-course-button')
fill_in_course_info()
css_click('input.new-course-save')
assert_true(world.browser.is_element_present_by_css('a#courseware-tab', 5))
def add_section(name='My Section'):
link_css = 'a.new-courseware-section-button'
css_click(link_css)
name_css = '.new-section-name'
save_css = '.new-section-name-save'
css_fill(name_css,name)
css_click(save_css)
def add_subsection(name='Subsection One'):
css = 'a.new-subsection-item'
css_click(css)
name_css = 'input.new-subsection-name-input'
save_css = 'input.new-subsection-name-save'
css_fill(name_css, name)
css_click(save_css)

View File

@@ -0,0 +1,13 @@
Feature: Create Course
In order offer a course on the edX platform
As a course author
I want to create courses
Scenario: Create a course
Given There are no courses
And I am logged into Studio
When I click the New Course button
And I fill in the new course information
And I press the "Save" button
Then the Courseware page has loaded in Studio
And I see a link for adding a new section

View File

@@ -0,0 +1,50 @@
from lettuce import world, step
from common import *
############### ACTIONS ####################
@step('There are no courses$')
def no_courses(step):
clear_courses()
@step('I click the New Course button$')
def i_click_new_course(step):
css_click('.new-course-button')
@step('I fill in the new course information$')
def i_fill_in_a_new_course_information(step):
fill_in_course_info()
@step('I create a new course$')
def i_create_a_course(step):
create_a_course()
@step('I click the course link in My Courses$')
def i_click_the_course_link_in_my_courses(step):
course_css = 'span.class-name'
css_click(course_css)
############ ASSERTIONS ###################
@step('the Courseware page has loaded in Studio$')
def courseware_page_has_loaded_in_studio(step):
courseware_css = 'a#courseware-tab'
assert world.browser.is_element_present_by_css(courseware_css)
@step('I see the course listed in My Courses$')
def i_see_the_course_in_my_courses(step):
course_css = 'span.class-name'
assert_css_with_text(course_css,'Robot Super Course')
@step('the course is loaded$')
def course_is_loaded(step):
class_css = 'a.class-name'
assert_css_with_text(class_css,'Robot Super Course')
@step('I am on the "([^"]*)" tab$')
def i_am_on_tab(step, tab_name):
header_css = 'div.inner-wrapper h1'
assert_css_with_text(header_css,tab_name)
@step('I see a link for adding a new section$')
def i_see_new_section_link(step):
link_css = 'a.new-courseware-section-button'
assert_css_with_text(link_css,'New Section')

View File

@@ -0,0 +1,31 @@
import factory
from student.models import User, UserProfile, Registration
from datetime import datetime
import uuid
class UserProfileFactory(factory.Factory):
FACTORY_FOR = UserProfile
user = None
name = 'Robot Studio'
courseware = 'course.xml'
class RegistrationFactory(factory.Factory):
FACTORY_FOR = Registration
user = None
activation_key = uuid.uuid4().hex
class UserFactory(factory.Factory):
FACTORY_FOR = User
username = 'robot-studio'
email = 'robot+studio@edx.org'
password = 'test'
first_name = 'Robot'
last_name = 'Studio'
is_staff = False
is_active = True
is_superuser = False
last_login = datetime.now()
date_joined = datetime.now()

View File

@@ -0,0 +1,26 @@
Feature: Create Section
In order offer a course on the edX platform
As a course author
I want to create and edit sections
Scenario: Add a new section to a course
Given I have opened a new course in Studio
When I click the New Section link
And I enter the section name and click save
Then I see my section on the Courseware page
And I see a release date for my section
And I see a link to create a new subsection
Scenario: Edit section release date
Given I have opened a new course in Studio
And I have added a new section
When I click the Edit link for the release date
And I save a new section release date
Then the section release date is updated
Scenario: Delete section
Given I have opened a new course in Studio
And I have added a new section
When I press the "section" delete icon
And I confirm the alert
Then the section does not exist

View File

@@ -0,0 +1,82 @@
from lettuce import world, step
from common import *
############### ACTIONS ####################
@step('I have opened a new course in Studio$')
def i_have_opened_a_new_course(step):
clear_courses()
log_into_studio()
create_a_course()
@step('I click the new section link$')
def i_click_new_section_link(step):
link_css = 'a.new-courseware-section-button'
css_click(link_css)
@step('I enter the section name and click save$')
def i_save_section_name(step):
name_css = '.new-section-name'
save_css = '.new-section-name-save'
css_fill(name_css,'My Section')
css_click(save_css)
@step('I have added a new section$')
def i_have_added_new_section(step):
add_section()
@step('I click the Edit link for the release date$')
def i_click_the_edit_link_for_the_release_date(step):
button_css = 'div.section-published-date a.edit-button'
css_click(button_css)
@step('I save a new section release date$')
def i_save_a_new_section_release_date(step):
date_css = 'input.start-date.date.hasDatepicker'
time_css = 'input.start-time.time.ui-timepicker-input'
css_fill(date_css,'12/25/2013')
# click here to make the calendar go away
css_click(time_css)
css_fill(time_css,'12:00am')
css_click('a.save-button')
############ ASSERTIONS ###################
@step('I see my section on the Courseware page$')
def i_see_my_section_on_the_courseware_page(step):
section_css = 'span.section-name-span'
assert_css_with_text(section_css,'My Section')
@step('the section does not exist$')
def section_does_not_exist(step):
css = 'span.section-name-span'
assert world.browser.is_element_not_present_by_css(css)
@step('I see a release date for my section$')
def i_see_a_release_date_for_my_section(step):
import re
css = 'span.published-status'
assert world.browser.is_element_present_by_css(css)
status_text = world.browser.find_by_css(css).text
# e.g. 11/06/2012 at 16:25
msg = 'Will Release:'
date_regex = '[01][0-9]\/[0-3][0-9]\/[12][0-9][0-9][0-9]'
time_regex = '[0-2][0-9]:[0-5][0-9]'
match_string = '%s %s at %s' % (msg, date_regex, time_regex)
assert re.match(match_string,status_text)
@step('I see a link to create a new subsection$')
def i_see_a_link_to_create_a_new_subsection(step):
css = 'a.new-subsection-item'
assert world.browser.is_element_present_by_css(css)
@step('the section release date picker is not visible$')
def the_section_release_date_picker_not_visible(step):
css = 'div.edit-subsection-publish-settings'
assert False, world.browser.find_by_css(css).visible
@step('the section release date is updated$')
def the_section_release_date_is_updated(step):
css = 'span.published-status'
status_text = world.browser.find_by_css(css).text
assert status_text == 'Will Release: 12/25/2013 at 12:00am'

View File

@@ -0,0 +1,12 @@
Feature: Sign in
In order to use the edX content
As a new user
I want to signup for a student account
Scenario: Sign up from the homepage
Given I visit the Studio homepage
When I click the link with the text "Sign up"
And I fill in the registration form
And I press the "Create My Account" button on the registration form
Then I should see be on the studio home page
And I should see the message "please click on the activation link in your email."

View File

@@ -0,0 +1,23 @@
from lettuce import world, step
@step('I fill in the registration form$')
def i_fill_in_the_registration_form(step):
register_form = world.browser.find_by_css('form#register_form')
register_form.find_by_name('email').fill('robot+studio@edx.org')
register_form.find_by_name('password').fill('test')
register_form.find_by_name('username').fill('robot-studio')
register_form.find_by_name('name').fill('Robot Studio')
register_form.find_by_name('terms_of_service').check()
@step('I press the "([^"]*)" button on the registration form$')
def i_press_the_button_on_the_registration_form(step, button):
register_form = world.browser.find_by_css('form#register_form')
register_form.find_by_value(button).click()
@step('I should see be on the studio home page$')
def i_should_see_be_on_the_studio_home_page(step):
assert world.browser.find_by_css('div.inner-wrapper')
@step(u'I should see the message "([^"]*)"$')
def i_should_see_the_message(step, msg):
assert world.browser.is_text_present(msg, 5)

View File

@@ -0,0 +1,18 @@
Feature: Create Subsection
In order offer a course on the edX platform
As a course author
I want to create and edit subsections
Scenario: Add a new subsection to a section
Given I have opened a new course section in Studio
When I click the New Subsection link
And I enter the subsection name and click save
Then I see my subsection on the Courseware page
Scenario: Delete a subsection
Given I have opened a new course section in Studio
And I have added a new subsection
And I see my subsection on the Courseware page
When I press the "subsection" delete icon
And I confirm the alert
Then the subsection does not exist

View File

@@ -0,0 +1,39 @@
from lettuce import world, step
from common import *
############### ACTIONS ####################
@step('I have opened a new course section in Studio$')
def i_have_opened_a_new_course_section(step):
clear_courses()
log_into_studio()
create_a_course()
add_section()
@step('I click the New Subsection link')
def i_click_the_new_subsection_link(step):
css = 'a.new-subsection-item'
css_click(css)
@step('I enter the subsection name and click save$')
def i_save_subsection_name(step):
name_css = 'input.new-subsection-name-input'
save_css = 'input.new-subsection-name-save'
css_fill(name_css,'Subsection One')
css_click(save_css)
@step('I have added a new subsection$')
def i_have_added_a_new_subsection(step):
add_subsection()
############ ASSERTIONS ###################
@step('I see my subsection on the Courseware page$')
def i_see_my_subsection_on_the_courseware_page(step):
css = 'span.subsection-name'
assert world.browser.is_element_present_by_css(css)
css = 'span.subsection-name-value'
assert_css_with_text(css,'Subsection One')
@step('the subsection does not exist$')
def the_subsection_does_not_exist(step):
css = 'span.subsection-name'
assert world.browser.is_element_not_present_by_css(css)

View File

@@ -73,6 +73,10 @@ class XModuleItemFactory(Factory):
@classmethod
def _create(cls, target_class, *args, **kwargs):
"""
kwargs must include parent_location, template. Can contain display_name
target_class is ignored
"""
DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info']

View File

@@ -0,0 +1,30 @@
from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase
from django.core.urlresolvers import reverse
import json
class CourseUpdateTest(CourseTestCase):
def test_course_update(self):
# first get the update to force the creation
url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
'name' : self.course_location.name })
self.client.get(url)
content = '<iframe width="560" height="315" src="http://www.youtube.com/embed/RocY-Jd93XU" frameborder="0"></iframe>'
payload = { 'content' : content,
'date' : 'January 8, 2013'}
url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
'provided_id' : ''})
resp = self.client.post(url, json.dumps(payload), "application/json")
payload= json.loads(resp.content)
self.assertHTMLEqual(content, payload['content'], "single iframe")
url = reverse('course_info', kwargs={'org' : self.course_location.org, 'course' : self.course_location.course,
'provided_id' : payload['id']})
content += '<div>div <p>p</p></div>'
payload['content'] = content
resp = self.client.post(url, json.dumps(payload), "application/json")
self.assertHTMLEqual(content, json.loads(resp.content)['content'], "iframe w/ div")

View File

@@ -22,6 +22,8 @@ from xmodule.modulestore.django import modulestore
from xmodule.contentstore.django import contentstore
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.xml_exporter import export_to_xml
from cms.djangoapps.contentstore.utils import get_modulestore
from xmodule.capa_module import CapaDescriptor
def parse_json(response):
"""Parse response, which is assumed to be json"""
@@ -422,10 +424,12 @@ class ContentStoreTest(TestCase):
ms = modulestore('direct')
cs = contentstore()
# import a test course
import_from_xml(ms, 'common/test/data/', ['full'])
handout_location= Location(['i4x', 'edX', 'full', 'course_info', 'handouts'])
# get module info
resp = self.client.get(reverse('module_info', kwargs={'module_location': handout_location}))
# make sure we got a successful response
@@ -436,13 +440,24 @@ class ContentStoreTest(TestCase):
self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf')
def test_capa_module(self):
"""Test that a problem treats markdown specially."""
CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
problem_data = {
'parent_location' : 'i4x://MITx/999/course/Robot_Super_Course',
'template' : 'i4x://edx/templates/problem/Empty'
}
resp = self.client.post(reverse('clone_item'), problem_data)
self.assertEqual(resp.status_code, 200)
payload = parse_json(resp)
problem_loc = payload['id']
problem = get_modulestore(problem_loc).get_item(problem_loc)
# should be a CapaDescriptor
self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor")
context = problem.get_context()
self.assertIn('markdown', context, "markdown is missing from context")
self.assertIn('markdown', problem.metadata, "markdown is missing from metadata")
self.assertNotIn('markdown', problem.editable_metadata_fields, "Markdown slipped into the editable metadata fields")

View File

@@ -200,7 +200,7 @@ def edit_subsection(request, location):
# make sure that location references a 'sequential', otherwise return BadRequest
if item.location.category != 'sequential':
return HttpResponseBadRequest
return HttpResponseBadRequest()
parent_locs = modulestore().get_parent_locations(location)
@@ -271,6 +271,8 @@ def edit_unit(request, location):
component_templates[template.location.category].append((
template.display_name,
template.location.url(),
'markdown' in template.metadata,
template.location.name == 'Empty'
))
components = [
@@ -973,6 +975,11 @@ def course_info_updates(request, org, course, provided_id=None):
# ??? No way to check for access permission afaik
# get current updates
location = ['i4x', org, course, 'course_info', "updates"]
# Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-(
# Possibly due to my removing the seemingly redundant pattern in urls.py
if provided_id == '':
provided_id = None
# check that logged in user has permissions to this item
if not has_access(request.user, location):
@@ -991,7 +998,7 @@ def course_info_updates(request, org, course, provided_id=None):
elif request.method == 'POST':
try:
return HttpResponse(json.dumps(update_course_updates(location, request.POST, provided_id)), mimetype="application/json")
except etree.XMLSyntaxError:
except:
return HttpResponseBadRequest("Failed to save: malformed html", content_type="text/plain")
@@ -1023,7 +1030,7 @@ def module_info(request, module_location):
elif real_method == 'POST' or real_method == 'PUT':
return HttpResponse(json.dumps(set_module_info(get_modulestore(location), location, request.POST)), mimetype="application/json")
else:
return HttpResponseBadRequest
return HttpResponseBadRequest()
@login_required
@ensure_csrf_cookie

38
cms/envs/acceptance.py Normal file
View File

@@ -0,0 +1,38 @@
"""
This config file extends the test environment configuration
so that we can run the lettuce acceptance tests.
"""
from .test import *
# You need to start the server in debug mode,
# otherwise the browser will not render the pages correctly
DEBUG = True
# Show the courses that are in the data directory
COURSES_ROOT = ENV_ROOT / "data"
DATA_DIR = COURSES_ROOT
# MODULESTORE = {
# 'default': {
# 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
# 'OPTIONS': {
# 'data_dir': DATA_DIR,
# 'default_class': 'xmodule.hidden_module.HiddenDescriptor',
# }
# }
# }
# Set this up so that rake lms[acceptance] and running the
# harvest command both use the same (test) database
# which they can flush without messing up your dev db
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ENV_ROOT / "db" / "test_mitx.db",
'TEST_NAME': ENV_ROOT / "db" / "test_mitx.db",
}
}
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
INSTALLED_APPS += ('lettuce.django',)
LETTUCE_APPS = ('contentstore',)
LETTUCE_SERVER_PORT = 8001

View File

@@ -34,6 +34,7 @@ MITX_FEATURES = {
'GITHUB_PUSH': False,
'ENABLE_DISCUSSION_SERVICE': False,
'AUTH_USE_MIT_CERTIFICATES' : False,
'STUB_VIDEO_FOR_TESTING': False, # do not display video when running automated acceptance tests
}
ENABLE_JASMINE = False

View File

@@ -55,8 +55,8 @@ class CMS.Views.ModuleEdit extends Backbone.View
clickSaveButton: (event) =>
event.preventDefault()
data = @module.save()
data.metadata = @metadata()
$modalCover.hide()
data.metadata = _.extend(data.metadata, @metadata())
@hideModal()
@model.save(data).done( =>
# # showToastMessage("Your changes have been saved.", null, 3)
@module = null
@@ -70,11 +70,15 @@ class CMS.Views.ModuleEdit extends Backbone.View
event.preventDefault()
@$el.removeClass('editing')
@$component_editor().slideUp(150)
@hideModal()
hideModal: ->
$modalCover.hide()
$modalCover.removeClass('is-fixed')
clickEditButton: (event) ->
event.preventDefault()
@$el.addClass('editing')
$modalCover.show()
$modalCover.show().addClass('is-fixed')
@$component_editor().slideDown(150)
@loadEdit()

View File

@@ -59,6 +59,9 @@ class CMS.Views.UnitEdit extends Backbone.View
type = $(event.currentTarget).data('type')
@$newComponentTypePicker.slideUp(250)
@$(".new-component-#{type}").slideDown(250)
$('html, body').animate({
scrollTop: @$(".new-component-#{type}").offset().top
}, 500)
closeNewComponent: (event) =>
event.preventDefault()

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -29,9 +29,7 @@ $(document).ready(function() {
$('.expand-collapse-icon').bind('click', toggleSubmodules);
$('.visibility-options').bind('change', setVisibility);
$('.unit-history ol a').bind('click', showHistoryModal);
$modal.bind('click', hideModal);
$modalCover.bind('click', hideHistoryModal);
$modalCover.bind('click', hideModal);
$('.assets .upload-button').bind('click', showUploadModal);
$('.upload-modal .close-button').bind('click', hideModal);
@@ -41,7 +39,13 @@ $(document).ready(function() {
$('.unit .item-actions .delete-button').bind('click', deleteUnit);
$('.new-unit-item').bind('click', createNewUnit);
$('.collapse-all-button').bind('click', collapseAll);
// toggling overview section details
$(function(){
if($('.courseware-section').length > 0) {
$('.toggle-button-sections').addClass('is-shown');
}
});
$('.toggle-button-sections').bind('click', toggleSections);
// autosave when a field is updated on the subsection page
$body.on('keyup', '.subsection-display-name-input, .unit-subtitle, .policy-list-value', checkForNewValue);
@@ -125,9 +129,30 @@ $(document).ready(function() {
});
});
function collapseAll(e) {
$('.branch').addClass('collapsed');
$('.expand-collapse-icon').removeClass('collapse').addClass('expand');
// function collapseAll(e) {
// $('.branch').addClass('collapsed');
// $('.expand-collapse-icon').removeClass('collapse').addClass('expand');
// }
function toggleSections(e) {
e.preventDefault();
$section = $('.courseware-section');
sectionCount = $section.length;
$button = $(this);
$labelCollapsed = $('<i class="ss-icon ss-symbolicons-block">up</i> <span class="label">Collapse All Sections</span>');
$labelExpanded = $('<i class="ss-icon ss-symbolicons-block">down</i> <span class="label">Expand All Sections</span>');
var buttonLabel = $button.hasClass('is-activated') ? $labelCollapsed : $labelExpanded;
$button.toggleClass('is-activated').html(buttonLabel);
if($button.hasClass('is-activated')) {
$section.addClass('collapsed');
$section.find('.expand-collapse-icon').removeClass('collapsed').addClass('expand');
} else {
$section.removeClass('collapsed');
$section.find('.expand-collapse-icon').removeClass('expand').addClass('collapse');
}
}
function editSectionPublishDate(e) {
@@ -498,9 +523,14 @@ function hideModal(e) {
if(e) {
e.preventDefault();
}
$('.file-input').unbind('change', startUpload);
$modal.hide();
$modalCover.hide();
// Unit editors do not want the modal cover to hide when users click outside
// of the editor. Users must press Cancel or Save to exit the editor.
// module_edit adds and removes the "is-fixed" class.
if (!$modalCover.hasClass("is-fixed")) {
$('.file-input').unbind('change', startUpload);
$modal.hide();
$modalCover.hide();
}
}
function onKeyUp(e) {
@@ -530,21 +560,6 @@ function closeComponentEditor(e) {
$(this).closest('.xmodule_edit').removeClass('editing').find('.component-editor').slideUp(150);
}
function showHistoryModal(e) {
e.preventDefault();
$modal.show();
$modalCover.show();
}
function hideHistoryModal(e) {
e.preventDefault();
$modal.hide();
$modalCover.hide();
}
function showDateSetter(e) {
e.preventDefault();
var $block = $(this).closest('.due-date-input');

View File

@@ -388,6 +388,9 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
var graceEle = this.$el.find('#course-grading-graceperiod');
graceEle.timepicker({'timeFormat' : 'H:i'}); // init doesn't take setTime
if (this.model.has('grace_period')) graceEle.timepicker('setTime', this.model.gracePeriodToDate());
// remove any existing listeners to keep them from piling on b/c render gets called frequently
graceEle.off('change', this.setGracePeriod);
graceEle.on('change', this, this.setGracePeriod);
return this;
},
@@ -398,14 +401,16 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({
fieldToSelectorMap : {
'grace_period' : 'course-grading-graceperiod'
},
setGracePeriod : function(event) {
event.data.clearValidationErrors();
var newVal = event.data.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime'));
if (event.data.model.get('grace_period') != newVal) event.data.model.save('grace_period', newVal);
},
updateModel : function(event) {
if (!this.selectorToField[event.currentTarget.id]) return;
switch (this.selectorToField[event.currentTarget.id]) {
case 'grace_period':
this.clearValidationErrors();
var newVal = this.model.dateToGracePeriod($(event.currentTarget).timepicker('getTime'));
if (this.model.get('grace_period') != newVal) this.model.save('grace_period', newVal);
case 'grace_period': // handled above
break;
default:

View File

@@ -98,69 +98,69 @@
}
.upload-modal {
display: none;
width: 640px !important;
margin-left: -320px !important;
display: none;
width: 640px !important;
margin-left: -320px !important;
.modal-body {
height: auto !important;
overflow-y: auto !important;
text-align: center;
}
.modal-body {
height: auto !important;
overflow-y: auto !important;
text-align: center;
}
.file-input {
display: none;
}
.file-input {
display: none;
}
.choose-file-button {
@include blue-button;
padding: 10px 82px 12px;
font-size: 17px;
}
.choose-file-button {
@include blue-button;
padding: 10px 82px 12px;
font-size: 17px;
}
.progress-bar {
display: none;
width: 350px;
height: 50px;
margin: 30px auto 10px;
border: 1px solid $blue;
.progress-bar {
display: none;
width: 350px;
height: 50px;
margin: 30px auto 10px;
border: 1px solid $blue;
&.loaded {
border-color: #66b93d;
&.loaded {
border-color: #66b93d;
.progress-fill {
background: #66b93d;
}
}
}
.progress-fill {
background: #66b93d;
}
}
}
.progress-fill {
width: 0%;
height: 50px;
background: $blue;
color: #fff;
line-height: 48px;
}
.progress-fill {
width: 0%;
height: 50px;
background: $blue;
color: #fff;
line-height: 48px;
}
h1 {
float: none;
margin: 40px 0 30px;
font-size: 34px;
font-weight: 300;
}
h1 {
float: none;
margin: 40px 0 30px;
font-size: 34px;
font-weight: 300;
}
.close-button {
@include white-button;
position: absolute;
top: 0;
right: 15px;
width: 29px;
height: 29px;
padding: 0 !important;
border-radius: 17px !important;
line-height: 29px;
text-align: center;
}
.close-button {
@include white-button;
position: absolute;
top: 0;
right: 15px;
width: 29px;
height: 29px;
padding: 0 !important;
border-radius: 17px !important;
line-height: 29px;
text-align: center;
}
.embeddable {
display: none;
@@ -178,9 +178,9 @@
width: 400px;
}
.copy-button {
@include white-button;
display: none;
margin-bottom: 100px;
}
.copy-button {
@include white-button;
display: none;
margin-bottom: 100px;
}
}

View File

@@ -1,6 +1,6 @@
// -------------------------------------
//
// Universal
// Universal
//
// -------------------------------------

View File

@@ -422,6 +422,14 @@ input.courseware-unit-search-input {
float: left;
margin: 29px 6px 16px 16px;
@include transition(none);
&.expand {
background-position: 0 0;
}
&.collapsed {
}
}
.drag-handle {
@@ -501,14 +509,31 @@ input.courseware-unit-search-input {
}
}
.collapse-all-button {
.toggle-button-sections {
display: none;
position: relative;
float: right;
margin-top: 10px;
font-size: 13px;
color: $darkGrey;
.collapse-all-icon {
margin-right: 6px;
&.is-shown {
display: block;
}
.ss-icon {
@include border-radius(20px);
position: relative;
top: -1px;
display: inline-block;
margin-right: 2px;
line-height: 5px;
font-size: 11px;
}
.label {
display: inline-block;
}
}

View File

@@ -56,6 +56,15 @@
z-index: 10;
margin: 20px 40px;
.title {
margin: 0 0 15px 0;
color: $mediumGrey;
.value {
}
}
&.new-component-item {
padding: 20px;
border: none;
@@ -116,7 +125,7 @@
a {
position: relative;
border: 1px solid $darkGreen;
background: $green;
background: tint($green,20%);
color: #fff;
@include transition(background-color .15s);
@@ -129,23 +138,71 @@
.new-component-template {
margin-bottom: 20px;
li:first-child {
li:last-child {
a {
border-radius: 0 0 3px 3px;
border-bottom: 1px solid $darkGreen;
}
}
li:nth-child(2) {
a {
border-radius: 3px 3px 0 0;
}
}
li:last-child {
a {
border-radius: 0 0 3px 3px;
}
}
a {
@include clearfix();
display: block;
padding: 7px 20px;
border-bottom: none;
font-weight: 300;
.name {
float: left;
.ss-icon {
@include transition(opacity .15s);
position: relative;
top: 1px;
font-size: 13px;
margin-right: 5px;
opacity: 0.5;
}
}
.editor-indicator {
@include transition(opacity .15s);
float: right;
position: relative;
top: 3px;
font-size: 12px;
opacity: 0.1;
}
&:hover {
.ss-icon {
opacity: 1.0;
}
.editor-indicator {
opacity: 1.0;
}
}
}
// specific editor types
.empty {
@include box-shadow(0 1px 3px rgba(0,0,0,0.2));
margin-bottom: 10px;
a {
border-bottom: 1px solid $darkGreen;
border-radius: 3px;
font-weight: 500;
background: $green;
}
}
}
@@ -188,8 +245,8 @@
&.editing {
border: 1px solid $lightBluishGrey2;
z-index: 9999;
z-index: auto;
.drag-handle,
.component-actions {
display: none;
@@ -211,7 +268,7 @@
display: block;
top: -1px;
right: -16px;
z-index: -1;
z-index: 10;
width: 15px;
height: 100%;
border-radius: 0 3px 3px 0;
@@ -224,14 +281,20 @@
.xmodule_display {
padding: 40px 20px 20px;
overflow-x: auto;
}
.wrapper-component-editor {
z-index: 9999;
position: relative;
}
.component-editor {
@include edit-box;
@include box-shadow(none);
display: none;
padding: 20px;
border-radius: 2px 2px 0 0;
@include box-shadow(none);
.metadata_edit {
margin-bottom: 20px;
@@ -473,7 +536,7 @@ body.unit {
.component-actions {
@include box-sizing(border-box);
position: absolute;
width: 811px;
width: 100%;
padding: 15px;
top: 0;
left: 0;

View File

@@ -1,13 +1,17 @@
<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 class="wrapper wrapper-component-editor">
<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>
<div class="component-actions">
<a href="#" class="edit-button standard"><span class="edit-icon"></span>Edit</a>
<a href="#" class="delete-button standard"><span class="delete-icon"></span>Delete</a>
</div>
<a data-tooltip="Drag to reorder" href="#" class="drag-handle"></a>
${preview}
${preview}

View File

@@ -122,7 +122,7 @@
<div class="inner-wrapper">
<div class="page-actions">
<a href="#" class="new-button new-courseware-section-button"><span class="plus-icon white"></span> New Section</a>
<a href="#" class="collapse-all-button"><span class="collapse-all-icon"></span>Collapse All</a>
<a href="#" class="toggle-button toggle-button-sections"><i class="ss-icon ss-symbolicons-block">up</i> <span class="label">Collapse All Sections</span></a>
</div>
<article class="courseware-overview" data-course-id="${context_course.location.url()}">
% for section in sections:

View File

@@ -5,6 +5,7 @@
<%block name="title">CMS Unit</%block>
<%block name="jsextra">
<script type='text/javascript'>
$(document).ready(function() {
new CMS.Views.UnitEdit({
el: $('.main-wrapper'),
model: new CMS.Models.Module({
@@ -12,7 +13,14 @@
state: '${unit_state}'
})
});
$('.new-component-template').each(function(){
$emptyEditor = $(this).find('.empty');
$(this).prepend($emptyEditor);
});
});
</script>
</%block>
<%block name="content">
<div class="main-wrapper edit-state-${unit_state}" data-id="${unit_location}">
@@ -46,20 +54,43 @@
% endfor
</ul>
</div>
% for type, templates in sorted(component_templates.items()):
<div class="new-component-templates new-component-${type}">
<ul class="new-component-template">
% for name, location in templates:
<li>
<a href="#" data-location="${location}">
<span class="name">${name}</span>
</a>
</li>
% endfor
</ul>
<a href="#" class="cancel-button">Cancel</a>
</div>
% endfor
% for type, templates in sorted(component_templates.items()):
<div class="new-component-templates new-component-${type}">
<h3 class="title">Select <span class="type">${type}</span> component type:</h3>
<ul class="new-component-template">
% for name, location, has_markdown, is_empty in templates:
% if is_empty:
<li class="editor-md empty">
<a href="#" data-location="${location}">
<span class="name"><i class="ss-icon ss-symbolicons-block">&#xE714;</i> ${name}</span>
<span class="editor-indicator">Simple <span class="sr">Editor</span></span>
</a>
</li>
% elif has_markdown:
<li class="editor-md">
<a href="#" data-location="${location}">
<span class="name"><i class="ss-icon ss-symbolicons-block">&#xE714;</i> ${name}</span>
<span class="editor-indicator">Simple <span class="sr">Editor</span></span>
</a>
</li>
% else:
<li class="editor-manual">
<a href="#" data-location="${location}">
<span class="name"><i class="ss-icon ss-symbolicons-block">&#x1F527;</i> ${name}</span>
<span class="editor-indicator">Advanced <span class="sr">Editor</span></span>
</a>
</li>
% endif
%endfor
</ul>
<a href="#" class="cancel-button">Cancel</a>
</div>
% endfor
</li>
</ol>
</article>

View File

@@ -0,0 +1,83 @@
<%include file="metadata-edit.html" />
<section class="problem-editor editor">
<div class="row">
%if markdown != '' or data == '<problem>\n</problem>\n':
<div class="editor-bar">
<ul class="format-buttons">
<li><a href="#" class="multiple-choice-button" data-tooltip="Multiple Choice"><span
class="problem-editor-icon multiple-choice"></span></a></li>
<li><a href="#" class="checks-button" data-tooltip="Check Multiple"><span
class="problem-editor-icon checks"></span></a></li>
<li><a href="#" class="string-button" data-tooltip="String Response"><span
class="problem-editor-icon string"></span></a></li>
<li><a href="#" class="number-button" data-tooltip="Numerical Response"><span
class="problem-editor-icon number"></span></a></li>
<li><a href="#" class="dropdown-button" data-tooltip="Option Response"><span
class="problem-editor-icon dropdown"></span></a></li>
</ul>
<ul class="editor-tabs">
<li><a href="#" class="xml-tab tab" data-tab="xml">Use Advanced Editor</a></li>
<li><a href="#" class="cheatsheet-toggle" data-tooltip="Toggle Cheatsheet">?</a></li>
</ul>
</div>
<textarea class="markdown-box">${markdown}</textarea>
%endif
<textarea class="xml-box" rows="8" cols="40">${data | h}</textarea>
</div>
</section>
<script type="text/template" id="simple-editor-cheatsheet">
<article class="simple-editor-cheatsheet">
<div class="cheatsheet-wrapper">
<div class="row">
<h6>Multiple Choice</h6>
<div class="col sample">
<img src="/static/img/choice-example.png" />
</div>
<div class="col">
<pre><code>( ) red
( ) green
(x) blue</code></pre>
</div>
</div>
<div class="row">
<h6>Check Multiple</h6>
<div class="col sample">
<img src="/static/img/multi-example.png" />
</div>
<div class="col">
<pre><code>[x] earth
[ ] wind
[x] water</code></pre>
</div>
</div>
<div class="row">
<h6>String Response</h6>
<div class="col sample">
<img src="/static/img/string-example.png" />
</div>
<div class="col">
<pre><code>= dog</code></pre>
</div>
</div>
<div class="row">
<h6>Numerical Response</h6>
<div class="col sample">
<img src="/static/img/number-example.png" />
</div>
<div class="col">
<pre><code>= 3.14 +- 2%</code></pre>
</div>
</div>
<div class="row">
<h6>Option Response</h6>
<div class="col sample">
<img src="/static/img/select-example.png" />
</div>
<div class="col">
<pre><code>[[wrong, (right)]]</code></pre>
</div>
</div>
</div>
</article>
</script>

View File

@@ -12,10 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
log = logging.getLogger("mitx." + __name__)
from django.template import Context
from django.http import HttpResponse

View File

@@ -54,5 +54,4 @@ class Template(MakoTemplate):
context_dictionary['MITX_ROOT_URL'] = settings.MITX_ROOT_URL
context_dictionary['django_context'] = context_instance
return super(Template, self).render(**context_dictionary)
return super(Template, self).render_unicode(**context_dictionary)

View File

@@ -36,10 +36,12 @@ file and check it in at the same time as your model changes. To do that,
3. Add the migration file created in mitx/common/djangoapps/student/migrations/
"""
from datetime import datetime
import hashlib
import json
import logging
import uuid
from django.conf import settings
from django.contrib.auth.models import User
from django.db import models
@@ -47,7 +49,6 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
import comment_client as cc
from django_comment_client.models import Role
log = logging.getLogger(__name__)
@@ -125,9 +126,9 @@ class UserProfile(models.Model):
self.meta = json.dumps(js)
class TestCenterUser(models.Model):
"""This is our representation of the User for in-person testing, and
"""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
@@ -135,9 +136,9 @@ class TestCenterUser(models.Model):
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
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...
@@ -148,21 +149,21 @@ class TestCenterUser(models.Model):
# 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)
@@ -175,7 +176,7 @@ class TestCenterUser(models.Model):
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)
@@ -183,14 +184,27 @@ class TestCenterUser(models.Model):
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
def unique_id_for_user(user):
"""
Return a unique id for a user, suitable for inserting into
e.g. personalized survey links.
"""
# include the secret key as a salt, and to make the ids unique accross
# different LMS installs.
h = hashlib.md5()
h.update(settings.SECRET_KEY)
h.update(str(user.id))
return h.hexdigest()
## TODO: Should be renamed to generic UserGroup, and possibly
# Given an optional field for type of group
class UserTestGroup(models.Model):
@@ -247,15 +261,6 @@ class CourseEnrollment(models.Model):
return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created)
@receiver(post_save, sender=CourseEnrollment)
def assign_default_role(sender, instance, **kwargs):
if instance.user.is_staff:
role = Role.objects.get_or_create(course_id=instance.course_id, name="Moderator")[0]
else:
role = Role.objects.get_or_create(course_id=instance.course_id, name="Student")[0]
logging.info("assign_default_role: adding %s as %s" % (instance.user, role))
instance.user.roles.add(role)
#cache_relation(User.profile)
@@ -363,10 +368,10 @@ def replicate_user_save(sender, **kwargs):
# @receiver(post_save, sender=CourseEnrollment)
def replicate_enrollment_save(sender, **kwargs):
"""This is called when a Student enrolls in a course. It has to do the
"""This is called when a Student enrolls in a course. It has to do the
following:
1. Make sure the User is copied into the Course DB. It may already exist
1. Make sure the User is copied into the Course DB. It may already exist
(someone deleting and re-adding a course). This has to happen first or
the foreign key constraint breaks.
2. Replicate the CourseEnrollment.
@@ -410,9 +415,9 @@ USER_FIELDS_TO_COPY = ["id", "username", "first_name", "last_name", "email",
def replicate_user(portal_user, course_db_name):
"""Replicate a User to the correct Course DB. This is more complicated than
it should be because Askbot extends the auth_user table and adds its own
it should be because Askbot extends the auth_user table and adds its own
fields. So we need to only push changes to the standard fields and leave
the rest alone so that Askbot changes at the Course DB level don't get
the rest alone so that Askbot changes at the Course DB level don't get
overridden.
"""
try:
@@ -457,7 +462,7 @@ def is_valid_course_id(course_id):
"""Right now, the only database that's not a course database is 'default'.
I had nicer checking in here originally -- it would scan the courses that
were in the system and only let you choose that. But it was annoying to run
tests with, since we don't have course data for some for our course test
tests with, since we don't have course data for some for our course test
databases. Hence the lazy version.
"""
return course_id != 'default'

View File

@@ -6,11 +6,16 @@ Replace this with more appropriate tests for your application.
"""
import logging
from datetime import datetime
from hashlib import sha1
from django.test import TestCase
from mock import patch, Mock
from nose.plugins.skip import SkipTest
from .models import User, UserProfile, CourseEnrollment, replicate_user, USER_FIELDS_TO_COPY
from .models import (User, UserProfile, CourseEnrollment,
replicate_user, USER_FIELDS_TO_COPY,
unique_id_for_user)
from .views import process_survey_link, _cert_info
COURSE_1 = 'edX/toy/2012_Fall'
COURSE_2 = 'edx/full/6.002_Spring_2012'
@@ -55,7 +60,7 @@ class ReplicationTest(TestCase):
# This hasattr lameness is here because we don't want this test to be
# triggered when we're being run by CMS tests (Askbot doesn't exist
# there, so the test will fail).
#
#
# seen_response_count isn't a field we care about, so it shouldn't have
# been copied over.
if hasattr(portal_user, 'seen_response_count'):
@@ -74,7 +79,7 @@ class ReplicationTest(TestCase):
# During this entire time, the user data should never have made it over
# to COURSE_2
self.assertRaises(User.DoesNotExist,
self.assertRaises(User.DoesNotExist,
User.objects.using(COURSE_2).get,
id=portal_user.id)
@@ -108,19 +113,19 @@ class ReplicationTest(TestCase):
# Grab all the copies we expect
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
self.assertEquals(portal_user, course_user)
self.assertRaises(User.DoesNotExist,
self.assertRaises(User.DoesNotExist,
User.objects.using(COURSE_2).get,
id=portal_user.id)
course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id)
self.assertEquals(portal_enrollment, course_enrollment)
self.assertRaises(CourseEnrollment.DoesNotExist,
self.assertRaises(CourseEnrollment.DoesNotExist,
CourseEnrollment.objects.using(COURSE_2).get,
id=portal_enrollment.id)
course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id)
self.assertEquals(portal_user_profile, course_user_profile)
self.assertRaises(UserProfile.DoesNotExist,
self.assertRaises(UserProfile.DoesNotExist,
UserProfile.objects.using(COURSE_2).get,
id=portal_user_profile.id)
@@ -174,30 +179,112 @@ class ReplicationTest(TestCase):
portal_user.save()
portal_user_profile.gender = 'm'
portal_user_profile.save()
# Grab all the copies we expect, and make sure it doesn't end up in
# Grab all the copies we expect, and make sure it doesn't end up in
# places we don't expect.
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
self.assertEquals(portal_user, course_user)
self.assertRaises(User.DoesNotExist,
self.assertRaises(User.DoesNotExist,
User.objects.using(COURSE_2).get,
id=portal_user.id)
course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id)
self.assertEquals(portal_enrollment, course_enrollment)
self.assertRaises(CourseEnrollment.DoesNotExist,
self.assertRaises(CourseEnrollment.DoesNotExist,
CourseEnrollment.objects.using(COURSE_2).get,
id=portal_enrollment.id)
course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id)
self.assertEquals(portal_user_profile, course_user_profile)
self.assertRaises(UserProfile.DoesNotExist,
self.assertRaises(UserProfile.DoesNotExist,
UserProfile.objects.using(COURSE_2).get,
id=portal_user_profile.id)
class CourseEndingTest(TestCase):
"""Test things related to course endings: certificates, surveys, etc"""
def test_process_survey_link(self):
username = "fred"
user = Mock(username=username)
id = unique_id_for_user(user)
link1 = "http://www.mysurvey.com"
self.assertEqual(process_survey_link(link1, user), link1)
link2 = "http://www.mysurvey.com?unique={UNIQUE_ID}"
link2_expected = "http://www.mysurvey.com?unique={UNIQUE_ID}".format(UNIQUE_ID=id)
self.assertEqual(process_survey_link(link2, user), link2_expected)
def test_cert_info(self):
user = Mock(username="fred")
survey_url = "http://a_survey.com"
course = Mock(end_of_course_survey_url=survey_url)
self.assertEqual(_cert_info(user, course, None),
{'status': 'processing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False,})
cert_status = {'status': 'unavailable'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'processing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False})
cert_status = {'status': 'generating', 'grade': '67'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'generating',
'show_disabled_download_button': True,
'show_download_url': False,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
})
cert_status = {'status': 'regenerating', 'grade': '67'}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'generating',
'show_disabled_download_button': True,
'show_download_url': False,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
})
download_url = 'http://s3.edx/cert'
cert_status = {'status': 'downloadable', 'grade': '67',
'download_url': download_url}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'ready',
'show_disabled_download_button': False,
'show_download_url': True,
'download_url': download_url,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
})
cert_status = {'status': 'notpassing', 'grade': '67',
'download_url': download_url}
self.assertEqual(_cert_info(user, course, cert_status),
{'status': 'notpassing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '67'
})
# Test a course that doesn't have a survey specified
course2 = Mock(end_of_course_survey_url=None)
cert_status = {'status': 'notpassing', 'grade': '67',
'download_url': download_url}
self.assertEqual(_cert_info(user, course2, cert_status),
{'status': 'notpassing',
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False,
'grade': '67'
})

View File

@@ -28,7 +28,7 @@ from django.core.cache import cache
from django_future.csrf import ensure_csrf_cookie, csrf_exempt
from student.models import (Registration, UserProfile,
PendingNameChange, PendingEmailChange,
CourseEnrollment)
CourseEnrollment, unique_id_for_user)
from certificates.models import CertificateStatuses, certificate_status_for_student
@@ -39,7 +39,8 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from datetime import date
from collections import namedtuple
from courseware.courses import get_courses_by_university
from courseware.courses import get_courses
from courseware.access import has_access
from statsd import statsd
@@ -68,31 +69,26 @@ def index(request, extra_context={}, user=None):
extra_context is used to allow immediate display of certain modal windows, eg signup,
as used by external_auth.
'''
feed_data = cache.get("students_index_rss_feed_data")
if feed_data == None:
if hasattr(settings, 'RSS_URL'):
feed_data = urllib.urlopen(settings.RSS_URL).read()
else:
feed_data = render_to_string("feed.rss", None)
cache.set("students_index_rss_feed_data", feed_data, settings.RSS_TIMEOUT)
feed = feedparser.parse(feed_data)
entries = feed['entries'][0:3]
for entry in entries:
soup = BeautifulSoup(entry.description)
entry.image = soup.img['src'] if soup.img else None
entry.summary = soup.getText()
# The course selection work is done in courseware.courses.
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
if domain==False: # do explicit check, because domain=None is valid
domain = request.META.get('HTTP_HOST')
universities = get_courses_by_university(None,
domain=domain)
context = {'universities': universities, 'entries': entries}
courses = get_courses(None, domain=domain)
# Sort courses by how far are they from they start day
key = lambda course: course.metadata['days_to_start']
courses = sorted(courses, key=key, reverse=True)
# Get the 3 most recent news
top_news = _get_news(top=3)
context = {'courses': courses, 'news': top_news}
context.update(extra_context)
return render_to_response('index.html', context)
def course_from_id(course_id):
"""Return the CourseDescriptor corresponding to this course_id"""
course_loc = CourseDescriptor.id_to_location(course_id)
@@ -107,9 +103,9 @@ def get_date_for_press(publish_date):
# strip off extra months, and just use the first:
date = re.sub(multimonth_pattern, ", ", publish_date)
if re.search(day_pattern, date):
date = datetime.datetime.strptime(date, "%B %d, %Y")
else:
date = datetime.datetime.strptime(date, "%B, %Y")
date = datetime.datetime.strptime(date, "%B %d, %Y")
else:
date = datetime.datetime.strptime(date, "%B, %Y")
return date
def press(request):
@@ -127,6 +123,87 @@ def press(request):
return render_to_response('static_templates/press.html', {'articles': articles})
def process_survey_link(survey_link, user):
"""
If {UNIQUE_ID} appears in the link, replace it with a unique id for the user.
Currently, this is sha1(user.username). Otherwise, return survey_link.
"""
return survey_link.format(UNIQUE_ID=unique_id_for_user(user))
def cert_info(user, course):
"""
Get the certificate info needed to render the dashboard section for the given
student and course. Returns a dictionary with keys:
'status': one of 'generating', 'ready', 'notpassing', 'processing'
'show_download_url': bool
'download_url': url, only present if show_download_url is True
'show_disabled_download_button': bool -- true if state is 'generating'
'show_survey_button': bool
'survey_url': url, only if show_survey_button is True
'grade': if status is not 'processing'
"""
if not course.has_ended():
return {}
return _cert_info(user, course, certificate_status_for_student(user, course.id))
def _cert_info(user, course, cert_status):
"""
Implements the logic for cert_info -- split out for testing.
"""
default_status = 'processing'
default_info = {'status': default_status,
'show_disabled_download_button': False,
'show_download_url': False,
'show_survey_button': False}
if cert_status is None:
return default_info
# simplify the status for the template using this lookup table
template_state = {
CertificateStatuses.generating: 'generating',
CertificateStatuses.regenerating: 'generating',
CertificateStatuses.downloadable: 'ready',
CertificateStatuses.notpassing: 'notpassing',
}
status = template_state.get(cert_status['status'], default_status)
d = {'status': status,
'show_download_url': status == 'ready',
'show_disabled_download_button': status == 'generating',}
if (status in ('generating', 'ready', 'notpassing') and
course.end_of_course_survey_url is not None):
d.update({
'show_survey_button': True,
'survey_url': process_survey_link(course.end_of_course_survey_url, user)})
else:
d['show_survey_button'] = False
if status == 'ready':
if 'download_url' not in cert_status:
log.warning("User %s has a downloadable cert for %s, but no download url",
user.username, course.id)
return default_info
else:
d['download_url'] = cert_status['download_url']
if status in ('generating', 'ready', 'notpassing'):
if 'grade' not in cert_status:
# Note: as of 11/20/2012, we know there are students in this state-- cs169.1x,
# who need to be regraded (we weren't tracking 'notpassing' at first).
# We can add a log.warning here once we think it shouldn't happen.
return default_info
else:
d['grade'] = cert_status['grade']
return d
@login_required
@ensure_csrf_cookie
def dashboard(request):
@@ -160,12 +237,10 @@ 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 = {}
cert_statuses = { course.id: cert_info(request.user, course) for course in courses}
# Get the 3 most recent news
top_news = _get_news(top=3)
context = {'courses': courses,
'message': message,
@@ -173,6 +248,7 @@ def dashboard(request):
'errored_courses': errored_courses,
'show_courseware_links_for' : show_courseware_links_for,
'cert_statuses': cert_statuses,
'news': top_news,
}
return render_to_response('dashboard.html', context)
@@ -262,6 +338,14 @@ def change_enrollment(request):
return {'success': False, 'error': 'We weren\'t able to unenroll you. Please try again.'}
@ensure_csrf_cookie
def accounts_login(request, error=""):
return render_to_response('accounts_login.html', { 'error': error })
# Need different levels of logging
@ensure_csrf_cookie
def login_user(request, error=""):
@@ -820,3 +904,24 @@ def test_center_login(request):
return redirect('/courses/MITx/6.002x/2012_Fall/courseware/Final_Exam/Final_Exam_Fall_2012/')
else:
return HttpResponseForbidden()
def _get_news(top=None):
"Return the n top news items on settings.RSS_URL"
feed_data = cache.get("students_index_rss_feed_data")
if feed_data == None:
if hasattr(settings, 'RSS_URL'):
feed_data = urllib.urlopen(settings.RSS_URL).read()
else:
feed_data = render_to_string("feed.rss", None)
cache.set("students_index_rss_feed_data", feed_data, settings.RSS_TIMEOUT)
feed = feedparser.parse(feed_data)
entries = feed['entries'][0:top] # all entries if top is None
for entry in entries:
soup = BeautifulSoup(entry.description)
entry.image = soup.img['src'] if soup.img else None
entry.summary = soup.getText()
return entries

View File

@@ -0,0 +1,48 @@
# -*- 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 'TrackingLog'
db.create_table('track_trackinglog', (
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
('dtcreated', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)),
('username', self.gf('django.db.models.fields.CharField')(max_length=32, blank=True)),
('ip', self.gf('django.db.models.fields.CharField')(max_length=32, blank=True)),
('event_source', self.gf('django.db.models.fields.CharField')(max_length=32)),
('event_type', self.gf('django.db.models.fields.CharField')(max_length=32, blank=True)),
('event', self.gf('django.db.models.fields.TextField')(blank=True)),
('agent', self.gf('django.db.models.fields.CharField')(max_length=256, blank=True)),
('page', self.gf('django.db.models.fields.CharField')(max_length=32, null=True, blank=True)),
('time', self.gf('django.db.models.fields.DateTimeField')()),
))
db.send_create_signal('track', ['TrackingLog'])
def backwards(self, orm):
# Deleting model 'TrackingLog'
db.delete_table('track_trackinglog')
models = {
'track.trackinglog': {
'Meta': {'object_name': 'TrackingLog'},
'agent': ('django.db.models.fields.CharField', [], {'max_length': '256', 'blank': 'True'}),
'dtcreated': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'event': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'event_source': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
'event_type': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'ip': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}),
'page': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}),
'time': ('django.db.models.fields.DateTimeField', [], {}),
'username': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'})
}
}
complete_apps = ['track']

View File

@@ -0,0 +1,51 @@
# -*- 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 field 'TrackingLog.host'
db.add_column('track_trackinglog', 'host',
self.gf('django.db.models.fields.CharField')(default='', max_length=64, blank=True),
keep_default=False)
# Changing field 'TrackingLog.event_type'
db.alter_column('track_trackinglog', 'event_type', self.gf('django.db.models.fields.CharField')(max_length=512))
# Changing field 'TrackingLog.page'
db.alter_column('track_trackinglog', 'page', self.gf('django.db.models.fields.CharField')(max_length=512, null=True))
def backwards(self, orm):
# Deleting field 'TrackingLog.host'
db.delete_column('track_trackinglog', 'host')
# Changing field 'TrackingLog.event_type'
db.alter_column('track_trackinglog', 'event_type', self.gf('django.db.models.fields.CharField')(max_length=32))
# Changing field 'TrackingLog.page'
db.alter_column('track_trackinglog', 'page', self.gf('django.db.models.fields.CharField')(max_length=32, null=True))
models = {
'track.trackinglog': {
'Meta': {'object_name': 'TrackingLog'},
'agent': ('django.db.models.fields.CharField', [], {'max_length': '256', 'blank': 'True'}),
'dtcreated': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
'event': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'event_source': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
'event_type': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}),
'host': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'ip': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}),
'page': ('django.db.models.fields.CharField', [], {'max_length': '512', 'null': 'True', 'blank': 'True'}),
'time': ('django.db.models.fields.DateTimeField', [], {}),
'username': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'})
}
}
complete_apps = ['track']

View File

@@ -7,11 +7,12 @@ class TrackingLog(models.Model):
username = models.CharField(max_length=32,blank=True)
ip = models.CharField(max_length=32,blank=True)
event_source = models.CharField(max_length=32)
event_type = models.CharField(max_length=32,blank=True)
event_type = models.CharField(max_length=512,blank=True)
event = models.TextField(blank=True)
agent = models.CharField(max_length=256,blank=True)
page = models.CharField(max_length=32,blank=True,null=True)
page = models.CharField(max_length=512,blank=True,null=True)
time = models.DateTimeField('event time')
host = models.CharField(max_length=64,blank=True)
def __unicode__(self):
s = "[%s] %s@%s: %s | %s | %s | %s" % (self.time, self.username, self.ip, self.event_source,

View File

@@ -17,7 +17,7 @@ from track.models import TrackingLog
log = logging.getLogger("tracking")
LOGFIELDS = ['username','ip','event_source','event_type','event','agent','page','time']
LOGFIELDS = ['username','ip','event_source','event_type','event','agent','page','time','host']
def log_event(event):
event_str = json.dumps(event)
@@ -58,6 +58,7 @@ def user_track(request):
"agent": agent,
"page": request.GET['page'],
"time": datetime.datetime.utcnow().isoformat(),
"host": request.META['SERVER_NAME'],
}
log_event(event)
return HttpResponse('success')
@@ -83,6 +84,7 @@ def server_track(request, event_type, event, page=None):
"agent": agent,
"page": page,
"time": datetime.datetime.utcnow().isoformat(),
"host": request.META['SERVER_NAME'],
}
if event_type.startswith("/event_logs") and request.user.is_staff: # don't log

View File

@@ -4,6 +4,11 @@ import json
def expect_json(view_function):
"""
View decorator for simplifying handing of requests that expect json. If the request's
CONTENT_TYPE is application/json, parses the json dict from request.body, and updates
request.POST with the contents.
"""
@wraps(view_function)
def expect_json_with_cloned_request(request, *args, **kwargs):
# cdodge: fix postback errors in CMS. The POST 'content-type' header can include additional information

View File

@@ -7,6 +7,7 @@ source = common/lib/capa
ignore_errors = True
[html]
title = Capa Python Test Coverage Report
directory = reports/common/lib/capa/cover
[xml]

View File

@@ -33,6 +33,7 @@ from xml.sax.saxutils import unescape
import chem
import chem.chemcalc
import chem.chemtools
import chem.miller
import calc
from correctmap import CorrectMap
@@ -52,7 +53,7 @@ response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__])
solution_tags = ['solution']
# these get captured as student responses
response_properties = ["codeparam", "responseparam", "answer"]
response_properties = ["codeparam", "responseparam", "answer", "openendedparam"]
# special problem tags which should be turned into innocuous HTML
html_transforms = {'problem': {'tag': 'div'},
@@ -67,10 +68,11 @@ global_context = {'random': random,
'calc': calc,
'eia': eia,
'chemcalc': chem.chemcalc,
'chemtools': chem.chemtools}
'chemtools': chem.chemtools,
'miller': chem.miller}
# These should be removed from HTML output, including all subelements
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup"]
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam","openendedrubric"]
log = logging.getLogger('mitx.' + __name__)
@@ -184,6 +186,24 @@ class LoncapaProblem(object):
maxscore += responder.get_max_score()
return maxscore
def message_post(self,event_info):
"""
Handle an ajax post that contains feedback on feedback
Returns a boolean success variable
Note: This only allows for feedback to be posted back to the grading controller for the first
open ended response problem on each page. Multiple problems will cause some sync issues.
TODO: Handle multiple problems on one page sync issues.
"""
success=False
message = "Could not find a valid responder."
log.debug("in lcp")
for responder in self.responders.values():
if hasattr(responder, 'handle_message_post'):
success, message = responder.handle_message_post(event_info)
if success:
break
return success, message
def get_score(self):
"""
Compute score for this problem. The score is the number of points awarded.

View File

@@ -0,0 +1,267 @@
""" Calculation of Miller indices """
import numpy as np
import math
import fractions as fr
import decimal
import json
def lcm(a, b):
"""
Returns least common multiple of a, b
Args:
a, b: floats
Returns:
float
"""
return a * b / fr.gcd(a, b)
def segment_to_fraction(distance):
"""
Converts lengths of which the plane cuts the axes to fraction.
Tries convert distance to closest nicest fraction with denominator less or
equal than 10. It is
purely for simplicity and clearance of learning purposes. Jenny: 'In typical
courses students usually do not encounter indices any higher than 6'.
If distance is not a number (numpy nan), it means that plane is parallel to
axis or contains it. Inverted fraction to nan (nan is 1/0) = 0 / 1 is
returned
Generally (special cases):
a) if distance is smaller than some constant, i.g. 0.01011,
than fraction's denominator usually much greater than 10.
b) Also, if student will set point on 0.66 -> 1/3, so it is 333 plane,
But if he will slightly move the mouse and click on 0.65 -> it will be
(16,15,16) plane. That's why we are doing adjustments for points coordinates,
to the closest tick, tick + tick / 2 value. And now UI sends to server only
values multiple to 0.05 (half of tick). Same rounding is implemented for
unittests.
But if one will want to calculate miller indices with exact coordinates and
with nice fractions (which produce small Miller indices), he may want shift
to new origin if segments are like S = (0.015, > 0.05, >0.05) - close to zero
in one coordinate. He may update S to (0, >0.05, >0.05) and shift origin.
In this way he can recieve nice small fractions. Also there is can be
degenerated case when S = (0.015, 0.012, >0.05) - if update S to (0, 0, >0.05) -
it is a line. This case should be considered separately. Small nice Miller
numbers and possibility to create very small segments can not be implemented
at same time).
Args:
distance: float distance that plane cuts on axis, it must not be 0.
Distance is multiple of 0.05.
Returns:
Inverted fraction.
0 / 1 if distance is nan
"""
if np.isnan(distance):
return fr.Fraction(0, 1)
else:
fract = fr.Fraction(distance).limit_denominator(10)
return fr.Fraction(fract.denominator, fract.numerator)
def sub_miller(segments):
'''
Calculates Miller indices from segments.
Algorithm:
1. Obtain inverted fraction from segments
2. Find common denominator of inverted fractions
3. Lead fractions to common denominator and throws denominator away.
4. Return obtained values.
Args:
List of 3 floats, meaning distances that plane cuts on x, y, z axes.
Any float not equals zero, it means that plane does not intersect origin,
i. e. shift of origin has already been done.
Returns:
String that represents Miller indices, e.g: (-6,3,-6) or (2,2,2)
'''
fracts = [segment_to_fraction(segment) for segment in segments]
common_denominator = reduce(lcm, [fract.denominator for fract in fracts])
miller = ([fract.numerator * math.fabs(common_denominator) /
fract.denominator for fract in fracts])
return'(' + ','.join(map(str, map(decimal.Decimal, miller))) + ')'
def miller(points):
"""
Calculates Miller indices from points.
Algorithm:
1. Calculate normal vector to a plane that goes trough all points.
2. Set origin.
3. Create Cartesian coordinate system (Ccs).
4. Find the lengths of segments of which the plane cuts the axes. Equation
of a line for axes: Origin + (Coordinate_vector - Origin) * parameter.
5. If plane goes trough Origin:
a) Find new random origin: find unit cube vertex, not crossed by a plane.
b) Repeat 2-4.
c) Fix signs of segments after Origin shift. This means to consider
original directions of axes. I.g.: Origin was 0,0,0 and became
new_origin. If new_origin has same Y coordinate as Origin, then segment
does not change its sign. But if new_origin has another Y coordinate than
origin (was 0, became 1), than segment has to change its sign (it now
lies on negative side of Y axis). New Origin 0 value of X or Y or Z
coordinate means that segment does not change sign, 1 value -> does
change. So new sign is (1 - 2 * new_origin): 0 -> 1, 1 -> -1
6. Run function that calculates miller indices from segments.
Args:
List of points. Each point is list of float coordinates. Order of
coordinates in point's list: x, y, z. Points are different!
Returns:
String that represents Miller indices, e.g: (-6,3,-6) or (2,2,2)
"""
N = np.cross(points[1] - points[0], points[2] - points[0])
O = np.array([0, 0, 0])
P = points[0] # point of plane
Ccs = map(np.array, [[1.0, 0, 0], [0, 1.0, 0], [0, 0, 1.0]])
segments = ([np.dot(P - O, N) / np.dot(ort, N) if np.dot(ort, N) != 0 else
np.nan for ort in Ccs])
if any(x == 0 for x in segments): # Plane goes through origin.
vertices = [ # top:
np.array([1.0, 1.0, 1.0]),
np.array([0.0, 0.0, 1.0]),
np.array([1.0, 0.0, 1.0]),
np.array([0.0, 1.0, 1.0]),
# bottom, except 0,0,0:
np.array([1.0, 0.0, 0.0]),
np.array([0.0, 1.0, 0.0]),
np.array([1.0, 1.0, 1.0]),
]
for vertex in vertices:
if np.dot(vertex - O, N) != 0: # vertex not in plane
new_origin = vertex
break
# obtain new axes with center in new origin
X = np.array([1 - new_origin[0], new_origin[1], new_origin[2]])
Y = np.array([new_origin[0], 1 - new_origin[1], new_origin[2]])
Z = np.array([new_origin[0], new_origin[1], 1 - new_origin[2]])
new_Ccs = [X - new_origin, Y - new_origin, Z - new_origin]
segments = ([np.dot(P - new_origin, N) / np.dot(ort, N) if
np.dot(ort, N) != 0 else np.nan for ort in new_Ccs])
# fix signs of indices: 0 -> 1, 1 -> -1 (
segments = (1 - 2 * new_origin) * segments
return sub_miller(segments)
def grade(user_input, correct_answer):
'''
Grade crystallography problem.
Returns true if lattices are the same and Miller indices are same or minus
same. E.g. (2,2,2) = (2, 2, 2) or (-2, -2, -2). Because sign depends only
on student's selection of origin.
Args:
user_input, correct_answer: json. Format:
user_input: {"lattice":"sc","points":[["0.77","0.00","1.00"],
["0.78","1.00","0.00"],["0.00","1.00","0.72"]]}
correct_answer: {'miller': '(00-1)', 'lattice': 'bcc'}
"lattice" is one of: "", "sc", "bcc", "fcc"
Returns:
True or false.
'''
def negative(m):
"""
Change sign of Miller indices.
Args:
m: string with meaning of Miller indices. E.g.:
(-6,3,-6) -> (6, -3, 6)
Returns:
String with changed signs.
"""
output = ''
i = 1
while i in range(1, len(m) - 1):
if m[i] in (',', ' '):
output += m[i]
elif m[i] not in ('-', '0'):
output += '-' + m[i]
elif m[i] == '0':
output += m[i]
else:
i += 1
output += m[i]
i += 1
return '(' + output + ')'
def round0_25(point):
"""
Rounds point coordinates to closest 0.5 value.
Args:
point: list of float coordinates. Order of coordinates: x, y, z.
Returns:
list of coordinates rounded to closes 0.5 value
"""
rounded_points = []
for coord in point:
base = math.floor(coord * 10)
fractional_part = (coord * 10 - base)
aliquot0_25 = math.floor(fractional_part / 0.25)
if aliquot0_25 == 0.0:
rounded_points.append(base / 10)
if aliquot0_25 in (1.0, 2.0):
rounded_points.append(base / 10 + 0.05)
if aliquot0_25 == 3.0:
rounded_points.append(base / 10 + 0.1)
return rounded_points
user_answer = json.loads(user_input)
if user_answer['lattice'] != correct_answer['lattice']:
return False
points = [map(float, p) for p in user_answer['points']]
if len(points) < 3:
return False
# round point to closes 0.05 value
points = [round0_25(point) for point in points]
points = [np.array(point) for point in points]
# print miller(points), (correct_answer['miller'].replace(' ', ''),
# negative(correct_answer['miller']).replace(' ', ''))
if miller(points) in (correct_answer['miller'].replace(' ', ''), negative(correct_answer['miller']).replace(' ', '')):
return True
return False

View File

@@ -1,13 +1,15 @@
import codecs
from fractions import Fraction
from pyparsing import ParseException
import unittest
from chemcalc import (compare_chemical_expression, divide_chemical_expression,
render_to_html, chemical_equations_equal)
import miller
local_debug = None
def log(s, output_type=None):
if local_debug:
print s
@@ -37,7 +39,6 @@ class Test_Compare_Equations(unittest.TestCase):
self.assertFalse(chemical_equations_equal('2H2 + O2 -> H2O2',
'2O2 + 2H2 -> 2H2O2'))
def test_different_arrows(self):
self.assertTrue(chemical_equations_equal('H2 + O2 -> H2O2',
'2O2 + 2H2 -> 2H2O2'))
@@ -56,7 +57,6 @@ class Test_Compare_Equations(unittest.TestCase):
self.assertTrue(chemical_equations_equal('H2 + O2 -> H2O2',
'O2 + H2 -> H2O2', exact=True))
def test_syntax_errors(self):
self.assertFalse(chemical_equations_equal('H2 + O2 a-> H2O2',
'2O2 + 2H2 -> 2H2O2'))
@@ -311,7 +311,6 @@ class Test_Render_Equations(unittest.TestCase):
log(out + ' ------- ' + correct, 'html')
self.assertEqual(out, correct)
def test_render_eq3(self):
s = "H^+ + OH^- <= H2O" # unsupported arrow
out = render_to_html(s)
@@ -320,10 +319,148 @@ class Test_Render_Equations(unittest.TestCase):
self.assertEqual(out, correct)
class Test_Crystallography_Miller(unittest.TestCase):
''' Tests for crystallography grade function.'''
def test_empty_points(self):
user_input = '{"lattice": "bcc", "points": []}'
self.assertFalse(miller.grade(user_input, {'miller': '(2,2,2)', 'lattice': 'bcc'}))
def test_only_one_point(self):
user_input = '{"lattice": "bcc", "points": [["0.50", "0.00", "0.00"]]}'
self.assertFalse(miller.grade(user_input, {'miller': '(2,2,2)', 'lattice': 'bcc'}))
def test_only_two_points(self):
user_input = '{"lattice": "bcc", "points": [["0.50", "0.00", "0.00"], ["0.00", "0.50", "0.00"]]}'
self.assertFalse(miller.grade(user_input, {'miller': '(2,2,2)', 'lattice': 'bcc'}))
def test_1(self):
user_input = '{"lattice": "bcc", "points": [["0.50", "0.00", "0.00"], ["0.00", "0.50", "0.00"], ["0.00", "0.00", "0.50"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(2,2,2)', 'lattice': 'bcc'}))
def test_2(self):
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "0.00"], ["0.00", "1.00", "0.00"], ["0.00", "0.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(1,1,1)', 'lattice': 'bcc'}))
def test_3(self):
user_input = '{"lattice": "bcc", "points": [["1.00", "0.50", "1.00"], ["1.00", "1.00", "0.50"], ["0.50", "1.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(2,2,2)', 'lattice': 'bcc'}))
def test_4(self):
user_input = '{"lattice": "bcc", "points": [["0.33", "1.00", "0.00"], ["0.00", "0.664", "0.00"], ["0.00", "1.00", "0.33"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(-3, 3, -3)', 'lattice': 'bcc'}))
def test_5(self):
""" return true only in case points coordinates are exact.
But if they transform to closest 0.05 value it is not true"""
user_input = '{"lattice": "bcc", "points": [["0.33", "1.00", "0.00"], ["0.00", "0.33", "0.00"], ["0.00", "1.00", "0.33"]]}'
self.assertFalse(miller.grade(user_input, {'miller': '(-6,3,-6)', 'lattice': 'bcc'}))
def test_6(self):
user_input = '{"lattice": "bcc", "points": [["0.00", "0.25", "0.00"], ["0.25", "0.00", "0.00"], ["0.00", "0.00", "0.25"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(4,4,4)', 'lattice': 'bcc'}))
def test_7(self): # goes throug origin
user_input = '{"lattice": "bcc", "points": [["0.00", "1.00", "0.00"], ["1.00", "0.00", "0.00"], ["0.50", "1.00", "0.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(0,0,-1)', 'lattice': 'bcc'}))
def test_8(self):
user_input = '{"lattice": "bcc", "points": [["0.00", "1.00", "0.50"], ["1.00", "0.00", "0.50"], ["0.50", "1.00", "0.50"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(0,0,2)', 'lattice': 'bcc'}))
def test_9(self):
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "1.00"], ["0.00", "1.00", "1.00"], ["1.00", "0.00", "0.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(1,1,0)', 'lattice': 'bcc'}))
def test_10(self):
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "1.00"], ["0.00", "0.00", "0.00"], ["0.00", "1.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(1,1,-1)', 'lattice': 'bcc'}))
def test_11(self):
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "0.50"], ["1.00", "1.00", "0.00"], ["0.00", "1.00", "0.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(0,1,2)', 'lattice': 'bcc'}))
def test_12(self):
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "0.50"], ["0.00", "0.00", "0.50"], ["1.00", "1.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(0,1,-2)', 'lattice': 'bcc'}))
def test_13(self):
user_input = '{"lattice": "bcc", "points": [["0.50", "0.00", "0.00"], ["0.50", "1.00", "0.00"], ["0.00", "0.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(2,0,1)', 'lattice': 'bcc'}))
def test_14(self):
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["0.00", "0.00", "1.00"], ["0.50", "1.00", "0.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(2,-1,0)', 'lattice': 'bcc'}))
def test_15(self):
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "1.00", "0.00"], ["0.00", "1.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(1,-1,1)', 'lattice': 'bcc'}))
def test_16(self):
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "0.00"], ["0.00", "1.00", "0.00"], ["1.00", "1.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(1,1,-1)', 'lattice': 'bcc'}))
def test_17(self):
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "0.00", "1.00"], ["1.00", "1.00", "0.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(-1,1,1)', 'lattice': 'bcc'}))
def test_18(self):
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "1.00", "0.00"], ["0.00", "1.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(1,-1,1)', 'lattice': 'bcc'}))
def test_19(self):
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "1.00", "0.00"], ["0.00", "0.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(-1,1,0)', 'lattice': 'bcc'}))
def test_20(self):
user_input = '{"lattice": "bcc", "points": [["1.00", "0.00", "0.00"], ["1.00", "1.00", "0.00"], ["0.00", "0.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(1,0,1)', 'lattice': 'bcc'}))
def test_21(self):
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["0.00", "1.00", "0.00"], ["1.00", "0.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(-1,0,1)', 'lattice': 'bcc'}))
def test_22(self):
user_input = '{"lattice": "bcc", "points": [["0.00", "1.00", "0.00"], ["1.00", "1.00", "0.00"], ["0.00", "0.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(0,1,1)', 'lattice': 'bcc'}))
def test_23(self):
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "0.00", "0.00"], ["1.00", "1.00", "1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(0,-1,1)', 'lattice': 'bcc'}))
def test_24(self):
user_input = '{"lattice": "bcc", "points": [["0.66", "0.00", "0.00"], ["0.00", "0.66", "0.00"], ["0.00", "0.00", "0.66"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(3,3,3)', 'lattice': 'bcc'}))
def test_25(self):
user_input = u'{"lattice":"","points":[["0.00","0.00","0.01"],["1.00","1.00","0.01"],["0.00","1.00","1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(1,-1,1)', 'lattice': ''}))
def test_26(self):
user_input = u'{"lattice":"","points":[["0.00","0.01","0.00"],["1.00","0.00","0.00"],["0.00","0.00","1.00"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(0,-1,0)', 'lattice': ''}))
def test_27(self):
""" rounding to 0.35"""
user_input = u'{"lattice":"","points":[["0.33","0.00","0.00"],["0.00","0.33","0.00"],["0.00","0.00","0.33"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(3,3,3)', 'lattice': ''}))
def test_28(self):
""" rounding to 0.30"""
user_input = u'{"lattice":"","points":[["0.30","0.00","0.00"],["0.00","0.30","0.00"],["0.00","0.00","0.30"]]}'
self.assertTrue(miller.grade(user_input, {'miller': '(10,10,10)', 'lattice': ''}))
def test_wrong_lattice(self):
user_input = '{"lattice": "bcc", "points": [["0.00", "0.00", "0.00"], ["1.00", "0.00", "0.00"], ["1.00", "1.00", "1.00"]]}'
self.assertFalse(miller.grade(user_input, {'miller': '(3,3,3)', 'lattice': 'fcc'}))
def suite():
testcases = [Test_Compare_Expressions, Test_Divide_Expressions, Test_Render_Equations]
testcases = [Test_Compare_Expressions,
Test_Divide_Expressions,
Test_Render_Equations,
Test_Crystallography_Miller]
suites = []
for testcase in testcases:
suites.append(unittest.TestLoader().loadTestsFromTestCase(testcase))

View File

@@ -671,18 +671,15 @@ class Crystallography(InputTypeBase):
"""
Note: height, width are required.
"""
return [Attribute('size', None),
Attribute('height'),
return [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
@@ -736,3 +733,53 @@ class ChemicalEquationInput(InputTypeBase):
return {'previewer': '/static/js/capa/chemical_equation_preview.js',}
registry.register(ChemicalEquationInput)
#-----------------------------------------------------------------------------
class OpenEndedInput(InputTypeBase):
"""
A text area input for code--uses codemirror, does syntax highlighting, special tab handling,
etc.
"""
template = "openendedinput.html"
tags = ['openendedinput']
# pulled out for testing
submitted_msg = ("Feedback not yet available. Reload to check again. "
"Once the problem 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', ''),
]
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(OpenEndedInput)
#-----------------------------------------------------------------------------

View File

@@ -8,22 +8,25 @@ Used by capa_problem.py
'''
# standard library imports
import abc
import cgi
import hashlib
import inspect
import json
import logging
import numbers
import numpy
import os
import random
import re
import requests
import traceback
import hashlib
import abc
import os
import subprocess
import traceback
import xml.sax.saxutils as saxutils
from collections import namedtuple
from shapely.geometry import Point, MultiPoint
# specific library imports
from calc import evaluator, UndefinedVariable
from correctmap import CorrectMap
@@ -1104,6 +1107,15 @@ class SymbolicResponse(CustomResponse):
#-----------------------------------------------------------------------------
"""
valid: Flag indicating valid score_msg format (Boolean)
correct: Correctness of submission (Boolean)
score: Points to be assigned (numeric, can be float)
msg: Message from grader to display to student (string)
"""
ScoreMessage = namedtuple('ScoreMessage',
['valid', 'correct', 'points', 'msg'])
class CodeResponse(LoncapaResponse):
"""
@@ -1149,7 +1161,7 @@ class CodeResponse(LoncapaResponse):
else:
self._parse_coderesponse_xml(codeparam)
def _parse_coderesponse_xml(self,codeparam):
def _parse_coderesponse_xml(self, codeparam):
'''
Parse the new CodeResponse XML format. When successful, sets:
self.initial_display
@@ -1161,17 +1173,9 @@ class CodeResponse(LoncapaResponse):
grader_payload = grader_payload.text if grader_payload is not None else ''
self.payload = {'grader_payload': grader_payload}
answer_display = codeparam.find('answer_display')
if answer_display is not None:
self.answer = answer_display.text
else:
self.answer = 'No answer provided.'
initial_display = codeparam.find('initial_display')
if initial_display is not None:
self.initial_display = initial_display.text
else:
self.initial_display = ''
self.initial_display = find_with_default(codeparam, 'initial_display', '')
self.answer = find_with_default(codeparam, 'answer_display',
'No answer provided.')
def _parse_externalresponse_xml(self):
'''
@@ -1325,8 +1329,6 @@ class CodeResponse(LoncapaResponse):
# Sanity check on returned points
if points < 0:
points = 0
elif points > self.maxpoints[self.answer_id]:
points = self.maxpoints[self.answer_id]
# Queuestate is consumed
oldcmap.set(self.answer_id, npoints=points, correctness=correctness,
msg=msg.replace('&nbsp;', '&#160;'), queuestate=None)
@@ -1734,15 +1736,38 @@ class ImageResponse(LoncapaResponse):
which produces an [x,y] coordinate pair. The click is correct if it falls
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>
should contain one or more <imageinput> stanzas. Each <imageinput> should specify
a rectangle, given as an attribute, defining the correct answer.
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> should contain one or more <imageinput> stanzas.
Each <imageinput> should specify a rectangle(s) or region(s), given as an
attribute, defining the correct answer.
<imageinput src="/static/images/Lecture2/S2_p04.png" width="811" height="610"
rectangle="(10,10)-(20,30);(12,12)-(40,60)"
regions="[[[10,10], [20,30], [40, 10]], [[100,100], [120,130], [110,150]]]"/>
Regions is list of lists [region1, region2, region3, ...] where regionN
is disordered list of points: [[1,1], [100,100], [50,50], [20, 70]].
If there is only one region in the list, simpler notation can be used:
regions="[[10,10], [30,30], [10, 30], [30, 10]]" (without explicitly
setting outer list)
Returns:
True, if click is inside any region or rectangle. Otherwise False.
"""
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)" />
<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="image3.jpg" width="210" height="130"
rectangle="(10,10)-(20,30);(12,12)-(40,60)" />
<imageinput src="image4.jpg" width="811" height="610"
rectangle="(10,10)-(20,30);(12,12)-(40,60)"
regions="[[[10,10], [20,30], [40, 10]], [[100,100], [120,130], [110,150]]]"/>
<imageinput src="image5.jpg" width="200" height="200"
regions="[[[10,10], [20,30], [40, 10]], [[100,100], [120,130], [110,150]]]"/>
</imageresponse>'''}]
response_tag = 'imageresponse'
@@ -1750,19 +1775,17 @@ class ImageResponse(LoncapaResponse):
def setup_response(self):
self.ielements = self.inputfields
self.answer_ids = [ie.get('id') for ie in self.ielements]
self.answer_ids = [ie.get('id') for ie in self.ielements]
def get_score(self, student_answers):
correct_map = CorrectMap()
expectedset = self.get_answers()
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]'
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
if not given: # No answer to parse. Mark as incorrect and move on
continue
# parse given answer
m = re.match('\[([0-9]+),([0-9]+)]', given.strip().replace(' ', ''))
if not m:
@@ -1770,28 +1793,481 @@ class ImageResponse(LoncapaResponse):
'error grading %s (input=%s)' % (aid, given))
(gx, gy) = [int(x) for x in m.groups()]
# 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
rectangles, regions = expectedset
if rectangles[aid]: # rectangles part - for backward compatibility
# Check whether given point lies in any of the solution rectangles
solution_rectangles = rectangles[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
if correct_map[aid]['correctness'] != 'correct' and regions[aid]:
parsed_region = json.loads(regions[aid])
if parsed_region:
if type(parsed_region[0][0]) != list:
# we have [[1,2],[3,4],[5,6]] - single region
# instead of [[[1,2],[3,4],[5,6], [[1,2],[3,4],[5,6]]]
# or [[[1,2],[3,4],[5,6]]] - multiple regions syntax
parsed_region = [parsed_region]
for region in parsed_region:
polygon = MultiPoint(region).convex_hull
if (polygon.type == 'Polygon' and
polygon.contains(Point(gx, gy))):
correct_map.set(aid, 'correct')
break
return correct_map
def get_answers(self):
return dict([(ie.get('id'), ie.get('rectangle')) for ie in self.ielements])
return (dict([(ie.get('id'), ie.get('rectangle')) for ie in self.ielements]),
dict([(ie.get('id'), ie.get('regions')) for ie in self.ielements]))
#-----------------------------------------------------------------------------
class OpenEndedResponse(LoncapaResponse):
"""
Grade student open ended responses using an external grading system,
accessed through the xqueue system.
Expects 'xqueue' dict in ModuleSystem with the following keys that are
needed by OpenEndedResponse:
system.xqueue = { 'interface': XqueueInterface object,
'callback_url': Per-StudentModule callback URL
where results are posted (string),
}
External requests are only submitted for student submission grading
(i.e. and not for getting reference answers)
By default, uses the OpenEndedResponse.DEFAULT_QUEUE queue.
"""
DEFAULT_QUEUE = 'open-ended'
DEFAULT_MESSAGE_QUEUE = 'open-ended-message'
response_tag = 'openendedresponse'
allowed_inputfields = ['openendedinput']
max_inputfields = 1
def setup_response(self):
'''
Configure OpenEndedResponse from XML.
'''
xml = self.xml
self.url = xml.get('url', None)
self.queue_name = xml.get('queuename', self.DEFAULT_QUEUE)
self.message_queue_name = xml.get('message-queuename', self.DEFAULT_MESSAGE_QUEUE)
# The openendedparam tag encapsulates all grader settings
oeparam = self.xml.find('openendedparam')
prompt = self.xml.find('prompt')
rubric = self.xml.find('openendedrubric')
#This is needed to attach feedback to specific responses later
self.submission_id=None
self.grader_id=None
if oeparam is None:
raise ValueError("No oeparam found in problem xml.")
if prompt is None:
raise ValueError("No prompt found in problem xml.")
if rubric is None:
raise ValueError("No rubric found in problem xml.")
self._parse(oeparam, prompt, rubric)
@staticmethod
def stringify_children(node):
"""
Modify code from stringify_children in xmodule. Didn't import directly
in order to avoid capa depending on xmodule (seems to be avoided in
code)
"""
parts=[node.text if node.text is not None else '']
for p in node.getchildren():
parts.append(etree.tostring(p, with_tail=True, encoding='unicode'))
return ' '.join(parts)
def _parse(self, oeparam, prompt, rubric):
'''
Parse OpenEndedResponse XML:
self.initial_display
self.payload - dict containing keys --
'grader' : path to grader settings file, 'problem_id' : id of the problem
self.answer - What to display when show answer is clicked
'''
# Note that OpenEndedResponse is agnostic to the specific contents of grader_payload
prompt_string = self.stringify_children(prompt)
rubric_string = self.stringify_children(rubric)
grader_payload = oeparam.find('grader_payload')
grader_payload = grader_payload.text if grader_payload is not None else ''
#Update grader payload with student id. If grader payload not json, error.
try:
parsed_grader_payload = json.loads(grader_payload)
# NOTE: self.system.location is valid because the capa_module
# __init__ adds it (easiest way to get problem location into
# response types)
except TypeError, ValueError:
log.exception("Grader payload %r is not a json object!", grader_payload)
self.initial_display = find_with_default(oeparam, 'initial_display', '')
self.answer = find_with_default(oeparam, 'answer_display', 'No answer given.')
parsed_grader_payload.update({
'location' : self.system.location,
'course_id' : self.system.course_id,
'prompt' : prompt_string,
'rubric' : rubric_string,
'initial_display' : self.initial_display,
'answer' : self.answer,
})
updated_grader_payload = json.dumps(parsed_grader_payload)
self.payload = {'grader_payload': updated_grader_payload}
try:
self.max_score = int(find_with_default(oeparam, 'max_score', 1))
except ValueError:
self.max_score = 1
def handle_message_post(self,event_info):
"""
Handles a student message post (a reaction to the grade they received from an open ended grader type)
Returns a boolean success/fail and an error message
"""
survey_responses=event_info['survey_responses']
for tag in ['feedback', 'submission_id', 'grader_id', 'score']:
if tag not in survey_responses:
return False, "Could not find needed tag {0}".format(tag)
try:
submission_id=int(survey_responses['submission_id'])
grader_id = int(survey_responses['grader_id'])
feedback = str(survey_responses['feedback'].encode('ascii', 'ignore'))
score = int(survey_responses['score'])
except:
error_message=("Could not parse submission id, grader id, "
"or feedback from message_post ajax call. Here is the message data: {0}".format(survey_responses))
log.exception(error_message)
return False, "There was an error saving your feedback. Please contact course staff."
qinterface = self.system.xqueue['interface']
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
anonymous_student_id = self.system.anonymous_student_id
queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
anonymous_student_id +
self.answer_id)
xheader = xqueue_interface.make_xheader(
lms_callback_url=self.system.xqueue['callback_url'],
lms_key=queuekey,
queue_name=self.message_queue_name
)
student_info = {'anonymous_student_id': anonymous_student_id,
'submission_time': qtime,
}
contents= {
'feedback' : feedback,
'submission_id' : submission_id,
'grader_id' : grader_id,
'score': score,
'student_info' : json.dumps(student_info),
}
(error, msg) = qinterface.send_to_queue(header=xheader,
body=json.dumps(contents))
#Convert error to a success value
success=True
if error:
success=False
return success, "Successfully submitted your feedback."
def get_score(self, student_answers):
try:
submission = student_answers[self.answer_id]
except KeyError:
msg = ('Cannot get student answer for answer_id: {0}. student_answers {1}'
.format(self.answer_id, student_answers))
log.exception(msg)
raise LoncapaProblemError(msg)
# Prepare xqueue request
#------------------------------------------------------------
qinterface = self.system.xqueue['interface']
qtime = datetime.strftime(datetime.now(), xqueue_interface.dateformat)
anonymous_student_id = self.system.anonymous_student_id
# Generate header
queuekey = xqueue_interface.make_hashkey(str(self.system.seed) + qtime +
anonymous_student_id +
self.answer_id)
xheader = xqueue_interface.make_xheader(lms_callback_url=self.system.xqueue['callback_url'],
lms_key=queuekey,
queue_name=self.queue_name)
self.context.update({'submission': submission})
contents = self.payload.copy()
# Metadata related to the student submission revealed to the external grader
student_info = {'anonymous_student_id': anonymous_student_id,
'submission_time': qtime,
}
#Update contents with student response and student info
contents.update({
'student_info': json.dumps(student_info),
'student_response': submission,
'max_score' : self.max_score,
})
# Submit request. When successful, 'msg' is the prior length of the queue
(error, msg) = qinterface.send_to_queue(header=xheader,
body=json.dumps(contents))
# State associated with the queueing request
queuestate = {'key': queuekey,
'time': qtime,}
cmap = CorrectMap()
if error:
cmap.set(self.answer_id, queuestate=None,
msg='Unable to deliver your submission to grader. (Reason: {0}.)'
' Please try again later.'.format(msg))
else:
# Queueing mechanism flags:
# 1) Backend: Non-null CorrectMap['queuestate'] indicates that
# the problem has been queued
# 2) Frontend: correctness='incomplete' eventually trickles down
# through inputtypes.textbox and .filesubmission to inform the
# browser that the submission is queued (and it could e.g. poll)
cmap.set(self.answer_id, queuestate=queuestate,
correctness='incomplete', msg=msg)
return cmap
def update_score(self, score_msg, oldcmap, queuekey):
log.debug(score_msg)
score_msg = self._parse_score_msg(score_msg)
if not score_msg.valid:
oldcmap.set(self.answer_id,
msg = 'Invalid grader reply. Please contact the course staff.')
return oldcmap
correctness = 'correct' if score_msg.correct else 'incorrect'
# TODO: Find out how this is used elsewhere, if any
self.context['correct'] = correctness
# Replace 'oldcmap' with new grading results if queuekey matches. If queuekey
# does not match, we keep waiting for the score_msg whose key actually matches
if oldcmap.is_right_queuekey(self.answer_id, queuekey):
# Sanity check on returned points
points = score_msg.points
if points < 0:
points = 0
# Queuestate is consumed, so reset it to None
oldcmap.set(self.answer_id, npoints=points, correctness=correctness,
msg = score_msg.msg.replace('&nbsp;', '&#160;'), queuestate=None)
else:
log.debug('OpenEndedResponse: queuekey {0} does not match for answer_id={1}.'.format(
queuekey, self.answer_id))
return oldcmap
def get_answers(self):
anshtml = '<span class="openended-answer"><pre><code>{0}</code></pre></span>'.format(self.answer)
return {self.answer_id: anshtml}
def get_initial_display(self):
return {self.answer_id: self.initial_display}
def _convert_longform_feedback_to_html(self, response_items):
"""
Take in a dictionary, and return html strings for display to student.
Input:
response_items: Dictionary with keys success, feedback.
if success is True, feedback should be a dictionary, with keys for
types of feedback, and the corresponding feedback values.
if success is False, feedback is actually an error string.
NOTE: this will need to change when we integrate peer grading, because
that will have more complex feedback.
Output:
String -- html that can be displayed to the student.
"""
# We want to display available feedback in a particular order.
# This dictionary specifies which goes first--lower first.
priorities = {# These go at the start of the feedback
'spelling': 0,
'grammar': 1,
# needs to be after all the other feedback
'markup_text': 3}
default_priority = 2
def get_priority(elt):
"""
Args:
elt: a tuple of feedback-type, feedback
Returns:
the priority for this feedback type
"""
return priorities.get(elt[0], default_priority)
def encode_values(feedback_type,value):
feedback_type=str(feedback_type).encode('ascii', 'ignore')
if not isinstance(value,basestring):
value=str(value)
value=value.encode('ascii', 'ignore')
return feedback_type,value
def format_feedback(feedback_type, value):
feedback_type,value=encode_values(feedback_type,value)
feedback= """
<div class="{feedback_type}">
{value}
</div>
""".format(feedback_type=feedback_type, value=value)
return feedback
def format_feedback_hidden(feedback_type , value):
feedback_type,value=encode_values(feedback_type,value)
feedback = """
<div class="{feedback_type}" style="display: none;">
{value}
</div>
""".format(feedback_type=feedback_type, value=value)
return feedback
# TODO (vshnayder): design and document the details of this format so
# that we can do proper escaping here (e.g. are the graders allowed to
# include HTML?)
for tag in ['success', 'feedback', 'submission_id', 'grader_id']:
if tag not in response_items:
return format_feedback('errors', 'Error getting feedback')
feedback_items = response_items['feedback']
try:
feedback = json.loads(feedback_items)
except (TypeError, ValueError):
log.exception("feedback_items have invalid json %r", feedback_items)
return format_feedback('errors', 'Could not parse feedback')
if response_items['success']:
if len(feedback) == 0:
return format_feedback('errors', 'No feedback available')
feedback_lst = sorted(feedback.items(), key=get_priority)
feedback_list_part1 = u"\n".join(format_feedback(k, v) for k, v in feedback_lst)
else:
feedback_list_part1 = format_feedback('errors', response_items['feedback'])
feedback_list_part2=(u"\n".join([format_feedback_hidden(feedback_type,value)
for feedback_type,value in response_items.items()
if feedback_type in ['submission_id', 'grader_id']]))
return u"\n".join([feedback_list_part1,feedback_list_part2])
def _format_feedback(self, response_items):
"""
Input:
Dictionary called feedback. Must contain keys seen below.
Output:
Return error message or feedback template
"""
feedback = self._convert_longform_feedback_to_html(response_items)
if not response_items['success']:
return self.system.render_template("open_ended_error.html",
{'errors' : feedback})
feedback_template = self.system.render_template("open_ended_feedback.html", {
'grader_type': response_items['grader_type'],
'score': "{0} / {1}".format(response_items['score'], self.max_score),
'feedback': feedback,
})
return feedback_template
def _parse_score_msg(self, score_msg):
"""
Grader reply is a JSON-dump of the following dict
{ 'correct': True/False,
'score': Numeric value (floating point is okay) to assign to answer
'msg': grader_msg
'feedback' : feedback from grader
}
Returns (valid_score_msg, correct, score, msg):
valid_score_msg: Flag indicating valid score_msg format (Boolean)
correct: Correctness of submission (Boolean)
score: Points to be assigned (numeric, can be float)
"""
fail = ScoreMessage(valid=False, correct=False, points=0, msg='')
try:
score_result = json.loads(score_msg)
except (TypeError, ValueError):
log.error("External grader message should be a JSON-serialized dict."
" Received score_msg = {0}".format(score_msg))
return fail
if not isinstance(score_result, dict):
log.error("External grader message should be a JSON-serialized dict."
" Received score_result = {0}".format(score_result))
return fail
for tag in ['score', 'feedback', 'grader_type', 'success', 'grader_id', 'submission_id']:
if tag not in score_result:
log.error("External grader message is missing required tag: {0}"
.format(tag))
return fail
feedback = self._format_feedback(score_result)
self.submission_id=score_result['submission_id']
self.grader_id=score_result['grader_id']
# HACK: for now, just assume it's correct if you got more than 2/3.
# Also assumes that score_result['score'] is an integer.
score_ratio = int(score_result['score']) / float(self.max_score)
correct = (score_ratio >= 0.66)
#Currently ignore msg and only return feedback (which takes the place of msg)
return ScoreMessage(valid=True, correct=correct,
points=score_result['score'], msg=feedback)
#-----------------------------------------------------------------------------
# TEMPORARY: List of all response subclasses
@@ -1810,4 +2286,5 @@ __all__ = [CodeResponse,
ChoiceResponse,
MultipleChoiceResponse,
TrueFalseResponse,
JavascriptResponse]
JavascriptResponse,
OpenEndedResponse]

View File

@@ -1,34 +1,28 @@
<section id="inputtype_${id}" class="capa_inputtype" >
<div id="holder" style="width:${width};height:${height}"></div>
<div class="crystalography_problem" style="width:${width};height:${height}"></div>
<div class="input_lattice">
Lattice: <select></select>
</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 status == 'unsubmitted':
<div class="unanswered" id="status_${id}">
<div class="unanswered" id="status_${id}">
% elif status == 'correct':
<div class="correct" id="status_${id}">
<div class="correct" id="status_${id}">
% elif status == 'incorrect':
<div class="incorrect" id="status_${id}">
<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}" />
<div class="incorrect" id="status_${id}">
% endif
<input type="text" name="input_${id}" id="input_${id}" value="${value|h}"
% if size:
size="${size}"
% endif
% if hidden:
style="display:none;"
% endif
/>
<p class="status">
<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':
@@ -38,14 +32,15 @@
% elif status == 'incomplete':
incomplete
% endif
</p>
</p>
<p id="answer_${id}" class="answer"></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
% if msg:
<span class="message">${msg|n}</span>
% endif
% if status in ['unsubmitted', 'correct', 'incorrect', 'incomplete']:
</div>
% endif
</section>

View File

@@ -0,0 +1,56 @@
<section id="openended_${id}" class="openended">
<textarea rows="${rows}" cols="${cols}" name="input_${id}" class="short-form-response" id="input_${id}"
% if hidden:
style="display:none;"
% endif
>${value|h}</textarea>
<div class="grader-status">
% if status == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
% elif status == 'correct':
<span class="correct" id="status_${id}">Correct</span>
% elif status == 'incorrect':
<span class="incorrect" id="status_${id}">Incorrect</span>
% elif status == 'queued':
<span class="grading" id="status_${id}">Submitted for grading</span>
% endif
% if hidden:
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
% endif
</div>
<span id="answer_${id}"></span>
% if status == 'queued':
<input name="reload" class="reload" type="button" value="Recheck for Feedback" onclick="document.location.reload(true);" />
% endif
<div class="external-grader-message">
${msg|n}
% if status in ['correct','incorrect']:
<div class="collapsible evaluation-response">
<header>
<a href="#">Respond to Feedback</a>
</header>
<section id="evaluation_${id}" class="evaluation">
<p>How accurate do you find this feedback?</p>
<div class="evaluation-scoring">
<ul class="scoring-list">
<li><input type="radio" name="evaluation-score" id="evaluation-score-5" value="5" /> <label for="evaluation-score-5"> Correct</label></li>
<li><input type="radio" name="evaluation-score" id="evaluation-score-4" value="4" /> <label for="evaluation-score-4"> Partially Correct</label></li>
<li><input type="radio" name="evaluation-score" id="evaluation-score-3" value="3" /> <label for="evaluation-score-3"> No Opinion</label></li>
<li><input type="radio" name="evaluation-score" id="evaluation-score-2" value="2" /> <label for="evaluation-score-2"> Partially Incorrect</label></li>
<li><input type="radio" name="evaluation-score" id="evaluation-score-1" value="1" /> <label for="evaluation-score-1"> Incorrect</label></li>
</ul>
</div>
<p>Additional comments:</p>
<textarea rows="${rows}" cols="${cols}" name="feedback_${id}" class="feedback-on-feedback" id="feedback_${id}"></textarea>
<div class="submit-message-container">
<input name="submit-message" class="submit-message" type="button" value="Submit your message"/>
</div>
</section>
</div>
% endif
</div>
</section>

View File

@@ -18,4 +18,23 @@ Hello</p></text>
<text><p>Use conservation of energy.</p></text>
</hintgroup>
</imageresponse>
<imageresponse max="1" loncapaid="12">
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98)" regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"/>
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" rectangle="(490,11)-(556,98)" regions='[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]'/>
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"/>
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"/>
<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" regions="[[[10,10], [20,10], [20, 30]]]"/>
<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" regions="[[10,10], [30,30], [15, 15]]"/>
<imageinput src="/static/Physics801/Figures/Skier-conservation of energy.jpg" width="560" height="388" regions="[[10,10], [30,30], [10, 30], [30, 10]]"/>
<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>

View File

@@ -407,13 +407,11 @@ class CrystallographyTest(unittest.TestCase):
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)
/>""".format(h=height, w=width)
element = etree.fromstring(xml_str)
@@ -428,9 +426,7 @@ class CrystallographyTest(unittest.TestCase):
expected = {'id': 'prob_1_2',
'value': value,
'status': 'unsubmitted',
'size': size,
'msg': '',
'hidden': '',
'width': width,
'height': height,
}

View File

@@ -52,24 +52,57 @@ class ImageResponseTest(unittest.TestCase):
def test_ir_grade(self):
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_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)',
# testing regions only
correct_answers = {
#regions
'1_2_1': '(490,11)-(556,98)',
'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)',
#testing regions and rectanges
'1_3_1': 'rectangle="(490,11)-(556,98)" \
regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
'1_3_2': 'rectangle="(490,11)-(556,98)" \
regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
'1_3_3': 'regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
'1_3_4': 'regions="[[[10,10], [20,10], [20, 30]], [[100,100], [120,100], [120,150]]]"',
'1_3_5': 'regions="[[[10,10], [20,10], [20, 30]]]"',
'1_3_6': 'regions="[[10,10], [30,30], [15, 15]]"',
'1_3_7': 'regions="[[10,10], [30,30], [10, 30], [30, 10]]"',
}
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]',
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]',
'1_3_1': '[500,20]',
'1_3_2': '[15,15]',
'1_3_3': '[500,20]',
'1_3_4': '[115,115]',
'1_3_5': '[15,15]',
'1_3_6': '[20,20]',
'1_3_7': '[20,15]',
}
# regions
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')
# regions and rectangles
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'correct')
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_2'), 'correct')
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_3'), 'incorrect')
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_4'), 'correct')
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_5'), 'correct')
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_6'), 'incorrect')
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_7'), 'correct')
class SymbolicResponseTest(unittest.TestCase):
def test_sr_grade(self):

View File

@@ -65,3 +65,25 @@ def is_file(file_to_test):
Duck typing to check if 'file_to_test' is a File object
'''
return all(hasattr(file_to_test, method) for method in ['read', 'name'])
def find_with_default(node, path, default):
"""
Look for a child of node using , and return its text if found.
Otherwise returns default.
Arguments:
node: lxml node
path: xpath search expression
default: value to return if nothing found
Returns:
node.find(path).text if the find succeeds, default otherwise.
"""
v = node.find(path)
if v is not None:
return v.text
else:
return default

View File

@@ -49,6 +49,7 @@ def parse_xreply(xreply):
return_code = xreply['return_code']
content = xreply['content']
return (return_code, content)
@@ -80,7 +81,11 @@ class XQueueInterface(object):
# Log in, then try again
if error and (msg == 'login_required'):
self._login()
(error, content) = self._login()
if error != 0:
# when the login fails
log.debug("Failed to login to queue: %s", content)
return (error, content)
if files_to_upload is not None:
# Need to rewind file pointers
for f in files_to_upload:

View File

@@ -45,7 +45,7 @@ def get_logger_config(log_dir,
logging_env=logging_env, hostname=hostname)
handlers = ['console', 'local'] if debug else ['console',
'syslogger-remote', 'local', 'newrelic']
'syslogger-remote', 'local']
logger_config = {
'version': 1,

View File

@@ -7,6 +7,7 @@ source = common/lib/xmodule
ignore_errors = True
[html]
title = XModule Python Test Coverage Report
directory = reports/common/lib/xmodule/cover
[xml]

View File

@@ -12,6 +12,7 @@
<script type="text/javascript" src="<%= common_coffee_root %>/logger.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/jquery.min.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/jasmine-jquery.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/jquery.cookie.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/CodeMirror/codemirror.js"></script>
<script type="text/javascript" src="<%= common_js_root %>/vendor/mathjax-MathJax-c9db6ac/MathJax.js"></script>

View File

@@ -145,6 +145,11 @@ class CapaModule(XModule):
else:
self.seed = None
# Need the problem location in openendedresponse to send out. Adding
# it to the system here seems like the least clunky way to get it
# there.
self.system.set('location', self.location.url())
try:
# TODO (vshnayder): move as much as possible of this work and error
# checking to descriptor load time
@@ -366,6 +371,7 @@ class CapaModule(XModule):
'problem_save': self.save_problem,
'problem_show': self.get_answer,
'score_update': self.update_score,
'message_post' : self.message_post,
}
if dispatch not in handlers:
@@ -380,6 +386,20 @@ class CapaModule(XModule):
})
return json.dumps(d, cls=ComplexEncoder)
def message_post(self, get):
"""
Posts a message from a form to an appropriate location
"""
event_info = dict()
event_info['state'] = self.lcp.get_state()
event_info['problem_id'] = self.location.url()
event_info['student_id'] = self.system.anonymous_student_id
event_info['survey_responses']= get
success, message = self.lcp.message_post(event_info)
return {'success' : success, 'message' : message}
def closed(self):
''' Is the student still allowed to submit answers? '''
if self.attempts == self.max_attempts:
@@ -650,11 +670,29 @@ class CapaDescriptor(RawDescriptor):
stores_state = True
has_score = True
template_dir_name = 'problem'
mako_template = "widgets/problem-edit.html"
js = {'coffee': [resource_string(__name__, 'js/src/problem/edit.coffee')]}
js_module_name = "MarkdownEditingDescriptor"
css = {'scss': [resource_string(__name__, 'css/problem/edit.scss')]}
# Capa modules have some additional metadata:
# TODO (vshnayder): do problems have any other metadata? Do they
# actually use type and points?
metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points')
def get_context(self):
_context = RawDescriptor.get_context(self)
_context.update({'markdown': self.metadata.get('markdown', '')})
return _context
@property
def editable_metadata_fields(self):
"""Remove metadata from the editable fields since it has its own editor"""
subset = super(CapaDescriptor,self).editable_metadata_fields
if 'markdown' in subset:
subset.remove('markdown')
return subset
# VS[compat]
# TODO (cpennington): Delete this method once all fall 2012 course are being

View File

@@ -6,6 +6,7 @@ from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor, SequenceModule
from xmodule.timeparse import parse_time, stringify_time
from xmodule.util.decorators import lazyproperty
from datetime import datetime
import json
import logging
import requests
@@ -18,6 +19,8 @@ log = logging.getLogger(__name__)
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True)
_cached_toc = {}
class CourseDescriptor(SequenceDescriptor):
module_class = SequenceModule
@@ -50,6 +53,24 @@ class CourseDescriptor(SequenceDescriptor):
"""
toc_url = self.book_url + 'toc.xml'
# cdodge: I've added this caching of TOC because in Mongo-backed instances (but not Filesystem stores)
# course modules have a very short lifespan and are constantly being created and torn down.
# Since this module in the __init__() method does a synchronous call to AWS to get the TOC
# this is causing a big performance problem. So let's be a bit smarter about this and cache
# each fetch and store in-mem for 10 minutes.
# NOTE: I have to get this onto sandbox ASAP as we're having runtime failures. I'd like to swing back and
# rewrite to use the traditional Django in-memory cache.
try:
# see if we already fetched this
if toc_url in _cached_toc:
(table_of_contents, timestamp) = _cached_toc[toc_url]
age = datetime.now() - timestamp
# expire every 10 minutes
if age.seconds < 600:
return table_of_contents
except Exception as err:
pass
# Get the table of contents from S3
log.info("Retrieving textbook table of contents from %s" % toc_url)
try:
@@ -62,6 +83,7 @@ class CourseDescriptor(SequenceDescriptor):
# TOC is XML. Parse it
try:
table_of_contents = etree.fromstring(r.text)
_cached_toc[toc_url] = (table_of_contents, datetime.now())
except Exception as err:
msg = 'Error %s: Unable to parse XML for textbook table of contents at %s' % (err, toc_url)
log.error(msg)
@@ -97,7 +119,6 @@ class CourseDescriptor(SequenceDescriptor):
# NOTE (THK): This is a last-minute addition for Fall 2012 launch to dynamically
# disable the syllabus content for courses that do not provide a syllabus
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
self.set_grading_policy(self.definition['data'].get('grading_policy', None))
def defaut_grading_policy(self):
@@ -186,7 +207,8 @@ class CourseDescriptor(SequenceDescriptor):
instance = super(CourseDescriptor, cls).from_xml(xml_data, system, org, course)
# bleh, have to parse the XML here to just pull out the url_name attribute
course_file = StringIO(xml_data)
# I don't think it's stored anywhere in the instance.
course_file = StringIO(xml_data.encode('ascii','ignore'))
xml_obj = etree.parse(course_file,parser=edx_xml_parser).getroot()
policy_dir = None
@@ -209,7 +231,7 @@ class CourseDescriptor(SequenceDescriptor):
instance.set_grading_policy(policy)
return instance
@classmethod
def definition_from_xml(cls, xml_object, system):
@@ -293,6 +315,10 @@ class CourseDescriptor(SequenceDescriptor):
self.definition['data'].setdefault('grading_policy',{})['GRADE_CUTOFFS'] = value
@property
def lowest_passing_grade(self):
return min(self._grading_policy['GRADE_CUTOFFS'].values())
@property
def tabs(self):
"""
@@ -395,7 +421,20 @@ class CourseDescriptor(SequenceDescriptor):
@property
def start_date_text(self):
displayed_start = self._try_parse_time('advertised_start') or self.start
parsed_advertised_start = self._try_parse_time('advertised_start')
# If the advertised start isn't a real date string, we assume it's free
# form text...
if parsed_advertised_start is None and \
('advertised_start' in self.metadata):
return self.metadata['advertised_start']
displayed_start = parsed_advertised_start or self.start
# If we have neither an advertised start or a real start, just return TBD
if not displayed_start:
return "TBD"
return time.strftime("%b %d, %Y", displayed_start)
@property
@@ -440,7 +479,7 @@ class CourseDescriptor(SequenceDescriptor):
return False
except:
log.exception("Error parsing discussion_blackouts for course {0}".format(self.id))
return True
@property

View File

@@ -121,16 +121,6 @@ section.problem {
}
}
&.processing {
p.status {
@include inline-block();
background: url('../images/spinner.gif') center center no-repeat;
height: 20px;
width: 20px;
text-indent: -9999px;
}
}
&.correct, &.ui-icon-check {
p.status {
@include inline-block();
@@ -250,6 +240,13 @@ section.problem {
}
}
.reload
{
float:right;
margin: 10px;
}
.grader-status {
padding: 9px;
background: #F6F6F6;
@@ -266,6 +263,13 @@ section.problem {
margin: -7px 7px 0 0;
}
.grading {
background: url('../images/info-icon.png') left center no-repeat;
padding-left: 25px;
text-indent: 0px;
margin: 0px 7px 0 0;
}
p {
line-height: 20px;
text-transform: capitalize;
@@ -293,6 +297,51 @@ section.problem {
float: left;
}
}
}
.evaluation {
p {
margin-bottom: 4px;
}
}
.feedback-on-feedback {
height: 100px;
margin-right: 20px;
}
.evaluation-response {
header {
text-align: right;
a {
font-size: .85em;
}
}
}
.evaluation-scoring {
.scoring-list {
list-style-type: none;
margin-left: 3px;
li {
&:first-child {
margin-left: 0px;
}
display:inline;
margin-left: 50px;
label {
font-size: .9em;
}
}
}
}
.submit-message-container {
margin: 10px 0px ;
}
}
@@ -630,6 +679,10 @@ section.problem {
color: #2C2C2C;
font-family: monospace;
font-size: 1em;
padding-top: 10px;
header {
font-size: 1.4em;
}
.shortform {
font-weight: bold;
@@ -685,6 +738,21 @@ section.problem {
color: #B00;
}
}
.markup-text{
margin: 5px;
padding: 20px 0px 15px 50px;
border-top: 1px solid #DDD;
border-left: 20px solid #FAFAFA;
bs {
color: #BB0000;
}
bg {
color: #BDA046;
}
}
}
}
}

View File

@@ -0,0 +1,153 @@
.editor-bar {
position: relative;
@include linear-gradient(top, #d4dee8, #c9d5e2);
padding: 5px;
border: 1px solid #3c3c3c;
border-radius: 3px 3px 0 0;
border-bottom-color: #a5aaaf;
@include clearfix;
a {
display: block;
float: left;
padding: 3px 10px 7px;
margin-left: 7px;
border-radius: 2px;
&:hover {
background: rgba(255, 255, 255, .5);
}
}
.editor-tabs {
position: absolute;
top: 10px;
right: 10px;
li {
float: left;
}
.tab {
height: 24px;
padding: 7px 20px 3px;
border: 1px solid #a5aaaf;
border-radius: 3px 3px 0 0;
@include linear-gradient(top, rgba(0, 0, 0, 0) 87%, rgba(0, 0, 0, .06));
background-color: #e5ecf3;
font-size: 13px;
color: #3c3c3c;
box-shadow: 1px -1px 1px rgba(0, 0, 0, .05);
&.current {
background: #fff;
border-bottom-color: #fff;
}
}
.cheatsheet-toggle {
width: 21px;
height: 21px;
padding: 0;
margin: 3px 5px 0 16px;
border-radius: 22px;
border: 1px solid #a5aaaf;
background: #e5ecf3;
font-size: 13px;
font-weight: 700;
color: #565d64;
text-align: center;
}
}
}
.simple-editor-cheatsheet {
position: absolute;
top: 0;
left: 100%;
width: 0;
border-radius: 0 3px 3px 0;
@include linear-gradient(left, rgba(0, 0, 0, .1), rgba(0, 0, 0, 0) 15px);
background-color: #fff;
overflow: hidden;
@include transition(width .3s);
&.shown {
width: 300px;
}
.cheatsheet-wrapper {
width: 240px;
padding: 20px 30px;
}
h6 {
margin-bottom: 7px;
font-size: 15px;
font-weight: 700;
}
.row {
@include clearfix;
padding-bottom: 5px !important;
margin-bottom: 10px !important;
border-bottom: 1px solid #ddd !important;
&:last-child {
border-bottom: none !important;
margin-bottom: 0 !important;
}
}
.col {
float: left;
&.sample {
width: 60px;
margin-right: 30px;
}
}
pre {
font-size: 12px;
line-height: 18px;
}
code {
padding: 0;
background: none;
}
}
.problem-editor-icon {
display: inline-block;
width: 26px;
height: 21px;
vertical-align: middle;
background: url(../img/problem-editor-icons.png) no-repeat;
}
.problem-editor-icon.multiple-choice {
background-position: 0 0;
}
.problem-editor-icon.checks {
background-position: -56px 0;
}
.problem-editor-icon.string {
width: 28px;
background-position: -111px 0;
}
.problem-editor-icon.number {
width: 24px;
background-position: -168px 0;
}
.problem-editor-icon.dropdown {
width: 17px;
background-position: -220px 0;
}

View File

@@ -149,14 +149,14 @@ class ErrorDescriptor(JSONEditingDescriptor):
'''
try:
xml = etree.fromstring(self.definition['data']['contents'])
return etree.tostring(xml)
return etree.tostring(xml, encoding='unicode')
except etree.XMLSyntaxError:
# still not valid.
root = etree.Element('error')
root.text = self.definition['data']['contents']
err_node = etree.SubElement(root, 'error_msg')
err_node.text = self.definition['data']['error_msg']
return etree.tostring(root)
return etree.tostring(root, encoding='unicode')
class NonStaffErrorDescriptor(ErrorDescriptor):

View File

@@ -262,7 +262,7 @@ class AssignmentFormatGrader(CourseGrader):
min_count = 2 would produce the labels "Assignment 3", "Assignment 4"
"""
def __init__(self, type, min_count, drop_count, category=None, section_type=None, short_label=None, show_only_average=False, starting_index=1):
def __init__(self, type, min_count, drop_count, category=None, section_type=None, short_label=None, show_only_average=False, hide_average=False, starting_index=1):
self.type = type
self.min_count = min_count
self.drop_count = drop_count
@@ -271,6 +271,7 @@ class AssignmentFormatGrader(CourseGrader):
self.short_label = short_label or self.type
self.show_only_average = show_only_average
self.starting_index = starting_index
self.hide_average = hide_average
def grade(self, grade_sheet, generate_random_scores=False):
def totalWithDrops(breakdown, drop_count):
@@ -331,7 +332,8 @@ class AssignmentFormatGrader(CourseGrader):
if self.show_only_average:
breakdown = []
breakdown.append({'percent': total_percent, 'label': total_label, 'detail': total_detail, 'category': self.category, 'prominent': True})
if not self.hide_average:
breakdown.append({'percent': total_percent, 'label': total_label, 'detail': total_detail, 'category': self.category, 'prominent': True})
return {'percent': total_percent,
'section_breakdown': breakdown,

View File

@@ -6,15 +6,14 @@ import sys
from lxml import etree
from path import path
from .x_module import XModule
from pkg_resources import resource_string
from .xml_module import XmlDescriptor, name_to_pathname
from .editing_module import EditingDescriptor
from .stringify import stringify_children
from .html_checker import check_html
from xmodule.modulestore import Location
from xmodule.contentstore.content import XASSET_SRCREF_PREFIX, StaticContent
from xmodule.editing_module import EditingDescriptor
from xmodule.html_checker import check_html
from xmodule.modulestore import Location
from xmodule.stringify import stringify_children
from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor, name_to_pathname
log = logging.getLogger("mitx.courseware")
@@ -121,7 +120,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
try:
with system.resources_fs.open(filepath) as file:
html = file.read()
html = file.read().decode('utf-8')
# Log a warning if we can't parse the file, but don't error
if not check_html(html):
msg = "Couldn't parse html in {0}.".format(filepath)
@@ -162,7 +161,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True)
with resource_fs.open(filepath, 'w') as file:
file.write(self.definition['data'])
file.write(self.definition['data'].encode('utf-8'))
# write out the relative name
relname = path(pathname).basename()

View File

@@ -70,7 +70,8 @@ describe 'Problem', ->
it 'bind the math input', ->
expect($('input.math')).toHandleWith 'keyup', @problem.refreshMath
it 'replace math content on the page', ->
# TODO: figure out why failing
xit 'replace math content on the page', ->
expect(MathJax.Hub.Queue.mostRecentCall.args).toEqual [
['Text', @stubbedJax, ''],
[@problem.updateMathML, @stubbedJax, $('#input_example_1').get(0)]
@@ -137,10 +138,11 @@ describe 'Problem', ->
@problem.check()
expect(@problem.el.html()).toEqual 'Incorrect!'
describe 'when the response is undetermined', ->
# TODO: figure out why failing
xdescribe 'when the response is undetermined', ->
it 'alert the response', ->
spyOn window, 'alert'
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) ->
callback(success: 'Number Only!')
@problem.check()
expect(window.alert).toHaveBeenCalledWith 'Number Only!'
@@ -262,7 +264,8 @@ describe 'Problem', ->
expect($.postWithPrefix).toHaveBeenCalledWith '/problem/Problem1/problem_save',
'foo=1&bar=2', jasmine.any(Function)
it 'alert to the user', ->
# TODO: figure out why failing
xit 'alert to the user', ->
spyOn window, 'alert'
spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> callback(success: 'OK')
@problem.save()
@@ -320,7 +323,8 @@ describe 'Problem', ->
@problem.refreshAnswers()
expect(@stubCodeMirror.save).toHaveBeenCalled()
it 'serialize all answers', ->
# TODO: figure out why failing
xit 'serialize all answers', ->
@problem.refreshAnswers()
expect(@problem.answers).toEqual "input_1_1=one&input_1_2=two"

View File

@@ -1,3 +1,13 @@
# Stub Youtube API
window.YT =
PlayerState:
UNSTARTED: -1
ENDED: 0
PLAYING: 1
PAUSED: 2
BUFFERING: 3
CUED: 5
jasmine.getFixtures().fixturesPath = 'xmodule/js/fixtures'
jasmine.stubbedMetadata =
@@ -56,16 +66,6 @@ jasmine.stubVideoPlayer = (context, enableParts, createPlayer=true) ->
spyOn(window, 'onunload')
# Stub Youtube API
window.YT =
PlayerState:
UNSTARTED: -1
ENDED: 0
PLAYING: 1
PAUSED: 2
BUFFERING: 3
CUED: 5
# Stub jQuery.cookie
$.cookie = jasmine.createSpy('jQuery.cookie').andReturn '1.0'

View File

@@ -0,0 +1,338 @@
describe 'MarkdownEditingDescriptor', ->
describe 'insertMultipleChoice', ->
it 'inserts the template if selection is empty', ->
revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('')
expect(revisedSelection).toEqual(MarkdownEditingDescriptor.multipleChoiceTemplate)
it 'wraps existing text', ->
revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('foo\nbar')
expect(revisedSelection).toEqual('( ) foo\n( ) bar\n')
it 'recognizes x as a selection if there is non-whitespace after x', ->
revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('a\nx b\nc\nx \nd\n x e')
expect(revisedSelection).toEqual('( ) a\n(x) b\n( ) c\n( ) x \n( ) d\n(x) e\n')
it 'recognizes x as a selection if it is first non whitespace and has whitespace with other non-whitespace', ->
revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice(' x correct\n x \nex post facto\nb x c\nx c\nxxp')
expect(revisedSelection).toEqual('(x) correct\n( ) x \n( ) ex post facto\n( ) b x c\n(x) c\n( ) xxp\n')
it 'removes multiple newlines but not last one', ->
revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice('a\nx b\n\n\nc\n')
expect(revisedSelection).toEqual('( ) a\n(x) b\n( ) c\n')
describe 'insertCheckboxChoice', ->
# Note, shares code with insertMultipleChoice. Therefore only doing smoke test.
it 'inserts the template if selection is empty', ->
revisedSelection = MarkdownEditingDescriptor.insertCheckboxChoice('')
expect(revisedSelection).toEqual(MarkdownEditingDescriptor.checkboxChoiceTemplate)
it 'wraps existing text', ->
revisedSelection = MarkdownEditingDescriptor.insertCheckboxChoice('foo\nbar')
expect(revisedSelection).toEqual('[ ] foo\n[ ] bar\n')
describe 'insertStringInput', ->
it 'inserts the template if selection is empty', ->
revisedSelection = MarkdownEditingDescriptor.insertStringInput('')
expect(revisedSelection).toEqual(MarkdownEditingDescriptor.stringInputTemplate)
it 'wraps existing text', ->
revisedSelection = MarkdownEditingDescriptor.insertStringInput('my text')
expect(revisedSelection).toEqual('= my text')
describe 'insertNumberInput', ->
it 'inserts the template if selection is empty', ->
revisedSelection = MarkdownEditingDescriptor.insertNumberInput('')
expect(revisedSelection).toEqual(MarkdownEditingDescriptor.numberInputTemplate)
it 'wraps existing text', ->
revisedSelection = MarkdownEditingDescriptor.insertNumberInput('my text')
expect(revisedSelection).toEqual('= my text')
describe 'insertSelect', ->
it 'inserts the template if selection is empty', ->
revisedSelection = MarkdownEditingDescriptor.insertSelect('')
expect(revisedSelection).toEqual(MarkdownEditingDescriptor.selectTemplate)
it 'wraps existing text', ->
revisedSelection = MarkdownEditingDescriptor.insertSelect('my text')
expect(revisedSelection).toEqual('[[my text]]')
describe 'markdownToXml', ->
it 'converts raw text to paragraph', ->
data = MarkdownEditingDescriptor.markdownToXml('foo')
expect(data).toEqual('<problem>\n<p>foo</p>\n</problem>')
# test default templates
it 'converts numerical response to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""A numerical response problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.
The answer is correct if it is within a specified numerical tolerance of the expected answer.
Enter the numerical value of Pi:
= 3.14159 +- .02
Enter the approximate value of 502*9:
= 4518 +- 15%
Enter the number of fingers on a human hand:
= 5
<solution>
<div class='detailed-solution'>
Explanation
Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.
Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.
If you look at your hand, you can count that you have five fingers.
</div>
</solution>
""")
expect(data).toEqual("""<problem>
<p>A numerical response problem accepts a line of text input from the student, and evaluates the input for correctness based on its numerical value.</p>
<p>The answer is correct if it is within a specified numerical tolerance of the expected answer.</p>
<p>Enter the numerical value of Pi:</p>
<numericalresponse answer="3.14159 ">
<responseparam type="tolerance" default=".02" />
<textline />
</numericalresponse>
<p>Enter the approximate value of 502*9:</p>
<numericalresponse answer="4518 ">
<responseparam type="tolerance" default="15%" />
<textline />
</numericalresponse>
<p>Enter the number of fingers on a human hand:</p>
<numericalresponse answer="5">
<textline />
</numericalresponse>
<solution>
<div class='detailed-solution'>
<p>Explanation</p>
<p>Pi, or the the ratio between a circle's circumference to its diameter, is an irrational number known to extreme precision. It is value is approximately equal to 3.14.</p>
<p>Although you can get an exact value by typing 502*9 into a calculator, the result will be close to 500*10, or 5,000. The grader accepts any response within 15% of the true value, 4518, so that you can use any estimation technique that you like.</p>
<p>If you look at your hand, you can count that you have five fingers.</p>
</div>
</solution>
</problem>""")
it 'converts multiple choice to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.
One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.
What Apple device competed with the portable CD player?
( ) The iPad
( ) Napster
(x) The iPod
( ) The vegetable peeler
( ) Android
( ) The Beatles
<solution>
<div class='detailed-solution'>
Explanation
The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.
</div>
</solution>
""")
expect(data).toEqual("""<problem>
<p>A multiple choice problem presents radio buttons for student input. Students can only select a single option presented. Multiple Choice questions have been the subject of many areas of research due to the early invention and adoption of bubble sheets.</p>
<p>One of the main elements that goes into a good multiple choice question is the existence of good distractors. That is, each of the alternate responses presented to the student should be the result of a plausible mistake that a student might make.</p>
<p>What Apple device competed with the portable CD player?</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
<choice correct="false">The iPad</choice>
<choice correct="false">Napster</choice>
<choice correct="true">The iPod</choice>
<choice correct="false">The vegetable peeler</choice>
<choice correct="false">Android</choice>
<choice correct="false">The Beatles</choice>
</choicegroup>
</multiplechoiceresponse>
<solution>
<div class='detailed-solution'>
<p>Explanation</p>
<p>The release of the iPod allowed consumers to carry their entire music library with them in a format that did not rely on fragile and energy-intensive spinning disks.</p>
</div>
</solution>
</problem>""")
it 'converts OptionResponse to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.
The answer options and the identification of the correct answer is defined in the <b>optioninput</b> tag.
Translation between Option Response and __________ is extremely straightforward:
[[(Multiple Choice), String Response, Numerical Response, External Response, Image Response]]
<solution>
<div class='detailed-solution'>
Explanation
Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.
</div>
</solution>
""")
expect(data).toEqual("""<problem>
<p>OptionResponse gives a limited set of options for students to respond with, and presents those options in a format that encourages them to search for a specific answer rather than being immediately presented with options from which to recognize the correct answer.</p>
<p>The answer options and the identification of the correct answer is defined in the <b>optioninput</b> tag.</p>
<p>Translation between Option Response and __________ is extremely straightforward:</p>
<optionresponse>
<optioninput options="('Multiple Choice','String Response','Numerical Response','External Response','Image Response')" correct="Multiple Choice"></optioninput>
</optionresponse>
<solution>
<div class='detailed-solution'>
<p>Explanation</p>
<p>Multiple Choice also allows students to select from a variety of pre-written responses, although the format makes it easier for students to read very long response options. Optionresponse also differs slightly because students are more likely to think of an answer and then search for it rather than relying purely on recognition to answer the question.</p>
</div>
</solution>
</problem>""")
it 'converts OptionResponse to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box.
The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.
Which US state has Lansing as its capital?
= Michigan
<solution>
<div class='detailed-solution'>
Explanation
Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides.
</div>
</solution>
""")
expect(data).toEqual("""<problem>
<p>A string response problem accepts a line of text input from the student, and evaluates the input for correctness based on an expected answer within each input box.</p>
<p>The answer is correct if it matches every character of the expected answer. This can be a problem with international spelling, dates, or anything where the format of the answer is not clear.</p>
<p>Which US state has Lansing as its capital?</p>
<stringresponse answer="Michigan" type="ci">
<textline size="20"/>
</stringresponse>
<solution>
<div class='detailed-solution'>
<p>Explanation</p>
<p>Lansing is the capital of Michigan, although it is not Michgan's largest city, or even the seat of the county in which it resides.</p>
</div>
</solution>
</problem>""")
# test oddities
it 'converts headers and oddities to xml', ->
data = MarkdownEditingDescriptor.markdownToXml("""Not a header
A header
==============
Multiple choice w/ parentheticals
( ) option (with parens)
( ) xd option (x)
()) parentheses inside
() no space b4 close paren
Choice checks
[ ] option1 [x]
[x] correct
[x] redundant
[(] distractor
[] no space
{{video abcd1s}}
Option with multiple correct ones
[[one option, (correct one), (should not be correct)]]
Option with embedded parens
[[My (heart), another, (correct)]]
What happens w/ empty correct options?
[[()]]
No p tags in the below
<script type='javascript'>
var two = 2;
console.log(two * 2);
</script>
But in this there should be
<div>
Great ideas require offsetting.
bad tests require drivel
</div>
""")
expect(data).toEqual("""<problem>
<p>Not a header</p>
<h1>A header</h1>
<p>Multiple choice w/ parentheticals</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoice">
<choice correct="false">option (with parens)</choice>
<choice correct="false">xd option (x)</choice>
<choice correct="false">parentheses inside</choice>
<choice correct="false">no space b4 close paren</choice>
</choicegroup>
</multiplechoiceresponse>
<p>Choice checks</p>
<multiplechoiceresponse>
<choicegroup type="MultipleChoiceChecks">
<choice correct="false">option1 [x]</choice>
<choice correct="true">correct</choice>
<choice correct="true">redundant</choice>
<choice correct="false">distractor</choice>
<choice correct="false">no space</choice>
</choicegroup>
</multiplechoiceresponse>
<video youtube="1.0:abcd1s" />
<p>Option with multiple correct ones</p>
<optionresponse>
<optioninput options="('one option','correct one','should not be correct')" correct="correct one"></optioninput>
</optionresponse>
<p>Option with embedded parens</p>
<optionresponse>
<optioninput options="('My (heart)','another','correct')" correct="correct"></optioninput>
</optionresponse>
<p>What happens w/ empty correct options?</p>
<optionresponse>
<optioninput options="('')" correct=""></optioninput>
</optionresponse>
<p>No p tags in the below</p>
<script type='javascript'>
var two = 2;
console.log(two * 2);
</script>
<p>But in this there should be</p>
<div>
<p>Great ideas require offsetting.</p>
<p>bad tests require drivel</p>
</div>
</problem>""")
# failure tests

View File

@@ -1,4 +1,5 @@
describe 'Sequence', ->
# TODO: figure out why failing
xdescribe 'Sequence', ->
beforeEach ->
# Stub MathJax
window.MathJax = { Hub: { Queue: -> } }

View File

@@ -1,4 +1,5 @@
describe 'VideoCaption', ->
# TODO: figure out why failing
xdescribe 'VideoCaption', ->
beforeEach ->
jasmine.stubVideoPlayer @
$('.subtitles').remove()

View File

@@ -1,4 +1,5 @@
describe 'VideoControl', ->
# TODO: figure out why failing
xdescribe 'VideoControl', ->
beforeEach ->
jasmine.stubVideoPlayer @
$('.video-controls').html ''

View File

@@ -1,4 +1,5 @@
describe 'VideoPlayer', ->
# TODO: figure out why failing
xdescribe 'VideoPlayer', ->
beforeEach ->
jasmine.stubVideoPlayer @, [], false

View File

@@ -1,4 +1,5 @@
describe 'VideoProgressSlider', ->
# TODO: figure out why failing
xdescribe 'VideoProgressSlider', ->
beforeEach ->
jasmine.stubVideoPlayer @

View File

@@ -1,4 +1,5 @@
describe 'VideoSpeedControl', ->
# TODO: figure out why failing
xdescribe 'VideoSpeedControl', ->
beforeEach ->
jasmine.stubVideoPlayer @
$('.speeds').remove()

View File

@@ -1,4 +1,5 @@
describe 'VideoVolumeControl', ->
# TODO: figure out why failing
xdescribe 'VideoVolumeControl', ->
beforeEach ->
jasmine.stubVideoPlayer @
$('.volume').remove()

View File

@@ -1,4 +1,5 @@
describe 'Video', ->
# TODO: figure out why failing
xdescribe 'Video', ->
beforeEach ->
loadFixtures 'video.html'
jasmine.stubRequests()

View File

@@ -25,6 +25,7 @@ class @Problem
@$('section.action input.reset').click @reset
@$('section.action input.show').click @show
@$('section.action input.save').click @save
@$('section.evaluation input.submit-message').click @message_post
# Collapsibles
Collapsible.setCollapsibles(@el)
@@ -197,6 +198,35 @@ class @Problem
else
@gentle_alert response.success
message_post: =>
Logger.log 'message_post', @answers
fd = new FormData()
feedback = @$('section.evaluation textarea.feedback-on-feedback')[0].value
submission_id = $('div.external-grader-message div.submission_id')[0].innerHTML
grader_id = $('div.external-grader-message div.grader_id')[0].innerHTML
score = $(".evaluation-scoring input:radio[name='evaluation-score']:checked").val()
fd.append('feedback', feedback)
fd.append('submission_id', submission_id)
fd.append('grader_id', grader_id)
if(!score)
@gentle_alert "You need to pick a rating before you can submit."
return
else
fd.append('score', score)
settings =
type: "POST"
data: fd
processData: false
contentType: false
success: (response) =>
@gentle_alert response.message
@$('section.evaluation').slideToggle()
$.ajaxWithPrefix("#{@url}/message_post", settings)
reset: =>
Logger.log 'problem_reset', @answers
$.postWithPrefix "#{@url}/problem_reset", id: @id, (response) =>

View File

@@ -1953,7 +1953,7 @@ cktsim = (function() {
var module = {
'Circuit': Circuit,
'parse_number': parse_number,
'parse_source': parse_source,
'parse_source': parse_source
}
return module;
}());
@@ -2068,7 +2068,7 @@ schematic = (function() {
'n': [NFet, 'NFet'],
'p': [PFet, 'PFet'],
's': [Probe, 'Voltage Probe'],
'a': [Ammeter, 'Current Probe'],
'a': [Ammeter, 'Current Probe']
};
// global clipboard
@@ -5502,7 +5502,7 @@ schematic = (function() {
'magenta' : 'rgb(255,64,255)',
'yellow': 'rgb(255,255,64)',
'black': 'rgb(0,0,0)',
'x-axis': undefined,
'x-axis': undefined
};
function Probe(x,y,rotation,color,offset) {
@@ -6100,7 +6100,7 @@ schematic = (function() {
'Amplitude',
'Frequency (Hz)',
'Delay until sin starts (secs)',
'Phase offset (degrees)'],
'Phase offset (degrees)']
}
// build property editor div
@@ -6300,7 +6300,7 @@ schematic = (function() {
var module = {
'Schematic': Schematic,
'component_slider': component_slider,
'component_slider': component_slider
}
return module;
}());

View File

@@ -0,0 +1,289 @@
class @MarkdownEditingDescriptor extends XModule.Descriptor
@multipleChoiceTemplate : "( ) incorrect\n( ) incorrect\n(x) correct\n"
@checkboxChoiceTemplate: "[x] correct\n[ ] incorrect\n[x] correct\n"
@stringInputTemplate: "= answer\n"
@numberInputTemplate: "= answer +- x%\n"
@selectTemplate: "[[incorrect, (correct), incorrect]]\n"
constructor: (element) ->
@element = element
if $(".markdown-box", @element).length != 0
@markdown_editor = CodeMirror.fromTextArea($(".markdown-box", element)[0], {
lineWrapping: true
mode: null
})
@setCurrentEditor(@markdown_editor)
# Add listeners for toolbar buttons (only present for markdown editor)
@element.on('click', '.xml-tab', @onShowXMLButton)
@element.on('click', '.format-buttons a', @onToolbarButton)
@element.on('click', '.cheatsheet-toggle', @toggleCheatsheet)
# Hide the XML text area
$(@element.find('.xml-box')).hide()
else
@createXMLEditor()
###
Creates the XML Editor and sets it as the current editor. If text is passed in,
it will replace the text present in the HTML template.
text: optional argument to override the text passed in via the HTML template
###
createXMLEditor: (text) ->
@xml_editor = CodeMirror.fromTextArea($(".xml-box", @element)[0], {
mode: "xml"
lineNumbers: true
lineWrapping: true
})
if text
@xml_editor.setValue(text)
@setCurrentEditor(@xml_editor)
###
User has clicked to show the XML editor. Before XML editor is swapped in,
the user will need to confirm the one-way conversion.
###
onShowXMLButton: (e) =>
e.preventDefault();
if @confirmConversionToXml()
@createXMLEditor(MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue()))
# Need to refresh to get line numbers to display properly (and put cursor position to 0)
@xml_editor.setCursor(0)
@xml_editor.refresh()
# Hide markdown-specific toolbar buttons
$(@element.find('.editor-bar')).hide()
###
Have the user confirm the one-way conversion to XML.
Returns true if the user clicked OK, else false.
###
confirmConversionToXml: ->
# TODO: use something besides a JavaScript confirm dialog?
return confirm("If you convert to the XML source representation, which is used by the Advanced Editor, you cannot go back to using the Simple Editor.\n\nProceed with conversion to XML?")
###
Event listener for toolbar buttons (only possible when markdown editor is visible).
###
onToolbarButton: (e) =>
e.preventDefault();
selection = @markdown_editor.getSelection()
revisedSelection = null
switch $(e.currentTarget).attr('class')
when "multiple-choice-button" then revisedSelection = MarkdownEditingDescriptor.insertMultipleChoice(selection)
when "string-button" then revisedSelection = MarkdownEditingDescriptor.insertStringInput(selection)
when "number-button" then revisedSelection = MarkdownEditingDescriptor.insertNumberInput(selection)
when "checks-button" then revisedSelection = MarkdownEditingDescriptor.insertCheckboxChoice(selection)
when "dropdown-button" then revisedSelection = MarkdownEditingDescriptor.insertSelect(selection)
else # ignore click
if revisedSelection != null
@markdown_editor.replaceSelection(revisedSelection)
@markdown_editor.focus()
###
Event listener for toggling cheatsheet (only possible when markdown editor is visible).
###
toggleCheatsheet: (e) =>
e.preventDefault();
if !$(@markdown_editor.getWrapperElement()).find('.simple-editor-cheatsheet')[0]
@cheatsheet = $($('#simple-editor-cheatsheet').html())
$(@markdown_editor.getWrapperElement()).append(@cheatsheet)
setTimeout (=> @cheatsheet.toggleClass('shown')), 10
###
Stores the current editor and hides the one that is not displayed.
###
setCurrentEditor: (editor) ->
if @current_editor
$(@current_editor.getWrapperElement()).hide()
@current_editor = editor
$(@current_editor.getWrapperElement()).show()
$(@current_editor).focus();
###
Called when save is called. Listeners are unregistered because editing the block again will
result in a new instance of the descriptor. Note that this is NOT the case for cancel--
when cancel is called the instance of the descriptor is reused if edit is selected again.
###
save: ->
@element.off('click', '.xml-tab', @changeEditor)
@element.off('click', '.format-buttons a', @onToolbarButton)
@element.off('click', '.cheatsheet-toggle', @toggleCheatsheet)
if @current_editor == @markdown_editor
{
data: MarkdownEditingDescriptor.markdownToXml(@markdown_editor.getValue())
metadata:
markdown: @markdown_editor.getValue()
}
else
{
data: @xml_editor.getValue()
metadata:
markdown: null
}
@insertMultipleChoice: (selectedText) ->
return MarkdownEditingDescriptor.insertGenericChoice(selectedText, '(', ')', MarkdownEditingDescriptor.multipleChoiceTemplate)
@insertCheckboxChoice: (selectedText) ->
return MarkdownEditingDescriptor.insertGenericChoice(selectedText, '[', ']', MarkdownEditingDescriptor.checkboxChoiceTemplate)
@insertGenericChoice: (selectedText, choiceStart, choiceEnd, template) ->
if selectedText.length > 0
# Replace adjacent newlines with a single newline, strip any trailing newline
cleanSelectedText = selectedText.replace(/\n+/g, '\n').replace(/\n$/,'')
lines = cleanSelectedText.split('\n')
revisedLines = ''
for line in lines
revisedLines += choiceStart
# a stand alone x before other text implies that this option is "correct"
if /^\s*x\s+(\S)/i.test(line)
# Remove the x and any initial whitespace as long as there's more text on the line
line = line.replace(/^\s*x\s+(\S)/i, '$1')
revisedLines += 'x'
else
revisedLines += ' '
revisedLines += choiceEnd + ' ' + line + '\n'
return revisedLines
else
return template
@insertStringInput: (selectedText) ->
return MarkdownEditingDescriptor.insertGenericInput(selectedText, '= ', '', MarkdownEditingDescriptor.stringInputTemplate)
@insertNumberInput: (selectedText) ->
return MarkdownEditingDescriptor.insertGenericInput(selectedText, '= ', '', MarkdownEditingDescriptor.numberInputTemplate)
@insertSelect: (selectedText) ->
return MarkdownEditingDescriptor.insertGenericInput(selectedText, '[[', ']]', MarkdownEditingDescriptor.selectTemplate)
@insertGenericInput: (selectedText, lineStart, lineEnd, template) ->
if selectedText.length > 0
# TODO: should this insert a newline afterwards?
return lineStart + selectedText + lineEnd
else
return template
# We may wish to add insertHeader and insertVideo. Here is Tom's code.
# function makeHeader() {
# var selection = simpleEditor.getSelection();
# var revisedSelection = selection + '\n';
# for(var i = 0; i < selection.length; i++) {
#revisedSelection += '=';
# }
# simpleEditor.replaceSelection(revisedSelection);
#}
#
#function makeVideo() {
#var selection = simpleEditor.getSelection();
#simpleEditor.replaceSelection('{{video ' + selection + '}}');
#}
#
@markdownToXml: (markdown)->
toXml = `function(markdown) {
var xml = markdown;
// replace headers
xml = xml.replace(/(^.*?$)(?=\n\=\=+$)/gm, '<h1>$1</h1>');
xml = xml.replace(/\n^\=\=+$/gm, '');
// group multiple choice answers
xml = xml.replace(/(^\s*\(.?\).*?$\n*)+/gm, function(match, p) {
var groupString = '<multiplechoiceresponse>\n';
groupString += ' <choicegroup type="MultipleChoice">\n';
var options = match.split('\n');
for(var i = 0; i < options.length; i++) {
if(options[i].length > 0) {
var value = options[i].split(/^\s*\(.?\)\s*/)[1];
var correct = /^\s*\(x\)/i.test(options[i]);
groupString += ' <choice correct="' + correct + '">' + value + '</choice>\n';
}
}
groupString += ' </choicegroup>\n';
groupString += '</multiplechoiceresponse>\n\n';
return groupString;
});
// group check answers
xml = xml.replace(/(^\s*\[.?\].*?$\n*)+/gm, function(match, p) {
var groupString = '<multiplechoiceresponse>\n';
groupString += ' <choicegroup type="MultipleChoiceChecks">\n';
var options = match.split('\n');
for(var i = 0; i < options.length; i++) {
if(options[i].length > 0) {
var value = options[i].split(/^\s*\[.?\]\s*/)[1];
var correct = /^\s*\[x\]/i.test(options[i]);
groupString += ' <choice correct="' + correct + '">' + value + '</choice>\n';
}
}
groupString += ' </choicegroup>\n';
groupString += '</multiplechoiceresponse>\n\n';
return groupString;
});
// replace videos
xml = xml.replace(/\{\{video\s(.*?)\}\}/g, '<video youtube="1.0:$1" />\n\n');
// replace string and numerical
xml = xml.replace(/^\=\s*(.*?$)/gm, function(match, p) {
var string;
var params = /(.*?)\+\-\s*(.*?$)/.exec(p);
if(parseFloat(p)) {
if(params) {
string = '<numericalresponse answer="' + params[1] + '">\n';
string += ' <responseparam type="tolerance" default="' + params[2] + '" />\n';
} else {
string = '<numericalresponse answer="' + p + '">\n';
}
string += ' <textline />\n';
string += '</numericalresponse>\n\n';
} else {
string = '<stringresponse answer="' + p + '" type="ci">\n <textline size="20"/>\n</stringresponse>\n\n';
}
return string;
});
// replace selects
xml = xml.replace(/\[\[(.+?)\]\]/g, function(match, p) {
var selectString = '\n<optionresponse>\n';
selectString += ' <optioninput options="(';
var options = p.split(/\,\s*/g);
for(var i = 0; i < options.length; i++) {
selectString += "'" + options[i].replace(/(?:^|,)\s*\((.*?)\)\s*(?:$|,)/g, '$1') + "'" + (i < options.length -1 ? ',' : '');
}
selectString += ')" correct="';
var correct = /(?:^|,)\s*\((.*?)\)\s*(?:$|,)/g.exec(p);
if (correct) selectString += correct[1];
selectString += '"></optioninput>\n';
selectString += '</optionresponse>\n\n';
return selectString;
});
// split scripts and wrap paragraphs
var splits = xml.split(/(\<\/?script.*?\>)/g);
var scriptFlag = false;
for(var i = 0; i < splits.length; i++) {
if(/\<script/.test(splits[i])) {
scriptFlag = true;
}
if(!scriptFlag) {
splits[i] = splits[i].replace(/(^(?!\s*\<|$).*$)/gm, '<p>$1</p>');
}
if(/\<\/script/.test(splits[i])) {
scriptFlag = false;
}
}
xml = splits.join('');
// rid white space
xml = xml.replace(/\n\n\n/g, '\n');
// surround w/ problem tag
xml = '<problem>\n' + xml + '\n</problem>';
return xml;
}
`
return toXml markdown

View File

@@ -120,7 +120,7 @@ class @SelfAssessment
if @state == 'done'
$.postWithPrefix "#{@ajax_url}/reset", {}, (response) =>
if response.success
@answer_area.html('')
@answer_area.val('')
@rubric_wrapper.html('')
@hint_wrapper.html('')
@message_wrapper.html('')

View File

@@ -2,6 +2,8 @@ class @Video
constructor: (element) ->
@el = $(element).find('.video')
@id = @el.attr('id').replace(/video_/, '')
@start = @el.data('start')
@end = @el.data('end')
@caption_data_dir = @el.data('caption-data-dir')
@caption_asset_path = @el.data('caption-asset-path')
@show_captions = @el.data('show-captions') == "true"

View File

@@ -36,14 +36,21 @@ class @VideoPlayer extends Subview
@volumeControl = new VideoVolumeControl el: @$('.secondary-controls')
@speedControl = new VideoSpeedControl el: @$('.secondary-controls'), speeds: @video.speeds, currentSpeed: @currentSpeed()
@progressSlider = new VideoProgressSlider el: @$('.slider')
@playerVars =
controls: 0
wmode: 'transparent'
rel: 0
showinfo: 0
enablejsapi: 1
modestbranding: 1
if @video.start
@playerVars.start = @video.start
if @video.end
# work in AS3, not HMLT5. but iframe use AS3
@playerVars.end = @video.end
@player = new YT.Player @video.id,
playerVars:
controls: 0
wmode: 'transparent'
rel: 0
showinfo: 0
enablejsapi: 1
modestbranding: 1
playerVars: @playerVars
videoId: @video.youtubeId()
events:
onReady: @onReady

View File

@@ -284,7 +284,7 @@ class ModuleStore(object):
"""
raise NotImplementedError
def get_items(self, location, depth=0):
def get_items(self, location, course_id=None, depth=0):
"""
Returns a list of XModuleDescriptor instances for the items
that match location. Any element of location that is None is treated
@@ -352,6 +352,18 @@ class ModuleStore(object):
course_filter = Location("i4x", category="course")
return self.get_items(course_filter)
def get_course(self, course_id):
'''
Look for a specific course id. Returns the course descriptor, or None if not found.
'''
raise NotImplementedError
def get_course(self, course_id):
'''
Look for a specific course id. Returns the course descriptor, or None if not found.
'''
raise NotImplementedError
def get_parent_locations(self, location):
'''Find all locations that are the parents of this location. Needed
for path_to_location().
@@ -413,3 +425,10 @@ class ModuleStoreBase(ModuleStore):
errorlog = self._get_errorlog(location)
return errorlog.errors
def get_course(self, course_id):
"""Default impl--linear search through course list"""
for c in self.get_courses():
if c.id == course_id:
return c
return None

View File

@@ -262,7 +262,7 @@ class MongoModuleStore(ModuleStoreBase):
"""
return self.get_item(location)
def get_items(self, location, depth=0):
def get_items(self, location, course_id=None, depth=0):
items = self.collection.find(
location_to_query(location),
sort=[('revision', pymongo.ASCENDING)],

View File

@@ -157,7 +157,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
make_name_unique(xml_data)
descriptor = XModuleDescriptor.load_from_xml(
etree.tostring(xml_data), self, self.org,
etree.tostring(xml_data, encoding='unicode'), self, self.org,
self.course, xmlstore.default_class)
except Exception as err:
print err, self.load_error_modules
@@ -419,7 +419,7 @@ class XMLModuleStore(ModuleStoreBase):
self.load_error_modules,
)
course_descriptor = system.process_xml(etree.tostring(course_data))
course_descriptor = system.process_xml(etree.tostring(course_data, encoding='unicode'))
# NOTE: The descriptors end up loading somewhat bottom up, which
# breaks metadata inheritance via get_children(). Instead
@@ -513,6 +513,24 @@ class XMLModuleStore(ModuleStoreBase):
raise NotImplementedError("XMLModuleStores can't guarantee that definitions"
" are unique. Use get_instance.")
def get_items(self, location, course_id=None, depth=0):
items = []
def _add_get_items(self, location, modules):
for mod_loc, module in modules.iteritems():
# Locations match if each value in `location` is None or if the value from `location`
# matches the value from `mod_loc`
if all(goal is None or goal == value for goal, value in zip(location, mod_loc)):
items.append(module)
if course_id is None:
for _, modules in self.modules.iteritems():
_add_get_items(self, location, modules)
else:
_add_get_items(self, location, self.modules[course_id])
return items
def get_courses(self, depth=0):
"""

View File

@@ -120,47 +120,16 @@ def import_from_xml(store, data_dir, course_dirs=None,
course_data_path = None
course_location = None
# Quick scan to get course Location as well as the course_data_path
# Quick scan to get course module as we need some info from there. Also we need to make sure that the
# course module is committed first into the store
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:
_namespace_rename = target_location_namespace if target_location_namespace is not None else module_store.modules[course_id].location
# first pass to find everything in /static/
import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store,
_namespace_rename, subpath='static')
module = remap_namespace(module, target_location_namespace)
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
@@ -174,12 +143,41 @@ def import_from_xml(store, data_dir, course_dirs=None,
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
store.update_item(module.location, module.definition['data'])
if 'children' in module.definition:
store.update_children(module.location, module.definition['children'])
store.update_metadata(module.location, dict(module.own_metadata))
# 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)
# then import all the static content
if static_content_store is not None:
_namespace_rename = target_location_namespace if target_location_namespace is not None else module_store.modules[course_id].location
# first pass to find everything in /static/
import_static_content(module_store.modules[course_id], course_location, course_data_path, static_content_store,
_namespace_rename, subpath='static')
# finally loop through all the modules
for module in module_store.modules[course_id].itervalues():
if module.category == 'course':
# we've already saved the course module up at the top of the loop
# so just skip over it in the inner loop
continue
# remap module to the new namespace
if target_location_namespace is not None:
module = remap_namespace(module, target_location_namespace)
if 'data' in module.definition:
module_data = module.definition['data']
@@ -216,6 +214,33 @@ def import_from_xml(store, data_dir, course_dirs=None,
return module_store, course_items
def remap_namespace(module, target_location_namespace):
if target_location_namespace is None:
return module
# 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
return module
def validate_category_hierarcy(module_store, course_id, parent_category, expected_child_category):
err_cnt = 0

View File

@@ -13,7 +13,7 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor):
"""
@classmethod
def definition_from_xml(cls, xml_object, system):
return {'data': etree.tostring(xml_object, pretty_print=True)}
return {'data': etree.tostring(xml_object, pretty_print=True,encoding='unicode')}
def definition_to_xml(self, resource_fs):
try:

View File

@@ -7,20 +7,21 @@ Parses xml definition file--see below for exact format.
import copy
from fs.errors import ResourceNotFoundError
import itertools
import json
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
import os
import sys
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 progress import Progress
from .stringify import stringify_children
from .x_module import XModule
from .xml_module import XmlDescriptor
@@ -52,6 +53,8 @@ class SelfAssessmentModule(XModule):
submissions too.)
"""
STATE_VERSION = 1
# states
INITIAL = 'initial'
ASSESSING = 'assessing'
@@ -102,35 +105,130 @@ class SelfAssessmentModule(XModule):
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', [])
instance_state = self.convert_state_to_current_format(instance_state)
# History is a list of tuples of (answer, score, hint), where hint may be
# None for any element, and score and hint can be None for the last (current)
# element.
# Scores are on scale from 0 to max_score
self.history = instance_state.get('history', [])
self.state = instance_state.get('state', 'initial')
self.attempts = instance_state.get('attempts', 0)
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
# 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 latest_answer(self):
"""None if not available"""
if not self.history:
return None
return self.history[-1].get('answer')
def latest_score(self):
"""None if not available"""
if not self.history:
return None
return self.history[-1].get('score')
def latest_hint(self):
"""None if not available"""
if not self.history:
return None
return self.history[-1].get('hint')
def new_history_entry(self, answer):
self.history.append({'answer': answer})
def record_latest_score(self, score):
"""Assumes that state is right, so we're adding a score to the latest
history element"""
self.history[-1]['score'] = score
def record_latest_hint(self, hint):
"""Assumes that state is right, so we're adding a score to the latest
history element"""
self.history[-1]['hint'] = hint
def change_state(self, new_state):
"""
A centralized place for state changes--allows for hooks. If the
current state matches the old state, don't run any hooks.
"""
if self.state == new_state:
return
self.state = new_state
if self.state == self.DONE:
self.attempts += 1
@staticmethod
def convert_state_to_current_format(old_state):
"""
This module used to use a problematic state representation. This method
converts that into the new format.
Args:
old_state: dict of state, as passed in. May be old.
Returns:
new_state: dict of new state
"""
if old_state.get('version', 0) == SelfAssessmentModule.STATE_VERSION:
# already current
return old_state
# for now, there's only one older format.
new_state = {'version': SelfAssessmentModule.STATE_VERSION}
def copy_if_present(key):
if key in old_state:
new_state[key] = old_state[key]
for to_copy in ['attempts', 'state']:
copy_if_present(to_copy)
# The answers, scores, and hints need to be kept together to avoid them
# getting out of sync.
# NOTE: Since there's only one problem with a few hundred submissions
# in production so far, not trying to be smart about matching up hints
# and submissions in cases where they got out of sync.
student_answers = old_state.get('student_answers', [])
scores = old_state.get('scores', [])
hints = old_state.get('hints', [])
new_state['history'] = [
{'answer': answer,
'score': score,
'hint': hint}
for answer, score, hint in itertools.izip_longest(
student_answers, scores, hints)]
return new_state
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]
if self.state != self.INITIAL:
latest = self.latest_answer()
previous_answer = latest if latest is not None else ''
else:
previous_answer = ''
@@ -149,26 +247,19 @@ class SelfAssessmentModule(XModule):
# 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):
def get_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
score = self.latest_score()
return {'score': score if score is not None else 0,
'total': self._max_score}
def get_progress(self):
'''
@@ -176,7 +267,7 @@ class SelfAssessmentModule(XModule):
'''
if self._max_score > 0:
try:
return Progress(self.get_last_score(), self._max_score)
return Progress(self.get_score()['score'], self._max_score)
except Exception as err:
log.exception("Got bad progress")
return None
@@ -250,9 +341,10 @@ class SelfAssessmentModule(XModule):
if self.state in (self.INITIAL, self.ASSESSING):
return ''
if self.state == self.DONE and len(self.hints) > 0:
if self.state == self.DONE:
# display the previous hint
hint = self.hints[-1]
latest = self.latest_hint()
hint = latest if latest is not None else ''
else:
hint = ''
@@ -281,6 +373,14 @@ class SelfAssessmentModule(XModule):
def save_answer(self, get):
"""
After the answer is submitted, show the rubric.
Args:
get: the GET dictionary passed to the ajax request. Should contain
a key 'student_answer'
Returns:
Dictionary with keys 'success' and either 'error' (if not success),
or 'rubric_html' (if success).
"""
# Check to see if attempts are less than max
if self.attempts > self.max_attempts:
@@ -295,8 +395,9 @@ class SelfAssessmentModule(XModule):
if self.state != self.INITIAL:
return self.out_of_sync_error(get)
self.student_answers.append(get['student_answer'])
self.state = self.ASSESSING
# add new history element with answer and empty score and hint.
self.new_history_entry(get['student_answer'])
self.change_state(self.ASSESSING)
return {
'success': True,
@@ -318,27 +419,24 @@ class SelfAssessmentModule(XModule):
'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)
if self.state != self.ASSESSING:
return self.out_of_sync_error(get)
try:
score = int(get['assessment'])
except:
except ValueError:
return {'success': False, 'error': "Non-integer score value"}
self.scores.append(score)
self.record_latest_score(score)
d = {'success': True,}
if score == self.max_score():
self.state = self.DONE
self.change_state(self.DONE)
d['message_html'] = self.get_message_html()
d['allow_reset'] = self._allow_reset()
else:
self.state = self.REQUEST_HINT
self.change_state(self.REQUEST_HINT)
d['hint_html'] = self.get_hint_html()
d['state'] = self.state
@@ -360,19 +458,15 @@ class SelfAssessmentModule(XModule):
# 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
self.record_latest_hint(get['hint'])
self.change_state(self.DONE)
# To the tracking logs!
event_info = {
'selfassessment_id': self.location.url(),
'state': {
'student_answers': self.student_answers,
'score': self.scores,
'hints': self.hints,
'version': self.STATE_VERSION,
'history': self.history,
}
}
self.system.track_function('save_hint', event_info)
@@ -397,7 +491,7 @@ class SelfAssessmentModule(XModule):
'success': False,
'error': 'Too many attempts.'
}
self.state = self.INITIAL
self.change_state(self.INITIAL)
return {'success': True}
@@ -407,12 +501,11 @@ class SelfAssessmentModule(XModule):
"""
state = {
'student_answers': self.student_answers,
'hints': self.hints,
'version': self.STATE_VERSION,
'history': self.history,
'state': self.state,
'scores': self.scores,
'max_score': self._max_score,
'attempts': self.attempts
'attempts': self.attempts,
}
return json.dumps(state)

View File

@@ -10,7 +10,7 @@ from xmodule.progress import Progress
from xmodule.exceptions import NotFoundError
from pkg_resources import resource_string
log = logging.getLogger("mitx.common.lib.seq_module")
log = logging.getLogger(__name__)
# HACK: This shouldn't be hard-coded to two types
# OBSOLETE: This obsoletes 'type'
@@ -126,8 +126,8 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor):
children = []
for child in xml_object:
try:
children.append(system.process_xml(etree.tostring(child)).location.url())
except Exception, e:
children.append(system.process_xml(etree.tostring(child, encoding='unicode')).location.url())
except Exception as e:
log.exception("Unable to load child when parsing Sequence. Continuing...")
if system.error_tracker is not None:
system.error_tracker("ERROR: " + str(e))

View File

@@ -22,7 +22,7 @@ def stringify_children(node):
# next element.
parts = [node.text]
for c in node.getchildren():
parts.append(etree.tostring(c, with_tail=True))
parts.append(etree.tostring(c, with_tail=True, encoding='unicode'))
# filter removes possible Nones in texts and tails
return ''.join(filter(None, parts))
return u''.join(filter(None, parts))

Some files were not shown because too many files have changed in this diff Show More