studio - alerts: resolving local master merge conflcits

This commit is contained in:
Brian Talbot
2013-03-18 10:58:16 -04:00
287 changed files with 7924 additions and 4448 deletions

View File

@@ -76,7 +76,7 @@ class TestCohorts(django.test.TestCase):
"id": to_id(name)})
for name in discussions)
course.metadata["discussion_topics"] = topics
course.discussion_topics = topics
d = {"cohorted": cohorted}
if cohorted_discussions is not None:
@@ -88,7 +88,7 @@ class TestCohorts(django.test.TestCase):
if auto_cohort_groups is not None:
d["auto_cohort_groups"] = auto_cohort_groups
course.metadata["cohort_config"] = d
course.cohort_config = d
def setUp(self):

View File

@@ -4,7 +4,7 @@ import os
from django.test.utils import override_settings
from tempfile import NamedTemporaryFile
from status import get_site_status_msg
from .status import get_site_status_msg
# Get a name where we can put test files
TMP_FILE = NamedTemporaryFile(delete=False)

View File

@@ -44,9 +44,8 @@ from collections import namedtuple
from courseware.courses import get_courses, sort_by_announcement
from courseware.access import has_access
from courseware.models import StudentModuleCache
from courseware.views import get_module_for_descriptor, jump_to
from courseware.module_render import get_instance_module
from courseware.model_data import ModelDataCache
from statsd import statsd
@@ -318,7 +317,7 @@ def change_enrollment(request):
if not has_access(user, course, 'enroll'):
return {'success': False,
'error': 'enrollment in {} not allowed at this time'
.format(course.display_name)}
.format(course.display_name_with_default)}
org, course_num, run = course_id.split("/")
statsd.increment("common.student.enrollment",
@@ -1071,14 +1070,14 @@ def accept_name_change(request):
@csrf_exempt
def test_center_login(request):
# errors are returned by navigating to the error_url, adding a query parameter named "code"
# errors are returned by navigating to the error_url, adding a query parameter named "code"
# which contains the error code describing the exceptional condition.
def makeErrorURL(error_url, error_code):
log.error("generating error URL with error code {}".format(error_code))
return "{}?code={}".format(error_url, error_code);
# get provided error URL, which will be used as a known prefix for returning error messages to the
# Pearson shell.
# Pearson shell.
error_url = request.POST.get("errorURL")
# TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson
@@ -1089,12 +1088,12 @@ def test_center_login(request):
# calculate SHA for query string
# TODO: figure out how to get the original query string, so we can hash it and compare.
if 'clientCandidateID' not in request.POST:
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID"));
client_candidate_id = request.POST.get("clientCandidateID")
# TODO: check remaining parameters, and maybe at least log if they're not matching
# expected values....
# registration_id = request.POST.get("registrationID")
@@ -1108,12 +1107,12 @@ def test_center_login(request):
return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID"));
# find testcenter_registration that matches the provided exam code:
# Note that we could rely in future on either the registrationId or the exam code,
# or possibly both. But for now we know what to do with an ExamSeriesCode,
# Note that we could rely in future on either the registrationId or the exam code,
# or possibly both. But for now we know what to do with an ExamSeriesCode,
# while we currently have no record of RegistrationID values at all.
if 'vueExamSeriesCode' not in request.POST:
# we are not allowed to make up a new error code, according to Pearson,
# so instead of "missingExamSeriesCode", we use a valid one that is
# we are not allowed to make up a new error code, according to Pearson,
# so instead of "missingExamSeriesCode", we use a valid one that is
# inaccurate but at least distinct. (Sigh.)
log.error("missing exam series code for cand ID {}".format(client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID"));
@@ -1127,11 +1126,11 @@ def test_center_login(request):
if not registrations:
log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id))
return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned"));
# TODO: figure out what to do if there are more than one registrations....
# for now, just take the first...
registration = registrations[0]
course_id = registration.course_id
course = course_from_id(course_id) # assume it will be found....
if not course:
@@ -1149,19 +1148,19 @@ def test_center_login(request):
if not timelimit_descriptor:
log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location))
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"));
timelimit_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, testcenteruser.user,
timelimit_descriptor, depth=None)
timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor,
timelimit_module_cache = ModelDataCache.cache_for_descriptor_descendents(course_id, testcenteruser.user,
timelimit_descriptor, depth=None)
timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor,
timelimit_module_cache, course_id, position=None)
if not timelimit_module.category == 'timelimit':
log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location))
return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram"));
if timelimit_module and timelimit_module.has_ended:
log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at))
return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken"));
# check if we need to provide an accommodation:
time_accommodation_mapping = {'ET12ET' : 'ADDHALFTIME',
'ET30MN' : 'ADD30MIN',
@@ -1174,27 +1173,24 @@ def test_center_login(request):
# special, hard-coded client ID used by Pearson shell for testing:
if client_candidate_id == "edX003671291147":
time_accommodation_code = 'TESTING'
if time_accommodation_code:
timelimit_module.accommodation_code = time_accommodation_code
instance_module = get_instance_module(course_id, testcenteruser.user, timelimit_module, timelimit_module_cache)
instance_module.state = timelimit_module.get_instance_state()
instance_module.save()
log.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code))
# UGLY HACK!!!
# Login assumes that authentication has occurred, and that there is a
# Login assumes that authentication has occurred, and that there is a
# backend annotation on the user object, indicating which backend
# against which the user was authenticated. We're authenticating here
# against the registration entry, and assuming that the request given
# this information is correct, we allow the user to be logged in
# without a password. This could all be formalized in a backend object
# that does the above checking.
# that does the above checking.
# TODO: (brian) create a backend class to do this.
# testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__)
testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass")
# testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__)
testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass")
login(request, testcenteruser.user)
# And start the test:
return jump_to(request, course_id, location)

View File

@@ -7,13 +7,14 @@ from xmodule.modulestore.django import modulestore
from time import gmtime
from uuid import uuid4
from xmodule.timeparse import stringify_time
from xmodule.modulestore.inheritance import own_metadata
class GroupFactory(Factory):
FACTORY_FOR = Group
name = 'staff_MITx/999/Robot_Super_Course'
class UserProfileFactory(Factory):
FACTORY_FOR = UserProfile
@@ -81,18 +82,17 @@ class XModuleCourseFactory(Factory):
# This metadata code was copied from cms/djangoapps/contentstore/views.py
if display_name is not None:
new_course.metadata['display_name'] = display_name
new_course.display_name = display_name
new_course.metadata['data_dir'] = uuid4().hex
new_course.metadata['start'] = stringify_time(gmtime())
new_course.lms.start = gmtime()
new_course.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"},
{"type": "progress", "name": "Progress"}]
# Update the data in the mongo datastore
store.update_metadata(new_course.location.url(), new_course.own_metadata)
store.update_metadata(new_course.location.url(), own_metadata(new_course))
return new_course
@@ -139,17 +139,14 @@ class XModuleItemFactory(Factory):
new_item = store.clone_item(template, dest_location)
# TODO: This needs to be deleted when we have proper storage for static content
new_item.metadata['data_dir'] = parent.metadata['data_dir']
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
new_item.metadata['display_name'] = display_name
new_item.display_name = display_name
store.update_metadata(new_item.location.url(), new_item.own_metadata)
store.update_metadata(new_item.location.url(), own_metadata(new_item))
if new_item.location.category not in DETACHED_CATEGORIES:
store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
store.update_children(parent_location, parent.children + [new_item.location.url()])
return new_item

View File

@@ -1,5 +1,5 @@
from lettuce import world, step
from factories import *
from .factories import *
from lettuce.django import django_url
from django.contrib.auth.models import User
from student.models import CourseEnrollment

View File

@@ -33,7 +33,7 @@ def wrap_xmodule(get_html, module, template, context=None):
def _get_html():
context.update({
'content': get_html(),
'display_name': module.metadata.get('display_name') if module.metadata is not None else None,
'display_name': module.display_name,
'class_': module.__class__.__name__,
'module_name': module.js_module_name
})
@@ -108,42 +108,25 @@ def add_histogram(get_html, module, user):
histogram = grade_histogram(module_id)
render_histogram = len(histogram) > 0
# TODO (ichuang): Remove after fall 2012 LMS migration done
if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'):
[filepath, filename] = module.definition.get('filename', ['', None])
osfs = module.system.filestore
if filename is not None and osfs.exists(filename):
# if original, unmangled filename exists then use it (github
# doesn't like symlinks)
filepath = filename
data_dir = osfs.root_path.rsplit('/')[-1]
giturl = module.metadata.get('giturl', 'https://github.com/MITx')
edit_link = "%s/%s/tree/master/%s" % (giturl, data_dir, filepath)
else:
edit_link = False
# Need to define all the variables that are about to be used
giturl = ""
data_dir = ""
source_file = module.metadata.get('source_file', '') # source used to generate the problem XML, eg latex or word
source_file = module.lms.source_file # source used to generate the problem XML, eg latex or word
# useful to indicate to staff if problem has been released or not
# TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access, instead of now>mstart comparison here
now = time.gmtime()
is_released = "unknown"
mstart = getattr(module.descriptor, 'start')
mstart = module.descriptor.lms.start
if mstart is not None:
is_released = "<font color='red'>Yes!</font>" if (now > mstart) else "<font color='green'>Not yet</font>"
staff_context = {'definition': module.definition.get('data'),
'metadata': json.dumps(module.metadata, indent=4),
staff_context = {'fields': [(field.name, getattr(module, field.name)) for field in module.fields],
'lms_fields': [(field.name, getattr(module.lms, field.name)) for field in module.lms.fields],
'location': module.location,
'xqa_key': module.metadata.get('xqa_key', ''),
'xqa_key': module.lms.xqa_key,
'source_file': source_file,
'source_url': '%s/%s/tree/master/%s' % (giturl, data_dir, source_file),
'category': str(module.__class__.__name__),
# Template uses element_id in js function names, so can't allow dashes
'element_id': module.location.html_id().replace('-', '_'),
'edit_link': edit_link,
'user': user,
'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER', 'http://xqa:server@content-qa.mitx.mit.edu/xqa'),
'histogram': json.dumps(histogram),

View File

@@ -39,11 +39,11 @@ import verifiers
import verifiers.draganddrop
import calc
from correctmap import CorrectMap
from .correctmap import CorrectMap
import eia
import inputtypes
import customrender
from util import contextualize_text, convert_files_to_filenames
from .util import contextualize_text, convert_files_to_filenames
import xqueue_interface
# to be replaced with auto-registering
@@ -78,7 +78,7 @@ global_context = {'random': random,
# These should be removed from HTML output, including all subelements
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup", "openendedparam", "openendedrubric"]
log = logging.getLogger('mitx.' + __name__)
log = logging.getLogger(__name__)
#-----------------------------------------------------------------------------
# main class for this module
@@ -108,6 +108,8 @@ class LoncapaProblem(object):
self.do_reset()
self.problem_id = id
self.system = system
if self.system is None:
raise Exception()
self.seed = seed
if state:

View File

@@ -12,8 +12,8 @@ from path import path
from cStringIO import StringIO
from collections import defaultdict
from calc import UndefinedVariable
from capa_problem import LoncapaProblem
from .calc import UndefinedVariable
from .capa_problem import LoncapaProblem
from mako.lookup import TemplateLookup
logging.basicConfig(format="%(levelname)s %(message)s")

View File

@@ -2,7 +2,7 @@ import codecs
from fractions import Fraction
import unittest
from chemcalc import (compare_chemical_expression, divide_chemical_expression,
from .chemcalc import (compare_chemical_expression, divide_chemical_expression,
render_to_html, chemical_equations_equal)
import miller
@@ -277,7 +277,6 @@ class Test_Render_Equations(unittest.TestCase):
def test_render9(self):
s = "5[Ni(NH3)4]^2+ + 5/2SO4^2-"
#import ipdb; ipdb.set_trace()
out = render_to_html(s)
correct = u'<span class="math">5[Ni(NH<sub>3</sub>)<sub>4</sub>]<sup>2+</sup>+<sup>5</sup>&frasl;<sub>2</sub>SO<sub>4</sub><sup>2-</sup></span>'
log(out + ' ------- ' + correct, 'html')

View File

@@ -47,7 +47,7 @@ class CorrectMap(object):
queuestate=None, **kwargs):
if answer_id is not None:
self.cmap[answer_id] = {'correctness': correctness,
self.cmap[str(answer_id)] = {'correctness': correctness,
'npoints': npoints,
'msg': msg,
'hint': hint,

View File

@@ -6,7 +6,7 @@ These tags do not have state, so they just get passed the system (for access to
and the xml element.
"""
from registry import TagRegistry
from .registry import TagRegistry
import logging
import re
@@ -15,9 +15,9 @@ import json
from lxml import etree
import xml.sax.saxutils as saxutils
from registry import TagRegistry
from .registry import TagRegistry
log = logging.getLogger('mitx.' + __name__)
log = logging.getLogger(__name__)
registry = TagRegistry()

View File

@@ -47,10 +47,10 @@ import sys
import os
import pyparsing
from registry import TagRegistry
from .registry import TagRegistry
from capa.chem import chemcalc
log = logging.getLogger('mitx.' + __name__)
log = logging.getLogger(__name__)
#########################################################################
@@ -857,6 +857,10 @@ class DragAndDropInput(InputTypeBase):
if tag_type == 'draggable' and not self.no_labels:
dic['label'] = dic['label'] or dic['id']
if tag_type == 'draggable':
dic['target_fields'] = [parse(target, 'target') for target in
tag.iterchildren('target')]
return dic
# add labels to images?:

View File

@@ -28,15 +28,15 @@ from collections import namedtuple
from shapely.geometry import Point, MultiPoint
# specific library imports
from calc import evaluator, UndefinedVariable
from correctmap import CorrectMap
from .calc import evaluator, UndefinedVariable
from .correctmap import CorrectMap
from datetime import datetime
from util import *
from .util import *
from lxml import etree
from lxml.html.soupparser import fromstring as fromstring_bs # uses Beautiful Soup!!! FIXME?
import xqueue_interface
log = logging.getLogger('mitx.' + __name__)
log = logging.getLogger(__name__)
#-----------------------------------------------------------------------------
@@ -231,16 +231,14 @@ class LoncapaResponse(object):
# hint specified by function?
hintfn = hintgroup.get('hintfn')
if hintfn:
'''
Hint is determined by a function defined in the <script> context; evaluate
that function to obtain list of hint, hintmode for each answer_id.
# Hint is determined by a function defined in the <script> context; evaluate
# that function to obtain list of hint, hintmode for each answer_id.
The function should take arguments (answer_ids, student_answers, new_cmap, old_cmap)
and it should modify new_cmap as appropriate.
# The function should take arguments (answer_ids, student_answers, new_cmap, old_cmap)
# and it should modify new_cmap as appropriate.
We may extend this in the future to add another argument which provides a
callback procedure to a social hint generation system.
'''
# We may extend this in the future to add another argument which provides a
# callback procedure to a social hint generation system.
if not hintfn in self.context:
msg = 'missing specified hint function %s in script context' % hintfn
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>')
@@ -329,7 +327,7 @@ class LoncapaResponse(object):
""" Render a <div> for a message that applies to the entire response.
*response_msg* is a string, which may contain XHTML markup
Returns an etree element representing the response message <div> """
# First try wrapping the text in a <div> and parsing
# it as an XHTML tree
@@ -872,7 +870,7 @@ class CustomResponse(LoncapaResponse):
Custom response. The python code to be run should be in <answer>...</answer>
or in a <script>...</script>
'''
snippets = [{'snippet': """<customresponse>
snippets = [{'snippet': r"""<customresponse>
<text>
<br/>
Suppose that \(I(t)\) rises from \(0\) to \(I_S\) at a time \(t_0 \neq 0\)
@@ -1104,7 +1102,7 @@ def sympy_check2():
# the form:
# {'overall_message': STRING,
# 'input_list': [{ 'ok': BOOLEAN, 'msg': STRING }, ...] }
#
#
# This allows the function to return an 'overall message'
# that applies to the entire problem, as well as correct/incorrect
# status and messages for individual inputs
@@ -1197,7 +1195,7 @@ class SymbolicResponse(CustomResponse):
"""
Symbolic math response checking, using symmath library.
"""
snippets = [{'snippet': '''<problem>
snippets = [{'snippet': r'''<problem>
<text>Compute \[ \exp\left(-i \frac{\theta}{2} \left[ \begin{matrix} 0 & 1 \\ 1 & 0 \end{matrix} \right] \right) \]
and give the resulting \(2\times 2\) matrix: <br/>
<symbolicresponse answer="">
@@ -1988,7 +1986,7 @@ class AnnotationResponse(LoncapaResponse):
for inputfield in self.inputfields:
option_scoring = dict([(option['id'], {
'correctness': choices.get(option['choice']),
'correctness': choices.get(option['choice']),
'points': scoring.get(option['choice'])
}) for option in self._find_options(inputfield) ])

View File

@@ -7,7 +7,7 @@ import json
import mock
from capa.capa_problem import LoncapaProblem
from response_xml_factory import StringResponseXMLFactory, CustomResponseXMLFactory
from .response_xml_factory import StringResponseXMLFactory, CustomResponseXMLFactory
from . import test_system
class CapaHtmlRenderTest(unittest.TestCase):

View File

@@ -557,14 +557,14 @@ class DragAndDropTest(unittest.TestCase):
"target_outline": "false",
"base_image": "/static/images/about_1.png",
"draggables": [
{"can_reuse": "", "label": "Label 1", "id": "1", "icon": ""},
{"can_reuse": "", "label": "cc", "id": "name_with_icon", "icon": "/static/images/cc.jpg", },
{"can_reuse": "", "label": "arrow-left", "id": "with_icon", "icon": "/static/images/arrow-left.png", "can_reuse": ""},
{"can_reuse": "", "label": "Label2", "id": "5", "icon": "", "can_reuse": ""},
{"can_reuse": "", "label": "Mute", "id": "2", "icon": "/static/images/mute.png", "can_reuse": ""},
{"can_reuse": "", "label": "spinner", "id": "name_label_icon3", "icon": "/static/images/spinner.gif", "can_reuse": ""},
{"can_reuse": "", "label": "Star", "id": "name4", "icon": "/static/images/volume.png", "can_reuse": ""},
{"can_reuse": "", "label": "Label3", "id": "7", "icon": "", "can_reuse": ""}],
{"can_reuse": "", "label": "Label 1", "id": "1", "icon": "", "target_fields": []},
{"can_reuse": "", "label": "cc", "id": "name_with_icon", "icon": "/static/images/cc.jpg", "target_fields": []},
{"can_reuse": "", "label": "arrow-left", "id": "with_icon", "icon": "/static/images/arrow-left.png", "can_reuse": "", "target_fields": []},
{"can_reuse": "", "label": "Label2", "id": "5", "icon": "", "can_reuse": "", "target_fields": []},
{"can_reuse": "", "label": "Mute", "id": "2", "icon": "/static/images/mute.png", "can_reuse": "", "target_fields": []},
{"can_reuse": "", "label": "spinner", "id": "name_label_icon3", "icon": "/static/images/spinner.gif", "can_reuse": "", "target_fields": []},
{"can_reuse": "", "label": "Star", "id": "name4", "icon": "/static/images/volume.png", "can_reuse": "", "target_fields": []},
{"can_reuse": "", "label": "Label3", "id": "7", "icon": "", "can_reuse": "", "target_fields": []}],
"one_per_target": "True",
"targets": [
{"y": "90", "x": "210", "id": "t1", "w": "90", "h": "90"},

View File

@@ -1,4 +1,4 @@
from calc import evaluator, UndefinedVariable
from .calc import evaluator, UndefinedVariable
#-----------------------------------------------------------------------------
#

View File

@@ -27,6 +27,49 @@ values are (x,y) coordinates of centers of dragged images.
import json
def flat_user_answer(user_answer):
"""
Convert nested `user_answer` to flat format.
{'up': {'first': {'p': 'p_l'}}}
to
{'up': 'p_l[p][first]'}
"""
def parse_user_answer(answer):
key = answer.keys()[0]
value = answer.values()[0]
if isinstance(value, dict):
# Make complex value:
# Example:
# Create like 'p_l[p][first]' from {'first': {'p': 'p_l'}
complex_value_list = []
v_value = value
while isinstance(v_value, dict):
v_key = v_value.keys()[0]
v_value = v_value.values()[0]
complex_value_list.append(v_key)
complex_value = '{0}'.format(v_value)
for i in reversed(complex_value_list):
complex_value = '{0}[{1}]'.format(complex_value, i)
res = {key: complex_value}
return res
else:
return answer
result = []
for answer in user_answer:
parse_answer = parse_user_answer(answer)
result.append(parse_answer)
return result
class PositionsCompare(list):
""" Class for comparing positions.
@@ -116,37 +159,36 @@ class DragAndDrop(object):
# Number of draggables in user_groups may be differ that in
# correct_groups, that is incorrect, except special case with 'number'
for groupname, draggable_ids in self.correct_groups.items():
for index, draggable_ids in enumerate(self.correct_groups):
# 'number' rule special case
# for reusable draggables we may get in self.user_groups
# {'1': [u'2', u'2', u'2'], '0': [u'1', u'1'], '2': [u'3']}
# if '+number' is in rule - do not remove duplicates and strip
# '+number' from rule
current_rule = self.correct_positions[groupname].keys()[0]
current_rule = self.correct_positions[index].keys()[0]
if 'number' in current_rule:
rule_values = self.correct_positions[groupname][current_rule]
rule_values = self.correct_positions[index][current_rule]
# clean rule, do not do clean duplicate items
self.correct_positions[groupname].pop(current_rule, None)
self.correct_positions[index].pop(current_rule, None)
parsed_rule = current_rule.replace('+', '').replace('number', '')
self.correct_positions[groupname][parsed_rule] = rule_values
self.correct_positions[index][parsed_rule] = rule_values
else: # remove dublicates
self.user_groups[groupname] = list(set(self.user_groups[groupname]))
self.user_groups[index] = list(set(self.user_groups[index]))
if sorted(draggable_ids) != sorted(self.user_groups[groupname]):
if sorted(draggable_ids) != sorted(self.user_groups[index]):
return False
# Check that in every group, for rule of that group, user positions of
# every element are equal with correct positions
for groupname in self.correct_groups:
for index, _ in enumerate(self.correct_groups):
rules_executed = 0
for rule in ('exact', 'anyof', 'unordered_equal'):
# every group has only one rule
if self.correct_positions[groupname].get(rule, None):
if self.correct_positions[index].get(rule, None):
rules_executed += 1
if not self.compare_positions(
self.correct_positions[groupname][rule],
self.user_positions[groupname]['user'], flag=rule):
self.correct_positions[index][rule],
self.user_positions[index]['user'], flag=rule):
return False
if not rules_executed: # no correct rules for current group
# probably xml content mistake - wrong rules names
@@ -248,7 +290,7 @@ class DragAndDrop(object):
correct_answer = {'name4': 't1',
'name_with_icon': 't1',
'5': 't2',
'7':'t2'}
'7': 't2'}
It is draggable_name: dragable_position mapping.
@@ -284,24 +326,25 @@ class DragAndDrop(object):
Args:
user_answer: json
correct_answer: dict or list
correct_answer: dict or list
"""
self.correct_groups = dict() # correct groups from xml
self.correct_positions = dict() # correct positions for comparing
self.user_groups = dict() # will be populated from user answer
self.user_positions = dict() # will be populated from user answer
self.correct_groups = [] # Correct groups from xml.
self.correct_positions = [] # Correct positions for comparing.
self.user_groups = [] # Will be populated from user answer.
self.user_positions = [] # Will be populated from user answer.
# convert from dict answer format to list format
# Convert from dict answer format to list format.
if isinstance(correct_answer, dict):
tmp = []
for key, value in correct_answer.items():
tmp_dict = {'draggables': [], 'targets': [], 'rule': 'exact'}
tmp_dict['draggables'].append(key)
tmp_dict['targets'].append(value)
tmp.append(tmp_dict)
tmp.append({
'draggables': [key],
'targets': [value],
'rule': 'exact'})
correct_answer = tmp
# Convert string `user_answer` to object.
user_answer = json.loads(user_answer)
# This dictionary will hold a key for each draggable the user placed on
@@ -309,27 +352,32 @@ class DragAndDrop(object):
# correct_answer entries. If the draggable is mentioned in at least one
# correct_answer entry, the value is False.
# default to consider every user answer excess until proven otherwise.
self.excess_draggables = dict((users_draggable.keys()[0],True)
for users_draggable in user_answer['draggables'])
self.excess_draggables = dict((users_draggable.keys()[0],True)
for users_draggable in user_answer)
# create identical data structures from user answer and correct answer
for i in xrange(0, len(correct_answer)):
groupname = str(i)
self.correct_groups[groupname] = correct_answer[i]['draggables']
self.correct_positions[groupname] = {correct_answer[i]['rule']:
correct_answer[i]['targets']}
self.user_groups[groupname] = []
self.user_positions[groupname] = {'user': []}
for draggable_dict in user_answer['draggables']:
# draggable_dict is 1-to-1 {draggable_name: position}
# Convert nested `user_answer` to flat format.
user_answer = flat_user_answer(user_answer)
# Create identical data structures from user answer and correct answer.
for answer in correct_answer:
user_groups_data = []
user_positions_data = []
for draggable_dict in user_answer:
# Draggable_dict is 1-to-1 {draggable_name: position}.
draggable_name = draggable_dict.keys()[0]
if draggable_name in self.correct_groups[groupname]:
self.user_groups[groupname].append(draggable_name)
self.user_positions[groupname]['user'].append(
if draggable_name in answer['draggables']:
user_groups_data.append(draggable_name)
user_positions_data.append(
draggable_dict[draggable_name])
# proved that this is not excess
self.excess_draggables[draggable_name] = False
self.correct_groups.append(answer['draggables'])
self.correct_positions.append({answer['rule']: answer['targets']})
self.user_groups.append(user_groups_data)
self.user_positions.append({'user': user_positions_data})
def grade(user_input, correct_answer):
""" Creates DragAndDrop instance from user_input and correct_answer and
calls DragAndDrop.grade for grading.

View File

@@ -1,7 +1,8 @@
import unittest
import draganddrop
from draganddrop import PositionsCompare
from .draganddrop import PositionsCompare
import json
class Test_PositionsCompare(unittest.TestCase):
@@ -40,90 +41,314 @@ class Test_PositionsCompare(unittest.TestCase):
class Test_DragAndDrop_Grade(unittest.TestCase):
def test_targets_are_draggable_1(self):
user_input = json.dumps([
{'p': 'p_l'},
{'up': {'first': {'p': 'p_l'}}}
])
correct_answer = [
{
'draggables': ['p'],
'targets': [
'p_l', 'p_r'
],
'rule': 'anyof'
},
{
'draggables': ['up'],
'targets': [
'p_l[p][first]'
],
'rule': 'anyof'
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_targets_are_draggable_2(self):
user_input = json.dumps([
{'p': 'p_l'},
{'p': 'p_r'},
{'s': 's_l'},
{'s': 's_r'},
{'up': {'1': {'p': 'p_l'}}},
{'up': {'3': {'p': 'p_l'}}},
{'up': {'1': {'p': 'p_r'}}},
{'up': {'3': {'p': 'p_r'}}},
{'up_and_down': {'1': {'s': 's_l'}}},
{'up_and_down': {'1': {'s': 's_r'}}}
])
correct_answer = [
{
'draggables': ['p'],
'targets': ['p_l', 'p_r'],
'rule': 'unordered_equal'
},
{
'draggables': ['s'],
'targets': ['s_l', 's_r'],
'rule': 'unordered_equal'
},
{
'draggables': ['up_and_down'],
'targets': [
's_l[s][1]', 's_r[s][1]'
],
'rule': 'unordered_equal'
},
{
'draggables': ['up'],
'targets': [
'p_l[p][1]', 'p_l[p][3]', 'p_r[p][1]', 'p_r[p][3]'
],
'rule': 'unordered_equal'
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_targets_are_draggable_2_manual_parsing(self):
user_input = json.dumps([
{'up': 'p_l[p][1]'},
{'p': 'p_l'},
{'up': 'p_l[p][3]'},
{'up': 'p_r[p][1]'},
{'p': 'p_r'},
{'up': 'p_r[p][3]'},
{'up_and_down': 's_l[s][1]'},
{'s': 's_l'},
{'up_and_down': 's_r[s][1]'},
{'s': 's_r'}
])
correct_answer = [
{
'draggables': ['p'],
'targets': ['p_l', 'p_r'],
'rule': 'unordered_equal'
},
{
'draggables': ['s'],
'targets': ['s_l', 's_r'],
'rule': 'unordered_equal'
},
{
'draggables': ['up_and_down'],
'targets': [
's_l[s][1]', 's_r[s][1]'
],
'rule': 'unordered_equal'
},
{
'draggables': ['up'],
'targets': [
'p_l[p][1]', 'p_l[p][3]', 'p_r[p][1]', 'p_r[p][3]'
],
'rule': 'unordered_equal'
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_targets_are_draggable_3_nested(self):
user_input = json.dumps([
{'molecule': 'left_side_tagret'},
{'molecule': 'right_side_tagret'},
{'p': {'p_target': {'molecule': 'left_side_tagret'}}},
{'p': {'p_target': {'molecule': 'right_side_tagret'}}},
{'s': {'s_target': {'molecule': 'left_side_tagret'}}},
{'s': {'s_target': {'molecule': 'right_side_tagret'}}},
{'up': {'1': {'p': {'p_target': {'molecule': 'left_side_tagret'}}}}},
{'up': {'3': {'p': {'p_target': {'molecule': 'left_side_tagret'}}}}},
{'up': {'1': {'p': {'p_target': {'molecule': 'right_side_tagret'}}}}},
{'up': {'3': {'p': {'p_target': {'molecule': 'right_side_tagret'}}}}},
{'up_and_down': {'1': {'s': {'s_target': {'molecule': 'left_side_tagret'}}}}},
{'up_and_down': {'1': {'s': {'s_target': {'molecule': 'right_side_tagret'}}}}}
])
correct_answer = [
{
'draggables': ['molecule'],
'targets': ['left_side_tagret', 'right_side_tagret'],
'rule': 'unordered_equal'
},
{
'draggables': ['p'],
'targets': [
'left_side_tagret[molecule][p_target]',
'right_side_tagret[molecule][p_target]'
],
'rule': 'unordered_equal'
},
{
'draggables': ['s'],
'targets': [
'left_side_tagret[molecule][s_target]',
'right_side_tagret[molecule][s_target]'
],
'rule': 'unordered_equal'
},
{
'draggables': ['up_and_down'],
'targets': [
'left_side_tagret[molecule][s_target][s][1]',
'right_side_tagret[molecule][s_target][s][1]'
],
'rule': 'unordered_equal'
},
{
'draggables': ['up'],
'targets': [
'left_side_tagret[molecule][p_target][p][1]',
'left_side_tagret[molecule][p_target][p][3]',
'right_side_tagret[molecule][p_target][p][1]',
'right_side_tagret[molecule][p_target][p][3]'
],
'rule': 'unordered_equal'
}
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_targets_are_draggable_4_real_example(self):
user_input = json.dumps([
{'single_draggable': 's_l'},
{'single_draggable': 's_r'},
{'single_draggable': 'p_sigma'},
{'single_draggable': 'p_sigma*'},
{'single_draggable': 's_sigma'},
{'single_draggable': 's_sigma*'},
{'double_draggable': 'p_pi*'},
{'double_draggable': 'p_pi'},
{'triple_draggable': 'p_l'},
{'triple_draggable': 'p_r'},
{'up': {'1': {'triple_draggable': 'p_l'}}},
{'up': {'2': {'triple_draggable': 'p_l'}}},
{'up': {'2': {'triple_draggable': 'p_r'}}},
{'up': {'3': {'triple_draggable': 'p_r'}}},
{'up_and_down': {'1': {'single_draggable': 's_l'}}},
{'up_and_down': {'1': {'single_draggable': 's_r'}}},
{'up_and_down': {'1': {'single_draggable': 's_sigma'}}},
{'up_and_down': {'1': {'single_draggable': 's_sigma*'}}},
{'up_and_down': {'1': {'double_draggable': 'p_pi'}}},
{'up_and_down': {'2': {'double_draggable': 'p_pi'}}}
])
# 10 targets:
# s_l, s_r, p_l, p_r, s_sigma, s_sigma*, p_pi, p_sigma, p_pi*, p_sigma*
#
# 3 draggable objects, which have targets (internal target ids - 1, 2, 3):
# single_draggable, double_draggable, triple_draggable
#
# 2 draggable objects:
# up, up_and_down
correct_answer = [
{
'draggables': ['triple_draggable'],
'targets': ['p_l', 'p_r'],
'rule': 'unordered_equal'
},
{
'draggables': ['double_draggable'],
'targets': ['p_pi', 'p_pi*'],
'rule': 'unordered_equal'
},
{
'draggables': ['single_draggable'],
'targets': ['s_l', 's_r', 's_sigma', 's_sigma*', 'p_sigma', 'p_sigma*'],
'rule': 'unordered_equal'
},
{
'draggables': ['up'],
'targets': ['p_l[triple_draggable][1]', 'p_l[triple_draggable][2]',
'p_r[triple_draggable][2]', 'p_r[triple_draggable][3]'],
'rule': 'unordered_equal'
},
{
'draggables': ['up_and_down'],
'targets': ['s_l[single_draggable][1]', 's_r[single_draggable][1]',
's_sigma[single_draggable][1]', 's_sigma*[single_draggable][1]',
'p_pi[double_draggable][1]', 'p_pi[double_draggable][2]'],
'rule': 'unordered_equal'
},
]
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_targets_true(self):
user_input = '{"draggables": [{"1": "t1"}, \
{"name_with_icon": "t2"}]}'
correct_answer = {'1': 't1', 'name_with_icon': 't2'}
user_input = '[{"1": "t1"}, \
{"name_with_icon": "t2"}]'
correct_answer = {'1': 't1', 'name_with_icon': 't2'}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_expect_no_actions_wrong(self):
user_input = '{"draggables": [{"1": "t1"}, \
{"name_with_icon": "t2"}]}'
user_input = '[{"1": "t1"}, \
{"name_with_icon": "t2"}]'
correct_answer = []
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_expect_no_actions_right(self):
user_input = '{"draggables": []}'
user_input = '[]'
correct_answer = []
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_targets_false(self):
user_input = '{"draggables": [{"1": "t1"}, \
{"name_with_icon": "t2"}]}'
correct_answer = {'1': 't3', 'name_with_icon': 't2'}
user_input = '[{"1": "t1"}, \
{"name_with_icon": "t2"}]'
correct_answer = {'1': 't3', 'name_with_icon': 't2'}
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_multiple_images_per_target_true(self):
user_input = '{\
"draggables": [{"1": "t1"}, {"name_with_icon": "t2"}, \
{"2": "t1"}]}'
correct_answer = {'1': 't1', 'name_with_icon': 't2',
user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}, \
{"2": "t1"}]'
correct_answer = {'1': 't1', 'name_with_icon': 't2',
'2': 't1'}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_multiple_images_per_target_false(self):
user_input = '{\
"draggables": [{"1": "t1"}, {"name_with_icon": "t2"}, \
{"2": "t1"}]}'
correct_answer = {'1': 't2', 'name_with_icon': 't2',
user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}, \
{"2": "t1"}]'
correct_answer = {'1': 't2', 'name_with_icon': 't2',
'2': 't1'}
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_targets_and_positions(self):
user_input = '{"draggables": [{"1": [10,10]}, \
{"name_with_icon": [[10,10],4]}]}'
user_input = '[{"1": [10,10]}, \
{"name_with_icon": [[10,10],4]}]'
correct_answer = {'1': [10, 10], 'name_with_icon': [[10, 10], 4]}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_position_and_targets(self):
user_input = '{"draggables": [{"1": "t1"}, {"name_with_icon": "t2"}]}'
user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}]'
correct_answer = {'1': 't1', 'name_with_icon': 't2'}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_positions_exact(self):
user_input = '{"draggables": \
[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
correct_answer = {'1': [10, 10], 'name_with_icon': [20, 20]}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_positions_false(self):
user_input = '{"draggables": \
[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
correct_answer = {'1': [25, 25], 'name_with_icon': [20, 20]}
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_positions_true_in_radius(self):
user_input = '{"draggables": \
[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
correct_answer = {'1': [14, 14], 'name_with_icon': [20, 20]}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_positions_true_in_manual_radius(self):
user_input = '{"draggables": \
[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
correct_answer = {'1': [[40, 10], 30], 'name_with_icon': [20, 20]}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_positions_false_in_manual_radius(self):
user_input = '{"draggables": \
[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
correct_answer = {'1': [[40, 10], 29], 'name_with_icon': [20, 20]}
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_correct_answer_not_has_key_from_user_answer(self):
user_input = '{"draggables": [{"1": "t1"}, \
{"name_with_icon": "t2"}]}'
user_input = '[{"1": "t1"}, {"name_with_icon": "t2"}]'
correct_answer = {'3': 't3', 'name_with_icon': 't2'}
self.assertFalse(draganddrop.grade(user_input, correct_answer))
@@ -131,20 +356,20 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
"""Draggables can be places anywhere on base image.
Place grass in the middle of the image and ant in the
right upper corner."""
user_input = '{"draggables": \
[{"ant":[610.5,57.449951171875]},{"grass":[322.5,199.449951171875]}]}'
user_input = '[{"ant":[610.5,57.449951171875]},\
{"grass":[322.5,199.449951171875]}]'
correct_answer = {'grass': [[300, 200], 200], 'ant': [[500, 0], 200]}
self.assertTrue(draganddrop.grade(user_input, correct_answer))
def test_lcao_correct(self):
"""Describe carbon molecule in LCAO-MO"""
user_input = '{"draggables":[{"1":"s_left"}, \
user_input = '[{"1":"s_left"}, \
{"5":"s_right"},{"4":"s_sigma"},{"6":"s_sigma_star"},{"7":"p_left_1"}, \
{"8":"p_left_2"},{"10":"p_right_1"},{"9":"p_right_2"}, \
{"2":"p_pi_1"},{"3":"p_pi_2"},{"11":"s_sigma_name"}, \
{"13":"s_sigma_star_name"},{"15":"p_pi_name"},{"16":"p_pi_star_name"}, \
{"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]}'
{"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]'
correct_answer = [{
'draggables': ['1', '2', '3', '4', '5', '6'],
@@ -178,12 +403,12 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_lcao_extra_element_incorrect(self):
"""Describe carbon molecule in LCAO-MO"""
user_input = '{"draggables":[{"1":"s_left"}, \
user_input = '[{"1":"s_left"}, \
{"5":"s_right"},{"4":"s_sigma"},{"6":"s_sigma_star"},{"7":"p_left_1"}, \
{"8":"p_left_2"},{"17":"p_left_3"},{"10":"p_right_1"},{"9":"p_right_2"}, \
{"2":"p_pi_1"},{"3":"p_pi_2"},{"11":"s_sigma_name"}, \
{"13":"s_sigma_star_name"},{"15":"p_pi_name"},{"16":"p_pi_star_name"}, \
{"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]}'
{"12":"p_sigma_name"},{"14":"p_sigma_star_name"}]'
correct_answer = [{
'draggables': ['1', '2', '3', '4', '5', '6'],
@@ -217,9 +442,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_reuse_draggable_no_mupliples(self):
"""Test reusable draggables (no mupltiple draggables per target)"""
user_input = '{"draggables":[{"1":"target1"}, \
user_input = '[{"1":"target1"}, \
{"2":"target2"},{"1":"target3"},{"2":"target4"},{"2":"target5"}, \
{"3":"target6"}]}'
{"3":"target6"}]'
correct_answer = [
{
'draggables': ['1'],
@@ -240,9 +465,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_reuse_draggable_with_mupliples(self):
"""Test reusable draggables with mupltiple draggables per target"""
user_input = '{"draggables":[{"1":"target1"}, \
user_input = '[{"1":"target1"}, \
{"2":"target2"},{"1":"target1"},{"2":"target4"},{"2":"target4"}, \
{"3":"target6"}]}'
{"3":"target6"}]'
correct_answer = [
{
'draggables': ['1'],
@@ -263,10 +488,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_reuse_many_draggable_with_mupliples(self):
"""Test reusable draggables with mupltiple draggables per target"""
user_input = '{"draggables":[{"1":"target1"}, \
user_input = '[{"1":"target1"}, \
{"2":"target2"},{"1":"target1"},{"2":"target4"},{"2":"target4"}, \
{"3":"target6"}, {"4": "target3"}, {"5": "target4"}, \
{"5": "target5"}, {"6": "target2"}]}'
{"5": "target5"}, {"6": "target2"}]'
correct_answer = [
{
'draggables': ['1', '4'],
@@ -292,12 +517,12 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_reuse_many_draggable_with_mupliples_wrong(self):
"""Test reusable draggables with mupltiple draggables per target"""
user_input = '{"draggables":[{"1":"target1"}, \
user_input = '[{"1":"target1"}, \
{"2":"target2"},{"1":"target1"}, \
{"2":"target3"}, \
{"2":"target4"}, \
{"3":"target6"}, {"4": "target3"}, {"5": "target4"}, \
{"5": "target5"}, {"6": "target2"}]}'
{"5": "target5"}, {"6": "target2"}]'
correct_answer = [
{
'draggables': ['1', '4'],
@@ -323,10 +548,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_false(self):
"""Test reusable draggables (no mupltiple draggables per target)"""
user_input = '{"draggables":[{"a":"target1"}, \
user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \
{"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \
{"a":"target1"}]}'
{"a":"target1"}]'
correct_answer = [
{
'draggables': ['a'],
@@ -347,10 +572,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_(self):
"""Test reusable draggables (no mupltiple draggables per target)"""
user_input = '{"draggables":[{"a":"target1"}, \
user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \
{"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \
{"a":"target10"}]}'
{"a":"target10"}]'
correct_answer = [
{
'draggables': ['a'],
@@ -371,10 +596,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_multiple(self):
"""Test reusable draggables (mupltiple draggables per target)"""
user_input = '{"draggables":[{"a":"target1"}, \
user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"b":"target5"}, \
{"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \
{"a":"target1"}]}'
{"a":"target1"}]'
correct_answer = [
{
'draggables': ['a', 'a', 'a'],
@@ -395,10 +620,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_multiple_false(self):
"""Test reusable draggables (mupltiple draggables per target)"""
user_input = '{"draggables":[{"a":"target1"}, \
user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"a":"target4"},{"b":"target5"}, \
{"c":"target6"}, {"a":"target7"},{"b":"target8"},{"c":"target9"}, \
{"a":"target1"}]}'
{"a":"target1"}]'
correct_answer = [
{
'draggables': ['a', 'a', 'a'],
@@ -419,10 +644,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_reused(self):
"""Test a b c in 10 labels reused"""
user_input = '{"draggables":[{"a":"target1"}, \
user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"b":"target5"}, \
{"c":"target6"}, {"b":"target8"},{"c":"target9"}, \
{"a":"target10"}]}'
{"a":"target10"}]'
correct_answer = [
{
'draggables': ['a', 'a'],
@@ -443,10 +668,10 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_label_10_targets_with_a_b_c_reused_false(self):
"""Test a b c in 10 labels reused false"""
user_input = '{"draggables":[{"a":"target1"}, \
user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"},{"b":"target5"}, {"a":"target8"},\
{"c":"target6"}, {"b":"target8"},{"c":"target9"}, \
{"a":"target10"}]}'
{"a":"target10"}]'
correct_answer = [
{
'draggables': ['a', 'a'],
@@ -467,9 +692,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_mixed_reuse_and_not_reuse(self):
"""Test reusable draggables """
user_input = '{"draggables":[{"a":"target1"}, \
user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"}, {"a":"target4"},\
{"a":"target5"}]}'
{"a":"target5"}]'
correct_answer = [
{
'draggables': ['a', 'b'],
@@ -485,8 +710,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_mixed_reuse_and_not_reuse_number(self):
"""Test reusable draggables with number """
user_input = '{"draggables":[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"}, {"a":"target4"}]}'
user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"}, {"a":"target4"}]'
correct_answer = [
{
'draggables': ['a', 'a', 'b'],
@@ -502,8 +727,8 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
def test_mixed_reuse_and_not_reuse_number_false(self):
"""Test reusable draggables with numbers, but wrong"""
user_input = '{"draggables":[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"}, {"a":"target4"}, {"a":"target10"}]}'
user_input = '[{"a":"target1"}, \
{"b":"target2"},{"c":"target3"}, {"a":"target4"}, {"a":"target10"}]'
correct_answer = [
{
'draggables': ['a', 'a', 'b'],
@@ -518,9 +743,9 @@ class Test_DragAndDrop_Grade(unittest.TestCase):
self.assertFalse(draganddrop.grade(user_input, correct_answer))
def test_alternative_correct_answer(self):
user_input = '{"draggables":[{"name_with_icon":"t1"},\
user_input = '[{"name_with_icon":"t1"},\
{"name_with_icon":"t1"},{"name_with_icon":"t1"},{"name4":"t1"}, \
{"name4":"t1"}]}'
{"name4":"t1"}]'
correct_answer = [
{'draggables': ['name4'], 'targets': ['t1', 't1'], 'rule': 'exact'},
{'draggables': ['name_with_icon'], 'targets': ['t1', 't1', 't1'],
@@ -533,14 +758,13 @@ class Test_DragAndDrop_Populate(unittest.TestCase):
def test_1(self):
correct_answer = {'1': [[40, 10], 29], 'name_with_icon': [20, 20]}
user_input = '{"draggables": \
[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]}'
user_input = '[{"1": [10, 10]}, {"name_with_icon": [20, 20]}]'
dnd = draganddrop.DragAndDrop(correct_answer, user_input)
correct_groups = {'1': ['name_with_icon'], '0': ['1']}
correct_positions = {'1': {'exact': [[20, 20]]}, '0': {'exact': [[[40, 10], 29]]}}
user_groups = {'1': [u'name_with_icon'], '0': [u'1']}
user_positions = {'1': {'user': [[20, 20]]}, '0': {'user': [[10, 10]]}}
correct_groups = [['1'], ['name_with_icon']]
correct_positions = [{'exact': [[[40, 10], 29]]}, {'exact': [[20, 20]]}]
user_groups = [['1'], ['name_with_icon']]
user_positions = [{'user': [[10, 10]]}, {'user': [[20, 20]]}]
self.assertEqual(correct_groups, dnd.correct_groups)
self.assertEqual(correct_positions, dnd.correct_positions)
@@ -551,49 +775,49 @@ class Test_DragAndDrop_Populate(unittest.TestCase):
class Test_DraAndDrop_Compare_Positions(unittest.TestCase):
def test_1(self):
dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertTrue(dnd.compare_positions(correct=[[1, 1], [2, 3]],
user=[[2, 3], [1, 1]],
flag='anyof'))
def test_2a(self):
dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertTrue(dnd.compare_positions(correct=[[1, 1], [2, 3]],
user=[[2, 3], [1, 1]],
flag='exact'))
def test_2b(self):
dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertFalse(dnd.compare_positions(correct=[[1, 1], [2, 3]],
user=[[2, 13], [1, 1]],
flag='exact'))
def test_3(self):
dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertFalse(dnd.compare_positions(correct=["a", "b"],
user=["a", "b", "c"],
flag='anyof'))
def test_4(self):
dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertTrue(dnd.compare_positions(correct=["a", "b", "c"],
user=["a", "b"],
flag='anyof'))
def test_5(self):
dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertFalse(dnd.compare_positions(correct=["a", "b", "c"],
user=["a", "c", "b"],
flag='exact'))
def test_6(self):
dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertTrue(dnd.compare_positions(correct=["a", "b", "c"],
user=["a", "c", "b"],
flag='anyof'))
def test_7(self):
dnd = draganddrop.DragAndDrop({'1': 't1'}, '{"draggables": [{"1": "t1"}]}')
dnd = draganddrop.DragAndDrop({'1': 't1'}, '[{"1": "t1"}]')
self.assertFalse(dnd.compare_positions(correct=["a", "b", "b"],
user=["a", "c", "b"],
flag='anyof'))

View File

@@ -7,7 +7,7 @@ import logging
import requests
log = logging.getLogger('mitx.' + __name__)
log = logging.getLogger(__name__)
dateformat = '%Y%m%d%H%M%S'

View File

@@ -4,5 +4,5 @@ setup(
name="capa",
version="0.1",
packages=find_packages(exclude=["tests"]),
install_requires=['distribute==0.6.34', 'pyparsing==1.5.6'],
install_requires=['distribute==0.6.30', 'pyparsing==1.5.6'],
)

View File

@@ -28,6 +28,7 @@ setup(
"image = xmodule.backcompat_module:TranslateCustomTagDescriptor",
"error = xmodule.error_module:ErrorDescriptor",
"peergrading = xmodule.peer_grading_module:PeerGradingDescriptor",
"poll_question = xmodule.poll_module:PollDescriptor",
"problem = xmodule.capa_module:CapaDescriptor",
"problemset = xmodule.seq_module:SequenceDescriptor",
"randomize = xmodule.randomize_module:RandomizeDescriptor",
@@ -45,6 +46,7 @@ setup(
"static_tab = xmodule.html_module:StaticTabDescriptor",
"custom_tag_template = xmodule.raw_module:RawDescriptor",
"about = xmodule.html_module:AboutDescriptor",
"wrapper = xmodule.wrapper_module:WrapperDescriptor",
"graphical_slider_tool = xmodule.gst_module:GraphicalSliderToolDescriptor",
"annotatable = xmodule.annotatable_module:AnnotatableDescriptor",
"foldit = xmodule.foldit_module:FolditDescriptor",

View File

@@ -1,4 +1,3 @@
import json
import random
import logging
from lxml import etree
@@ -7,6 +6,7 @@ from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.xml_module import XmlDescriptor
from xmodule.exceptions import InvalidDefinitionError
from xblock.core import String, Scope, Object, BlockScope
DEFAULT = "_DEFAULT_GROUP"
@@ -31,29 +31,42 @@ def group_from_value(groups, v):
return g
class ABTestModule(XModule):
class ABTestFields(object):
group_portions = Object(help="What proportions of students should go in each group", default={DEFAULT: 1}, scope=Scope.content)
group_assignments = Object(help="What group this user belongs to", scope=Scope.student_preferences, default={})
group_content = Object(help="What content to display to each group", scope=Scope.content, default={DEFAULT: []})
experiment = String(help="Experiment that this A/B test belongs to", scope=Scope.content)
has_children = True
class ABTestModule(ABTestFields, XModule):
"""
Implements an A/B test with an aribtrary number of competing groups
"""
def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs)
if shared_state is None:
def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs)
if self.group is None:
self.group = group_from_value(
self.definition['data']['group_portions'].items(),
self.group_portions.items(),
random.uniform(0, 1)
)
else:
shared_state = json.loads(shared_state)
self.group = shared_state['group']
def get_shared_state(self):
return json.dumps({'group': self.group})
@property
def group(self):
return self.group_assignments.get(self.experiment)
@group.setter
def group(self, value):
self.group_assignments[self.experiment] = value
@group.deleter
def group(self):
del self.group_assignments[self.experiment]
def get_child_descriptors(self):
active_locations = set(self.definition['data']['group_content'][self.group])
active_locations = set(self.group_content[self.group])
return [desc for desc in self.descriptor.get_children() if desc.location.url() in active_locations]
def displayable_items(self):
@@ -64,43 +77,11 @@ class ABTestModule(XModule):
# TODO (cpennington): Use Groups should be a first class object, rather than being
# managed by ABTests
class ABTestDescriptor(RawDescriptor, XmlDescriptor):
class ABTestDescriptor(ABTestFields, RawDescriptor, XmlDescriptor):
module_class = ABTestModule
template_dir_name = "abtest"
def __init__(self, system, definition=None, **kwargs):
"""
definition is a dictionary with the following layout:
{'data': {
'experiment': 'the name of the experiment',
'group_portions': {
'group_a': 0.1,
'group_b': 0.2
},
'group_contents': {
'group_a': [
'url://for/content/module/1',
'url://for/content/module/2',
],
'group_b': [
'url://for/content/module/3',
],
DEFAULT: [
'url://for/default/content/1'
]
}
},
'children': [
'url://for/content/module/1',
'url://for/content/module/2',
'url://for/content/module/3',
'url://for/default/content/1',
]}
"""
kwargs['shared_state_key'] = definition['data']['experiment']
RawDescriptor.__init__(self, system, definition, **kwargs)
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
@@ -118,19 +99,16 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
"ABTests must specify an experiment. Not found in:\n{xml}"
.format(xml=etree.tostring(xml_object, pretty_print=True)))
definition = {
'data': {
'experiment': experiment,
'group_portions': {},
'group_content': {DEFAULT: []},
},
'children': []}
group_portions = {}
group_content = {}
children = []
for group in xml_object:
if group.tag == 'default':
name = DEFAULT
else:
name = group.get('name')
definition['data']['group_portions'][name] = float(group.get('portion', 0))
group_portions[name] = float(group.get('portion', 0))
child_content_urls = []
for child in group:
@@ -140,29 +118,33 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
log.exception("Unable to load child when parsing ABTest. Continuing...")
continue
definition['data']['group_content'][name] = child_content_urls
definition['children'].extend(child_content_urls)
group_content[name] = child_content_urls
children.extend(child_content_urls)
default_portion = 1 - sum(
portion for (name, portion) in definition['data']['group_portions'].items())
portion for (name, portion) in group_portions.items()
)
if default_portion < 0:
raise InvalidDefinitionError("ABTest portions must add up to less than or equal to 1")
definition['data']['group_portions'][DEFAULT] = default_portion
definition['children'].sort()
group_portions[DEFAULT] = default_portion
children.sort()
return definition
return {
'group_portions': group_portions,
'group_content': group_content,
}, children
def definition_to_xml(self, resource_fs):
xml_object = etree.Element('abtest')
xml_object.set('experiment', self.definition['data']['experiment'])
for name, group in self.definition['data']['group_content'].items():
xml_object.set('experiment', self.experiment)
for name, group in self.group_content.items():
if name == DEFAULT:
group_elem = etree.SubElement(xml_object, 'default')
else:
group_elem = etree.SubElement(xml_object, 'group', attrib={
'portion': str(self.definition['data']['group_portions'][name]),
'portion': str(self.group_portions[name]),
'name': name,
})
@@ -172,6 +154,5 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
return xml_object
def has_dynamic_children(self):
return True

View File

@@ -5,13 +5,17 @@ from pkg_resources import resource_string, resource_listdir
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xblock.core import Scope, String
log = logging.getLogger(__name__)
class AnnotatableModule(XModule):
class AnnotatableFields(object):
data = String(help="XML data for the annotation", scope=Scope.content)
class AnnotatableModule(AnnotatableFields, XModule):
js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/html/display.coffee'),
@@ -22,6 +26,17 @@ class AnnotatableModule(XModule):
css = {'scss': [resource_string(__name__, 'css/annotatable/display.scss')]}
icon_class = 'annotatable'
def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs)
xmltree = etree.fromstring(self.data)
self.instructions = self._extract_instructions(xmltree)
self.content = etree.tostring(xmltree, encoding='unicode')
self.element_id = self.location.html_id()
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
def _get_annotation_class_attr(self, index, el):
""" Returns a dict with the CSS class attribute to set on the annotation
and an XML key to delete from the element.
@@ -103,7 +118,7 @@ class AnnotatableModule(XModule):
def get_html(self):
""" Renders parameters to template. """
context = {
'display_name': self.display_name,
'display_name': self.display_name_with_default,
'element_id': self.element_id,
'instructions_html': self.instructions,
'content_html': self._render_content()
@@ -111,19 +126,8 @@ class AnnotatableModule(XModule):
return self.system.render_template('annotatable.html', context)
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
xmltree = etree.fromstring(self.definition['data'])
self.instructions = self._extract_instructions(xmltree)
self.content = etree.tostring(xmltree, encoding='unicode')
self.element_id = self.location.html_id()
self.highlight_colors = ['yellow', 'orange', 'purple', 'blue', 'green']
class AnnotatableDescriptor(RawDescriptor):
class AnnotatableDescriptor(AnnotatableFields, RawDescriptor):
module_class = AnnotatableModule
stores_state = True
template_dir_name = "annotatable"

View File

@@ -1,7 +1,7 @@
"""
These modules exist to translate old format XML into newer, semantic forms
"""
from x_module import XModuleDescriptor
from .x_module import XModuleDescriptor
from lxml import etree
from functools import wraps
import logging

View File

@@ -6,25 +6,45 @@ import hashlib
import json
import logging
import traceback
import re
import sys
from datetime import timedelta
from lxml import etree
from pkg_resources import resource_string
from capa.capa_problem import LoncapaProblem
from capa.responsetypes import StudentInputError
from capa.util import convert_files_to_filenames
from progress import Progress
from .progress import Progress
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.exceptions import NotFoundError
from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, Float
from .fields import Timedelta
log = logging.getLogger("mitx.courseware")
#-----------------------------------------------------------------------------
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
class StringyInteger(Integer):
"""
A model type that converts from strings to integers when reading from json
"""
def from_json(self, value):
try:
return int(value)
except:
return None
class StringyFloat(Float):
"""
A model type that converts from string to floats when reading from json
"""
def from_json(self, value):
try:
return float(value)
except:
return None
# Generated this many different variants of problems with rerandomize=per_student
NUM_RANDOMIZATION_BINS = 20
@@ -45,41 +65,15 @@ def randomization_bin(seed, problem_id):
return int(h.hexdigest()[:7], 16) % NUM_RANDOMIZATION_BINS
def only_one(lst, default="", process=lambda x: x):
"""
If lst is empty, returns default
class Randomization(String):
def from_json(self, value):
if value in ("", "true"):
return "always"
elif value == "false":
return "per_student"
return value
If lst has a single element, applies process to that element and returns it.
Otherwise, raises an exception.
"""
if len(lst) == 0:
return default
elif len(lst) == 1:
return process(lst[0])
else:
raise Exception('Malformed XML: expected at most one element in list.')
def parse_timedelta(time_str):
"""
time_str: A string with the following components:
<D> day[s] (optional)
<H> hour[s] (optional)
<M> minute[s] (optional)
<S> second[s] (optional)
Returns a datetime.timedelta parsed from the string
"""
parts = TIMEDELTA_REGEX.match(time_str)
if not parts:
return
parts = parts.groupdict()
time_params = {}
for (name, param) in parts.iteritems():
if param:
time_params[name] = int(param)
return timedelta(**time_params)
to_json = from_json
class ComplexEncoder(json.JSONEncoder):
@@ -89,13 +83,32 @@ class ComplexEncoder(json.JSONEncoder):
return json.JSONEncoder.default(self, obj)
class CapaModule(XModule):
class CapaFields(object):
attempts = StringyInteger(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state)
max_attempts = StringyInteger(help="Maximum number of attempts that a student is allowed", scope=Scope.settings)
due = String(help="Date that this problem is due by", scope=Scope.settings)
graceperiod = Timedelta(help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings)
showanswer = String(help="When to show the problem answer to the student", scope=Scope.settings, default="closed")
force_save_button = Boolean(help="Whether to force the save button to appear on the page", scope=Scope.settings, default=False)
rerandomize = Randomization(help="When to rerandomize the problem", default="always", scope=Scope.settings)
data = String(help="XML data for the problem", scope=Scope.content)
correct_map = Object(help="Dictionary with the correctness of current student answers", scope=Scope.student_state, default={})
student_answers = Object(help="Dictionary with the current student responses", scope=Scope.student_state)
done = Boolean(help="Whether the student has answered the problem", scope=Scope.student_state)
display_name = String(help="Display name for this module", scope=Scope.settings)
seed = StringyInteger(help="Random seed for this student", scope=Scope.student_state)
weight = StringyFloat(help="How much to weight this problem by", scope=Scope.settings)
markdown = String(help="Markdown source of this module", scope=Scope.settings)
class CapaModule(CapaFields, XModule):
'''
An XModule implementing LonCapa format problems, implemented by way of
capa.capa_problem.LoncapaProblem
'''
icon_class = 'problem'
js = {'coffee': [resource_string(__name__, 'js/src/capa/display.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/javascript_loader.coffee'),
@@ -107,61 +120,25 @@ class CapaModule(XModule):
js_module_name = "Problem"
css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]}
def __init__(self, system, location, definition, descriptor, instance_state=None,
shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor, instance_state,
shared_state, **kwargs)
def __init__(self, system, location, descriptor, model_data):
XModule.__init__(self, system, location, descriptor, model_data)
self.attempts = 0
self.max_attempts = None
dom2 = etree.fromstring(definition['data'])
display_due_date_string = self.metadata.get('due', None)
if display_due_date_string is not None:
self.display_due_date = dateutil.parser.parse(display_due_date_string)
#log.debug("Parsed " + display_due_date_string +
# " to " + str(self.display_due_date))
if self.due:
due_date = dateutil.parser.parse(self.due)
else:
self.display_due_date = None
due_date = None
grace_period_string = self.metadata.get('graceperiod', None)
if grace_period_string is not None and self.display_due_date:
self.grace_period = parse_timedelta(grace_period_string)
self.close_date = self.display_due_date + self.grace_period
#log.debug("Then parsed " + grace_period_string +
# " to closing date" + str(self.close_date))
if self.graceperiod is not None and due_date:
self.close_date = due_date + self.graceperiod
else:
self.grace_period = None
self.close_date = self.display_due_date
self.close_date = due_date
max_attempts = self.metadata.get('attempts')
if max_attempts is not None and max_attempts != '':
self.max_attempts = int(max_attempts)
else:
self.max_attempts = None
self.show_answer = self.metadata.get('showanswer', 'closed')
self.force_save_button = self.metadata.get('force_save_button', 'false')
if self.show_answer == "":
self.show_answer = "closed"
if instance_state is not None:
instance_state = json.loads(instance_state)
if instance_state is not None and 'attempts' in instance_state:
self.attempts = instance_state['attempts']
self.name = only_one(dom2.xpath('/problem/@name'))
if self.rerandomize == 'never':
self.seed = 1
elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'):
# see comment on randomization_bin
self.seed = randomization_bin(system.seed, self.location.url)
else:
self.seed = None
if self.seed is None:
if self.rerandomize == 'never':
self.seed = 1
elif self.rerandomize == "per_student" and hasattr(self.system, 'seed'):
# see comment on randomization_bin
self.seed = randomization_bin(system.seed, self.location.url)
# Need the problem location in openendedresponse to send out. Adding
# it to the system here seems like the least clunky way to get it
@@ -171,8 +148,7 @@ class CapaModule(XModule):
try:
# TODO (vshnayder): move as much as possible of this work and error
# checking to descriptor load time
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
instance_state, seed=self.seed, system=self.system)
self.lcp = self.new_lcp(self.get_state_for_lcp())
except Exception as err:
msg = 'cannot create LoncapaProblem {loc}: {err}'.format(
loc=self.location.url(), err=err)
@@ -189,35 +165,38 @@ class CapaModule(XModule):
problem_text = ('<problem><text><span class="inline-error">'
'Problem %s has an error:</span>%s</text></problem>' %
(self.location.url(), msg))
self.lcp = LoncapaProblem(
problem_text, self.location.html_id(),
instance_state, seed=self.seed, system=self.system)
self.lcp = self.new_lcp(self.get_state_for_lcp(), text=problem_text)
else:
# add extra info and raise
raise Exception(msg), None, sys.exc_info()[2]
@property
def rerandomize(self):
"""
Property accessor that returns self.metadata['rerandomize'] in a
canonical form
"""
rerandomize = self.metadata.get('rerandomize', 'always')
if rerandomize in ("", "always", "true"):
return "always"
elif rerandomize in ("false", "per_student"):
return "per_student"
elif rerandomize == "never":
return "never"
elif rerandomize == "onreset":
return "onreset"
else:
raise Exception("Invalid rerandomize attribute " + rerandomize)
self.set_state_from_lcp()
def get_instance_state(self):
state = self.lcp.get_state()
state['attempts'] = self.attempts
return json.dumps(state)
def new_lcp(self, state, text=None):
if text is None:
text = self.data
return LoncapaProblem(
problem_text=text,
id=self.location.html_id(),
state=state,
system=self.system,
)
def get_state_for_lcp(self):
return {
'done': self.done,
'correct_map': self.correct_map,
'student_answers': self.student_answers,
'seed': self.seed,
}
def set_state_from_lcp(self):
lcp_state = self.lcp.get_state()
self.done = lcp_state['done']
self.correct_map = lcp_state['correct_map']
self.student_answers = lcp_state['student_answers']
self.seed = lcp_state['seed']
def get_score(self):
return self.lcp.get_score()
@@ -234,7 +213,7 @@ class CapaModule(XModule):
if total > 0:
try:
return Progress(score, total)
except Exception as err:
except Exception:
log.exception("Got bad progress")
return None
return None
@@ -291,7 +270,6 @@ class CapaModule(XModule):
return False
else:
return True
# Only randomized problems need a "reset" button
else:
return False
@@ -310,11 +288,26 @@ class CapaModule(XModule):
is_survey_question = (self.max_attempts == 0)
needs_reset = self.is_completed() and self.rerandomize == "always"
# If the student has unlimited attempts, and their answers
# are not randomized, then we do not need a save button
# because they can use the "Check" button without consequences.
#
# The consequences we want to avoid are:
# * Using up an attempt (if max_attempts is set)
# * Changing the current problem, and no longer being
# able to view it (if rerandomize is "always")
#
# In those cases. the if statement below is false,
# and the save button can still be displayed.
#
if self.max_attempts is None and self.rerandomize != "always":
return False
# If the problem is closed (and not a survey question with max_attempts==0),
# then do NOT show the reset button
# then do NOT show the save button
# If we're waiting for the user to reset a randomized problem
# then do NOT show the reset button
if (self.closed() and not is_survey_question) or needs_reset:
# then do NOT show the save button
elif (self.closed() and not is_survey_question) or needs_reset:
return False
else:
return True
@@ -343,6 +336,8 @@ class CapaModule(XModule):
# We're in non-debug mode, and possibly even in production. We want
# to avoid bricking of problem as much as possible
else:
# We're in non-debug mode, and possibly even in production. We want
# to avoid bricking of problem as much as possible
# Presumably, student submission has corrupted LoncapaProblem HTML.
# First, pull down all student answers
@@ -359,9 +354,8 @@ class CapaModule(XModule):
student_answers.pop(answer_id)
# Next, generate a fresh LoncapaProblem
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
state=None, # Tabula rasa
seed=self.seed, system=self.system)
self.lcp = self.new_lcp(None)
self.set_state_from_lcp()
# Prepend a scary warning to the student
warning = '<div class="capa_reset">'\
@@ -379,8 +373,8 @@ class CapaModule(XModule):
html = warning
try:
html += self.lcp.get_html()
except Exception, err: # Couldn't do it. Give up
log.exception(err)
except Exception: # Couldn't do it. Give up
log.exception("Unable to generate html from LoncapaProblem")
raise
return html
@@ -403,16 +397,15 @@ class CapaModule(XModule):
# if we want to show a check button, and False otherwise
# This works because non-empty strings evaluate to True
if self.should_show_check_button():
check_button = self.check_button_name()
check_button = self.check_button_name()
else:
check_button = False
content = {'name': self.display_name,
content = {'name': self.display_name_with_default,
'html': html,
'weight': self.descriptor.weight,
'weight': self.weight,
}
context = {'problem': content,
'id': self.id,
'check_button': check_button,
@@ -499,28 +492,28 @@ class CapaModule(XModule):
'''
Is the user allowed to see an answer?
'''
if self.show_answer == '':
if self.showanswer == '':
return False
elif self.show_answer == "never":
elif self.showanswer == "never":
return False
elif self.system.user_is_staff:
# This is after the 'never' check because admins can see the answer
# unless the problem explicitly prevents it
return True
elif self.show_answer == 'attempted':
elif self.showanswer == 'attempted':
return self.attempts > 0
elif self.show_answer == 'answered':
elif self.showanswer == 'answered':
# NOTE: this is slightly different from 'attempted' -- resetting the problems
# makes lcp.done False, but leaves attempts unchanged.
return self.lcp.done
elif self.show_answer == 'closed':
elif self.showanswer == 'closed':
return self.closed()
elif self.show_answer == 'finished':
elif self.showanswer == 'finished':
return self.closed() or self.is_correct()
elif self.show_answer == 'past_due':
elif self.showanswer == 'past_due':
return self.is_past_due()
elif self.show_answer == 'always':
elif self.showanswer == 'always':
return True
return False
@@ -539,6 +532,8 @@ class CapaModule(XModule):
queuekey = get['queuekey']
score_msg = get['xqueue_body']
self.lcp.update_score(score_msg, queuekey)
self.set_state_from_lcp()
self.publish_grade()
return dict() # No AJAX return is needed
@@ -550,13 +545,14 @@ class CapaModule(XModule):
'''
event_info = dict()
event_info['problem_id'] = self.location.url()
self.system.track_function('show_answer', event_info)
self.system.track_function('showanswer', event_info)
if not self.answer_available():
raise NotFoundError('Answer is not available')
else:
answers = self.lcp.get_question_answers()
self.set_state_from_lcp()
# answers (eg <solution>) may have embedded images
# answers (eg <solution>) may have embedded images
# but be careful, some problems are using non-string answer dicts
new_answers = dict()
for answer_id in answers:
@@ -606,7 +602,7 @@ class CapaModule(XModule):
to 'input_1' in the returned dict)
'''
answers = dict()
for key in get:
# e.g. input_resistor_1 ==> resistor_1
_, _, name = key.partition('_')
@@ -639,6 +635,18 @@ class CapaModule(XModule):
return answers
def publish_grade(self):
"""
Publishes the student's current grade to the system as an event
"""
score = self.lcp.get_score()
self.system.publish({
'event_name': 'grade',
'value': score['score'],
'max_value': score['total'],
})
def check_problem(self, get):
''' Checks whether answers to a problem are correct, and
returns a map of correct/incorrect answers:
@@ -652,7 +660,6 @@ class CapaModule(XModule):
answers = self.make_dict_of_responses(get)
event_info['answers'] = convert_files_to_filenames(answers)
# Too late. Cannot submit
if self.closed():
event_info['failure'] = 'closed'
@@ -660,7 +667,7 @@ class CapaModule(XModule):
raise NotFoundError('Problem is closed')
# Problem submitted. Student should reset before checking again
if self.lcp.done and self.rerandomize == "always":
if self.done and self.rerandomize == "always":
event_info['failure'] = 'unreset'
self.system.track_function('save_problem_check_fail', event_info)
raise NotFoundError('Problem must be reset before it can be checked again')
@@ -672,12 +679,11 @@ class CapaModule(XModule):
waittime_between_requests = self.system.xqueue['waittime']
if (current_time - prev_submit_time).total_seconds() < waittime_between_requests:
msg = 'You must wait at least %d seconds between submissions' % waittime_between_requests
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
return {'success': msg, 'html': ''} # Prompts a modal dialog in ajax callback
try:
old_state = self.lcp.get_state()
lcp_id = self.lcp.problem_id
correct_map = self.lcp.grade_answers(answers)
self.set_state_from_lcp()
except StudentInputError as inst:
log.exception("StudentInputError in capa_module:problem_check")
return {'success': inst.message}
@@ -686,12 +692,14 @@ class CapaModule(XModule):
msg = "Error checking problem: " + str(err)
msg += '\nTraceback:\n' + traceback.format_exc()
return {'success': msg}
log.exception("Error in capa_module problem checking")
raise Exception("error in capa_module")
raise
self.attempts = self.attempts + 1
self.lcp.done = True
self.set_state_from_lcp()
self.publish_grade()
# success = correct if ALL questions in this problem are correct
success = 'correct'
for answer_id in correct_map:
@@ -705,7 +713,7 @@ class CapaModule(XModule):
event_info['attempts'] = self.attempts
self.system.track_function('save_problem_check', event_info)
if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback
if hasattr(self.system, 'psychometrics_handler'): # update PsychometricsData using callback
self.system.psychometrics_handler(self.get_instance_state())
# render problem into HTML
@@ -729,7 +737,7 @@ class CapaModule(XModule):
event_info['answers'] = answers
# Too late. Cannot submit
if self.closed() and not self.max_attempts==0:
if self.closed() and not self.max_attempts ==0:
event_info['failure'] = 'closed'
self.system.track_function('save_problem_fail', event_info)
return {'success': False,
@@ -737,7 +745,7 @@ class CapaModule(XModule):
# Problem submitted. Student should reset before saving
# again.
if self.lcp.done and self.rerandomize == "always":
if self.done and self.rerandomize == "always":
event_info['failure'] = 'done'
self.system.track_function('save_problem_fail', event_info)
return {'success': False,
@@ -745,9 +753,11 @@ class CapaModule(XModule):
self.lcp.student_answers = answers
self.set_state_from_lcp()
self.system.track_function('save_problem_success', event_info)
msg = "Your answers have been saved"
if not self.max_attempts==0:
if not self.max_attempts ==0:
msg += " but not graded. Hit 'Check' to grade them."
return {'success': True,
'msg': msg}
@@ -773,31 +783,33 @@ class CapaModule(XModule):
return {'success': False,
'error': "Problem is closed"}
if not self.lcp.done:
if not self.done:
event_info['failure'] = 'not_done'
self.system.track_function('reset_problem_fail', event_info)
return {'success': False,
'error': "Refresh the page and make an attempt before resetting."}
self.lcp.do_reset()
if self.rerandomize in ["always", "onreset"]:
# reset random number generator seed (note the self.lcp.get_state()
# in next line)
self.lcp.seed = None
seed = None
else:
seed = self.lcp.seed
self.lcp = LoncapaProblem(self.definition['data'],
self.location.html_id(), self.lcp.get_state(),
system=self.system)
# Generate a new problem with either the previous seed or a new seed
self.lcp = self.new_lcp({'seed': seed})
# Pull in the new problem seed
self.set_state_from_lcp()
event_info['new_state'] = self.lcp.get_state()
self.system.track_function('reset_problem', event_info)
return { 'success': True,
return {'success': True,
'html': self.get_problem_html(encapsulate=False)}
class CapaDescriptor(RawDescriptor):
class CapaDescriptor(CapaFields, RawDescriptor):
"""
Module implementing problems in the LON-CAPA format,
as implemented by capa.capa_problem
@@ -818,20 +830,27 @@ class CapaDescriptor(RawDescriptor):
# actually use type and points?
metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points')
# The capa format specifies that what we call max_attempts in the code
# is the attribute `attempts`. This will do that conversion
metadata_translations = dict(RawDescriptor.metadata_translations)
metadata_translations['attempts'] = 'max_attempts'
def get_context(self):
_context = RawDescriptor.get_context(self)
_context.update({'markdown': self.metadata.get('markdown', ''),
'enable_markdown' : 'markdown' in self.metadata})
_context.update({'markdown': self.markdown,
'enable_markdown': self.markdown is not None})
return _context
@property
def editable_metadata_fields(self):
"""Remove any metadata from the editable fields which have their own editor or shouldn't be edited by user."""
subset = [field for field in super(CapaDescriptor,self).editable_metadata_fields
if field not in ['markdown', 'empty']]
"""Remove metadata from the editable fields since it has its own editor"""
subset = super(CapaDescriptor, self).editable_metadata_fields
if 'markdown' in subset:
del subset['markdown']
if 'empty' in subset:
del subset['empty']
return subset
# VS[compat]
# TODO (cpennington): Delete this method once all fall 2012 course are being
# edited in the cms
@@ -841,12 +860,3 @@ class CapaDescriptor(RawDescriptor):
'problems/' + path[8:],
path[8:],
]
def __init__(self, *args, **kwargs):
super(CapaDescriptor, self).__init__(*args, **kwargs)
weight_string = self.metadata.get('weight', None)
if weight_string:
self.weight = float(weight_string)
else:
self.weight = None

View File

@@ -6,19 +6,47 @@ from pkg_resources import resource_string
from xmodule.raw_module import RawDescriptor
from .x_module import XModule
from xblock.core import Integer, Scope, BlockScope, ModelType, String, Boolean, Object, Float, List
from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor
log = logging.getLogger("mitx.courseware")
V1_SETTINGS_ATTRIBUTES = ["display_name", "attempts", "is_graded", "accept_file_upload",
"skip_spelling_checks", "due", "graceperiod", "max_score"]
V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state",
"student_attempts", "ready_to_reset"]
V1_ATTRIBUTES = V1_SETTINGS_ATTRIBUTES + V1_STUDENT_ATTRIBUTES
VERSION_TUPLES = (
('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module),
('1', CombinedOpenEndedV1Descriptor, CombinedOpenEndedV1Module, V1_SETTINGS_ATTRIBUTES, V1_STUDENT_ATTRIBUTES),
)
DEFAULT_VERSION = 1
DEFAULT_VERSION = str(DEFAULT_VERSION)
class CombinedOpenEndedModule(XModule):
class CombinedOpenEndedFields(object):
display_name = String(help="Display name for this module", default="Open Ended Grading", scope=Scope.settings)
current_task_number = Integer(help="Current task that the student is on.", default=0, scope=Scope.student_state)
task_states = List(help="List of state dictionaries of each task within this module.", scope=Scope.student_state)
state = String(help="Which step within the current task that the student is on.", default="initial", scope=Scope.student_state)
student_attempts = Integer(help="Number of attempts taken by the student on this problem", default=0, scope=Scope.student_state)
ready_to_reset = Boolean(help="If the problem is ready to be reset or not.", default=False, scope=Scope.student_state)
attempts = Integer(help="Maximum number of attempts that a student is allowed.", default=1, scope=Scope.settings)
is_graded = Boolean(help="Whether or not the problem is graded.", default=False, scope=Scope.settings)
accept_file_upload = Boolean(help="Whether or not the problem accepts file uploads.", default=False, scope=Scope.settings)
skip_spelling_checks = Boolean(help="Whether or not to skip initial spelling checks.", default=True, scope=Scope.settings)
due = String(help="Date that this problem is due by", default=None, scope=Scope.settings)
graceperiod = String(help="Amount of time after the due date that submissions will be accepted", default=None, scope=Scope.settings)
max_score = Integer(help="Maximum score for the problem.", default=1, scope=Scope.settings)
version = Integer(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings)
data = String(help="XML data for the problem", scope=Scope.content)
class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule):
"""
This is a module that encapsulates all open ended grading (self assessment, peer assessment, etc).
It transitions between problems, and support arbitrary ordering.
@@ -49,6 +77,8 @@ class CombinedOpenEndedModule(XModule):
INTERMEDIATE_DONE = 'intermediate_done'
DONE = 'done'
icon_class = 'problem'
js = {'coffee': [resource_string(__name__, 'js/src/combinedopenended/display.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/javascript_loader.coffee'),
@@ -57,11 +87,8 @@ class CombinedOpenEndedModule(XModule):
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
def __init__(self, system, location, descriptor, model_data):
XModule.__init__(self, system, location, descriptor, model_data)
"""
Definition file should have one or many task blocks, a rubric block, and a prompt block:
@@ -100,25 +127,15 @@ class CombinedOpenEndedModule(XModule):
self.system = system
self.system.set('location', location)
# Load instance state
if instance_state is not None:
instance_state = json.loads(instance_state)
else:
instance_state = {}
self.version = self.metadata.get('version', DEFAULT_VERSION)
version_error_string = "Version of combined open ended module {0} is not correct. Going with version {1}"
if not isinstance(self.version, basestring):
try:
self.version = str(self.version)
except:
#This is a dev_facing_error
log.info(version_error_string.format(self.version, DEFAULT_VERSION))
self.version = DEFAULT_VERSION
if self.task_states is None:
self.task_states = []
versions = [i[0] for i in VERSION_TUPLES]
descriptors = [i[1] for i in VERSION_TUPLES]
modules = [i[2] for i in VERSION_TUPLES]
settings_attributes = [i[3] for i in VERSION_TUPLES]
student_attributes = [i[4] for i in VERSION_TUPLES]
version_error_string = "Could not find version {0}, using version {1} instead"
try:
version_index = versions.index(self.version)
@@ -128,22 +145,31 @@ class CombinedOpenEndedModule(XModule):
self.version = DEFAULT_VERSION
version_index = versions.index(self.version)
self.student_attributes = student_attributes[version_index]
self.settings_attributes = settings_attributes[version_index]
attributes = self.student_attributes + self.settings_attributes
static_data = {
'rewrite_content_links': self.rewrite_content_links,
}
instance_state = {k: getattr(self, k) for k in attributes}
self.child_descriptor = descriptors[version_index](self.system)
self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(definition['data']),
self.system)
self.child_definition = descriptors[version_index].definition_from_xml(etree.fromstring(self.data), self.system)
self.child_module = modules[version_index](self.system, location, self.child_definition, self.child_descriptor,
instance_state=json.dumps(instance_state), metadata=self.metadata,
static_data=static_data)
instance_state=instance_state, static_data=static_data, attributes=attributes)
self.save_instance_data()
def get_html(self):
return self.child_module.get_html()
self.save_instance_data()
return_value = self.child_module.get_html()
return return_value
def handle_ajax(self, dispatch, get):
return self.child_module.handle_ajax(dispatch, get)
self.save_instance_data()
return_value = self.child_module.handle_ajax(dispatch, get)
self.save_instance_data()
return return_value
def get_instance_state(self):
return self.child_module.get_instance_state()
@@ -151,8 +177,8 @@ class CombinedOpenEndedModule(XModule):
def get_score(self):
return self.child_module.get_score()
def max_score(self):
return self.child_module.max_score()
#def max_score(self):
# return self.child_module.max_score()
def get_progress(self):
return self.child_module.get_progress()
@@ -161,12 +187,14 @@ class CombinedOpenEndedModule(XModule):
def due_date(self):
return self.child_module.due_date
@property
def display_name(self):
return self.child_module.display_name
def save_instance_data(self):
for attribute in self.student_attributes:
child_attr = getattr(self.child_module, attribute)
if child_attr != getattr(self, attribute):
setattr(self, attribute, getattr(self.child_module, attribute))
class CombinedOpenEndedDescriptor(RawDescriptor):
class CombinedOpenEndedDescriptor(CombinedOpenEndedFields, RawDescriptor):
"""
Module for adding combined open ended questions
"""

View File

@@ -1,126 +1,147 @@
"""Conditional module is the xmodule, which you can use for disabling
some xmodules by conditions.
"""
import json
import logging
from lxml import etree
from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor
from xblock.core import String, Scope, List
from xmodule.modulestore.exceptions import ItemNotFoundError
from pkg_resources import resource_string
log = logging.getLogger('mitx.' + __name__)
class ConditionalModule(XModule):
'''
class ConditionalFields(object):
show_tag_list = List(help="Poll answers", scope=Scope.content)
class ConditionalModule(ConditionalFields, XModule):
"""
Blocks child module from showing unless certain conditions are met.
Example:
<conditional condition="require_completed" required="tag/url_name1&tag/url_name2">
<conditional sources="i4x://.../problem_1; i4x://.../problem_2" completed="True">
<show sources="i4x://.../test_6; i4x://.../Avi_resources"/>
<video url_name="secret_video" />
</conditional>
<conditional condition="require_attempted" required="tag/url_name1&tag/url_name2">
<video url_name="secret_video" />
</conditional>
<conditional> tag attributes:
sources - location id of required modules, separated by ';'
'''
completed - map to `is_completed` module method
attempted - map to `is_attempted` module method
poll_answer - map to `poll_answer` module attribute
voted - map to `voted` module attribute
js = {'coffee': [resource_string(__name__, 'js/src/conditional/display.coffee'),
<conditional> tag attributes:
sources - location id of modules, separated by ';'
"""
js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
resource_string(__name__, 'js/src/conditional/display.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/javascript_loader.coffee'),
]}
js_module_name = "Conditional"
css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]}
# Map
# key: <tag attribute in xml>
# value: <name of module attribute>
conditions_map = {
'poll_answer': 'poll_answer', # poll_question attr
'completed': 'is_completed', # capa_problem attr
'attempted': 'is_attempted', # capa_problem attr
'voted': 'voted' # poll_question attr
}
def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs):
"""
In addition to the normal XModule init, provide:
self.condition = string describing condition required
"""
XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs)
self.contents = None
self.condition = self.metadata.get('condition', '')
self._get_required_modules()
children = self.get_display_items()
if children:
self.icon_class = children[0].get_icon_class()
#log.debug('conditional module required=%s' % self.required_modules_list)
def _get_required_modules(self):
self.required_modules = []
for descriptor in self.descriptor.get_required_module_descriptors():
module = self.system.get_module(descriptor)
self.required_modules.append(module)
#log.debug('required_modules=%s' % (self.required_modules))
def _get_condition(self):
# Get first valid condition.
for xml_attr, attr_name in self.conditions_map.iteritems():
xml_value = self.descriptor.xml_attributes.get(xml_attr)
if xml_value:
return xml_value, attr_name
raise Exception('Error in conditional module: unknown condition "%s"'
% xml_attr)
def is_condition_satisfied(self):
self._get_required_modules()
self.required_modules = [self.system.get_module(descriptor) for
descriptor in self.descriptor.get_required_module_descriptors()]
if self.condition == 'require_completed':
# all required modules must be completed, as determined by
# the modules .is_completed() method
for module in self.required_modules:
#log.debug('in is_condition_satisfied; student_answers=%s' % module.lcp.student_answers)
#log.debug('in is_condition_satisfied; instance_state=%s' % module.instance_state)
if not hasattr(module, 'is_completed'):
raise Exception('Error in conditional module: required module %s has no .is_completed() method' % module)
if not module.is_completed():
log.debug('conditional module: %s not completed' % module)
return False
else:
log.debug('conditional module: %s IS completed' % module)
return True
elif self.condition == 'require_attempted':
# all required modules must be attempted, as determined by
# the modules .is_attempted() method
for module in self.required_modules:
if not hasattr(module, 'is_attempted'):
raise Exception('Error in conditional module: required module %s has no .is_attempted() method' % module)
if not module.is_attempted():
log.debug('conditional module: %s not attempted' % module)
return False
else:
log.debug('conditional module: %s IS attempted' % module)
return True
else:
raise Exception('Error in conditional module: unknown condition "%s"' % self.condition)
xml_value, attr_name = self._get_condition()
return True
if xml_value and self.required_modules:
for module in self.required_modules:
if not hasattr(module, attr_name):
raise Exception('Error in conditional module: \
required module {module} has no {module_attr}'.format(
module=module, module_attr=attr_name))
attr = getattr(module, attr_name)
if callable(attr):
attr = attr()
if xml_value != str(attr):
break
else:
return True
return False
def get_html(self):
self.is_condition_satisfied()
# Calculate html ids of dependencies
self.required_html_ids = [descriptor.location.html_id() for
descriptor in self.descriptor.get_required_module_descriptors()]
return self.system.render_template('conditional_ajax.html', {
'element_id': self.location.html_id(),
'id': self.id,
'ajax_url': self.system.ajax_url,
'depends': ';'.join(self.required_html_ids)
})
def handle_ajax(self, dispatch, post):
'''
This is called by courseware.module_render, to handle an AJAX call.
'''
#log.debug('conditional_module handle_ajax: dispatch=%s' % dispatch)
"""This is called by courseware.moduleodule_render, to handle
an AJAX call.
"""
if not self.is_condition_satisfied():
context = {'module': self}
html = self.system.render_template('conditional_module.html', context)
return json.dumps({'html': html})
message = self.descriptor.xml_attributes.get('message')
context = {'module': self,
'message': message}
html = self.system.render_template('conditional_module.html',
context)
return json.dumps({'html': [html], 'message': bool(message)})
if self.contents is None:
self.contents = [child.get_html() for child in self.get_display_items()]
# for now, just deal with one child
html = self.contents[0]
html = [child.get_html() for child in self.get_display_items()]
return json.dumps({'html': html})
def get_icon_class(self):
new_class = 'other'
if self.is_condition_satisfied():
# HACK: This shouldn't be hard-coded to two types
# OBSOLETE: This obsoletes 'type'
class_priority = ['video', 'problem']
child_classes = [self.system.get_module(child_descriptor).get_icon_class()
for child_descriptor in self.descriptor.get_children()]
for c in class_priority:
if c in child_classes:
new_class = c
return new_class
class ConditionalDescriptor(ConditionalFields, SequenceDescriptor):
"""Descriptor for conditional xmodule."""
_tag_name = 'conditional'
class ConditionalDescriptor(SequenceDescriptor):
module_class = ConditionalModule
filename_extension = "xml"
@@ -128,26 +149,68 @@ class ConditionalDescriptor(SequenceDescriptor):
stores_state = True
has_score = False
def __init__(self, *args, **kwargs):
super(ConditionalDescriptor, self).__init__(*args, **kwargs)
required_module_list = [tuple(x.split('/', 1)) for x in self.metadata.get('required', '').split('&')]
self.required_module_locations = []
for rm in required_module_list:
try:
(tag, name) = rm
except Exception as err:
msg = "Specification of required module in conditional is broken: %s" % self.metadata.get('required')
log.warning(msg)
self.system.error_tracker(msg)
continue
loc = self.location.dict()
loc['category'] = tag
loc['name'] = name
self.required_module_locations.append(Location(loc))
log.debug('ConditionalDescriptor required_module_locations=%s' % self.required_module_locations)
@staticmethod
def parse_sources(xml_element, system, return_descriptor=False):
"""Parse xml_element 'sources' attr and:
if return_descriptor=True - return list of descriptors
if return_descriptor=False - return list of locations
"""
result = []
sources = xml_element.get('sources')
if sources:
locations = [location.strip() for location in sources.split(';')]
for location in locations:
if Location.is_valid(location): # Check valid location url.
try:
if return_descriptor:
descriptor = system.load_item(location)
result.append(descriptor)
else:
result.append(location)
except ItemNotFoundError:
msg = "Invalid module by location."
log.exception(msg)
system.error_tracker(msg)
return result
def get_required_module_descriptors(self):
"""Returns a list of XModuleDescritpor instances upon which this module depends, but are
not children of this module"""
return [self.system.load_item(loc) for loc in self.required_module_locations]
"""Returns a list of XModuleDescritpor instances upon
which this module depends.
"""
return ConditionalDescriptor.parse_sources(
self.xml_attributes, self.system, True)
@classmethod
def definition_from_xml(cls, xml_object, system):
children = []
show_tag_list = []
for child in xml_object:
if child.tag == 'show':
location = ConditionalDescriptor.parse_sources(
child, system)
children.extend(location)
show_tag_list.extend(location)
else:
try:
descriptor = system.process_xml(etree.tostring(child))
module_url = descriptor.location.url()
children.append(module_url)
except:
msg = "Unable to load child when parsing Conditional."
log.exception(msg)
system.error_tracker(msg)
return {'show_tag_list': show_tag_list}, children
def definition_to_xml(self, resource_fs):
xml_object = etree.Element(self._tag_name)
for child in self.get_children():
location = str(child.location)
if location in self.show_tag_list:
show_str = '<{tag_name} sources="{sources}" />'.format(
tag_name='show', sources=location)
xml_object.append(etree.fromstring(show_str))
else:
xml_object.append(
etree.fromstring(child.export_to_xml(resource_fs)))
return xml_object

View File

@@ -9,7 +9,7 @@ from datetime import datetime
from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor, SequenceModule
from xmodule.timeparse import parse_time, stringify_time
from xmodule.timeparse import parse_time
from xmodule.util.decorators import lazyproperty
from xmodule.graders import grader_from_conf
from datetime import datetime
@@ -19,107 +19,212 @@ import requests
import time
import copy
from xblock.core import Scope, ModelType, List, String, Object, Boolean
from .fields import Date
log = logging.getLogger(__name__)
class StringOrDate(Date):
def from_json(self, value):
"""
Parse an optional metadata key containing a time: if present, complain
if it doesn't parse.
Return None if not present or invalid.
"""
if value is None:
return None
try:
return time.strptime(value, self.time_format)
except ValueError:
return value
def to_json(self, value):
"""
Convert a time struct to a string
"""
if value is None:
return None
try:
return time.strftime(self.time_format, value)
except (ValueError, TypeError):
return value
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True)
_cached_toc = {}
class Textbook(object):
def __init__(self, title, book_url):
self.title = title
self.book_url = book_url
self.start_page = int(self.table_of_contents[0].attrib['page'])
class CourseDescriptor(SequenceDescriptor):
module_class = SequenceModule
# The last page should be the last element in the table of contents,
# but it may be nested. So recurse all the way down the last element
last_el = self.table_of_contents[-1]
while last_el.getchildren():
last_el = last_el[-1]
template_dir_name = 'course'
self.end_page = int(last_el.attrib['page'])
class Textbook:
def __init__(self, title, book_url):
self.title = title
self.book_url = book_url
self.table_of_contents = self._get_toc_from_s3()
self.start_page = int(self.table_of_contents[0].attrib['page'])
@lazyproperty
def table_of_contents(self):
"""
Accesses the textbook's table of contents (default name "toc.xml") at the URL self.book_url
# The last page should be the last element in the table of contents,
# but it may be nested. So recurse all the way down the last element
last_el = self.table_of_contents[-1]
while last_el.getchildren():
last_el = last_el[-1]
Returns XML tree representation of the table of contents
"""
toc_url = self.book_url + 'toc.xml'
self.end_page = int(last_el.attrib['page'])
# 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
@property
def table_of_contents(self):
return self.table_of_contents
# Get the table of contents from S3
log.info("Retrieving textbook table of contents from %s" % toc_url)
try:
r = requests.get(toc_url)
except Exception as err:
msg = 'Error %s: Unable to retrieve textbook table of contents at %s' % (err, toc_url)
log.error(msg)
raise Exception(msg)
def _get_toc_from_s3(self):
"""
Accesses the textbook's table of contents (default name "toc.xml") at the URL self.book_url
# TOC is XML. Parse it
try:
table_of_contents = etree.fromstring(r.text)
except Exception as err:
msg = 'Error %s: Unable to parse XML for textbook table of contents at %s' % (err, toc_url)
log.error(msg)
raise Exception(msg)
Returns XML tree representation of the table of contents
"""
toc_url = self.book_url + 'toc.xml'
return table_of_contents
# 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.
class TextbookList(List):
def from_json(self, values):
textbooks = []
for title, book_url in values:
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:
r = requests.get(toc_url)
except Exception as err:
msg = 'Error %s: Unable to retrieve textbook table of contents at %s' % (err, toc_url)
log.error(msg)
raise Exception(msg)
# 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)
raise Exception(msg)
return table_of_contents
def __init__(self, system, definition=None, **kwargs):
super(CourseDescriptor, self).__init__(system, definition, **kwargs)
self.textbooks = []
for title, book_url in self.definition['data']['textbooks']:
try:
self.textbooks.append(self.Textbook(title, book_url))
textbooks.append(Textbook(title, book_url))
except:
# If we can't get to S3 (e.g. on a train with no internet), don't break
# the rest of the courseware.
log.exception("Couldn't load textbook ({0}, {1})".format(title, book_url))
continue
self.wiki_slug = self.definition['data']['wiki_slug'] or self.location.course
return textbooks
def to_json(self, values):
json_data = []
for val in values:
if isinstance(val, Textbook):
json_data.append((val.title, val.book_url))
elif isinstance(val, tuple):
json_data.append(val)
else:
continue
return json_data
class CourseFields(object):
textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", scope=Scope.content)
wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content)
enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings)
enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings)
start = Date(help="Start time when this module is visible", scope=Scope.settings)
end = Date(help="Date that this class ends", scope=Scope.settings)
advertised_start = StringOrDate(help="Date that this course is advertised to start", scope=Scope.settings)
grading_policy = Object(help="Grading policy definition for this class", scope=Scope.content)
show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings)
display_name = String(help="Display name for this module", scope=Scope.settings)
tabs = List(help="List of tabs to enable in this course", scope=Scope.settings)
end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings)
discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings)
discussion_topics = Object(
help="Map of topics names to ids",
scope=Scope.settings,
computed_default=lambda c: {'General': {'id': c.location.html_id()}},
)
testcenter_info = Object(help="Dictionary of Test Center info", scope=Scope.settings)
announcement = Date(help="Date this course is announced", scope=Scope.settings)
cohort_config = Object(help="Dictionary defining cohort configuration", scope=Scope.settings)
is_new = Boolean(help="Whether this course should be flagged as new", scope=Scope.settings)
no_grade = Boolean(help="True if this course isn't graded", default=False, scope=Scope.settings)
disable_progress_graph = Boolean(help="True if this course shouldn't display the progress graph", default=False, scope=Scope.settings)
pdf_textbooks = List(help="List of dictionaries containing pdf_textbook configuration", scope=Scope.settings)
html_textbooks = List(help="List of dictionaries containing html_textbook configuration", scope=Scope.settings)
remote_gradebook = Object(scope=Scope.settings)
allow_anonymous = Boolean(scope=Scope.settings, default=True)
allow_anonymous_to_peers = Boolean(scope=Scope.settings, default=False)
advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings)
has_children = True
info_sidebar_name = String(scope=Scope.settings, default='Course Handouts')
# An extra property is used rather than the wiki_slug/number because
# there are courses that change the number for different runs. This allows
# courses to share the same css_class across runs even if they have
# different numbers.
#
# TODO get rid of this as soon as possible or potentially build in a robust
# way to add in course-specific styling. There needs to be a discussion
# about the right way to do this, but arjun will address this ASAP. Also
# note that the courseware template needs to change when this is removed.
css_class = String(help="DO NOT USE THIS", scope=Scope.settings)
# TODO: This is a quick kludge to allow CS50 (and other courses) to
# specify their own discussion forums as external links by specifying a
# "discussion_link" in their policy JSON file. This should later get
# folded in with Syllabus, Course Info, and additional Custom tabs in a
# more sensible framework later.
discussion_link = String(help="DO NOT USE THIS", scope=Scope.settings)
# TODO: same as above, intended to let internal CS50 hide the progress tab
# until we get grade integration set up.
# Explicit comparison to True because we always want to return a bool.
hide_progress_tab = Boolean(help="DO NOT USE THIS", scope=Scope.settings)
class CourseDescriptor(CourseFields, SequenceDescriptor):
module_class = SequenceModule
template_dir_name = 'course'
def __init__(self, *args, **kwargs):
super(CourseDescriptor, self).__init__(*args, **kwargs)
if self.wiki_slug is None:
self.wiki_slug = self.location.course
msg = None
if self.start is None:
msg = "Course loaded without a valid start date. id = %s" % self.id
# hack it -- start in 1970
self.metadata['start'] = stringify_time(time.gmtime(0))
self.start = time.gmtime(0)
log.critical(msg)
system.error_tracker(msg)
self.system.error_tracker(msg)
# NOTE: relies on the modulestore to call set_grading_policy() right after
# init. (Modulestore is in charge of figuring out where to load the policy from)
@@ -128,10 +233,10 @@ class CourseDescriptor(SequenceDescriptor):
# disable the syllabus content for courses that do not provide a syllabus
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
self._grading_policy = {}
self.set_grading_policy(self.definition['data'].get('grading_policy', None))
self.set_grading_policy(self.grading_policy)
self.test_center_exams = []
test_center_info = self.metadata.get('testcenter_info')
test_center_info = self.testcenter_info
if test_center_info is not None:
for exam_name in test_center_info:
try:
@@ -144,11 +249,11 @@ class CourseDescriptor(SequenceDescriptor):
log.error(msg)
continue
def defaut_grading_policy(self):
def default_grading_policy(self):
"""
Return a dict which is a copy of the default grading policy
"""
default = {"GRADER": [
return {"GRADER": [
{
"type": "Homework",
"min_count": 12,
@@ -180,7 +285,6 @@ class CourseDescriptor(SequenceDescriptor):
"GRADE_CUTOFFS": {
"Pass": 0.5
}}
return copy.deepcopy(default)
def set_grading_policy(self, course_policy):
"""
@@ -191,7 +295,7 @@ class CourseDescriptor(SequenceDescriptor):
course_policy = {}
# Load the global settings as a dictionary
grading_policy = self.defaut_grading_policy()
grading_policy = self.default_grading_policy()
# Override any global settings with the course settings
grading_policy.update(course_policy)
@@ -222,7 +326,6 @@ class CourseDescriptor(SequenceDescriptor):
return policy_str
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
instance = super(CourseDescriptor, cls).from_xml(xml_data, system, org, course)
@@ -250,14 +353,13 @@ class CourseDescriptor(SequenceDescriptor):
# cdodge: import the grading policy information that is on disk and put into the
# descriptor 'definition' bucket as a dictionary so that it is persisted in the DB
instance.definition['data']['grading_policy'] = policy
instance.grading_policy = policy
# now set the current instance. set_grading_policy() will apply some inheritance rules
instance.set_grading_policy(policy)
return instance
@classmethod
def definition_from_xml(cls, xml_object, system):
textbooks = []
@@ -272,12 +374,12 @@ class CourseDescriptor(SequenceDescriptor):
wiki_slug = wiki_tag.attrib.get("slug", default=None)
xml_object.remove(wiki_tag)
definition = super(CourseDescriptor, cls).definition_from_xml(xml_object, system)
definition, children = super(CourseDescriptor, cls).definition_from_xml(xml_object, system)
definition.setdefault('data', {})['textbooks'] = textbooks
definition['data']['wiki_slug'] = wiki_slug
definition['textbooks'] = textbooks
definition['wiki_slug'] = wiki_slug
return definition
return definition, children
def has_ended(self):
"""
@@ -292,30 +394,6 @@ class CourseDescriptor(SequenceDescriptor):
def has_started(self):
return time.gmtime() > self.start
@property
def end(self):
return self._try_parse_time("end")
@end.setter
def end(self, value):
if isinstance(value, time.struct_time):
self.metadata['end'] = stringify_time(value)
@property
def enrollment_start(self):
return self._try_parse_time("enrollment_start")
@enrollment_start.setter
def enrollment_start(self, value):
if isinstance(value, time.struct_time):
self.metadata['enrollment_start'] = stringify_time(value)
@property
def enrollment_end(self):
return self._try_parse_time("enrollment_end")
@enrollment_end.setter
def enrollment_end(self, value):
if isinstance(value, time.struct_time):
self.metadata['enrollment_end'] = stringify_time(value)
@property
def grader(self):
return grader_from_conf(self.raw_grader)
@@ -328,7 +406,7 @@ class CourseDescriptor(SequenceDescriptor):
def raw_grader(self, value):
# NOTE WELL: this change will not update the processed graders. If we need that, this needs to call grader_from_conf
self._grading_policy['RAW_GRADER'] = value
self.definition['data'].setdefault('grading_policy', {})['GRADER'] = value
self.grading_policy['GRADER'] = value
@property
def grade_cutoffs(self):
@@ -337,48 +415,23 @@ class CourseDescriptor(SequenceDescriptor):
@grade_cutoffs.setter
def grade_cutoffs(self, value):
self._grading_policy['GRADE_CUTOFFS'] = value
self.definition['data'].setdefault('grading_policy', {})['GRADE_CUTOFFS'] = value
# XBlock fields don't update after mutation
policy = self.grading_policy
policy['GRADE_CUTOFFS'] = value
self.grading_policy = policy
@property
def lowest_passing_grade(self):
return min(self._grading_policy['GRADE_CUTOFFS'].values())
@property
def tabs(self):
"""
Return the tabs config, as a python object, or None if not specified.
"""
return self.metadata.get('tabs')
@property
def pdf_textbooks(self):
"""
Return the pdf_textbooks config, as a python object, or None if not specified.
"""
return self.metadata.get('pdf_textbooks', [])
@property
def html_textbooks(self):
"""
Return the html_textbooks config, as a python object, or None if not specified.
"""
return self.metadata.get('html_textbooks', [])
@tabs.setter
def tabs(self, value):
self.metadata['tabs'] = value
@property
def show_calculator(self):
return self.metadata.get("show_calculator", None) == "Yes"
@property
def is_cohorted(self):
"""
Return whether the course is cohorted.
"""
config = self.metadata.get("cohort_config")
config = self.cohort_config
if config is None:
return False
@@ -392,7 +445,7 @@ class CourseDescriptor(SequenceDescriptor):
if not self.is_cohorted:
return False
return bool(self.metadata.get("cohort_config", {}).get(
return bool(self.cohort_config.get(
"auto_cohort", False))
@property
@@ -402,8 +455,10 @@ class CourseDescriptor(SequenceDescriptor):
specified. Returns specified list even if is_cohorted and/or auto_cohort are
false.
"""
return self.metadata.get("cohort_config", {}).get(
"auto_cohort_groups", [])
if self.cohort_config is None:
return []
else:
return self.cohort_config.get("auto_cohort_groups", [])
@property
@@ -411,7 +466,7 @@ class CourseDescriptor(SequenceDescriptor):
"""
Return list of topic ids defined in course policy.
"""
topics = self.metadata.get("discussion_topics", {})
topics = self.discussion_topics
return [d["id"] for d in topics.values()]
@@ -422,7 +477,7 @@ class CourseDescriptor(SequenceDescriptor):
the empty set. Note that all inline discussions are automatically
cohorted based on the course's is_cohorted setting.
"""
config = self.metadata.get("cohort_config")
config = self.cohort_config
if config is None:
return set()
@@ -431,13 +486,13 @@ class CourseDescriptor(SequenceDescriptor):
@property
def is_new(self):
def is_newish(self):
"""
Returns if the course has been flagged as new in the metadata. If
Returns if the course has been flagged as new. If
there is no flag, return a heuristic value considering the
announcement and the start dates.
"""
flag = self.metadata.get('is_new', None)
flag = self.is_new
if flag is None:
# Use a heuristic if the course has not been flagged
announcement, start, now = self._sorting_dates()
@@ -457,8 +512,8 @@ class CourseDescriptor(SequenceDescriptor):
@property
def sorting_score(self):
"""
Returns a number that can be used to sort the courses according
the how "new"" they are. The "newness"" score is computed using a
Returns a tuple that can be used to sort the courses according
the how "new" they are. The "newness" score is computed using a
heuristic that takes into account the announcement and
(advertized) start dates of the course if available.
@@ -483,12 +538,13 @@ class CourseDescriptor(SequenceDescriptor):
def to_datetime(timestamp):
return datetime(*timestamp[:6])
def get_date(field):
timetuple = self._try_parse_time(field)
return to_datetime(timetuple) if timetuple else None
announcement = get_date('announcement')
start = get_date('advertised_start') or to_datetime(self.start)
announcement = self.announcement
if announcement is not None:
announcement = to_datetime(announcement)
if self.advertised_start is None or isinstance(self.advertised_start, basestring):
start = to_datetime(self.start)
else:
start = to_datetime(self.advertised_start)
now = to_datetime(time.gmtime())
return announcement, start, now
@@ -513,7 +569,7 @@ class CourseDescriptor(SequenceDescriptor):
all_descriptors - This contains a list of all xmodules that can
effect grading a student. This is used to efficiently fetch
all the xmodule state for a StudentModuleCache without walking
all the xmodule state for a ModelDataCache without walking
the descriptor tree again.
@@ -531,14 +587,14 @@ class CourseDescriptor(SequenceDescriptor):
for c in self.get_children():
sections = []
for s in c.get_children():
if s.metadata.get('graded', False):
if s.lms.graded:
xmoduledescriptors = list(yield_descriptor_descendents(s))
xmoduledescriptors.append(s)
# The xmoduledescriptors included here are only the ones that have scores.
section_description = {'section_descriptor': s, 'xmoduledescriptors': filter(lambda child: child.has_score, xmoduledescriptors)}
section_format = s.metadata.get('format', "")
section_format = s.lms.format if s.lms.format is not None else ''
graded_sections[section_format] = graded_sections.get(section_format, []) + [section_description]
all_descriptors.extend(xmoduledescriptors)
@@ -579,58 +635,23 @@ class CourseDescriptor(SequenceDescriptor):
@property
def start_date_text(self):
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)
if isinstance(self.advertised_start, basestring):
return self.advertised_start
elif self.advertised_start is None and self.start is None:
return 'TBD'
else:
return time.strftime("%b %d, %Y", self.advertised_start or self.start)
@property
def end_date_text(self):
return time.strftime("%b %d, %Y", self.end)
# An extra property is used rather than the wiki_slug/number because
# there are courses that change the number for different runs. This allows
# courses to share the same css_class across runs even if they have
# different numbers.
#
# TODO get rid of this as soon as possible or potentially build in a robust
# way to add in course-specific styling. There needs to be a discussion
# about the right way to do this, but arjun will address this ASAP. Also
# note that the courseware template needs to change when this is removed.
@property
def css_class(self):
return self.metadata.get('css_class', '')
@property
def info_sidebar_name(self):
return self.metadata.get('info_sidebar_name', 'Course Handouts')
@property
def discussion_link(self):
"""TODO: This is a quick kludge to allow CS50 (and other courses) to
specify their own discussion forums as external links by specifying a
"discussion_link" in their policy JSON file. This should later get
folded in with Syllabus, Course Info, and additional Custom tabs in a
more sensible framework later."""
return self.metadata.get('discussion_link', None)
@property
def forum_posts_allowed(self):
try:
blackout_periods = [(parse_time(start), parse_time(end))
for start, end
in self.metadata.get('discussion_blackouts', [])]
in self.discussion_blackouts]
now = time.gmtime()
for start, end in blackout_periods:
if start <= now <= end:
@@ -640,23 +661,6 @@ class CourseDescriptor(SequenceDescriptor):
return True
@property
def hide_progress_tab(self):
"""TODO: same as above, intended to let internal CS50 hide the progress tab
until we get grade integration set up."""
# Explicit comparison to True because we always want to return a bool.
return self.metadata.get('hide_progress_tab') == True
@property
def end_of_course_survey_url(self):
"""
Pull from policy. Once we have our own survey module set up, can change this to point to an automatically
created survey for each class.
Returns None if no url specified.
"""
return self.metadata.get('end_of_course_survey_url')
class TestCenterExam(object):
def __init__(self, course_id, exam_name, exam_info):
self.course_id = course_id
@@ -743,10 +747,6 @@ class CourseDescriptor(SequenceDescriptor):
exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code]
return exams[0] if len(exams) == 1 else None
@property
def title(self):
return self.display_name
@property
def number(self):
return self.location.course

View File

@@ -0,0 +1,221 @@
section.poll_question {
@media print {
display: block;
width: auto;
padding: 0;
canvas, img {
page-break-inside: avoid;
}
}
.inline {
display: inline;
}
h3 {
margin-top: 0;
margin-bottom: 15px;
color: #fe57a1;
font-size: 1.9em;
&.problem-header {
section.staff {
margin-top: 30px;
font-size: 80%;
}
}
@media print {
display: block;
width: auto;
border-right: 0;
}
}
p {
text-align: justify;
font-weight: bold;
}
.poll_answer {
margin-bottom: 20px;
&.short {
clear: both;
}
.question {
height: auto;
clear: both;
min-height: 30px;
&.short {
clear: none;
width: 30%;
display: inline;
float: left;
}
.button {
-webkit-appearance: none;
-webkit-background-clip: padding-box;
-webkit-border-image: none;
-webkit-box-align: center;
-webkit-box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset;
-webkit-font-smoothing: antialiased;
-webkit-rtl-ordering: logical;
-webkit-user-select: text;
-webkit-writing-mode: horizontal-tb;
background-clip: padding-box;
background-color: rgb(238, 238, 238);
background-image: -webkit-linear-gradient(top, rgb(238, 238, 238), rgb(210, 210, 210));
border-bottom-color: rgb(202, 202, 202);
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-style: solid;
border-bottom-width: 1px;
border-left-color: rgb(202, 202, 202);
border-left-style: solid;
border-left-width: 1px;
border-right-color: rgb(202, 202, 202);
border-right-style: solid;
border-right-width: 1px;
border-top-color: rgb(202, 202, 202);
border-top-left-radius: 3px;
border-top-right-radius: 3px;
border-top-style: solid;
border-top-width: 1px;
box-shadow: rgb(255, 255, 255) 0px 1px 0px 0px inset;
box-sizing: border-box;
color: rgb(51, 51, 51);
cursor: pointer;
/* display: inline-block; */
display: inline;
float: left;
font-family: 'Open Sans', Verdana, Geneva, sans-serif;
font-size: 13px;
font-style: normal;
font-variant: normal;
font-weight: bold;
letter-spacing: normal;
line-height: 25.59375px;
margin-bottom: 15px;
margin: 0px;
padding: 0px;
text-align: center;
text-decoration: none;
text-indent: 0px;
text-shadow: rgb(248, 248, 248) 0px 1px 0px;
text-transform: none;
vertical-align: top;
white-space: pre-line;
width: 25px;
height: 25px;
word-spacing: 0px;
writing-mode: lr-tb;
}
.button.answered {
-webkit-box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
background-color: rgb(29, 157, 217);
background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176));
border-bottom-color: rgb(13, 114, 162);
border-left-color: rgb(13, 114, 162);
border-right-color: rgb(13, 114, 162);
border-top-color: rgb(13, 114, 162);
box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
color: rgb(255, 255, 255);
text-shadow: rgb(7, 103, 148) 0px 1px 0px;
}
.text {
display: inline;
float: left;
width: 80%;
text-align: left;
min-height: 30px;
margin-left: 20px;
height: auto;
margin-bottom: 20px;
cursor: pointer;
&.short {
width: 100px;
}
}
}
.stats {
min-height: 40px;
margin-top: 20px;
clear: both;
&.short {
margin-top: 0;
clear: none;
display: inline;
float: right;
width: 70%;
}
.bar {
width: 75%;
height: 20px;
border: 1px solid black;
display: inline;
float: left;
margin-right: 10px;
&.short {
width: 65%;
height: 20px;
margin-top: 3px;
}
.percent {
background-color: gray;
width: 0px;
height: 20px;
&.short { }
}
}
.number {
width: 80px;
display: inline;
float: right;
height: 28px;
text-align: right;
&.short {
width: 120px;
height: auto;
}
}
}
}
.poll_answer.answered {
-webkit-box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
background-color: rgb(29, 157, 217);
background-image: -webkit-linear-gradient(top, rgb(29, 157, 217), rgb(14, 124, 176));
border-bottom-color: rgb(13, 114, 162);
border-left-color: rgb(13, 114, 162);
border-right-color: rgb(13, 114, 162);
border-top-color: rgb(13, 114, 162);
box-shadow: rgb(97, 184, 225) 0px 1px 0px 0px inset;
color: rgb(255, 255, 255);
text-shadow: rgb(7, 103, 148) 0px 1px 0px;
}
.button.reset-button {
clear: both;
float: right;
}
}

View File

@@ -3,35 +3,38 @@ from pkg_resources import resource_string, resource_listdir
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
import json
from xblock.core import String, Scope
class DiscussionModule(XModule):
class DiscussionFields(object):
discussion_id = String(scope=Scope.settings)
discussion_category = String(scope=Scope.settings)
discussion_target = String(scope=Scope.settings)
sort_key = String(scope=Scope.settings)
class DiscussionModule(DiscussionFields, XModule):
js = {'coffee':
[resource_string(__name__, 'js/src/time.coffee'),
resource_string(__name__, 'js/src/discussion/display.coffee')]
}
js_module_name = "InlineDiscussion"
def get_html(self):
context = {
'discussion_id': self.discussion_id,
}
return self.system.render_template('discussion/_discussion_module.html', context)
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
if isinstance(instance_state, str):
instance_state = json.loads(instance_state)
xml_data = etree.fromstring(definition['data'])
self.discussion_id = xml_data.attrib['id']
self.title = xml_data.attrib['for']
self.discussion_category = xml_data.attrib['discussion_category']
class DiscussionDescriptor(RawDescriptor):
class DiscussionDescriptor(DiscussionFields, RawDescriptor):
module_class = DiscussionModule
template_dir_name = "discussion"
# The discussion XML format uses `id` and `for` attributes,
# but these would overload other module attributes, so we prefix them
# for actual use in the code
metadata_translations = dict(RawDescriptor.metadata_translations)
metadata_translations['id'] = 'discussion_id'
metadata_translations['for'] = 'discussion_target'

View File

@@ -1,11 +1,16 @@
from pkg_resources import resource_string
from xmodule.mako_module import MakoModuleDescriptor
from xblock.core import Scope, String
import logging
log = logging.getLogger(__name__)
class EditingDescriptor(MakoModuleDescriptor):
class EditingFields(object):
data = String(scope=Scope.content, default='')
class EditingDescriptor(EditingFields, MakoModuleDescriptor):
"""
Module that provides a raw editing view of its data and children. It does not
perform any validation on its definition---just passes it along to the browser.
@@ -20,7 +25,7 @@ class EditingDescriptor(MakoModuleDescriptor):
def get_context(self):
_context = MakoModuleDescriptor.get_context(self)
# Add our specific template information (the raw data body)
_context.update({'data': self.definition.get('data', '')})
_context.update({'data': self.data})
return _context

View File

@@ -8,6 +8,7 @@ from xmodule.x_module import XModule
from xmodule.editing_module import JSONEditingDescriptor
from xmodule.errortracker import exc_info_to_str
from xmodule.modulestore import Location
from xblock.core import String, Scope
log = logging.getLogger(__name__)
@@ -20,7 +21,14 @@ log = logging.getLogger(__name__)
# decides whether to create a staff or not-staff module.
class ErrorModule(XModule):
class ErrorFields(object):
contents = String(scope=Scope.content)
error_msg = String(scope=Scope.content)
display_name = String(scope=Scope.settings)
class ErrorModule(ErrorFields, XModule):
def get_html(self):
'''Show an error to staff.
TODO (vshnayder): proper style, divs, etc.
@@ -28,12 +36,12 @@ class ErrorModule(XModule):
# staff get to see all the details
return self.system.render_template('module-error.html', {
'staff_access': True,
'data': self.definition['data']['contents'],
'error': self.definition['data']['error_msg'],
'data': self.contents,
'error': self.error_msg,
})
class NonStaffErrorModule(XModule):
class NonStaffErrorModule(ErrorFields, XModule):
def get_html(self):
'''Show an error to a student.
TODO (vshnayder): proper style, divs, etc.
@@ -46,7 +54,7 @@ class NonStaffErrorModule(XModule):
})
class ErrorDescriptor(JSONEditingDescriptor):
class ErrorDescriptor(ErrorFields, JSONEditingDescriptor):
"""
Module that provides a raw editing view of broken xml.
"""
@@ -66,26 +74,22 @@ class ErrorDescriptor(JSONEditingDescriptor):
name=hashlib.sha1(contents).hexdigest()
)
definition = {
'data': {
'error_msg': str(error_msg),
'contents': contents,
}
}
# real metadata stays in the content, but add a display name
metadata = {'display_name': 'Error: ' + location.name}
model_data = {
'error_msg': str(error_msg),
'contents': contents,
'display_name': 'Error: ' + location.name
}
return ErrorDescriptor(
system,
definition,
location=location,
metadata=metadata
location,
model_data,
)
def get_context(self):
return {
'module': self,
'data': self.definition['data']['contents'],
'data': self.contents,
}
@classmethod
@@ -101,10 +105,7 @@ class ErrorDescriptor(JSONEditingDescriptor):
def from_descriptor(cls, descriptor, error_msg='Error not available'):
return cls._construct(
descriptor.system,
json.dumps({
'definition': descriptor.definition,
'metadata': descriptor.metadata,
}, indent=4),
descriptor._model_data,
error_msg,
location=descriptor.location,
)
@@ -148,14 +149,14 @@ class ErrorDescriptor(JSONEditingDescriptor):
files, etc. That would just get re-wrapped on import.
'''
try:
xml = etree.fromstring(self.definition['data']['contents'])
xml = etree.fromstring(self.contents)
return etree.tostring(xml, encoding='unicode')
except etree.XMLSyntaxError:
# still not valid.
root = etree.Element('error')
root.text = self.definition['data']['contents']
root.text = self.contents
err_node = etree.SubElement(root, 'error_msg')
err_node.text = self.definition['data']['error_msg']
err_node.text = self.error_msg
return etree.tostring(root, encoding='unicode')

View File

@@ -0,0 +1,69 @@
import time
import logging
import re
from datetime import timedelta
from xblock.core import ModelType
log = logging.getLogger(__name__)
class Date(ModelType):
time_format = "%Y-%m-%dT%H:%M"
def from_json(self, value):
"""
Parse an optional metadata key containing a time: if present, complain
if it doesn't parse.
Return None if not present or invalid.
"""
if value is None:
return None
try:
return time.strptime(value, self.time_format)
except ValueError as e:
msg = "Field {0} has bad value '{1}': '{2}'".format(
self._name, value, e)
log.warning(msg)
return None
def to_json(self, value):
"""
Convert a time struct to a string
"""
if value is None:
return None
return time.strftime(self.time_format, value)
TIMEDELTA_REGEX = re.compile(r'^((?P<days>\d+?) day(?:s?))?(\s)?((?P<hours>\d+?) hour(?:s?))?(\s)?((?P<minutes>\d+?) minute(?:s)?)?(\s)?((?P<seconds>\d+?) second(?:s)?)?$')
class Timedelta(ModelType):
def from_json(self, time_str):
"""
time_str: A string with the following components:
<D> day[s] (optional)
<H> hour[s] (optional)
<M> minute[s] (optional)
<S> second[s] (optional)
Returns a datetime.timedelta parsed from the string
"""
parts = TIMEDELTA_REGEX.match(time_str)
if not parts:
return
parts = parts.groupdict()
time_params = {}
for (name, param) in parts.iteritems():
if param:
time_params[name] = int(param)
return timedelta(**time_params)
def to_json(self, value):
values = []
for attr in ('days', 'hours', 'minutes', 'seconds'):
cur_value = getattr(value, attr, 0)
if cur_value > 0:
values.append("%d %s" % (cur_value, attr))
return ' '.join(values)

View File

@@ -7,17 +7,27 @@ from pkg_resources import resource_string
from xmodule.editing_module import EditingDescriptor
from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor
from xblock.core import Scope, Integer, String
log = logging.getLogger(__name__)
class FolditModule(XModule):
class FolditFields(object):
# default to what Spring_7012x uses
required_level = Integer(default=4, scope=Scope.settings)
required_sublevel = Integer(default=5, scope=Scope.settings)
due = String(help="Date that this problem is due by", scope=Scope.settings, default='')
show_basic_score = String(scope=Scope.settings, default='false')
show_leaderboard = String(scope=Scope.settings, default='false')
class FolditModule(FolditFields, XModule):
css = {'scss': [resource_string(__name__, 'css/foldit/leaderboard.scss')]}
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs)
"""
Example:
@@ -26,25 +36,17 @@ class FolditModule(XModule):
required_sublevel="3"
show_leaderboard="false"/>
"""
req_level = self.metadata.get("required_level")
req_sublevel = self.metadata.get("required_sublevel")
# default to what Spring_7012x uses
self.required_level = req_level if req_level else 4
self.required_sublevel = req_sublevel if req_sublevel else 5
def parse_due_date():
"""
Pull out the date, or None
"""
s = self.metadata.get("due")
s = self.due
if s:
return parser.parse(s)
else:
return None
self.due_str = self.metadata.get("due", "None")
self.due = parse_due_date()
self.due_time = parse_due_date()
def is_complete(self):
"""
@@ -59,7 +61,7 @@ class FolditModule(XModule):
self.system.anonymous_student_id,
self.required_level,
self.required_sublevel,
self.due)
self.due_time)
return complete
def completed_puzzles(self):
@@ -87,7 +89,7 @@ class FolditModule(XModule):
from foldit.models import Score
leaders = [(e['username'], e['score']) for e in Score.get_tops_n(10)]
leaders.sort(key=lambda x: x[1])
leaders.sort(key=lambda x: -x[1])
return leaders
@@ -99,11 +101,11 @@ class FolditModule(XModule):
self.required_level,
self.required_sublevel)
showbasic = (self.metadata.get("show_basic_score", "").lower() == "true")
showleader = (self.metadata.get("show_leaderboard", "").lower() == "true")
showbasic = (self.show_basic_score.lower() == "true")
showleader = (self.show_leaderboard.lower() == "true")
context = {
'due': self.due_str,
'due': self.due,
'success': self.is_complete(),
'goal_level': goal_level,
'completed': self.completed_puzzles(),
@@ -125,7 +127,7 @@ class FolditModule(XModule):
self.required_sublevel)
context = {
'due': self.due_str,
'due': self.due,
'success': self.is_complete(),
'goal_level': goal_level,
'completed': self.completed_puzzles(),
@@ -155,7 +157,7 @@ class FolditModule(XModule):
class FolditDescriptor(XmlDescriptor, EditingDescriptor):
class FolditDescriptor(FolditFields, XmlDescriptor, EditingDescriptor):
"""
Module for adding Foldit problems to courses
"""
@@ -176,7 +178,8 @@ class FolditDescriptor(XmlDescriptor, EditingDescriptor):
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
Get the xml_object's attributes.
"""
return {'metadata': xml_object.attrib}
return ({}, [])
def definition_to_xml(self):
xml_object = etree.Element('foldit')
return xml_object

View File

@@ -14,12 +14,18 @@ from xmodule.xml_module import XmlDescriptor
from xmodule.x_module import XModule
from xmodule.stringify import stringify_children
from pkg_resources import resource_string
from xblock.core import String, Scope
log = logging.getLogger(__name__)
class GraphicalSliderToolModule(XModule):
class GraphicalSliderToolFields(object):
render = String(scope=Scope.content)
configuration = String(scope=Scope.content)
class GraphicalSliderToolModule(GraphicalSliderToolFields, XModule):
''' Graphical-Slider-Tool Module
'''
@@ -43,15 +49,6 @@ class GraphicalSliderToolModule(XModule):
}
js_module_name = "GraphicalSliderTool"
def __init__(self, system, location, definition, descriptor, instance_state=None,
shared_state=None, **kwargs):
"""
For XML file format please look at documentation. TODO - receive
information where to store XML documentation.
"""
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
def get_html(self):
""" Renders parameters to template. """
@@ -60,14 +57,14 @@ class GraphicalSliderToolModule(XModule):
self.html_class = self.location.category
self.configuration_json = self.build_configuration_json()
params = {
'gst_html': self.substitute_controls(self.definition['render']),
'gst_html': self.substitute_controls(self.render),
'element_id': self.html_id,
'element_class': self.html_class,
'configuration_json': self.configuration_json
}
self.content = self.system.render_template(
content = self.system.render_template(
'graphical_slider_tool.html', params)
return self.content
return content
def substitute_controls(self, html_string):
""" Substitutes control elements (slider, textbox and plot) in
@@ -139,10 +136,10 @@ class GraphicalSliderToolModule(XModule):
# <root> added for interface compatibility with xmltodict.parse
# class added for javascript's part purposes
return json.dumps(xmltodict.parse('<root class="' + self.html_class +
'">' + self.definition['configuration'] + '</root>'))
'">' + self.configuration + '</root>'))
class GraphicalSliderToolDescriptor(MakoModuleDescriptor, XmlDescriptor):
class GraphicalSliderToolDescriptor(GraphicalSliderToolFields, MakoModuleDescriptor, XmlDescriptor):
module_class = GraphicalSliderToolModule
template_dir_name = 'graphical_slider_tool'
@@ -177,14 +174,14 @@ class GraphicalSliderToolDescriptor(MakoModuleDescriptor, XmlDescriptor):
return {
'render': parse('render'),
'configuration': parse('configuration')
}
}, []
def definition_to_xml(self, resource_fs):
'''Return an xml element representing this definition.'''
xml_object = etree.Element('graphical_slider_tool')
def add_child(k):
child_str = '<{tag}>{body}</{tag}>'.format(tag=k, body=self.definition[k])
child_str = '<{tag}>{body}</{tag}>'.format(tag=k, body=getattr(self, k))
child_node = etree.fromstring(child_str)
xml_object.append(child_node)

View File

@@ -7,10 +7,9 @@ from lxml import etree
from path import path
from pkg_resources import resource_string
from xmodule.contentstore.content import XASSET_SRCREF_PREFIX, StaticContent
from xblock.core import Scope, String
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
@@ -18,7 +17,11 @@ from xmodule.xml_module import XmlDescriptor, name_to_pathname
log = logging.getLogger("mitx.courseware")
class HtmlModule(XModule):
class HtmlFields(object):
data = String(help="Html contents to display for this module", scope=Scope.content)
class HtmlModule(HtmlFields, XModule):
js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'),
resource_string(__name__, 'js/src/collapsible.coffee'),
resource_string(__name__, 'js/src/html/display.coffee')
@@ -28,17 +31,10 @@ class HtmlModule(XModule):
css = {'scss': [resource_string(__name__, 'css/html/display.scss')]}
def get_html(self):
return self.html
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
self.html = self.definition['data']
return self.data
class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor):
"""
Module for putting raw html in a course
"""
@@ -91,7 +87,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
if filename is None:
definition_xml = copy.deepcopy(xml_object)
cls.clean_metadata_from_xml(definition_xml)
return {'data': stringify_children(definition_xml)}
return {'data': stringify_children(definition_xml)}, []
else:
# html is special. cls.filename_extension is 'xml', but
# if 'filename' is in the definition, that means to load
@@ -105,8 +101,6 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
filepath = "{base}/{name}.html".format(base=base, name=filename)
#log.debug("looking for html file for {0} at {1}".format(location, filepath))
# VS[compat]
# TODO (cpennington): If the file doesn't exist at the right path,
# give the class a chance to fix it up. The file will be written out
@@ -135,7 +129,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
# for Fall 2012 LMS migration: keep filename (and unmangled filename)
definition['filename'] = [filepath, filename]
return definition
return definition, []
except (ResourceNotFoundError) as err:
msg = 'Unable to load file contents at path {0}: {1} '.format(
@@ -151,19 +145,18 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
string to filename.html.
'''
try:
return etree.fromstring(self.definition['data'])
return etree.fromstring(self.data)
except etree.XMLSyntaxError:
pass
# Not proper format. Write html to file, return an empty tag
pathname = name_to_pathname(self.url_name)
pathdir = path(pathname).dirname()
filepath = u'{category}/{pathname}.html'.format(category=self.category,
pathname=pathname)
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'].encode('utf-8'))
file.write(self.data.encode('utf-8'))
# write out the relative name
relname = path(pathname).basename()
@@ -175,8 +168,11 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
@property
def editable_metadata_fields(self):
"""Remove any metadata from the editable fields which have their own editor or shouldn't be edited by user."""
subset = [field for field in super(HtmlDescriptor,self).editable_metadata_fields
if field not in ['empty']]
subset = super(HtmlDescriptor, self).editable_metadata_fields
if 'empty' in subset:
del subset['empty']
return subset

View File

@@ -1,26 +1,35 @@
class @Conditional
constructor: (element) ->
constructor: (element, callerElId) ->
@el = $(element).find('.conditional-wrapper')
@id = @el.data('problem-id')
@element_id = @el.attr('id')
@callerElId = callerElId
if callerElId isnt undefined
dependencies = @el.data('depends')
if (typeof dependencies is 'string') and (dependencies.length > 0) and (dependencies.indexOf(callerElId) is -1)
return
@url = @el.data('url')
@render()
@render(element)
$: (selector) ->
$(selector, @el)
updateProgress: (response) =>
if response.progress_changed
@el.attr progress: response.progress_status
@el.trigger('progressChanged')
render: (content) ->
if content
@el.html(content)
XModule.loadModules(@el)
else
render: (element) ->
$.postWithPrefix "#{@url}/conditional_get", (response) =>
@el.html(response.html)
XModule.loadModules(@el)
@el.html ''
@el.append(i) for i in response.html
parentEl = $(element).parent()
parentId = parentEl.attr 'id'
if response.message is false
if parentId.indexOf('vert') is 0
parentEl.hide()
else
$(element).hide()
else
if parentId.indexOf('vert') is 0
parentEl.show()
else
$(element).show()
XModule.loadModules @el

View File

@@ -0,0 +1,54 @@
// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
// define() functions from Require JS available inside the anonymous function.
(function (requirejs, require, define) {
define('logme', [], function () {
var debugMode;
// debugMode can be one of the following:
//
// true - All messages passed to logme will be written to the internal
// browser console.
// false - Suppress all output to the internal browser console.
//
// Obviously, if anywhere there is a direct console.log() call, we can't do
// anything about it. That's why use logme() - it will allow to turn off
// the output of debug information with a single change to a variable.
debugMode = true;
return logme;
/*
* function: logme
*
* A helper function that provides logging facilities. We don't want
* to call console.log() directly, because sometimes it is not supported
* by the browser. Also when everything is routed through this function.
* the logging output can be easily turned off.
*
* logme() supports multiple parameters. Each parameter will be passed to
* console.log() function separately.
*
*/
function logme() {
var i;
if (
(typeof debugMode === 'undefined') ||
(debugMode !== true) ||
(typeof window.console === 'undefined')
) {
return;
}
for (i = 0; i < arguments.length; i++) {
window.console.log(arguments[i]);
}
} // End-of: function logme
});
// End of wrapper for RequireJS. As you can see, we are passing
// namespaced Require JS variables to an anonymous function. Within
// it, you can use the standard requirejs(), require(), and define()
// functions as if they were in the global namespace.
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)

View File

@@ -0,0 +1,5 @@
window.Poll = function (el) {
RequireJS.require(['PollMain'], function (PollMain) {
new PollMain(el);
});
};

View File

@@ -0,0 +1,323 @@
(function (requirejs, require, define) {
define('PollMain', ['logme'], function (logme) {
PollMain.prototype = {
'showAnswerGraph': function (poll_answers, total) {
var _this, totalValue;
totalValue = parseFloat(total);
if (isFinite(totalValue) === false) {
return;
}
_this = this;
$.each(poll_answers, function (index, value) {
var numValue, percentValue;
numValue = parseFloat(value);
if (isFinite(numValue) === false) {
return;
}
percentValue = (numValue / totalValue) * 100.0;
_this.answersObj[index].statsEl.show();
_this.answersObj[index].numberEl.html('' + value + ' (' + percentValue.toFixed(1) + '%)');
_this.answersObj[index].percentEl.css({
'width': '' + percentValue.toFixed(1) + '%'
});
});
},
'submitAnswer': function (answer, answerObj) {
var _this;
// Make sure that the user can answer a question only once.
if (this.questionAnswered === true) {
return;
}
this.questionAnswered = true;
_this = this;
console.log('submit answer');
answerObj.buttonEl.addClass('answered');
// Send the data to the server as an AJAX request. Attach a callback that will
// be fired on server's response.
$.postWithPrefix(
_this.ajax_url + '/' + answer, {},
function (response) {
console.log('success! response = ');
console.log(response);
_this.showAnswerGraph(response.poll_answers, response.total);
if (_this.canReset === true) {
_this.resetButton.show();
}
// Initialize Conditional constructors.
if (_this.wrapperSectionEl !== null) {
$(_this.wrapperSectionEl).find('.xmodule_ConditionalModule').each(function (index, value) {
new window.Conditional(value, _this.id.replace(/^poll_/, ''));
});
}
}
);
}, // End-of: 'submitAnswer': function (answer, answerEl) {
'submitReset': function () {
var _this;
_this = this;
console.log('submit reset');
// Send the data to the server as an AJAX request. Attach a callback that will
// be fired on server's response.
$.postWithPrefix(
this.ajax_url + '/' + 'reset_poll',
{},
function (response) {
console.log('success! response = ');
console.log(response);
if (
(response.hasOwnProperty('status') !== true) ||
(typeof response.status !== 'string') ||
(response.status.toLowerCase() !== 'success')) {
return;
}
_this.questionAnswered = false;
_this.questionEl.find('.button.answered').removeClass('answered');
_this.questionEl.find('.stats').hide();
_this.resetButton.hide();
// Initialize Conditional constructors. We will specify the third parameter as 'true'
// notifying the constructor that this is a reset operation.
if (_this.wrapperSectionEl !== null) {
$(_this.wrapperSectionEl).find('.xmodule_ConditionalModule').each(function (index, value) {
new window.Conditional(value, _this.id.replace(/^poll_/, ''));
});
}
}
);
}, // End-of: 'submitAnswer': function (answer, answerEl) {
'postInit': function () {
var _this;
// Access this object inside inner functions.
_this = this;
if (
(this.jsonConfig.poll_answer.length > 0) &&
(this.jsonConfig.answers.hasOwnProperty(this.jsonConfig.poll_answer) === false)
) {
this.questionEl.append(
'<h3>Error!</h3>' +
'<p>XML data format changed. List of answers was modified, but poll data was not updated.</p>'
);
return;
}
// Get the DOM id of the question.
this.id = this.questionEl.attr('id');
// Get the URL to which we will post the users answer to the question.
this.ajax_url = this.questionEl.data('ajax-url');
this.questionHtmlMarkup = $('<div />').html(this.jsonConfig.question).text();
this.questionEl.append(this.questionHtmlMarkup);
// When the user selects and answer, we will set this flag to true.
this.questionAnswered = false;
this.answersObj = {};
this.shortVersion = true;
$.each(this.jsonConfig.answers, function (index, value) {
if (value.length >= 18) {
_this.shortVersion = false;
}
});
$.each(this.jsonConfig.answers, function (index, value) {
var answer;
answer = {};
_this.answersObj[index] = answer;
answer.el = $('<div class="poll_answer"></div>');
answer.questionEl = $('<div class="question"></div>');
answer.buttonEl = $('<div class="button"></div>');
answer.textEl = $('<div class="text"></div>');
answer.questionEl.append(answer.buttonEl);
answer.questionEl.append(answer.textEl);
answer.el.append(answer.questionEl);
answer.statsEl = $('<div class="stats"></div>');
answer.barEl = $('<div class="bar"></div>');
answer.percentEl = $('<div class="percent"></div>');
answer.barEl.append(answer.percentEl);
answer.numberEl = $('<div class="number"></div>');
answer.statsEl.append(answer.barEl);
answer.statsEl.append(answer.numberEl);
answer.statsEl.hide();
answer.el.append(answer.statsEl);
answer.textEl.html(value);
if (_this.shortVersion === true) {
$.each(answer, function (index, value) {
if (value instanceof jQuery) {
value.addClass('short');
}
});
}
answer.el.appendTo(_this.questionEl);
answer.textEl.on('click', function () {
_this.submitAnswer(index, answer);
});
answer.buttonEl.on('click', function () {
_this.submitAnswer(index, answer);
});
if (index === _this.jsonConfig.poll_answer) {
answer.buttonEl.addClass('answered');
_this.questionAnswered = true;
}
});
console.log(this.jsonConfig.reset);
if ((typeof this.jsonConfig.reset === 'string') && (this.jsonConfig.reset.toLowerCase() === 'true')) {
this.canReset = true;
this.resetButton = $('<div class="button reset-button">Change your vote</div>');
if (this.questionAnswered === false) {
this.resetButton.hide();
}
this.resetButton.appendTo(this.questionEl);
this.resetButton.on('click', function () {
_this.submitReset();
});
} else {
this.canReset = false;
}
// If it turns out that the user already answered the question, show the answers graph.
if (this.questionAnswered === true) {
this.showAnswerGraph(this.jsonConfig.poll_answers, this.jsonConfig.total);
}
} // End-of: 'postInit': function () {
}; // End-of: PollMain.prototype = {
return PollMain;
function PollMain(el) {
var _this;
this.questionEl = $(el).find('.poll_question');
if (this.questionEl.length !== 1) {
// We require one question DOM element.
logme('ERROR: PollMain constructor requires one question DOM element.');
return;
}
// Just a safety precussion. If we run this code more than once, multiple 'click' callback handlers will be
// attached to the same DOM elements. We don't want this to happen.
if (this.questionEl.attr('poll_main_processed') === 'true') {
logme(
'ERROR: PolMain JS constructor was called on a DOM element that has already been processed once.'
);
return;
}
// This element was not processed earlier.
// Make sure that next time we will not process this element a second time.
this.questionEl.attr('poll_main_processed', 'true');
// Access this object inside inner functions.
_this = this;
// DOM element which contains the current poll along with any conditionals. By default we assume that such
// element is not present. We will try to find it.
this.wrapperSectionEl = null;
(function (tempEl, c1) {
while (tempEl.tagName.toLowerCase() !== 'body') {
tempEl = $(tempEl).parent()[0];
c1 += 1;
if (
(tempEl.tagName.toLowerCase() === 'section') &&
($(tempEl).hasClass('xmodule_WrapperModule') === true)
) {
_this.wrapperSectionEl = tempEl;
break;
} else if (c1 > 50) {
// In case something breaks, and we enter an endless loop, a sane
// limit for loop iterations.
break;
}
}
}($(el)[0], 0));
try {
this.jsonConfig = JSON.parse(this.questionEl.children('.poll_question_div').html());
$.postWithPrefix(
'' + this.questionEl.data('ajax-url') + '/' + 'get_state', {},
function (response) {
_this.jsonConfig.poll_answer = response.poll_answer;
_this.jsonConfig.total = response.total;
$.each(response.poll_answers, function (index, value) {
_this.jsonConfig.poll_answers[index] = value;
});
_this.questionEl.children('.poll_question_div').html(JSON.stringify(_this.jsonConfig));
_this.postInit();
}
);
return;
} catch (err) {
logme(
'ERROR: Invalid JSON config for poll ID "' + this.id + '".',
'Error messsage: "' + err.message + '".'
);
return;
}
} // End-of: function PollMain(el) {
}); // End-of: define('PollMain', ['logme'], function (logme) {
// End-of: (function (requirejs, require, define) {
}(RequireJS.requirejs, RequireJS.require, RequireJS.define));

View File

@@ -56,7 +56,7 @@ class @Sequence
element.removeClass('progress-none')
.removeClass('progress-some')
.removeClass('progress-done')
switch progress
when 'none' then element.addClass('progress-none')
when 'in_progress' then element.addClass('progress-some')
@@ -65,6 +65,11 @@ class @Sequence
toggleArrows: =>
@$('.sequence-nav-buttons a').unbind('click')
if @contents.length == 0
@$('.sequence-nav-buttons .prev a').addClass('disabled')
@$('.sequence-nav-buttons .next a').addClass('disabled')
return
if @position == 1
@$('.sequence-nav-buttons .prev a').addClass('disabled')
else
@@ -105,8 +110,8 @@ class @Sequence
if (1 <= new_position) and (new_position <= @num_contents)
Logger.log "seq_goto", old: @position, new: new_position, id: @id
# On Sequence chage, destroy any existing polling thread
# On Sequence chage, destroy any existing polling thread
# for queued submissions, see ../capa/display.coffee
if window.queuePollerID
window.clearTimeout(window.queuePollerID)

View File

@@ -4,7 +4,6 @@ class @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"
window.player = null

View File

@@ -0,0 +1,10 @@
class @WrapperDescriptor extends XModule.Descriptor
constructor: (@element) ->
console.log 'WrapperDescriptor'
@$items = $(@element).find(".vert-mod")
@$items.sortable(
update: (event, ui) => @update()
)
save: ->
children: $('.vert-mod li', @element).map((idx, el) -> $(el).data('id')).toArray()

View File

@@ -1,5 +1,5 @@
from x_module import XModuleDescriptor, DescriptorSystem
import logging
from .x_module import XModuleDescriptor, DescriptorSystem
from .modulestore.inheritance import own_metadata
class MakoDescriptorSystem(DescriptorSystem):
@@ -21,21 +21,21 @@ class MakoModuleDescriptor(XModuleDescriptor):
the descriptor as the `module` parameter to that template
"""
def __init__(self, system, definition=None, **kwargs):
def __init__(self, system, location, model_data):
if getattr(system, 'render_template', None) is None:
raise TypeError('{system} must have a render_template function'
' in order to use a MakoDescriptor'.format(
system=system))
super(MakoModuleDescriptor, self).__init__(system, definition, **kwargs)
super(MakoModuleDescriptor, self).__init__(system, location, model_data)
def get_context(self):
"""
Return the context to render the mako template with
"""
return {'module': self,
'metadata': self.metadata,
'editable_metadata_fields': self.editable_metadata_fields
}
return {
'module': self,
'editable_metadata_fields': self.editable_metadata_fields,
}
def get_html(self):
return self.system.render_template(
@@ -44,6 +44,10 @@ class MakoModuleDescriptor(XModuleDescriptor):
# cdodge: encapsulate a means to expose "editable" metadata fields (i.e. not internal system metadata)
@property
def editable_metadata_fields(self):
subset = [name for name in self.metadata.keys() if name not in self.system_metadata_fields and
name not in self._inherited_metadata]
return subset
fields = {}
for field, value in own_metadata(self).items():
if field in self.system_metadata_fields:
continue
fields[field] = value
return fields

View File

@@ -423,6 +423,7 @@ class ModuleStoreBase(ModuleStore):
Set up the error-tracking logic.
'''
self._location_errors = {} # location -> ErrorLog
self.metadata_inheritance_cache = None
def _get_errorlog(self, location):
"""

View File

@@ -33,11 +33,12 @@ def modulestore(name='default'):
class_ = load_function(settings.MODULESTORE[name]['ENGINE'])
options = {}
options.update(settings.MODULESTORE[name]['OPTIONS'])
for key in FUNCTION_KEYS:
if key in options:
options[key] = load_function(options[key])
_MODULESTORES[name] = class_(
**options
)

View File

@@ -15,11 +15,11 @@ def as_draft(location):
def wrap_draft(item):
"""
Sets `item.metadata['is_draft']` to `True` if the item is a
draft, and false otherwise. Sets the item's location to the
Sets `item.cms.is_draft` to `True` if the item is a
draft, and `False` otherwise. Sets the item's location to the
non-draft location in either case
"""
item.metadata['is_draft'] = item.location.revision == DRAFT
item.cms.is_draft = item.location.revision == DRAFT
item.location = item.location._replace(revision=None)
return item
@@ -118,7 +118,7 @@ class DraftModuleStore(ModuleStoreBase):
"""
draft_loc = as_draft(location)
draft_item = self.get_item(location)
if not draft_item.metadata['is_draft']:
if not draft_item.cms.is_draft:
self.clone_item(location, draft_loc)
return super(DraftModuleStore, self).update_item(draft_loc, data)
@@ -133,7 +133,7 @@ class DraftModuleStore(ModuleStoreBase):
"""
draft_loc = as_draft(location)
draft_item = self.get_item(location)
if not draft_item.metadata['is_draft']:
if not draft_item.cms.is_draft:
self.clone_item(location, draft_loc)
return super(DraftModuleStore, self).update_children(draft_loc, children)
@@ -149,7 +149,7 @@ class DraftModuleStore(ModuleStoreBase):
draft_loc = as_draft(location)
draft_item = self.get_item(location)
if not draft_item.metadata['is_draft']:
if not draft_item.cms.is_draft:
self.clone_item(location, draft_loc)
if 'is_draft' in metadata:
@@ -179,13 +179,11 @@ class DraftModuleStore(ModuleStoreBase):
Save a current draft to the underlying modulestore
"""
draft = self.get_item(location)
metadata = {}
metadata.update(draft.metadata)
metadata['published_date'] = tuple(datetime.utcnow().timetuple())
metadata['published_by'] = published_by_id
super(DraftModuleStore, self).update_item(location, draft.definition.get('data', {}))
super(DraftModuleStore, self).update_children(location, draft.definition.get('children', []))
super(DraftModuleStore, self).update_metadata(location, metadata)
draft.cms.published_date = datetime.utcnow()
draft.cms.published_by = published_by_id
super(DraftModuleStore, self).update_item(location, draft._model_data._kvs._data)
super(DraftModuleStore, self).update_children(location, draft._model_data._kvs._children)
super(DraftModuleStore, self).update_metadata(location, draft._model_data._kvs._metadata)
self.delete_item(location)
def unpublish(self, location):

View File

@@ -0,0 +1,67 @@
from xblock.core import Scope
# A list of metadata that this module can inherit from its parent module
INHERITABLE_METADATA = (
'graded', 'start', 'due', 'graceperiod', 'showanswer', 'rerandomize',
# TODO (ichuang): used for Fall 2012 xqa server access
'xqa_key',
# How many days early to show a course element to beta testers (float)
# intended to be set per-course, but can be overridden in for specific
# elements. Can be a float.
'days_early_for_beta'
)
def compute_inherited_metadata(descriptor):
"""Given a descriptor, traverse all of its descendants and do metadata
inheritance. Should be called on a CourseDescriptor after importing a
course.
NOTE: This means that there is no such thing as lazy loading at the
moment--this accesses all the children."""
for child in descriptor.get_children():
inherit_metadata(child, descriptor._model_data)
compute_inherited_metadata(child)
def inherit_metadata(descriptor, model_data):
"""
Updates this module with metadata inherited from a containing module.
Only metadata specified in self.inheritable_metadata will
be inherited
"""
if not hasattr(descriptor, '_inherited_metadata'):
setattr(descriptor, '_inherited_metadata', {})
# Set all inheritable metadata from kwargs that are
# in self.inheritable_metadata and aren't already set in metadata
for attr in INHERITABLE_METADATA:
if attr not in descriptor._model_data and attr in model_data:
descriptor._inherited_metadata[attr] = model_data[attr]
descriptor._model_data[attr] = model_data[attr]
def own_metadata(module):
"""
Return a dictionary that contains only non-inherited field keys,
mapped to their values
"""
inherited_metadata = getattr(module, '_inherited_metadata', {})
metadata = {}
for field in module.fields + module.lms.fields:
# Only save metadata that wasn't inherited
if field.scope != Scope.settings:
continue
if field.name in inherited_metadata and module._model_data.get(field.name) == inherited_metadata.get(field.name):
continue
if field.name not in module._model_data:
continue
try:
metadata[field.name] = module._model_data[field.name]
except KeyError:
# Ignore any missing keys in _model_data
pass
return metadata

View File

@@ -4,6 +4,7 @@ import logging
import copy
from bson.son import SON
from collections import namedtuple
from fs.osfs import OSFS
from itertools import repeat
from path import path
@@ -11,20 +12,93 @@ from datetime import datetime, timedelta
from importlib import import_module
from xmodule.errortracker import null_error_tracker, exc_info_to_str
from xmodule.x_module import XModuleDescriptor
from xmodule.mako_module import MakoDescriptorSystem
from xmodule.x_module import XModuleDescriptor
from xmodule.error_module import ErrorDescriptor
from xblock.runtime import DbModel, KeyValueStore, InvalidScopeError
from xblock.core import Scope
from . import ModuleStoreBase, Location
from .draft import DraftModuleStore
from .exceptions import (ItemNotFoundError,
DuplicateItemError)
from .inheritance import own_metadata, INHERITABLE_METADATA, inherit_metadata
log = logging.getLogger(__name__)
# TODO (cpennington): This code currently operates under the assumption that
# there is only one revision for each item. Once we start versioning inside the CMS,
# that assumption will have to change
class MongoKeyValueStore(KeyValueStore):
"""
A KeyValueStore that maps keyed data access to one of the 3 data areas
known to the MongoModuleStore (data, children, and metadata)
"""
def __init__(self, data, children, metadata):
self._data = data
self._children = children
self._metadata = metadata
def get(self, key):
if key.scope == Scope.children:
return self._children
elif key.scope == Scope.parent:
return None
elif key.scope == Scope.settings:
return self._metadata[key.field_name]
elif key.scope == Scope.content:
if key.field_name == 'data' and not isinstance(self._data, dict):
return self._data
else:
return self._data[key.field_name]
else:
raise InvalidScopeError(key.scope)
def set(self, key, value):
if key.scope == Scope.children:
self._children = value
elif key.scope == Scope.settings:
self._metadata[key.field_name] = value
elif key.scope == Scope.content:
if key.field_name == 'data' and not isinstance(self._data, dict):
self._data = value
else:
self._data[key.field_name] = value
else:
raise InvalidScopeError(key.scope)
def delete(self, key):
if key.scope == Scope.children:
self._children = []
elif key.scope == Scope.settings:
if key.field_name in self._metadata:
del self._metadata[key.field_name]
elif key.scope == Scope.content:
if key.field_name == 'data' and not isinstance(self._data, dict):
self._data = None
else:
del self._data[key.field_name]
else:
raise InvalidScopeError(key.scope)
def has(self, key):
if key.scope in (Scope.children, Scope.parent):
return True
elif key.scope == Scope.settings:
return key.field_name in self._metadata
elif key.scope == Scope.content:
if key.field_name == 'data' and not isinstance(self._data, dict):
return True
else:
return key.field_name in self._data
else:
return False
MongoUsage = namedtuple('MongoUsage', 'id, def_id')
class CachingDescriptorSystem(MakoDescriptorSystem):
"""
A system that has a cache of module json that it will use to load modules
@@ -72,12 +146,31 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
else:
# load the module and apply the inherited metadata
try:
module = XModuleDescriptor.load_from_json(json_data, self, self.default_class)
class_ = XModuleDescriptor.load_class(
json_data['location']['category'],
self.default_class
)
definition = json_data.get('definition', {})
metadata = json_data.get('metadata', {})
for old_name, new_name in class_.metadata_translations.items():
if old_name in metadata:
metadata[new_name] = metadata[old_name]
del metadata[old_name]
kvs = MongoKeyValueStore(
definition.get('data', {}),
definition.get('children', []),
metadata,
)
model_data = DbModel(kvs, class_, None, MongoUsage(self.course_id, location))
module = class_(self, location, model_data)
if self.metadata_inheritance_tree is not None:
metadata_to_inherit = self.metadata_inheritance_tree.get('parent_metadata', {}).get(location.url(),{})
module.inherit_metadata(metadata_to_inherit)
metadata_to_inherit = self.metadata_inheritance_tree.get('parent_metadata', {}).get(location.url(), {})
inherit_metadata(module, metadata_to_inherit)
return module
except:
log.warning("Failed to load descriptor", exc_info=True)
return ErrorDescriptor.from_json(
json_data,
self,
@@ -153,26 +246,21 @@ class MongoModuleStore(ModuleStoreBase):
self.fs_root = path(fs_root)
self.error_tracker = error_tracker
self.render_template = render_template
self.metadata_inheritance_cache = {}
def get_metadata_inheritance_tree(self, location):
'''
TODO (cdodge) This method can be deleted when the 'split module store' work has been completed
'''
# get all collections in the course, this query should not return any leaf nodes
query = {
# note this is a bit ugly as when we add new categories of containers, we have to add it here
query = {
'_id.org': location.org,
'_id.course': location.course,
'$or': [
{"_id.category":"course"},
{"_id.category":"chapter"},
{"_id.category":"sequential"},
{"_id.category":"vertical"}
]
'_id.category': {'$in': [ 'course', 'chapter', 'sequential', 'vertical']}
}
# we just want the Location, children, and metadata
record_filter = {'_id':1,'definition.children':1,'metadata':1}
record_filter = {'_id': 1, 'definition.children': 1, 'metadata': 1}
# call out to the DB
resultset = self.collection.find(query, record_filter)
@@ -190,9 +278,15 @@ class MongoModuleStore(ModuleStoreBase):
# now traverse the tree and compute down the inherited metadata
metadata_to_inherit = {}
def _compute_inherited_metadata(url):
my_metadata = results_by_url[url]['metadata']
my_metadata = {}
# check for presence of metadata key. Note that a given module may not yet be fully formed.
# example: update_item -> update_children -> update_metadata sequence on new item create
# if we get called here without update_metadata called first then 'metadata' hasn't been set
# as we're not fully transactional at the DB layer. Same comment applies to below key name
# check
my_metadata = results_by_url[url].get('metadata', {})
for key in my_metadata.keys():
if key not in XModuleDescriptor.inheritable_metadata:
if key not in INHERITABLE_METADATA:
del my_metadata[key]
results_by_url[url]['metadata'] = my_metadata
@@ -201,39 +295,44 @@ class MongoModuleStore(ModuleStoreBase):
for child in results_by_url[url].get('definition',{}).get('children',[]):
if child in results_by_url:
new_child_metadata = copy.deepcopy(my_metadata)
new_child_metadata.update(results_by_url[child]['metadata'])
new_child_metadata.update(results_by_url[child].get('metadata', {}))
results_by_url[child]['metadata'] = new_child_metadata
metadata_to_inherit[child] = new_child_metadata
_compute_inherited_metadata(child)
else:
# this is likely a leaf node, so let's record what metadata we need to inherit
metadata_to_inherit[child] = my_metadata
if root is not None:
_compute_inherited_metadata(root)
cache = {'parent_metadata': metadata_to_inherit,
return {'parent_metadata': metadata_to_inherit,
'timestamp' : datetime.now()}
return cache
def get_cached_metadata_inheritance_tree(self, location, max_age_allowed):
def get_cached_metadata_inheritance_tree(self, location, force_refresh=False):
'''
TODO (cdodge) This method can be deleted when the 'split module store' work has been completed
'''
cache_name = '{0}/{1}'.format(location.org, location.course)
cache = self.metadata_inheritance_cache.get(cache_name,{'parent_metadata': {},
'timestamp': datetime.now() - timedelta(hours=1)})
age = (datetime.now() - cache['timestamp'])
key_name = '{0}/{1}'.format(location.org, location.course)
if age.seconds >= max_age_allowed:
logging.debug('loading entire inheritance tree for {0}'.format(cache_name))
cache = self.get_metadata_inheritance_tree(location)
self.metadata_inheritance_cache[cache_name] = cache
tree = None
if self.metadata_inheritance_cache is not None:
tree = self.metadata_inheritance_cache.get(key_name)
else:
# This is to help guard against an accident prod runtime without a cache
logging.warning('Running MongoModuleStore without metadata_inheritance_cache. This should not happen in production!')
return cache
if tree is None or force_refresh:
tree = self.get_metadata_inheritance_tree(location)
if self.metadata_inheritance_cache is not None:
self.metadata_inheritance_cache.set(key_name, tree)
return tree
def clear_cached_metadata_inheritance_tree(self, location):
key_name = '{0}/{1}'.format(location.org, location.course)
if self.metadata_inheritance_cache is not None:
self.metadata_inheritance_cache.delete(key_name)
def _clean_item_data(self, item):
"""
@@ -280,7 +379,7 @@ class MongoModuleStore(ModuleStoreBase):
"""
Load an XModuleDescriptor from item, using the children stored in data_cache
"""
data_dir = item.get('metadata', {}).get('data_dir', item['location']['course'])
data_dir = getattr(item, 'data_dir', item['location']['course'])
root = self.fs_root / data_dir
if not root.isdir():
@@ -293,7 +392,7 @@ class MongoModuleStore(ModuleStoreBase):
# if we are loading a course object, there is no parent to inherit the metadata from
# so don't bother getting it
if item['location']['category'] != 'course':
metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location']), 300)
metadata_inheritance_tree = self.get_cached_metadata_inheritance_tree(Location(item['location']))
# TODO (cdodge): When the 'split module store' work has been completed, we should remove
# the 'metadata_inheritance_tree' parameter
@@ -407,14 +506,20 @@ class MongoModuleStore(ModuleStoreBase):
if location.category == 'static_tab':
course = self.get_course_for_item(item.location)
existing_tabs = course.tabs or []
existing_tabs.append({'type': 'static_tab', 'name': item.metadata.get('display_name'), 'url_slug': item.location.name})
existing_tabs.append({
'type': 'static_tab',
'name': item.display_name,
'url_slug': item.location.name
})
course.tabs = existing_tabs
self.update_metadata(course.location, course.metadata)
self.update_metadata(course.location, course._model_data._kvs._metadata)
return item
except pymongo.errors.DuplicateKeyError:
raise DuplicateItemError(location)
# recompute (and update) the metadata inheritance tree which is cached
self.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True)
def get_course_for_item(self, location, depth=0):
'''
@@ -435,10 +540,10 @@ class MongoModuleStore(ModuleStoreBase):
# make sure we found exactly one match on this above course search
found_cnt = len(courses)
if found_cnt == 0:
raise BaseException('Could not find course at {0}'.format(course_search_location))
raise Exception('Could not find course at {0}'.format(course_search_location))
if found_cnt > 1:
raise BaseException('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
raise Exception('Found more than one course at {0}. There should only be one!!! Dump = {1}'.format(course_search_location, courses))
return courses[0]
@@ -480,6 +585,8 @@ class MongoModuleStore(ModuleStoreBase):
"""
self._update_single_item(location, {'definition.children': children})
# recompute (and update) the metadata inheritance tree which is cached
self.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True)
def update_metadata(self, location, metadata):
"""
@@ -501,10 +608,11 @@ class MongoModuleStore(ModuleStoreBase):
tab['name'] = metadata.get('display_name')
break
course.tabs = existing_tabs
self.update_metadata(course.location, course.metadata)
self.update_metadata(course.location, own_metadata(course))
self._update_single_item(location, {'metadata': metadata})
# recompute (and update) the metadata inheritance tree which is cached
self.get_cached_metadata_inheritance_tree(loc, force_refresh = True)
def delete_item(self, location):
"""
@@ -520,9 +628,11 @@ class MongoModuleStore(ModuleStoreBase):
course = self.get_course_for_item(item.location)
existing_tabs = course.tabs or []
course.tabs = [tab for tab in existing_tabs if tab.get('url_slug') != location.name]
self.update_metadata(course.location, course.metadata)
self.update_metadata(course.location, own_metadata(course))
self.collection.remove({'_id': Location(location).dict()})
# recompute (and update) the metadata inheritance tree which is cached
self.get_cached_metadata_inheritance_tree(Location(location), force_refresh = True)
def get_parent_locations(self, location, course_id):

View File

@@ -41,22 +41,24 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele
print "Cloning module {0} to {1}....".format(original_loc, module.location)
if 'data' in module.definition:
modulestore.update_item(module.location, module.definition['data'])
modulestore.update_item(module.location, module._model_data._kvs._data)
# repoint children
if 'children' in module.definition:
if module.has_children:
new_children = []
for child_loc_url in module.definition['children']:
for child_loc_url in module.children:
child_loc = Location(child_loc_url)
child_loc = child_loc._replace(tag=dest_location.tag, org=dest_location.org,
course=dest_location.course)
new_children = new_children + [child_loc.url()]
child_loc = child_loc._replace(
tag=dest_location.tag,
org=dest_location.org,
course=dest_location.course
)
new_children.append(child_loc.url())
modulestore.update_children(module.location, new_children)
# save metadata
modulestore.update_metadata(module.location, module.metadata)
modulestore.update_metadata(module.location, module._model_data._kvs._metadata)
# now iterate through all of the assets and clone them
# first the thumbnails

View File

@@ -4,6 +4,7 @@ from uuid import uuid4
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.timeparse import stringify_time
from xmodule.modulestore.inheritance import own_metadata
def XMODULE_COURSE_CREATION(class_to_create, **kwargs):
@@ -40,10 +41,9 @@ class XModuleCourseFactory(Factory):
# This metadata code was copied from cms/djangoapps/contentstore/views.py
if display_name is not None:
new_course.metadata['display_name'] = display_name
new_course.display_name = display_name
new_course.metadata['data_dir'] = uuid4().hex
new_course.metadata['start'] = stringify_time(gmtime())
new_course.start = gmtime()
new_course.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
@@ -52,7 +52,7 @@ class XModuleCourseFactory(Factory):
{"type": "progress", "name": "Progress"}]
# Update the data in the mongo datastore
store.update_metadata(new_course.location.url(), new_course.own_metadata)
store.update_metadata(new_course.location.url(), own_metadata(new_course))
return new_course
@@ -99,17 +99,14 @@ class XModuleItemFactory(Factory):
new_item = store.clone_item(template, dest_location)
# TODO: This needs to be deleted when we have proper storage for static content
new_item.metadata['data_dir'] = parent.metadata['data_dir']
# replace the display name with an optional parameter passed in from the caller
if display_name is not None:
new_item.metadata['display_name'] = display_name
new_item.display_name = display_name
store.update_metadata(new_item.location.url(), new_item.own_metadata)
store.update_metadata(new_item.location.url(), own_metadata(new_item))
if new_item.location.category not in DETACHED_CATEGORIES:
store.update_children(parent_location, parent.definition.get('children', []) + [new_item.location.url()])
store.update_children(parent_location, parent.children + [new_item.location.url()])
return new_item

View File

@@ -23,13 +23,14 @@ from xmodule.html_module import HtmlDescriptor
from . import ModuleStoreBase, Location
from .exceptions import ItemNotFoundError
from .inheritance import compute_inherited_metadata
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True)
etree.set_default_parser(edx_xml_parser)
log = logging.getLogger('mitx.' + __name__)
log = logging.getLogger(__name__)
# VS[compat]
@@ -73,7 +74,8 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
# VS[compat]. Take this out once course conversion is done (perhaps leave the uniqueness check)
# tags that really need unique names--they store (or should store) state.
need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', 'videosequence', 'timelimit')
need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter',
'videosequence', 'poll_question', 'timelimit')
attr = xml_data.attrib
tag = xml_data.tag
@@ -161,7 +163,6 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
etree.tostring(xml_data, encoding='unicode'), self, self.org,
self.course, xmlstore.default_class)
except Exception as err:
print err, self.load_error_modules
if not self.load_error_modules:
raise
@@ -174,7 +175,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
# Normally, we don't want lots of exception traces in our logs from common
# content problems. But if you're debugging the xml loading code itself,
# uncomment the next line.
# log.exception(msg)
log.exception(msg)
self.error_tracker(msg)
err_msg = msg + "\n" + exc_info_to_str(sys.exc_info())
@@ -186,12 +187,13 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
err_msg
)
descriptor.metadata['data_dir'] = course_dir
setattr(descriptor, 'data_dir', course_dir)
xmlstore.modules[course_id][descriptor.location] = descriptor
for child in descriptor.get_children():
parent_tracker.add_parent(child.location, descriptor.location)
if hasattr(descriptor, 'children'):
for child in descriptor.get_children():
parent_tracker.add_parent(child.location, descriptor.location)
return descriptor
render_template = lambda: ''
@@ -318,8 +320,6 @@ class XMLModuleStore(ModuleStoreBase):
# Didn't load course. Instead, save the errors elsewhere.
self.errored_courses[course_dir] = errorlog
def __unicode__(self):
'''
String representation - for debugging
@@ -345,8 +345,6 @@ class XMLModuleStore(ModuleStoreBase):
log.warning(msg + " " + str(err))
return {}
def load_course(self, course_dir, tracker):
"""
Load a course into this module store
@@ -430,7 +428,7 @@ class XMLModuleStore(ModuleStoreBase):
# breaks metadata inheritance via get_children(). Instead
# (actually, in addition to, for now), we do a final inheritance pass
# after we have the course descriptor.
XModuleDescriptor.compute_inherited_metadata(course_descriptor)
compute_inherited_metadata(course_descriptor)
# now import all pieces of course_info which is expected to be stored
# in <content_dir>/info or <content_dir>/info/<url_name>
@@ -449,7 +447,6 @@ class XMLModuleStore(ModuleStoreBase):
def load_extra_content(self, system, course_descriptor, category, base_dir, course_dir, url_name):
self._load_extra_content(system, course_descriptor, category, base_dir, course_dir)
# then look in a override folder based on the course run
@@ -460,26 +457,29 @@ class XMLModuleStore(ModuleStoreBase):
def _load_extra_content(self, system, course_descriptor, category, path, course_dir):
for filepath in glob.glob(path / '*'):
if not os.path.isdir(filepath):
with open(filepath) as f:
try:
html = f.read().decode('utf-8')
# tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix
slug = os.path.splitext(os.path.basename(filepath))[0]
loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug)
module = HtmlDescriptor(system, definition={'data': html}, **{'location': loc})
# VS[compat]:
# Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them)
# from the course policy
if category == "static_tab":
for tab in course_descriptor.tabs or []:
if tab.get('url_slug') == slug:
module.metadata['display_name'] = tab['name']
module.metadata['data_dir'] = course_dir
self.modules[course_descriptor.id][module.location] = module
except Exception, e:
logging.exception("Failed to load {0}. Skipping... Exception: {1}".format(filepath, str(e)))
system.error_tracker("ERROR: " + str(e))
if not os.path.isfile(filepath):
continue
with open(filepath) as f:
try:
html = f.read().decode('utf-8')
# tabs are referenced in policy.json through a 'slug' which is just the filename without the .html suffix
slug = os.path.splitext(os.path.basename(filepath))[0]
loc = Location('i4x', course_descriptor.location.org, course_descriptor.location.course, category, slug)
module = HtmlDescriptor(system, loc, {'data': html})
# VS[compat]:
# Hack because we need to pull in the 'display_name' for static tabs (because we need to edit them)
# from the course policy
if category == "static_tab":
for tab in course_descriptor.tabs or []:
if tab.get('url_slug') == slug:
module.display_name = tab['name']
module.data_dir = course_dir
self.modules[course_descriptor.id][module.location] = module
except Exception, e:
logging.exception("Failed to load {0}. Skipping... Exception: {1}".format(filepath, str(e)))
system.error_tracker("ERROR: " + str(e))
def get_instance(self, course_id, location, depth=0):
"""

View File

@@ -1,6 +1,7 @@
import logging
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.inheritance import own_metadata
from fs.osfs import OSFS
from json import dumps
@@ -31,14 +32,12 @@ def export_to_xml(modulestore, contentstore, course_location, root_dir, course_d
# export the grading policy
policies_dir = export_fs.makeopendir('policies')
course_run_policy_dir = policies_dir.makeopendir(course.location.name)
if 'grading_policy' in course.definition['data']:
with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
grading_policy.write(dumps(course.definition['data']['grading_policy']))
with course_run_policy_dir.open('grading_policy.json', 'w') as grading_policy:
grading_policy.write(dumps(course.grading_policy))
# export all of the course metadata in policy.json
with course_run_policy_dir.open('policy.json', 'w') as course_policy:
policy = {}
policy = {'course/' + course.location.name: course.metadata}
policy = {'course/' + course.location.name: own_metadata(course)}
course_policy.write(dumps(policy))
@@ -50,4 +49,4 @@ def export_extra_content(export_fs, modulestore, course_location, category_type,
item_dir = export_fs.makeopendir(dirname)
for item in items:
with item_dir.open(item.location.name + file_suffix, 'w') as item_file:
item_file.write(item.definition['data'].encode('utf8'))
item_file.write(item.data.encode('utf8'))

View File

@@ -8,6 +8,7 @@ from .xml import XMLModuleStore
from .exceptions import DuplicateItemError
from xmodule.modulestore import Location
from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX
from .inheritance import own_metadata
log = logging.getLogger(__name__)
@@ -20,6 +21,8 @@ def import_static_content(modules, course_loc, course_data_path, static_content_
# now import all static assets
static_dir = course_data_path / subpath
verbose = True
for dirname, dirnames, filenames in os.walk(static_dir):
for filename in filenames:
@@ -95,6 +98,79 @@ def verify_content_links(module, base_dir, static_content_store, link, remap_dic
return link
def import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False):
# 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
if module.has_children:
children_locs = module.children
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.children = new_locs
if hasattr(module, 'data'):
# cdodge: now go through any link references to '/static/' and make sure we've imported
# it as a StaticContent asset
try:
remap_dict = {}
# use the rewrite_links as a utility means to enumerate through all links
# in the module data. We use that to load that reference into our asset store
# IMPORTANT: There appears to be a bug in lxml.rewrite_link which makes us not be able to
# do the rewrites natively in that code.
# For example, what I'm seeing is <img src='foo.jpg' /> -> <img src='bar.jpg'>
# Note the dropped element closing tag. This causes the LMS to fail when rendering modules - that's
# no good, so we have to do this kludge
if isinstance(module.data, str) or isinstance(module.data, unicode): # some module 'data' fields are non strings which blows up the link traversal code
lxml_rewrite_links(module.data, lambda link: verify_content_links(module, course_data_path,
static_content_store, link, remap_dict))
for key in remap_dict.keys():
module.data = module.data.replace(key, remap_dict[key])
except Exception:
logging.exception("failed to rewrite links on {0}. Continuing...".format(module.location))
modulestore.update_item(module.location, module.data)
if module.has_children:
modulestore.update_children(module.location, module.children)
modulestore.update_metadata(module.location, own_metadata(module))
def import_course_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False):
# cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which
# does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS,
# but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that -
# if there is *any* tabs - then there at least needs to be some predefined ones
if module.tabs is None or len(module.tabs) == 0:
module.tabs = [{"type": "courseware"},
{"type": "course_info", "name": "Course Info"},
{"type": "discussion", "name": "Discussion"},
{"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge
# 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')
import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace, verbose=verbose)
def import_from_xml(store, data_dir, course_dirs=None,
default_class='xmodule.raw_module.RawDescriptor',
load_error_modules=True, static_content_store=None, target_location_namespace=None, verbose=False):
@@ -135,7 +211,7 @@ def import_from_xml(store, data_dir, course_dirs=None,
# 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_data_path = path(data_dir) / module.data_dir
course_location = module.location
module = remap_namespace(module, target_location_namespace)
@@ -151,10 +227,10 @@ def import_from_xml(store, data_dir, course_dirs=None,
{"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))
if hasattr(module, 'data'):
store.update_item(module.location, module.data)
store.update_children(module.location, module.children)
store.update_metadata(module.location, dict(own_metadata(module)))
# 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
@@ -186,8 +262,8 @@ def import_from_xml(store, data_dir, course_dirs=None,
if verbose:
log.debug('importing module location {0}'.format(module.location))
if 'data' in module.definition:
module_data = module.definition['data']
if hasattr(module, 'data'):
module_data = module.data
# cdodge: now go through any link references to '/static/' and make sure we've imported
# it as a StaticContent asset
@@ -213,16 +289,15 @@ def import_from_xml(store, data_dir, course_dirs=None,
store.update_item(module.location, module_data)
if 'children' in module.definition:
store.update_children(module.location, module.definition['children'])
if hasattr(module, 'children') and module.children != []:
store.update_children(module.location, module.children)
# NOTE: It's important to use own_metadata here to avoid writing
# inherited metadata everywhere.
store.update_metadata(module.location, dict(module.own_metadata))
store.update_metadata(module.location, dict(own_metadata(module)))
return module_store, course_items
def remap_namespace(module, target_location_namespace):
if target_location_namespace is None:
return module
@@ -237,21 +312,21 @@ def remap_namespace(module, target_location_namespace):
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)
if hasattr(module,'children'):
children_locs = module.children
if children_locs is not None and children_locs != []:
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())
new_locs.append(new_child_loc.url())
module.definition['children'] = new_locs
module.children = new_locs
return module
def validate_category_hierarchy(module_store, course_id, parent_category, expected_child_category):
err_cnt = 0
@@ -262,7 +337,7 @@ def validate_category_hierarchy(module_store, course_id, parent_category, expect
parents.append(module)
for parent in parents:
for child_loc in [Location(child) for child in parent.definition.get('children', [])]:
for child_loc in [Location(child) for child in parent.children]:
if child_loc.category != expected_child_category:
err_cnt += 1
print 'ERROR: child {0} of parent {1} was expected to be category of {2} but was {3}'.format(
@@ -274,7 +349,7 @@ def validate_category_hierarchy(module_store, course_id, parent_category, expect
def validate_data_source_path_existence(path, is_err=True, extra_msg=None):
_cnt = 0
if not os.path.exists(path):
print ("{0}: Expected folder at {1}. {2}".format('ERROR' if is_err == True else 'WARNING', path, extra_msg if
print ("{0}: Expected folder at {1}. {2}".format('ERROR' if is_err == True else 'WARNING', path, extra_msg if
extra_msg is not None else ''))
_cnt = 1
return _cnt

View File

@@ -3,16 +3,14 @@ import logging
from lxml import etree
from lxml.html import rewrite_links
from xmodule.timeinfo import TimeInfo
from xmodule.capa_module import only_one, ComplexEncoder
from xmodule.capa_module import ComplexEncoder
from xmodule.editing_module import EditingDescriptor
from xmodule.html_checker import check_html
from xmodule.progress import Progress
from xmodule.stringify import stringify_children
from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor
import self_assessment_module
import open_ended_module
from combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST
from .combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST
log = logging.getLogger("mitx.courseware")
@@ -121,17 +119,10 @@ class CombinedOpenEndedV1Module():
"""
self.metadata = metadata
self.display_name = metadata.get('display_name', "Open Ended")
self.instance_state = instance_state
self.display_name = instance_state.get('display_name', "Open Ended")
self.rewrite_content_links = static_data.get('rewrite_content_links', "")
# Load instance state
if instance_state is not None:
instance_state = json.loads(instance_state)
else:
instance_state = {}
#We need to set the location here so the child modules can use it
system.set('location', location)
self.system = system
@@ -143,18 +134,18 @@ class CombinedOpenEndedV1Module():
#Overall state of the combined open ended module
self.state = instance_state.get('state', self.INITIAL)
self.attempts = instance_state.get('attempts', 0)
self.student_attempts = instance_state.get('student_attempts', 0)
#Allow reset is true if student has failed the criteria to move to the next child task
self.allow_reset = instance_state.get('ready_to_reset', False)
self.max_attempts = int(self.metadata.get('attempts', MAX_ATTEMPTS))
self.is_scored = self.metadata.get('is_graded', IS_SCORED) in TRUE_DICT
self.accept_file_upload = self.metadata.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT
self.skip_basic_checks = self.metadata.get('skip_spelling_checks', SKIP_BASIC_CHECKS)
self.ready_to_reset = instance_state.get('ready_to_reset', False)
self.attempts = self.instance_state.get('attempts', MAX_ATTEMPTS)
self.is_scored = self.instance_state.get('is_graded', IS_SCORED) in TRUE_DICT
self.accept_file_upload = self.instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT
self.skip_basic_checks = self.instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) in TRUE_DICT
display_due_date_string = self.metadata.get('due', None)
display_due_date_string = self.instance_state.get('due', None)
grace_period_string = self.metadata.get('graceperiod', None)
grace_period_string = self.instance_state.get('graceperiod', None)
try:
self.timeinfo = TimeInfo(display_due_date_string, grace_period_string)
except:
@@ -164,7 +155,7 @@ class CombinedOpenEndedV1Module():
# 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._max_score = self.instance_state.get('max_score', MAX_SCORE)
self.rubric_renderer = CombinedOpenEndedRubric(system, True)
rubric_string = stringify_children(definition['rubric'])
@@ -173,7 +164,7 @@ class CombinedOpenEndedV1Module():
#Static data is passed to the child modules to render
self.static_data = {
'max_score': self._max_score,
'max_attempts': self.max_attempts,
'max_attempts': self.attempts,
'prompt': definition['prompt'],
'rubric': definition['rubric'],
'display_name': self.display_name,
@@ -207,10 +198,10 @@ class CombinedOpenEndedV1Module():
last_response = last_response_data['response']
loaded_task_state = json.loads(current_task_state)
if loaded_task_state['state'] == self.INITIAL:
loaded_task_state['state'] = self.ASSESSING
loaded_task_state['created'] = True
loaded_task_state['history'].append({'answer': last_response})
if loaded_task_state['child_state'] == self.INITIAL:
loaded_task_state['child_state'] = self.ASSESSING
loaded_task_state['child_created'] = True
loaded_task_state['child_history'].append({'answer': last_response})
current_task_state = json.dumps(loaded_task_state)
return current_task_state
@@ -249,8 +240,8 @@ class CombinedOpenEndedV1Module():
self.current_task_xml = self.task_xml[self.current_task_number]
if self.current_task_number > 0:
self.allow_reset = self.check_allow_reset()
if self.allow_reset:
self.ready_to_reset = self.check_allow_reset()
if self.ready_to_reset:
self.current_task_number = self.current_task_number - 1
current_task_type = self.get_tag_name(self.current_task_xml)
@@ -276,12 +267,12 @@ class CombinedOpenEndedV1Module():
last_response_data = self.get_last_response(self.current_task_number - 1)
last_response = last_response_data['response']
current_task_state = json.dumps({
'state': self.ASSESSING,
'child_state': self.ASSESSING,
'version': self.STATE_VERSION,
'max_score': self._max_score,
'attempts': 0,
'created': True,
'history': [{'answer': last_response}],
'child_attempts': 0,
'child_created': True,
'child_history': [{'answer': last_response}],
})
self.current_task = child_task_module(self.system, self.location,
self.current_task_parsed_xml, self.current_task_descriptor,
@@ -306,7 +297,7 @@ class CombinedOpenEndedV1Module():
Input: None
Output: the allow_reset attribute of the current module.
"""
if not self.allow_reset:
if not self.ready_to_reset:
if self.current_task_number > 0:
last_response_data = self.get_last_response(self.current_task_number - 1)
current_response_data = self.get_current_attributes(self.current_task_number)
@@ -314,9 +305,9 @@ class CombinedOpenEndedV1Module():
if (current_response_data['min_score_to_attempt'] > last_response_data['score']
or current_response_data['max_score_to_attempt'] < last_response_data['score']):
self.state = self.DONE
self.allow_reset = True
self.ready_to_reset = True
return self.allow_reset
return self.ready_to_reset
def get_context(self):
"""
@@ -330,7 +321,7 @@ class CombinedOpenEndedV1Module():
context = {
'items': [{'content': task_html}],
'ajax_url': self.system.ajax_url,
'allow_reset': self.allow_reset,
'allow_reset': self.ready_to_reset,
'state': self.state,
'task_count': len(self.task_xml),
'task_number': self.current_task_number + 1,
@@ -426,7 +417,7 @@ class CombinedOpenEndedV1Module():
else:
last_post_evaluation = task.format_feedback_with_evaluation(self.system, last_post_assessment)
last_post_assessment = last_post_evaluation
rubric_data = task._parse_score_msg(task.history[-1].get('post_assessment', ""), self.system)
rubric_data = task._parse_score_msg(task.child_history[-1].get('post_assessment', ""), self.system)
rubric_scores = rubric_data['rubric_scores']
grader_types = rubric_data['grader_types']
feedback_items = rubric_data['feedback_items']
@@ -440,7 +431,7 @@ class CombinedOpenEndedV1Module():
last_post_assessment = ""
last_correctness = task.is_last_response_correct()
max_score = task.max_score()
state = task.state
state = task.child_state
if task_type in HUMAN_TASK_TYPE:
human_task_name = HUMAN_TASK_TYPE[task_type]
else:
@@ -490,10 +481,10 @@ class CombinedOpenEndedV1Module():
Output: boolean indicating whether or not the task state changed.
"""
changed = False
if not self.allow_reset:
if not self.ready_to_reset:
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
current_task_state = json.loads(self.task_states[self.current_task_number])
if current_task_state['state'] == self.DONE:
if current_task_state['child_state'] == self.DONE:
self.current_task_number += 1
if self.current_task_number >= (len(self.task_xml)):
self.state = self.DONE
@@ -647,7 +638,7 @@ class CombinedOpenEndedV1Module():
Output: Dictionary to be rendered
"""
self.update_task_states()
return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.allow_reset}
return {'success': True, 'html': self.get_html_nonsystem(), 'allow_reset': self.ready_to_reset}
def reset(self, get):
"""
@@ -656,26 +647,26 @@ class CombinedOpenEndedV1Module():
Output: AJAX dictionary to tbe rendered
"""
if self.state != self.DONE:
if not self.allow_reset:
if not self.ready_to_reset:
return self.out_of_sync_error(get)
if self.attempts > self.max_attempts:
if self.student_attempts > self.attempts:
return {
'success': False,
#This is a student_facing_error
'error': ('You have attempted this question {0} times. '
'You are only allowed to attempt it {1} times.').format(
self.attempts, self.max_attempts)
self.student_attempts, self.attempts)
}
self.state = self.INITIAL
self.allow_reset = False
self.ready_to_reset = False
for i in xrange(0, len(self.task_xml)):
self.current_task_number = i
self.setup_next_task(reset=True)
self.current_task.reset(self.system)
self.task_states[self.current_task_number] = self.current_task.get_instance_state()
self.current_task_number = 0
self.allow_reset = False
self.ready_to_reset = False
self.setup_next_task()
return {'success': True, 'html': self.get_html_nonsystem()}
@@ -691,8 +682,8 @@ class CombinedOpenEndedV1Module():
'current_task_number': self.current_task_number,
'state': self.state,
'task_states': self.task_states,
'attempts': self.attempts,
'ready_to_reset': self.allow_reset,
'student_attempts': self.student_attempts,
'ready_to_reset': self.ready_to_reset,
}
return json.dumps(state)
@@ -727,7 +718,7 @@ class CombinedOpenEndedV1Module():
entirely, in which case they will be in the self.DONE state), and if it is scored or not.
@return: Boolean corresponding to the above.
"""
return (self.state == self.DONE or self.allow_reset) and self.is_scored
return (self.state == self.DONE or self.ready_to_reset) and self.is_scored
def get_score(self):
"""
@@ -778,7 +769,7 @@ class CombinedOpenEndedV1Module():
return progress_object
class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor):
class CombinedOpenEndedV1Descriptor():
"""
Module for adding combined open ended questions
"""
@@ -790,6 +781,9 @@ class CombinedOpenEndedV1Descriptor(XmlDescriptor, EditingDescriptor):
has_score = True
template_dir_name = "combinedopenended"
def __init__(self, system):
self.system =system
@classmethod
def definition_from_xml(cls, xml_object, system):
"""

View File

@@ -101,7 +101,7 @@ class CombinedOpenEndedRubric(object):
log.error(error_message)
raise RubricParsingError(error_message)
if total != max_score:
if int(total) != int(max_score):
#This is a staff_facing_error
error_msg = "The max score {0} for problem {1} does not match the total number of points in the rubric {2}. Contact the learning sciences group for assistance.".format(
max_score, location, total)

View File

@@ -1,5 +1,5 @@
import logging
from grading_service_module import GradingService
from .grading_service_module import GradingService
log = logging.getLogger(__name__)

View File

@@ -5,7 +5,7 @@ import requests
from requests.exceptions import RequestException, ConnectionError, HTTPError
import sys
from combined_open_ended_rubric import CombinedOpenEndedRubric
from .combined_open_ended_rubric import CombinedOpenEndedRubric
from lxml import etree
log = logging.getLogger(__name__)

View File

@@ -22,7 +22,7 @@ from numpy import median
from datetime import datetime
from combined_open_ended_rubric import CombinedOpenEndedRubric
from .combined_open_ended_rubric import CombinedOpenEndedRubric
log = logging.getLogger("mitx.courseware")
@@ -65,17 +65,17 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
if oeparam is None:
#This is a staff_facing_error
raise ValueError(error_message.format('oeparam'))
if self.prompt is None:
if self.child_prompt is None:
raise ValueError(error_message.format('prompt'))
if self.rubric is None:
if self.child_rubric is None:
raise ValueError(error_message.format('rubric'))
self._parse(oeparam, self.prompt, self.rubric, system)
self._parse(oeparam, self.child_prompt, self.child_rubric, system)
if self.created == True and self.state == self.ASSESSING:
self.created = False
if self.child_created == True and self.child_state == self.ASSESSING:
self.child_created = False
self.send_to_grader(self.latest_answer(), system)
self.created = False
self.child_created = False
def _parse(self, oeparam, prompt, rubric, system):
'''
@@ -89,8 +89,8 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
# Note that OpenEndedResponse is agnostic to the specific contents of grader_payload
prompt_string = stringify_children(prompt)
rubric_string = stringify_children(rubric)
self.prompt = prompt_string
self.rubric = rubric_string
self.child_prompt = prompt_string
self.child_rubric = rubric_string
grader_payload = oeparam.find('grader_payload')
grader_payload = grader_payload.text if grader_payload is not None else ''
@@ -131,7 +131,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
@param system: ModuleSystem
@return: Success indicator
"""
self.state = self.DONE
self.child_state = self.DONE
return {'success': True}
def message_post(self, get, system):
@@ -171,7 +171,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
anonymous_student_id = system.anonymous_student_id
queuekey = xqueue_interface.make_hashkey(str(system.seed) + qtime +
anonymous_student_id +
str(len(self.history)))
str(len(self.child_history)))
xheader = xqueue_interface.make_xheader(
lms_callback_url=system.xqueue['callback_url'],
@@ -198,7 +198,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
if error:
success = False
self.state = self.DONE
self.child_state = self.DONE
#This is a student_facing_message
return {'success': success, 'msg': "Successfully submitted your feedback."}
@@ -222,7 +222,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
# Generate header
queuekey = xqueue_interface.make_hashkey(str(system.seed) + qtime +
anonymous_student_id +
str(len(self.history)))
str(len(self.child_history)))
xheader = xqueue_interface.make_xheader(lms_callback_url=system.xqueue['callback_url'],
lms_key=queuekey,
@@ -265,7 +265,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
self.record_latest_score(new_score_msg['score'])
self.record_latest_post_assessment(score_msg)
self.state = self.POST_ASSESSMENT
self.child_state = self.POST_ASSESSMENT
return True
@@ -542,16 +542,16 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
@param short_feedback: If the long feedback is wanted or not
@return: Returns formatted feedback
"""
if not self.history:
if not self.child_history:
return ""
feedback_dict = self._parse_score_msg(self.history[-1].get('post_assessment', ""), system,
feedback_dict = self._parse_score_msg(self.child_history[-1].get('post_assessment', ""), system,
join_feedback=join_feedback)
if not short_feedback:
return feedback_dict['feedback'] if feedback_dict['valid'] else ''
if feedback_dict['valid']:
short_feedback = self._convert_longform_feedback_to_html(
json.loads(self.history[-1].get('post_assessment', "")))
json.loads(self.child_history[-1].get('post_assessment', "")))
return short_feedback if feedback_dict['valid'] else ''
def format_feedback_with_evaluation(self, system, feedback):
@@ -604,7 +604,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
@param system: Modulesystem (needed to align with other ajax functions)
@return: Returns the current state
"""
state = self.state
state = self.child_state
return {'state': state}
def save_answer(self, get, system):
@@ -620,7 +620,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
if closed:
return msg
if self.state != self.INITIAL:
if self.child_state != self.INITIAL:
return self.out_of_sync_error(get)
# add new history element with answer and empty score and hint.
@@ -667,13 +667,13 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
"""
#set context variables and render template
eta_string = None
if self.state != self.INITIAL:
if self.child_state != self.INITIAL:
latest = self.latest_answer()
previous_answer = latest if latest is not None else self.initial_display
post_assessment = self.latest_post_assessment(system)
score = self.latest_score()
correct = 'correct' if self.is_submission_correct(score) else 'incorrect'
if self.state == self.ASSESSING:
if self.child_state == self.ASSESSING:
eta_string = self.get_eta()
else:
post_assessment = ""
@@ -681,9 +681,9 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
previous_answer = self.initial_display
context = {
'prompt': self.prompt,
'prompt': self.child_prompt,
'previous_answer': previous_answer,
'state': self.state,
'state': self.child_state,
'allow_reset': self._allow_reset(),
'rows': 30,
'cols': 80,
@@ -698,7 +698,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild):
return html
class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
class OpenEndedDescriptor():
"""
Module for adding open ended response questions to courses
"""
@@ -710,6 +710,9 @@ class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
has_score = True
template_dir_name = "openended"
def __init__(self, system):
self.system =system
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
@@ -731,7 +734,7 @@ class OpenEndedDescriptor(XmlDescriptor, EditingDescriptor):
"""Assumes that xml_object has child k"""
return xml_object.xpath(k)[0]
return {'oeparam': parse('openendedparam'), }
return {'oeparam': parse('openendedparam')}
def definition_to_xml(self, resource_fs):

View File

@@ -1,19 +1,9 @@
import copy
from fs.errors import ResourceNotFoundError
import itertools
import json
import logging
from lxml import etree
from lxml.html import rewrite_links
from lxml.html.clean import Cleaner, autolink_html
from path import path
import os
import sys
import hashlib
import capa.xqueue_interface as xqueue_interface
import re
from xmodule.capa_module import only_one, ComplexEncoder
from xmodule.capa_module import ComplexEncoder
import open_ended_image_submission
from xmodule.editing_module import EditingDescriptor
from xmodule.html_checker import check_html
@@ -22,7 +12,7 @@ from xmodule.stringify import stringify_children
from xmodule.xml_module import XmlDescriptor
from xmodule.modulestore import Location
from capa.util import *
from peer_grading_service import PeerGradingService, MockPeerGradingService
from .peer_grading_service import PeerGradingService, MockPeerGradingService
import controller_query_service
from datetime import datetime
@@ -77,8 +67,12 @@ class OpenEndedChild(object):
def __init__(self, system, location, definition, descriptor, static_data,
instance_state=None, shared_state=None, **kwargs):
# Load instance state
if instance_state is not None:
instance_state = json.loads(instance_state)
try:
instance_state = json.loads(instance_state)
except:
log.error("Could not load instance state for open ended. Setting it to nothing.: {0}".format(instance_state))
else:
instance_state = {}
@@ -86,26 +80,24 @@ class OpenEndedChild(object):
# 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', self.INITIAL)
self.child_history=instance_state.get('child_history',[])
self.child_state=instance_state.get('child_state', self.INITIAL)
self.child_created = instance_state.get('child_created', False)
self.child_attempts = instance_state.get('child_attempts', 0)
self.created = instance_state.get('created', False)
self.attempts = instance_state.get('attempts', 0)
self.max_attempts = static_data['max_attempts']
self.prompt = static_data['prompt']
self.rubric = static_data['rubric']
self.child_prompt = static_data['prompt']
self.child_rubric = static_data['rubric']
self.display_name = static_data['display_name']
self.accept_file_upload = static_data['accept_file_upload']
self.close_date = static_data['close_date']
self.s3_interface = static_data['s3_interface']
self.skip_basic_checks = static_data['skip_basic_checks']
self._max_score = static_data['max_score']
# Used for progress / grading. Currently get credit just for
# completion (doesn't matter if you self-assessed correct/incorrect).
self._max_score = static_data['max_score']
if system.open_ended_grading_interface:
self.peer_gs = PeerGradingService(system.open_ended_grading_interface, system)
self.controller_qs = controller_query_service.ControllerQueryService(system.open_ended_grading_interface,
@@ -147,33 +139,34 @@ class OpenEndedChild(object):
#This is a student_facing_error
'error': 'The problem close date has passed, and this problem is now closed.'
}
elif self.attempts > self.max_attempts:
elif self.child_attempts > self.max_attempts:
return True, {
'success': False,
#This is a student_facing_error
'error': 'You have attempted this problem {0} times. You are allowed {1} attempts.'.format(
self.attempts, self.max_attempts)
self.child_attempts, self.max_attempts
)
}
else:
return False, {}
def latest_answer(self):
"""Empty string if not available"""
if not self.history:
if not self.child_history:
return ""
return self.history[-1].get('answer', "")
return self.child_history[-1].get('answer', "")
def latest_score(self):
"""None if not available"""
if not self.history:
if not self.child_history:
return None
return self.history[-1].get('score')
return self.child_history[-1].get('score')
def latest_post_assessment(self, system):
"""Empty string if not available"""
if not self.history:
if not self.child_history:
return ""
return self.history[-1].get('post_assessment', "")
return self.child_history[-1].get('post_assessment', "")
@staticmethod
def sanitize_html(answer):
@@ -195,30 +188,30 @@ class OpenEndedChild(object):
@return: None
"""
answer = OpenEndedChild.sanitize_html(answer)
self.history.append({'answer': answer})
self.child_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
self.child_history[-1]['score'] = score
def record_latest_post_assessment(self, post_assessment):
"""Assumes that state is right, so we're adding a score to the latest
history element"""
self.history[-1]['post_assessment'] = post_assessment
self.child_history[-1]['post_assessment'] = post_assessment
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:
if self.child_state == new_state:
return
self.state = new_state
self.child_state = new_state
if self.state == self.DONE:
self.attempts += 1
if self.child_state == self.DONE:
self.child_attempts += 1
def get_instance_state(self):
"""
@@ -227,17 +220,17 @@ class OpenEndedChild(object):
state = {
'version': self.STATE_VERSION,
'history': self.history,
'state': self.state,
'child_history': self.child_history,
'child_state': self.child_state,
'max_score': self._max_score,
'attempts': self.attempts,
'created': False,
'child_attempts': self.child_attempts,
'child_created': False,
}
return json.dumps(state)
def _allow_reset(self):
"""Can the module be reset?"""
return (self.state == self.DONE and self.attempts < self.max_attempts)
return (self.child_state == self.DONE and self.child_attempts < self.max_attempts)
def max_score(self):
"""
@@ -269,10 +262,10 @@ class OpenEndedChild(object):
'''
if self._max_score > 0:
try:
return Progress(self.get_score()['score'], self._max_score)
return Progress(int(self.get_score()['score']), int(self._max_score))
except Exception as err:
#This is a dev_facing_error
log.exception("Got bad progress from open ended child module. Max Score: {1}".format(self._max_score))
log.exception("Got bad progress from open ended child module. Max Score: {0}".format(self._max_score))
return None
return None
@@ -282,7 +275,7 @@ class OpenEndedChild(object):
"""
#This is a dev_facing_error
log.warning("Open ended child state out sync. state: %r, get: %r. %s",
self.state, get, msg)
self.child_state, get, msg)
#This is a student_facing_error
return {'success': False,
'error': 'The problem state got out-of-sync. Please try reloading the page.'}

View File

@@ -1,7 +1,7 @@
import json
import logging
from grading_service_module import GradingService
from .grading_service_module import GradingService
log = logging.getLogger(__name__)

View File

@@ -3,13 +3,11 @@ import logging
from lxml import etree
from xmodule.capa_module import ComplexEncoder
from xmodule.editing_module import EditingDescriptor
from xmodule.progress import Progress
from xmodule.stringify import stringify_children
from xmodule.xml_module import XmlDescriptor
import openendedchild
from combined_open_ended_rubric import CombinedOpenEndedRubric
from .combined_open_ended_rubric import CombinedOpenEndedRubric
log = logging.getLogger("mitx.courseware")
@@ -31,8 +29,12 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
</submitmessage>
</selfassessment>
"""
TEMPLATE_DIR = "combinedopenended/selfassessment"
# states
INITIAL = 'initial'
ASSESSING = 'assessing'
REQUEST_HINT = 'request_hint'
DONE = 'done'
def setup_response(self, system, location, definition, descriptor):
"""
@@ -43,8 +45,8 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
@param descriptor: SelfAssessmentDescriptor
@return: None
"""
self.prompt = stringify_children(self.prompt)
self.rubric = stringify_children(self.rubric)
self.child_prompt = stringify_children(self.child_prompt)
self.child_rubric = stringify_children(self.child_rubric)
def get_html(self, system):
"""
@@ -53,18 +55,18 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
@return: Rendered HTML
"""
#set context variables and render template
if self.state != self.INITIAL:
if self.child_state != self.INITIAL:
latest = self.latest_answer()
previous_answer = latest if latest is not None else ''
else:
previous_answer = ''
context = {
'prompt': self.prompt,
'prompt': self.child_prompt,
'previous_answer': previous_answer,
'ajax_url': system.ajax_url,
'initial_rubric': self.get_rubric_html(system),
'state': self.state,
'state': self.child_state,
'allow_reset': self._allow_reset(),
'child_type': 'selfassessment',
'accept_file_upload': self.accept_file_upload,
@@ -109,11 +111,11 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
"""
Return the appropriate version of the rubric, based on the state.
"""
if self.state == self.INITIAL:
if self.child_state == self.INITIAL:
return ''
rubric_renderer = CombinedOpenEndedRubric(system, False)
rubric_dict = rubric_renderer.render_rubric(self.rubric)
rubric_dict = rubric_renderer.render_rubric(self.child_rubric)
success = rubric_dict['success']
rubric_html = rubric_dict['html']
@@ -122,13 +124,13 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
'max_score': self._max_score,
}
if self.state == self.ASSESSING:
if self.child_state == self.ASSESSING:
context['read_only'] = False
elif self.state in (self.POST_ASSESSMENT, self.DONE):
elif self.child_state in (self.POST_ASSESSMENT, self.DONE):
context['read_only'] = True
else:
#This is a dev_facing_error
raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.state))
raise ValueError("Self assessment module is in an illegal state '{0}'".format(self.child_state))
return system.render_template('{0}/self_assessment_rubric.html'.format(self.TEMPLATE_DIR), context)
@@ -136,10 +138,10 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
"""
Return the appropriate version of the hint view, based on state.
"""
if self.state in (self.INITIAL, self.ASSESSING):
if self.child_state in (self.INITIAL, self.ASSESSING):
return ''
if self.state == self.DONE:
if self.child_state == self.DONE:
# display the previous hint
latest = self.latest_post_assessment(system)
hint = latest if latest is not None else ''
@@ -148,13 +150,13 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
context = {'hint': hint}
if self.state == self.POST_ASSESSMENT:
if self.child_state == self.POST_ASSESSMENT:
context['read_only'] = False
elif self.state == self.DONE:
elif self.child_state == self.DONE:
context['read_only'] = True
else:
#This is a dev_facing_error
raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.state))
raise ValueError("Self Assessment module is in an illegal state '{0}'".format(self.child_state))
return system.render_template('{0}/self_assessment_hint.html'.format(self.TEMPLATE_DIR), context)
@@ -175,7 +177,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
if closed:
return msg
if self.state != self.INITIAL:
if self.child_state != self.INITIAL:
return self.out_of_sync_error(get)
error_message = ""
@@ -216,7 +218,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
'message_html' only if success is true
"""
if self.state != self.ASSESSING:
if self.child_state != self.ASSESSING:
return self.out_of_sync_error(get)
try:
@@ -239,7 +241,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
self.change_state(self.DONE)
d['allow_reset'] = self._allow_reset()
d['state'] = self.state
d['state'] = self.child_state
return d
def save_hint(self, get, system):
@@ -253,7 +255,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
with the error key only present if success is False and message_html
only if True.
'''
if self.state != self.POST_ASSESSMENT:
if self.child_state != self.POST_ASSESSMENT:
# Note: because we only ask for hints on wrong answers, may not have
# the same number of hints and answers.
return self.out_of_sync_error(get)
@@ -276,7 +278,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild):
return [rubric_scores]
class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
class SelfAssessmentDescriptor():
"""
Module for adding self assessment questions to courses
"""
@@ -288,6 +290,9 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
has_score = True
template_dir_name = "selfassessment"
def __init__(self, system):
self.system =system
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
@@ -318,7 +323,7 @@ class SelfAssessmentDescriptor(XmlDescriptor, EditingDescriptor):
elt = etree.Element('selfassessment')
def add_child(k):
child_str = '<{tag}>{body}</{tag}>'.format(tag=k, body=self.definition[k])
child_str = '<{tag}>{body}</{tag}>'.format(tag=k, body=getattr(self, k))
child_node = etree.fromstring(child_str)
elt.append(child_node)

View File

@@ -6,13 +6,13 @@ from lxml import etree
from datetime import datetime
from pkg_resources import resource_string
from .capa_module import ComplexEncoder
from .editing_module import EditingDescriptor
from .stringify import stringify_children
from .x_module import XModule
from .xml_module import XmlDescriptor
from xmodule.raw_module import RawDescriptor
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from timeinfo import TimeInfo
from .timeinfo import TimeInfo
from xblock.core import Object, Integer, Boolean, String, Scope
from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService
@@ -27,7 +27,17 @@ IS_GRADED = True
EXTERNAL_GRADER_NO_CONTACT_ERROR = "Failed to contact external graders. Please notify course staff."
class PeerGradingModule(XModule):
class PeerGradingFields(object):
use_for_single_location = Boolean(help="Whether to use this for a single location or as a panel.", default=USE_FOR_SINGLE_LOCATION, scope=Scope.settings)
link_to_location = String(help="The location this problem is linked to.", default=LINK_TO_LOCATION, scope=Scope.settings)
is_graded = Boolean(help="Whether or not this module is scored.",default=IS_GRADED, scope=Scope.settings)
display_due_date_string = String(help="Due date that should be displayed.", default=None, scope=Scope.settings)
grace_period_string = String(help="Amount of grace to give on the due date.", default=None, scope=Scope.settings)
max_grade = Integer(help="The maximum grade that a student can receieve for this problem.", default=MAX_SCORE, scope=Scope.settings)
student_data_for_location = Object(help="Student data for a given peer grading problem.", default=json.dumps({}),scope=Scope.student_state)
class PeerGradingModule(PeerGradingFields, XModule):
_VERSION = 1
js = {'coffee': [resource_string(__name__, 'js/src/peergrading/peer_grading.coffee'),
@@ -39,16 +49,8 @@ class PeerGradingModule(XModule):
css = {'scss': [resource_string(__name__, 'css/combinedopenended/display.scss')]}
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
# Load instance state
if instance_state is not None:
instance_state = json.loads(instance_state)
else:
instance_state = {}
def __init__(self, system, location, descriptor, model_data):
XModule.__init__(self, system, location, descriptor, model_data)
#We need to set the location here so the child modules can use it
system.set('location', location)
@@ -58,43 +60,34 @@ class PeerGradingModule(XModule):
else:
self.peer_gs = MockPeerGradingService()
self.use_for_single_location = self.metadata.get('use_for_single_location', USE_FOR_SINGLE_LOCATION)
if isinstance(self.use_for_single_location, basestring):
self.use_for_single_location = (self.use_for_single_location in TRUE_DICT)
self.link_to_location = self.metadata.get('link_to_location', USE_FOR_SINGLE_LOCATION)
if self.use_for_single_location == True:
if self.use_for_single_location in TRUE_DICT:
try:
self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location)
except:
log.error("Linked location {0} for peer grading module {1} does not exist".format(
self.link_to_location, self.location))
raise
due_date = self.linked_problem.metadata.get('peer_grading_due', None)
due_date = self.linked_problem._model_data.get('peer_grading_due', None)
if due_date:
self.metadata['due'] = due_date
self.is_graded = self.metadata.get('is_graded', IS_GRADED)
if isinstance(self.is_graded, basestring):
self.is_graded = (self.is_graded in TRUE_DICT)
display_due_date_string = self.metadata.get('due', None)
grace_period_string = self.metadata.get('graceperiod', None)
self._model_data['due'] = due_date
try:
self.timeinfo = TimeInfo(display_due_date_string, grace_period_string)
self.timeinfo = TimeInfo(self.display_due_date_string, self.grace_period_string)
except:
log.error("Error parsing due date information in location {0}".format(location))
raise
self.display_due_date = self.timeinfo.display_due_date
try:
self.student_data_for_location = json.loads(self.student_data_for_location)
except:
pass
self.ajax_url = self.system.ajax_url
if not self.ajax_url.endswith("/"):
self.ajax_url = self.ajax_url + "/"
self.student_data_for_location = instance_state.get('student_data_for_location', {})
self.max_grade = instance_state.get('max_grade', MAX_SCORE)
if not isinstance(self.max_grade, (int, long)):
#This could result in an exception, but not wrapping in a try catch block so it moves up the stack
self.max_grade = int(self.max_grade)
@@ -129,7 +122,7 @@ class PeerGradingModule(XModule):
"""
if self.closed():
return self.peer_grading_closed()
if not self.use_for_single_location:
if self.use_for_single_location not in TRUE_DICT:
return self.peer_grading()
else:
return self.peer_grading_problem({'location': self.link_to_location})['html']
@@ -180,7 +173,7 @@ class PeerGradingModule(XModule):
pass
def get_score(self):
if not self.use_for_single_location or not self.is_graded:
if self.use_for_single_location not in TRUE_DICT or self.is_graded not in TRUE_DICT:
return None
try:
@@ -214,7 +207,7 @@ class PeerGradingModule(XModule):
randomization, and 5/7 on another
'''
max_grade = None
if self.use_for_single_location and self.is_graded:
if self.use_for_single_location in TRUE_DICT and self.is_graded in TRUE_DICT:
max_grade = self.max_grade
return max_grade
@@ -467,11 +460,13 @@ class PeerGradingModule(XModule):
except GradingServiceError:
#This is a student_facing_error
error_text = EXTERNAL_GRADER_NO_CONTACT_ERROR
log.error(error_text)
success = False
# catch error if if the json loads fails
except ValueError:
#This is a student_facing_error
error_text = "Could not get list of problems to peer grade. Please notify course staff."
log.error(error_text)
success = False
except:
log.exception("Could not contact peer grading service.")
@@ -494,8 +489,8 @@ class PeerGradingModule(XModule):
problem_location = problem['location']
descriptor = _find_corresponding_module_for_location(problem_location)
if descriptor:
problem['due'] = descriptor.metadata.get('peer_grading_due', None)
grace_period_string = descriptor.metadata.get('graceperiod', None)
problem['due'] = descriptor._model_data.get('peer_grading_due', None)
grace_period_string = descriptor._model_data.get('graceperiod', None)
try:
problem_timeinfo = TimeInfo(problem['due'], grace_period_string)
except:
@@ -506,7 +501,7 @@ class PeerGradingModule(XModule):
else:
problem['closed'] = False
else:
# if we can't find the due date, assume that it doesn't have one
# if we can't find the due date, assume that it doesn't have one
problem['due'] = None
problem['closed'] = False
@@ -529,7 +524,7 @@ class PeerGradingModule(XModule):
Show individual problem interface
'''
if get is None or get.get('location') is None:
if not self.use_for_single_location:
if self.use_for_single_location not in TRUE_DICT:
#This is an error case, because it must be set to use a single location to be called without get parameters
#This is a dev_facing_error
log.error(
@@ -567,9 +562,9 @@ class PeerGradingModule(XModule):
return json.dumps(state)
class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor):
class PeerGradingDescriptor(PeerGradingFields, RawDescriptor):
"""
Module for adding combined open ended questions
Module for adding peer grading questions
"""
mako_template = "widgets/raw-edit.html"
module_class = PeerGradingModule
@@ -578,42 +573,3 @@ class PeerGradingDescriptor(XmlDescriptor, EditingDescriptor):
stores_state = True
has_score = True
template_dir_name = "peer_grading"
js = {'coffee': [resource_string(__name__, 'js/src/html/edit.coffee')]}
js_module_name = "HTMLEditingDescriptor"
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
Pull out the individual tasks, the rubric, and the prompt, and parse
Returns:
{
'rubric': 'some-html',
'prompt': 'some-html',
'task_xml': dictionary of xml strings,
}
"""
expected_children = []
for child in expected_children:
if len(xml_object.xpath(child)) == 0:
#This is a staff_facing_error
raise ValueError(
"Peer grading definition must include at least one '{0}' tag. Contact the learning sciences group for assistance.".format(
child))
def parse_task(k):
"""Assumes that xml_object has child k"""
return [stringify_children(xml_object.xpath(k)[i]) for i in xrange(0, len(xml_object.xpath(k)))]
def parse(k):
"""Assumes that xml_object has child k"""
return xml_object.xpath(k)[0]
return {}
def definition_to_xml(self, resource_fs):
'''Return an xml element representing this definition.'''
elt = etree.Element('peergrading')
return elt

View File

@@ -0,0 +1,64 @@
import pkg_resources
import logging
log = logging.getLogger(__name__)
class PluginNotFoundError(Exception):
pass
class Plugin(object):
"""
Base class for a system that uses entry_points to load plugins.
Implementing classes are expected to have the following attributes:
entry_point: The name of the entry point to load plugins from
"""
_plugin_cache = None
@classmethod
def load_class(cls, identifier, default=None):
"""
Loads a single class instance specified by identifier. If identifier
specifies more than a single class, then logs a warning and returns the
first class identified.
If default is not None, will return default if no entry_point matching
identifier is found. Otherwise, will raise a ModuleMissingError
"""
if cls._plugin_cache is None:
cls._plugin_cache = {}
if identifier not in cls._plugin_cache:
identifier = identifier.lower()
classes = list(pkg_resources.iter_entry_points(
cls.entry_point, name=identifier))
if len(classes) > 1:
log.warning("Found multiple classes for {entry_point} with "
"identifier {id}: {classes}. "
"Returning the first one.".format(
entry_point=cls.entry_point,
id=identifier,
classes=", ".join(
class_.module_name for class_ in classes)))
if len(classes) == 0:
if default is not None:
return default
raise PluginNotFoundError(identifier)
cls._plugin_cache[identifier] = classes[0].load()
return cls._plugin_cache[identifier]
@classmethod
def load_classes(cls):
"""
Returns a list of containing the identifiers and their corresponding classes for all
of the available instances of this plugin
"""
return [(class_.name, class_.load())
for class_
in pkg_resources.iter_entry_points(cls.entry_point)]

View File

@@ -0,0 +1,205 @@
"""Poll module is ungraded xmodule used by students to
to do set of polls.
On the client side we show:
If student does not yet anwered - Question with set of choices.
If student have answered - Question with statistics for each answers.
Student can't change his answer.
"""
import cgi
import json
import logging
from copy import deepcopy
from collections import OrderedDict
from lxml import etree
from pkg_resources import resource_string
from xmodule.x_module import XModule
from xmodule.stringify import stringify_children
from xmodule.mako_module import MakoModuleDescriptor
from xmodule.xml_module import XmlDescriptor
from xblock.core import Scope, String, Object, Boolean, List
log = logging.getLogger(__name__)
class PollFields(object):
# Name of poll to use in links to this poll
display_name = String(help="Display name for this module", scope=Scope.settings)
voted = Boolean(help="Whether this student has voted on the poll", scope=Scope.student_state, default=False)
poll_answer = String(help="Student answer", scope=Scope.student_state, default='')
poll_answers = Object(help="All possible answers for the poll fro other students", scope=Scope.content)
answers = List(help="Poll answers from xml", scope=Scope.content, default=[])
question = String(help="Poll question", scope=Scope.content, default='')
class PollModule(PollFields, XModule):
"""Poll Module"""
js = {
'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee')],
'js': [resource_string(__name__, 'js/src/poll/logme.js'),
resource_string(__name__, 'js/src/poll/poll.js'),
resource_string(__name__, 'js/src/poll/poll_main.js')]
}
css = {'scss': [resource_string(__name__, 'css/poll/display.scss')]}
js_module_name = "Poll"
def handle_ajax(self, dispatch, get):
"""Ajax handler.
Args:
dispatch: string request slug
get: dict request get parameters
Returns:
json string
"""
if dispatch in self.poll_answers and not self.voted:
# FIXME: fix this, when xblock will support mutable types.
# Now we use this hack.
temp_poll_answers = self.poll_answers
temp_poll_answers[dispatch] += 1
self.poll_answers = temp_poll_answers
self.voted = True
self.poll_answer = dispatch
return json.dumps({'poll_answers': self.poll_answers,
'total': sum(self.poll_answers.values()),
'callback': {'objectName': 'Conditional'}
})
elif dispatch == 'get_state':
return json.dumps({'poll_answer': self.poll_answer,
'poll_answers': self.poll_answers,
'total': sum(self.poll_answers.values())
})
elif dispatch == 'reset_poll' and self.voted and \
self.descriptor.xml_attributes.get('reset', 'True').lower() != 'false':
self.voted = False
# FIXME: fix this, when xblock will support mutable types.
# Now we use this hack.
temp_poll_answers = self.poll_answers
temp_poll_answers[self.poll_answer] -= 1
self.poll_answers = temp_poll_answers
self.poll_answer = ''
return json.dumps({'status': 'success'})
else: # return error message
return json.dumps({'error': 'Unknown Command!'})
def get_html(self):
"""Renders parameters to template."""
params = {
'element_id': self.location.html_id(),
'element_class': self.location.category,
'ajax_url': self.system.ajax_url,
'configuration_json': self.dump_poll(),
}
self.content = self.system.render_template('poll.html', params)
return self.content
def dump_poll(self):
"""Dump poll information.
Returns:
string - Serialize json.
"""
# FIXME: hack for resolving caching `default={}` during definition
# poll_answers field
if self.poll_answers is None:
self.poll_answers = {}
answers_to_json = OrderedDict()
# FIXME: fix this, when xblock support mutable types.
# Now we use this hack.
temp_poll_answers = self.poll_answers
# Fill self.poll_answers, prepare data for template context.
for answer in self.answers:
# Set default count for answer = 0.
if answer['id'] not in temp_poll_answers:
temp_poll_answers[answer['id']] = 0
answers_to_json[answer['id']] = cgi.escape(answer['text'])
self.poll_answers = temp_poll_answers
return json.dumps({'answers': answers_to_json,
'question': cgi.escape(self.question),
# to show answered poll after reload:
'poll_answer': self.poll_answer,
'poll_answers': self.poll_answers if self.voted else {},
'total': sum(self.poll_answers.values()) if self.voted else 0,
'reset': str(self.descriptor.xml_attributes.get('reset', 'true')).lower()})
class PollDescriptor(PollFields, MakoModuleDescriptor, XmlDescriptor):
_tag_name = 'poll_question'
_child_tag_name = 'answer'
module_class = PollModule
template_dir_name = 'poll'
stores_state = True
@classmethod
def definition_from_xml(cls, xml_object, system):
"""Pull out the data into dictionary.
Args:
xml_object: xml from file.
system: `system` object.
Returns:
(definition, children) - tuple
definition - dict:
{
'answers': <List of answers>,
'question': <Question string>
}
"""
# Check for presense of required tags in xml.
if len(xml_object.xpath(cls._child_tag_name)) == 0:
raise ValueError("Poll_question definition must include \
at least one 'answer' tag")
xml_object_copy = deepcopy(xml_object)
answers = []
for element_answer in xml_object_copy.findall(cls._child_tag_name):
answer_id = element_answer.get('id', None)
if answer_id:
answers.append({
'id': answer_id,
'text': stringify_children(element_answer)
})
xml_object_copy.remove(element_answer)
definition = {
'answers': answers,
'question': stringify_children(xml_object_copy)
}
children = []
return (definition, children)
def definition_to_xml(self, resource_fs):
"""Return an xml element representing to this definition."""
poll_str = '<{tag_name}>{text}</{tag_name}>'.format(
tag_name=self._tag_name, text=self.question)
xml_object = etree.fromstring(poll_str)
xml_object.set('display_name', self.display_name)
def add_child(xml_obj, answer):
child_str = '<{tag_name} id="{id}">{text}</{tag_name}>'.format(
tag_name=self._child_tag_name, id=answer['id'],
text=answer['text'])
child_node = etree.fromstring(child_str)
xml_object.append(child_node)
for answer in self.answers:
add_child(xml_object, answer)
return xml_object

View File

@@ -1,19 +1,19 @@
import json
import logging
import random
from xmodule.mako_module import MakoModuleDescriptor
from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor
from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor
from pkg_resources import resource_string
from xblock.core import Scope, Integer
log = logging.getLogger('mitx.' + __name__)
class RandomizeModule(XModule):
class RandomizeFields(object):
choice = Integer(help="Which random child was chosen", scope=Scope.student_state)
class RandomizeModule(RandomizeFields, XModule):
"""
Chooses a random child module. Chooses the same one every time for each student.
@@ -35,30 +35,23 @@ class RandomizeModule(XModule):
grading interaction is a tangle between super and subclasses of descriptors and
modules.
"""
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs)
# NOTE: calling self.get_children() creates a circular reference--
# it calls get_child_descriptors() internally, but that doesn't work until
# we've picked a choice
num_choices = len(self.descriptor.get_children())
self.choice = None
if instance_state is not None:
state = json.loads(instance_state)
self.choice = state.get('choice', None)
if self.choice > num_choices:
# Oops. Children changed. Reset.
self.choice = None
if self.choice > num_choices:
# Oops. Children changed. Reset.
self.choice = None
if self.choice is None:
# choose one based on the system seed, or randomly if that's not available
if num_choices > 0:
if system.seed is not None:
self.choice = system.seed % num_choices
if self.system.seed is not None:
self.choice = self.system.seed % num_choices
else:
self.choice = random.randrange(0, num_choices)
@@ -72,11 +65,6 @@ class RandomizeModule(XModule):
self.child_descriptor = None
self.child = None
def get_instance_state(self):
return json.dumps({'choice': self.choice})
def get_child_descriptors(self):
"""
For grading--return just the chosen child.
@@ -98,7 +86,7 @@ class RandomizeModule(XModule):
return self.child.get_icon_class() if self.child else 'other'
class RandomizeDescriptor(SequenceDescriptor):
class RandomizeDescriptor(RandomizeFields, SequenceDescriptor):
# the editing interface can be the same as for sequences -- just a container
module_class = RandomizeModule
@@ -107,6 +95,7 @@ class RandomizeDescriptor(SequenceDescriptor):
stores_state = True
def definition_to_xml(self, resource_fs):
xml_object = etree.Element('randomize')
for child in self.get_children():
xml_object.append(

View File

@@ -3,6 +3,7 @@ from xmodule.editing_module import XMLEditingDescriptor
from xmodule.xml_module import XmlDescriptor
import logging
import sys
from xblock.core import String, Scope
log = logging.getLogger(__name__)
@@ -12,17 +13,19 @@ class RawDescriptor(XmlDescriptor, XMLEditingDescriptor):
Module that provides a raw editing view of its data and children. It
requires that the definition xml is valid.
"""
data = String(help="XML data for the module", scope=Scope.content)
@classmethod
def definition_from_xml(cls, xml_object, system):
return {'data': etree.tostring(xml_object, pretty_print=True, encoding='unicode')}
return {'data': etree.tostring(xml_object, pretty_print=True, encoding='unicode')}, []
def definition_to_xml(self, resource_fs):
try:
return etree.fromstring(self.definition['data'])
return etree.fromstring(self.data)
except etree.XMLSyntaxError as err:
# Can't recover here, so just add some info and
# re-raise
lines = self.definition['data'].split('\n')
lines = self.data.split('\n')
line, offset = err.position
msg = ("Unable to create xml for problem {loc}. "
"Context: '{context}'".format(

View File

@@ -1,6 +1,6 @@
import json
from x_module import XModule, XModuleDescriptor
from .x_module import XModule, XModuleDescriptor
class ModuleDescriptor(XModuleDescriptor):

View File

@@ -8,6 +8,7 @@ from xmodule.xml_module import XmlDescriptor
from xmodule.x_module import XModule
from xmodule.progress import Progress
from xmodule.exceptions import NotFoundError
from xblock.core import Integer, Scope
from pkg_resources import resource_string
log = logging.getLogger(__name__)
@@ -17,7 +18,15 @@ log = logging.getLogger(__name__)
class_priority = ['video', 'problem']
class SequenceModule(XModule):
class SequenceFields(object):
has_children = True
# NOTE: Position is 1-indexed. This is silly, but there are now student
# positions saved on prod, so it's not easy to fix.
position = Integer(help="Last tab viewed in this sequence", scope=Scope.student_state)
class SequenceModule(SequenceFields, XModule):
''' Layout module which lays out content in a temporal sequence
'''
js = {'coffee': [resource_string(__name__,
@@ -26,22 +35,13 @@ class SequenceModule(XModule):
css = {'scss': [resource_string(__name__, 'css/sequence/display.scss')]}
js_module_name = "Sequence"
def __init__(self, system, location, definition, descriptor, instance_state=None,
shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
# NOTE: Position is 1-indexed. This is silly, but there are now student
# positions saved on prod, so it's not easy to fix.
self.position = 1
if instance_state is not None:
state = json.loads(instance_state)
if 'position' in state:
self.position = int(state['position'])
def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs)
# if position is specified in system, then use that instead
if system.get('position'):
self.position = int(system.get('position'))
if self.system.get('position'):
self.position = int(self.system.get('position'))
self.rendered = False
@@ -70,6 +70,11 @@ class SequenceModule(XModule):
raise NotFoundError('Unexpected dispatch type')
def render(self):
# If we're rendering this sequence, but no position is set yet,
# default the position to the first element
if self.position is None:
self.position = 1
if self.rendered:
return
## Returns a set of all types of all sub-children
@@ -79,9 +84,9 @@ class SequenceModule(XModule):
childinfo = {
'content': child.get_html(),
'title': "\n".join(
grand_child.display_name.strip()
grand_child.display_name
for grand_child in child.get_children()
if 'display_name' in grand_child.metadata
if grand_child.display_name is not None
),
'progress_status': Progress.to_js_status_str(progress),
'progress_detail': Progress.to_js_detail_str(progress),
@@ -89,7 +94,7 @@ class SequenceModule(XModule):
'id': child.id,
}
if childinfo['title'] == '':
childinfo['title'] = child.metadata.get('display_name', '')
childinfo['title'] = child.display_name_with_default
contents.append(childinfo)
params = {'items': contents,
@@ -112,11 +117,11 @@ class SequenceModule(XModule):
return new_class
class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor):
class SequenceDescriptor(SequenceFields, MakoModuleDescriptor, XmlDescriptor):
mako_template = 'widgets/sequence-edit.html'
module_class = SequenceModule
stores_state = True # For remembering where in the sequence the student is
stores_state = True # For remembering where in the sequence the student is
js = {'coffee': [resource_string(__name__, 'js/src/sequence/edit.coffee')]}
js_module_name = "SequenceDescriptor"
@@ -132,7 +137,7 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor):
if system.error_tracker is not None:
system.error_tracker("ERROR: " + str(e))
continue
return {'children': children}
return {}, children
def definition_to_xml(self, resource_fs):
xml_object = etree.Element('sequential')

View File

@@ -1,4 +1,5 @@
from itertools import chain
# -*- coding: utf-8 -*-
from lxml import etree

View File

@@ -28,11 +28,6 @@ class CustomTagModule(XModule):
More information given in <a href="/book/234">the text</a>
"""
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
def get_html(self):
return self.descriptor.rendered_html
@@ -62,19 +57,15 @@ class CustomTagDescriptor(RawDescriptor):
# cdodge: look up the template as a module
template_loc = self.location._replace(category='custom_tag_template', name=template_name)
template_module = self.system.load_item(template_loc)
template_module_data = template_module.definition['data']
template_module = modulestore().get_instance(system.course_id, template_loc)
template_module_data = template_module.data
template = Template(template_module_data)
return template.render(**params)
def __init__(self, system, definition, **kwargs):
'''Render and save the template for this descriptor instance'''
super(CustomTagDescriptor, self).__init__(system, definition, **kwargs)
@property
def rendered_html(self):
return self.render_template(self.system, self.definition['data'])
return self.render_template(self.system, self.data)
def export_to_file(self):
"""

View File

@@ -54,6 +54,7 @@ def test_system():
debug=True,
xqueue={'interface': None, 'callback_url': '/', 'default_queuename': 'testqueue', 'waittime': 10},
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
xblock_model_data=lambda descriptor: descriptor._model_data,
anonymous_student_id='student',
open_ended_grading_interface= open_ended_grading_interface
)

View File

@@ -28,13 +28,11 @@ class AnnotatableModuleTestCase(unittest.TestCase):
<annotation title="footnote" body="the end">The Iliad of Homer by Samuel Butler</annotation>
</annotatable>
'''
definition = { 'data': sample_xml }
descriptor = Mock()
instance_state = None
shared_state = None
module_data = {'data': sample_xml}
def setUp(self):
self.annotatable = AnnotatableModule(test_system(), self.location, self.definition, self.descriptor, self.instance_state, self.shared_state)
self.annotatable = AnnotatableModule(test_system(), self.location, self.descriptor, self.module_data)
def test_annotation_data_attr(self):
el = etree.fromstring('<annotation title="bar" body="foo" problem="0">test</annotation>')

View File

@@ -59,7 +59,8 @@ class CapaFactory(object):
force_save_button=None,
attempts=None,
problem_state=None,
correct=False
correct=False,
done=None
):
"""
All parameters are optional, and are added to the created problem if specified.
@@ -77,48 +78,42 @@ class CapaFactory(object):
attempts: also added to instance state. Will be converted to an int.
"""
definition = {'data': CapaFactory.sample_problem_xml, }
location = Location(["i4x", "edX", "capa_test", "problem",
"SampleProblem%d" % CapaFactory.next_num()])
metadata = {}
if graceperiod is not None:
metadata['graceperiod'] = graceperiod
if due is not None:
metadata['due'] = due
if max_attempts is not None:
metadata['attempts'] = max_attempts
if showanswer is not None:
metadata['showanswer'] = showanswer
if force_save_button is not None:
metadata['force_save_button'] = force_save_button
if rerandomize is not None:
metadata['rerandomize'] = rerandomize
"SampleProblem{0}".format(CapaFactory.next_num())])
model_data = {'data': CapaFactory.sample_problem_xml}
if graceperiod is not None:
model_data['graceperiod'] = graceperiod
if due is not None:
model_data['due'] = due
if max_attempts is not None:
model_data['max_attempts'] = max_attempts
if showanswer is not None:
model_data['showanswer'] = showanswer
if force_save_button is not None:
model_data['force_save_button'] = force_save_button
if rerandomize is not None:
model_data['rerandomize'] = rerandomize
if done is not None:
model_data['done'] = done
descriptor = Mock(weight="1")
instance_state_dict = {}
if problem_state is not None:
instance_state_dict = problem_state
model_data.update(problem_state)
if attempts is not None:
# converting to int here because I keep putting "0" and "1" in the tests
# since everything else is a string.
instance_state_dict['attempts'] = int(attempts)
if len(instance_state_dict) > 0:
instance_state = json.dumps(instance_state_dict)
else:
instance_state = None
model_data['attempts'] = int(attempts)
system = test_system()
system.render_template = Mock(return_value="<div>Test Template HTML</div>")
module = CapaModule(system, location,
definition, descriptor,
instance_state, None, metadata=metadata)
module = CapaModule(system, location, descriptor, model_data)
if correct:
# TODO: probably better to actually set the internal state properly, but...
module.get_score = lambda: {'score': 1, 'total': 1}
else:
module.get_score = lambda: {'score': 0, 'total': 1}
return module
@@ -356,7 +351,7 @@ class CapaModuleTest(unittest.TestCase):
valid_get_dict = self._querydict_from_dict({'input_2[]': ['test1', 'test2']})
result = CapaModule.make_dict_of_responses(valid_get_dict)
self.assertTrue('2' in result)
self.assertEqual(['test1','test2'], result['2'])
self.assertEqual(['test1', 'test2'], result['2'])
# If we use [] at the end of a key name, we should always
# get a list, even if there's just one value
@@ -374,7 +369,7 @@ class CapaModuleTest(unittest.TestCase):
# One of the values would overwrite the other, so detect this
# and raise an exception
invalid_get_dict = self._querydict_from_dict({'input_1[]': 'test 1',
'input_1': 'test 2' })
'input_1': 'test 2'})
with self.assertRaises(ValueError):
result = CapaModule.make_dict_of_responses(invalid_get_dict)
@@ -412,7 +407,7 @@ class CapaModuleTest(unittest.TestCase):
mock_html.return_value = "Test HTML"
# Check the problem
get_request_dict = { CapaFactory.input_key(): '3.14' }
get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
# Expect that the problem is marked correct
@@ -424,7 +419,6 @@ class CapaModuleTest(unittest.TestCase):
# Expect that the number of attempts is incremented by 1
self.assertEqual(module.attempts, 2)
def test_check_problem_incorrect(self):
module = CapaFactory.create(attempts=0)
@@ -434,7 +428,7 @@ class CapaModuleTest(unittest.TestCase):
mock_is_correct.return_value = False
# Check the problem
get_request_dict = { CapaFactory.input_key(): '0' }
get_request_dict = { CapaFactory.input_key(): '0'}
result = module.check_problem(get_request_dict)
# Expect that the problem is marked correct
@@ -452,38 +446,33 @@ class CapaModuleTest(unittest.TestCase):
with patch('xmodule.capa_module.CapaModule.closed') as mock_closed:
mock_closed.return_value = True
with self.assertRaises(xmodule.exceptions.NotFoundError):
get_request_dict = { CapaFactory.input_key(): '3.14' }
get_request_dict = { CapaFactory.input_key(): '3.14'}
module.check_problem(get_request_dict)
# Expect that number of attempts NOT incremented
self.assertEqual(module.attempts, 3)
def test_check_problem_resubmitted_with_randomize(self):
# Randomize turned on
module = CapaFactory.create(rerandomize='always', attempts=0)
# Simulate that the problem is completed
module.lcp.done = True
module.done = True
# Expect that we cannot submit
with self.assertRaises(xmodule.exceptions.NotFoundError):
get_request_dict = { CapaFactory.input_key(): '3.14' }
get_request_dict = {CapaFactory.input_key(): '3.14'}
module.check_problem(get_request_dict)
# Expect that number of attempts NOT incremented
self.assertEqual(module.attempts, 0)
def test_check_problem_resubmitted_no_randomize(self):
# Randomize turned off
module = CapaFactory.create(rerandomize='never', attempts=0)
# Simulate that the problem is completed
module.lcp.done = True
module = CapaFactory.create(rerandomize='never', attempts=0, done=True)
# Expect that we can submit successfully
get_request_dict = { CapaFactory.input_key(): '3.14' }
get_request_dict = {CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
self.assertEqual(result['success'], 'correct')
@@ -491,7 +480,6 @@ class CapaModuleTest(unittest.TestCase):
# Expect that number of attempts IS incremented
self.assertEqual(module.attempts, 1)
def test_check_problem_queued(self):
module = CapaFactory.create(attempts=1)
@@ -504,7 +492,7 @@ class CapaModuleTest(unittest.TestCase):
mock_is_queued.return_value = True
mock_get_queuetime.return_value = datetime.datetime.now()
get_request_dict = { CapaFactory.input_key(): '3.14' }
get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
# Expect an AJAX alert message in 'success'
@@ -521,7 +509,7 @@ class CapaModuleTest(unittest.TestCase):
with patch('capa.capa_problem.LoncapaProblem.grade_answers') as mock_grade:
mock_grade.side_effect = capa.responsetypes.StudentInputError('test error')
get_request_dict = { CapaFactory.input_key(): '3.14' }
get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.check_problem(get_request_dict)
# Expect an AJAX alert message in 'success'
@@ -532,13 +520,8 @@ class CapaModuleTest(unittest.TestCase):
def test_reset_problem(self):
module = CapaFactory.create()
# Mock the module's capa problem
# to simulate that the problem is done
mock_problem = MagicMock(capa.capa_problem.LoncapaProblem)
mock_problem.done = True
module.lcp = mock_problem
module = CapaFactory.create(done=True)
module.new_lcp = Mock(wraps=module.new_lcp)
# Stub out HTML rendering
with patch('xmodule.capa_module.CapaModule.get_problem_html') as mock_html:
@@ -556,7 +539,7 @@ class CapaModuleTest(unittest.TestCase):
self.assertEqual(result['html'], "<div>Test HTML</div>")
# Expect that the problem was reset
mock_problem.do_reset.assert_called_once_with()
module.new_lcp.assert_called_once_with({'seed': None})
def test_reset_problem_closed(self):
@@ -575,10 +558,8 @@ class CapaModuleTest(unittest.TestCase):
def test_reset_problem_not_done(self):
module = CapaFactory.create()
# Simulate that the problem is NOT done
module.lcp.done = False
module = CapaFactory.create(done=False)
# Try to reset the problem
get_request_dict = {}
@@ -589,17 +570,14 @@ class CapaModuleTest(unittest.TestCase):
def test_save_problem(self):
module = CapaFactory.create()
# Simulate that the problem is not done (not attempted or reset)
module.lcp.done = False
module = CapaFactory.create(done=False)
# Save the problem
get_request_dict = { CapaFactory.input_key(): '3.14' }
get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Expect that answers are saved to the problem
expected_answers = { CapaFactory.answer_key(): '3.14' }
expected_answers = { CapaFactory.answer_key(): '3.14'}
self.assertEqual(module.lcp.student_answers, expected_answers)
# Expect that the result is success
@@ -607,17 +585,14 @@ class CapaModuleTest(unittest.TestCase):
def test_save_problem_closed(self):
module = CapaFactory.create()
# Simulate that the problem is NOT done (not attempted or reset)
module.lcp.done = False
module = CapaFactory.create(done=False)
# Simulate that the problem is closed
with patch('xmodule.capa_module.CapaModule.closed') as mock_closed:
mock_closed.return_value = True
# Try to save the problem
get_request_dict = { CapaFactory.input_key(): '3.14' }
get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Expect that the result is failure
@@ -625,13 +600,10 @@ class CapaModuleTest(unittest.TestCase):
def test_save_problem_submitted_with_randomize(self):
module = CapaFactory.create(rerandomize='always')
# Simulate that the problem is completed
module.lcp.done = True
module = CapaFactory.create(rerandomize='always', done=True)
# Try to save
get_request_dict = { CapaFactory.input_key(): '3.14' }
get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Expect that we cannot save
@@ -639,13 +611,10 @@ class CapaModuleTest(unittest.TestCase):
def test_save_problem_submitted_no_randomize(self):
module = CapaFactory.create(rerandomize='never')
# Simulate that the problem is completed
module.lcp.done = True
module = CapaFactory.create(rerandomize='never', done=True)
# Try to save
get_request_dict = { CapaFactory.input_key(): '3.14' }
get_request_dict = { CapaFactory.input_key(): '3.14'}
result = module.save_problem(get_request_dict)
# Expect that we succeed
@@ -657,7 +626,7 @@ class CapaModuleTest(unittest.TestCase):
# Just in case, we also check what happens if we have
# more attempts than allowed.
attempts = random.randint(1, 10)
module = CapaFactory.create(attempts=attempts-1, max_attempts=attempts)
module = CapaFactory.create(attempts=attempts -1, max_attempts=attempts)
self.assertEqual(module.check_button_name(), "Final Check")
module = CapaFactory.create(attempts=attempts, max_attempts=attempts)
@@ -667,14 +636,14 @@ class CapaModuleTest(unittest.TestCase):
self.assertEqual(module.check_button_name(), "Final Check")
# Otherwise, button name is "Check"
module = CapaFactory.create(attempts=attempts-2, max_attempts=attempts)
module = CapaFactory.create(attempts=attempts -2, max_attempts=attempts)
self.assertEqual(module.check_button_name(), "Check")
module = CapaFactory.create(attempts=attempts-3, max_attempts=attempts)
module = CapaFactory.create(attempts=attempts -3, max_attempts=attempts)
self.assertEqual(module.check_button_name(), "Check")
# If no limit on attempts, then always show "Check"
module = CapaFactory.create(attempts=attempts-3)
module = CapaFactory.create(attempts=attempts -3)
self.assertEqual(module.check_button_name(), "Check")
module = CapaFactory.create(attempts=0)
@@ -682,7 +651,7 @@ class CapaModuleTest(unittest.TestCase):
def test_should_show_check_button(self):
attempts = random.randint(1,10)
attempts = random.randint(1, 10)
# If we're after the deadline, do NOT show check button
module = CapaFactory.create(due=self.yesterday_str)
@@ -699,8 +668,7 @@ class CapaModuleTest(unittest.TestCase):
# If user submitted a problem but hasn't reset,
# do NOT show the check button
# Note: we can only reset when rerandomize="always"
module = CapaFactory.create(rerandomize="always")
module.lcp.done = True
module = CapaFactory.create(rerandomize="always", done=True)
self.assertFalse(module.should_show_check_button())
# Otherwise, DO show the check button
@@ -711,105 +679,101 @@ class CapaModuleTest(unittest.TestCase):
# and we do NOT have a reset button, then we can show the check button
# Setting rerandomize to "never" ensures that the reset button
# is not shown
module = CapaFactory.create(rerandomize="never")
module.lcp.done = True
module = CapaFactory.create(rerandomize="never", done=True)
self.assertTrue(module.should_show_check_button())
def test_should_show_reset_button(self):
attempts = random.randint(1,10)
attempts = random.randint(1, 10)
# If we're after the deadline, do NOT show the reset button
module = CapaFactory.create(due=self.yesterday_str)
module.lcp.done = True
module = CapaFactory.create(due=self.yesterday_str, done=True)
self.assertFalse(module.should_show_reset_button())
# If the user is out of attempts, do NOT show the reset button
module = CapaFactory.create(attempts=attempts, max_attempts=attempts)
module.lcp.done = True
module = CapaFactory.create(attempts=attempts, max_attempts=attempts, done=True)
self.assertFalse(module.should_show_reset_button())
# If we're NOT randomizing, then do NOT show the reset button
module = CapaFactory.create(rerandomize="never")
module.lcp.done = True
module = CapaFactory.create(rerandomize="never", done=True)
self.assertFalse(module.should_show_reset_button())
# If the user hasn't submitted an answer yet,
# then do NOT show the reset button
module = CapaFactory.create()
module.lcp.done = False
module = CapaFactory.create(done=False)
self.assertFalse(module.should_show_reset_button())
# Otherwise, DO show the reset button
module = CapaFactory.create()
module.lcp.done = True
module = CapaFactory.create(done=True)
self.assertTrue(module.should_show_reset_button())
# If survey question for capa (max_attempts = 0),
# DO show the reset button
module = CapaFactory.create(max_attempts=0)
module.lcp.done = True
module = CapaFactory.create(max_attempts=0, done=True)
self.assertTrue(module.should_show_reset_button())
def test_should_show_save_button(self):
attempts = random.randint(1,10)
attempts = random.randint(1, 10)
# If we're after the deadline, do NOT show the save button
module = CapaFactory.create(due=self.yesterday_str)
module.lcp.done = True
module = CapaFactory.create(due=self.yesterday_str, done=True)
self.assertFalse(module.should_show_save_button())
# If the user is out of attempts, do NOT show the save button
module = CapaFactory.create(attempts=attempts, max_attempts=attempts)
module.lcp.done = True
module = CapaFactory.create(attempts=attempts, max_attempts=attempts, done=True)
self.assertFalse(module.should_show_save_button())
# If user submitted a problem but hasn't reset, do NOT show the save button
module = CapaFactory.create(rerandomize="always")
module.lcp.done = True
module = CapaFactory.create(rerandomize="always", done=True)
self.assertFalse(module.should_show_save_button())
# If the user has unlimited attempts and we are not randomizing,
# then do NOT show a save button
# because they can keep using "Check"
module = CapaFactory.create(max_attempts=None, rerandomize="never", done=False)
self.assertFalse(module.should_show_save_button())
module = CapaFactory.create(max_attempts=None, rerandomize="never", done=True)
self.assertFalse(module.should_show_save_button())
# Otherwise, DO show the save button
module = CapaFactory.create()
module.lcp.done = False
module = CapaFactory.create(done=False)
self.assertTrue(module.should_show_save_button())
# If we're not randomizing, then we can re-save
module = CapaFactory.create(rerandomize="never")
module.lcp.done = True
# If we're not randomizing and we have limited attempts, then we can save
module = CapaFactory.create(rerandomize="never", max_attempts=2, done=True)
self.assertTrue(module.should_show_save_button())
# If survey question for capa (max_attempts = 0),
# DO show the save button
module = CapaFactory.create(max_attempts=0)
module.lcp.done = False
module = CapaFactory.create(max_attempts=0, done=False)
self.assertTrue(module.should_show_save_button())
def test_should_show_save_button_force_save_button(self):
# If we're after the deadline, do NOT show the save button
# even though we're forcing a save
module = CapaFactory.create(due=self.yesterday_str,
force_save_button="true")
module.lcp.done = True
force_save_button="true",
done=True)
self.assertFalse(module.should_show_save_button())
# If the user is out of attempts, do NOT show the save button
attempts = random.randint(1,10)
attempts = random.randint(1, 10)
module = CapaFactory.create(attempts=attempts,
max_attempts=attempts,
force_save_button="true")
module.lcp.done = True
force_save_button="true",
done=True)
self.assertFalse(module.should_show_save_button())
# Otherwise, if we force the save button,
# then show it even if we would ordinarily
# require a reset first
module = CapaFactory.create(force_save_button="true",
rerandomize="always")
module.lcp.done = True
rerandomize="always",
done=True)
self.assertTrue(module.should_show_save_button())
def test_no_max_attempts(self):
@@ -823,9 +787,9 @@ class CapaModuleTest(unittest.TestCase):
# We've tested the show/hide button logic in other tests,
# so here we hard-wire the values
show_check_button = bool(random.randint(0,1) % 2)
show_reset_button = bool(random.randint(0,1) % 2)
show_save_button = bool(random.randint(0,1) % 2)
show_check_button = bool(random.randint(0, 1) % 2)
show_reset_button = bool(random.randint(0, 1) % 2)
show_save_button = bool(random.randint(0, 1) % 2)
module.should_show_check_button = Mock(return_value=show_check_button)
module.should_show_reset_button = Mock(return_value=show_reset_button)
@@ -848,7 +812,7 @@ class CapaModuleTest(unittest.TestCase):
self.assertEqual(html, "<div>Test Template HTML</div>")
# Check the rendering context
render_args,_ = module.system.render_template.call_args
render_args, _ = module.system.render_template.call_args
self.assertEqual(len(render_args), 2)
template_name = render_args[0]
@@ -889,7 +853,7 @@ class CapaModuleTest(unittest.TestCase):
html = module.get_problem_html()
# Check the rendering context
render_args,_ = module.system.render_template.call_args
render_args, _ = module.system.render_template.call_args
context = render_args[1]
self.assertTrue("error" in context['problem']['html'])

View File

@@ -57,7 +57,8 @@ class OpenEndedChildTest(unittest.TestCase):
def setUp(self):
self.test_system = test_system()
self.openendedchild = OpenEndedChild(self.test_system, self.location,
self.definition, self.descriptor, self.static_data, self.metadata)
self.definition, self.descriptor, self.static_data, self.metadata)
def test_latest_answer_empty(self):
answer = self.openendedchild.latest_answer()
@@ -123,7 +124,7 @@ class OpenEndedChildTest(unittest.TestCase):
def test_reset(self):
self.openendedchild.reset(self.test_system)
state = json.loads(self.openendedchild.get_instance_state())
self.assertEqual(state['state'], OpenEndedChild.INITIAL)
self.assertEqual(state['child_state'], OpenEndedChild.INITIAL)
def test_is_last_response_correct(self):
new_answer = "New Answer"
@@ -209,7 +210,7 @@ class OpenEndedModuleTest(unittest.TestCase):
self.mock_xqueue.send_to_queue.assert_called_with(body=json.dumps(contents), header=ANY)
state = json.loads(self.openendedmodule.get_instance_state())
self.assertIsNotNone(state['state'], OpenEndedModule.DONE)
self.assertIsNotNone(state['child_state'], OpenEndedModule.DONE)
def test_send_to_grader(self):
submission = "This is a student submission"
@@ -335,12 +336,15 @@ class CombinedOpenEndedModuleTest(unittest.TestCase):
def setUp(self):
self.test_system = test_system()
# TODO: this constructor call is definitely wrong, but neither branch
# of the merge matches the module constructor. Someone (Vik?) should fix this.
self.combinedoe = CombinedOpenEndedV1Module(self.test_system,
self.location,
self.definition,
self.descriptor,
static_data=self.static_data,
metadata=self.metadata)
metadata=self.metadata,
instance_state={})
def test_get_tag_name(self):
name = self.combinedoe.get_tag_name("<t>Tag</t>")

View File

@@ -73,24 +73,21 @@ class ConditionalModuleTest(unittest.TestCase):
"""Make sure that conditional module works"""
print "Starting import"
course = self.get_course('conditional')
course = self.get_course('conditional_and_poll')
print "Course: ", course
print "id: ", course.id
instance_states = dict(problem=None)
shared_state = None
def inner_get_module(descriptor):
if isinstance(descriptor, Location):
location = descriptor
descriptor = self.modulestore.get_instance(course.id, location, depth=None)
location = descriptor.location
instance_state = instance_states.get(location.category, None)
print "inner_get_module, location=%s, inst_state=%s" % (location, instance_state)
return descriptor.xmodule_constructor(self.test_system)(instance_state, shared_state)
return descriptor.xmodule(self.test_system)
location = Location(["i4x", "edX", "cond_test", "conditional", "condone"])
# edx - HarvardX
# cond_test - ER22x
location = Location(["i4x", "HarvardX", "ER22x", "conditional", "condone"])
def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None):
return text
@@ -99,26 +96,28 @@ class ConditionalModuleTest(unittest.TestCase):
module = inner_get_module(location)
print "module: ", module
print "module definition: ", module.definition
print "module.conditions_map: ", module.conditions_map
print "module children: ", module.get_children()
print "module display items (children): ", module.get_display_items()
html = module.get_html()
print "html type: ", type(html)
print "html: ", html
html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-edX-cond_test-conditional-condone', 'id': 'i4x://edX/cond_test/conditional/condone'}"
html_expect = "{'ajax_url': 'courses/course_id/modx/a_location', 'element_id': 'i4x-HarvardX-ER22x-conditional-condone', 'id': 'i4x://HarvardX/ER22x/conditional/condone', 'depends': 'i4x-HarvardX-ER22x-problem-choiceprob'}"
self.assertEqual(html, html_expect)
gdi = module.get_display_items()
print "gdi=", gdi
ajax = json.loads(module.handle_ajax('', ''))
self.assertTrue('xmodule.conditional_module' in ajax['html'])
print "ajax: ", ajax
html = ajax['html']
self.assertFalse(any(['This is a secret' in item for item in html]))
# now change state of the capa problem to make it completed
instance_states['problem'] = json.dumps({'attempts': 1})
inner_get_module(Location('i4x://HarvardX/ER22x/problem/choiceprob')).attempts = 1
ajax = json.loads(module.handle_ajax('', ''))
self.assertTrue('This is a secret' in ajax['html'])
print "post-attempt ajax: ", ajax
html = ajax['html']
self.assertTrue(any(['This is a secret' in item for item in html]))

View File

@@ -39,7 +39,7 @@ class DummySystem(ImportSystem):
class IsNewCourseTestCase(unittest.TestCase):
"""Make sure the property is_new works on courses"""
@staticmethod
def get_dummy_course(start, announcement=None, is_new=None):
def get_dummy_course(start, announcement=None, is_new=None, advertised_start=None):
"""Get a dummy course"""
system = DummySystem(load_error_modules=True)
@@ -49,71 +49,87 @@ class IsNewCourseTestCase(unittest.TestCase):
is_new = to_attrb('is_new', is_new)
announcement = to_attrb('announcement', announcement)
advertised_start = to_attrb('advertised_start', advertised_start)
start_xml = '''
<course org="{org}" course="{course}"
graceperiod="1 day" url_name="test"
start="{start}"
{announcement}
{is_new}>
{is_new}
{advertised_start}>
<chapter url="hi" url_name="ch" display_name="CH">
<html url_name="h" display_name="H">Two houses, ...</html>
</chapter>
</course>
'''.format(org=ORG, course=COURSE, start=start, is_new=is_new,
announcement=announcement)
announcement=announcement, advertised_start=advertised_start)
return system.process_xml(start_xml)
@patch('xmodule.course_module.time.gmtime')
def test_sorting_score(self, gmtime_mock):
gmtime_mock.return_value = NOW
dates = [('2012-10-01T12:00', '2012-09-01T12:00'), # 0
('2012-12-01T12:00', '2012-11-01T12:00'), # 1
('2013-02-01T12:00', '2012-12-01T12:00'), # 2
('2013-02-01T12:00', '2012-11-10T12:00'), # 3
('2013-02-01T12:00', None), # 4
('2013-03-01T12:00', None), # 5
('2013-04-01T12:00', None), # 6
('2012-11-01T12:00', None), # 7
('2012-09-01T12:00', None), # 8
('1990-01-01T12:00', None), # 9
('2013-01-02T12:00', None), # 10
('2013-01-10T12:00', '2012-12-31T12:00'), # 11
('2013-01-10T12:00', '2013-01-01T12:00'), # 12
day1 = '2012-01-01T12:00'
day2 = '2012-01-02T12:00'
dates = [
# Announce date takes priority over actual start
# and courses announced on a later date are newer
# than courses announced for an earlier date
((day1, day2, None), (day1, day1, None), self.assertLess),
((day1, day1, None), (day2, day1, None), self.assertEqual),
# Announce dates take priority over advertised starts
((day1, day2, day1), (day1, day1, day1), self.assertLess),
((day1, day1, day2), (day2, day1, day2), self.assertEqual),
# Later start == newer course
((day2, None, None), (day1, None, None), self.assertLess),
((day1, None, None), (day1, None, None), self.assertEqual),
# Non-parseable advertised starts are ignored in preference
# to actual starts
((day2, None, "Spring 2013"), (day1, None, "Fall 2012"), self.assertLess),
((day1, None, "Spring 2013"), (day1, None, "Fall 2012"), self.assertEqual),
# Parseable advertised starts take priority over start dates
((day1, None, day2), (day1, None, day1), self.assertLess),
((day2, None, day2), (day1, None, day2), self.assertEqual),
]
data = []
for i, d in enumerate(dates):
descriptor = self.get_dummy_course(start=d[0], announcement=d[1])
score = descriptor.sorting_score
data.append((score, i))
for a, b, assertion in dates:
a_score = self.get_dummy_course(start=a[0], announcement=a[1], advertised_start=a[2]).sorting_score
b_score = self.get_dummy_course(start=b[0], announcement=b[1], advertised_start=b[2]).sorting_score
print "Comparing %s to %s" % (a, b)
assertion(a_score, b_score)
result = [d[1] for d in sorted(data)]
assert(result == [12, 11, 2, 3, 1, 0, 6, 5, 4, 10, 7, 8, 9])
@patch('xmodule.course_module.time.gmtime')
def test_is_new(self, gmtime_mock):
def test_is_newish(self, gmtime_mock):
gmtime_mock.return_value = NOW
descriptor = self.get_dummy_course(start='2012-12-02T12:00', is_new=True)
assert(descriptor.is_new is True)
assert(descriptor.is_newish is True)
descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=False)
assert(descriptor.is_new is False)
assert(descriptor.is_newish is False)
descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=True)
assert(descriptor.is_new is True)
assert(descriptor.is_newish is True)
descriptor = self.get_dummy_course(start='2013-01-15T12:00')
assert(descriptor.is_new is True)
assert(descriptor.is_newish is True)
descriptor = self.get_dummy_course(start='2013-03-00T12:00')
assert(descriptor.is_new is True)
assert(descriptor.is_newish is True)
descriptor = self.get_dummy_course(start='2012-10-15T12:00')
assert(descriptor.is_new is False)
assert(descriptor.is_newish is False)
descriptor = self.get_dummy_course(start='2012-12-31T12:00')
assert(descriptor.is_new is True)
assert(descriptor.is_newish is True)

View File

@@ -18,27 +18,16 @@ TEST_DIR = TEST_DIR / 'test'
DATA_DIR = TEST_DIR / 'data'
def strip_metadata(descriptor, key):
"""
Recursively strips tag from all children.
"""
print "strip {key} from {desc}".format(key=key, desc=descriptor.location.url())
descriptor.metadata.pop(key, None)
for d in descriptor.get_children():
strip_metadata(d, key)
def strip_filenames(descriptor):
"""
Recursively strips 'filename' from all children's definitions.
"""
print "strip filename from {desc}".format(desc=descriptor.location.url())
descriptor.definition.pop('filename', None)
descriptor._model_data.pop('filename', None)
for d in descriptor.get_children():
strip_filenames(d)
class RoundTripTestCase(unittest.TestCase):
''' Check that our test courses roundtrip properly.
Same course imported , than exported, then imported again.
@@ -77,10 +66,6 @@ class RoundTripTestCase(unittest.TestCase):
exported_course = courses2[0]
print "Checking course equality"
# HACK: data_dir metadata tags break equality because they
# aren't real metadata, and depend on paths. Remove them.
strip_metadata(initial_course, 'data_dir')
strip_metadata(exported_course, 'data_dir')
# HACK: filenames change when changing file formats
# during imports from old-style courses. Ignore them.
@@ -105,7 +90,6 @@ class RoundTripTestCase(unittest.TestCase):
self.assertEquals(initial_import.modules[course_id][location],
second_import.modules[course_id][location])
def setUp(self):
self.maxDiff = None
self.temp_dir = mkdtemp()
@@ -120,6 +104,9 @@ class RoundTripTestCase(unittest.TestCase):
def test_full_roundtrip(self):
self.check_export_roundtrip(DATA_DIR, "full")
def test_conditional_and_poll_roundtrip(self):
self.check_export_roundtrip(DATA_DIR, "conditional_and_poll")
def test_selfassessment_roundtrip(self):
#Test selfassessment xmodule to see if it exports correctly
self.check_export_roundtrip(DATA_DIR, "self_assessment")

View File

@@ -1,3 +1,5 @@
# -*- coding: utf-8 -*-
from path import path
import unittest
from fs.memoryfs import MemoryFS
@@ -12,6 +14,7 @@ from xmodule.errortracker import make_error_tracker
from xmodule.modulestore import Location
from xmodule.modulestore.xml import ImportSystem, XMLModuleStore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.inheritance import compute_inherited_metadata
from .test_export import DATA_DIR
@@ -75,7 +78,6 @@ class ImportTestCase(BaseCourseTestCase):
self.assertEqual(descriptor.__class__.__name__,
'ErrorDescriptor')
def test_unique_url_names(self):
'''Check that each error gets its very own url_name'''
bad_xml = '''<sequential display_name="oops"><video url="hi"></sequential>'''
@@ -87,7 +89,6 @@ class ImportTestCase(BaseCourseTestCase):
self.assertNotEqual(descriptor1.location, descriptor2.location)
def test_reimport(self):
'''Make sure an already-exported error xml tag loads properly'''
@@ -103,8 +104,10 @@ class ImportTestCase(BaseCourseTestCase):
self.assertEqual(re_import_descriptor.__class__.__name__,
'ErrorDescriptor')
self.assertEqual(descriptor.definition['data'],
re_import_descriptor.definition['data'])
self.assertEqual(descriptor.contents,
re_import_descriptor.contents)
self.assertEqual(descriptor.error_msg,
re_import_descriptor.error_msg)
def test_fixed_xml_tag(self):
"""Make sure a tag that's been fixed exports as the original tag type"""
@@ -138,23 +141,20 @@ class ImportTestCase(BaseCourseTestCase):
url_name = 'test1'
start_xml = '''
<course org="{org}" course="{course}"
graceperiod="{grace}" url_name="{url_name}" unicorn="purple">
due="{due}" url_name="{url_name}" unicorn="purple">
<chapter url="hi" url_name="ch" display_name="CH">
<html url_name="h" display_name="H">Two houses, ...</html>
</chapter>
</course>'''.format(grace=v, org=ORG, course=COURSE, url_name=url_name)
</course>'''.format(due=v, org=ORG, course=COURSE, url_name=url_name)
descriptor = system.process_xml(start_xml)
compute_inherited_metadata(descriptor)
print descriptor, descriptor.metadata
self.assertEqual(descriptor.metadata['graceperiod'], v)
self.assertEqual(descriptor.metadata['unicorn'], 'purple')
print descriptor, descriptor._model_data
self.assertEqual(descriptor.lms.due, v)
# Check that the child inherits graceperiod correctly
# Check that the child inherits due correctly
child = descriptor.get_children()[0]
self.assertEqual(child.metadata['graceperiod'], v)
# check that the child does _not_ inherit any unicorns
self.assertTrue('unicorn' not in child.metadata)
self.assertEqual(child.lms.due, v)
# Now export and check things
resource_fs = MemoryFS()
@@ -181,12 +181,12 @@ class ImportTestCase(BaseCourseTestCase):
# did we successfully strip the url_name from the definition contents?
self.assertTrue('url_name' not in course_xml.attrib)
# Does the chapter tag now have a graceperiod attribute?
# Does the chapter tag now have a due attribute?
# hardcoded path to child
with resource_fs.open('chapter/ch.xml') as f:
chapter_xml = etree.fromstring(f.read())
self.assertEqual(chapter_xml.tag, 'chapter')
self.assertFalse('graceperiod' in chapter_xml.attrib)
self.assertFalse('due' in chapter_xml.attrib)
def test_is_pointer_tag(self):
"""
@@ -224,13 +224,12 @@ class ImportTestCase(BaseCourseTestCase):
def check_for_key(key, node):
"recursive check for presence of key"
print "Checking {0}".format(node.location.url())
self.assertTrue(key in node.metadata)
self.assertTrue(key in node._model_data)
for c in node.get_children():
check_for_key(key, c)
check_for_key('graceperiod', course)
def test_policy_loading(self):
"""Make sure that when two courses share content with the same
org and course names, policy applies to the right one."""
@@ -252,8 +251,7 @@ class ImportTestCase(BaseCourseTestCase):
# Also check that keys from policy are run through the
# appropriate attribute maps -- 'graded' should be True, not 'true'
self.assertEqual(toy.metadata['graded'], True)
self.assertEqual(toy.lms.graded, True)
def test_definition_loading(self):
"""When two courses share the same org and course name and
@@ -271,9 +269,8 @@ class ImportTestCase(BaseCourseTestCase):
location = Location(["i4x", "edX", "toy", "video", "Welcome"])
toy_video = modulestore.get_instance(toy_id, location)
two_toy_video = modulestore.get_instance(two_toy_id, location)
self.assertEqual(toy_video.metadata['youtube'], "1.0:p2Q6BrNhdh8")
self.assertEqual(two_toy_video.metadata['youtube'], "1.0:p2Q6BrNhdh9")
self.assertEqual(etree.fromstring(toy_video.data).get('youtube'), "1.0:p2Q6BrNhdh8")
self.assertEqual(etree.fromstring(two_toy_video.data).get('youtube'), "1.0:p2Q6BrNhdh9")
def test_colon_in_url_name(self):
"""Ensure that colons in url_names convert to file paths properly"""
@@ -331,6 +328,22 @@ class ImportTestCase(BaseCourseTestCase):
self.assertEqual(len(video.url_name), len('video_') + 12)
def test_poll_and_conditional_xmodule(self):
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['conditional_and_poll'])
course = modulestore.get_courses()[0]
chapters = course.get_children()
ch1 = chapters[0]
sections = ch1.get_children()
self.assertEqual(len(sections), 1)
location = course.location
location = Location(location.tag, location.org, location.course,
'sequential', 'Problem_Demos')
module = modulestore.get_instance(course.id, location)
self.assertEqual(len(module.children), 2)
def test_error_on_import(self):
'''Check that when load_error_module is false, an exception is raised, rather than returning an ErrorModule'''
@@ -354,7 +367,7 @@ class ImportTestCase(BaseCourseTestCase):
render_string_from_sample_gst_xml = """
<slider var="a" style="width:400px;float:left;"/>\
<plot style="margin-top:15px;margin-bottom:15px;"/>""".strip()
self.assertEqual(gst_sample.definition['render'], render_string_from_sample_gst_xml)
self.assertEqual(gst_sample.render, render_string_from_sample_gst_xml)
def test_cohort_config(self):
"""
@@ -370,13 +383,13 @@ class ImportTestCase(BaseCourseTestCase):
self.assertFalse(course.is_cohorted)
# empty config -> False
course.metadata['cohort_config'] = {}
course.cohort_config = {}
self.assertFalse(course.is_cohorted)
# false config -> False
course.metadata['cohort_config'] = {'cohorted': False}
course.cohort_config = {'cohorted': False}
self.assertFalse(course.is_cohorted)
# and finally...
course.metadata['cohort_config'] = {'cohorted': True}
course.cohort_config = {'cohorted': True}
self.assertTrue(course.is_cohorted)

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
import json
import unittest
from xmodule.poll_module import PollDescriptor
from xmodule.conditional_module import ConditionalDescriptor
class LogicTest(unittest.TestCase):
"""Base class for testing xmodule logic."""
descriptor_class = None
raw_model_data = {}
def setUp(self):
class EmptyClass: pass
self.system = None
self.location = None
self.descriptor = EmptyClass()
self.xmodule_class = self.descriptor_class.module_class
self.xmodule = self.xmodule_class(self.system, self.location,
self.descriptor, self.raw_model_data)
def ajax_request(self, dispatch, get):
return json.loads(self.xmodule.handle_ajax(dispatch, get))
class PollModuleTest(LogicTest):
descriptor_class = PollDescriptor
raw_model_data = {
'poll_answers': {'Yes': 1, 'Dont_know': 0, 'No': 0},
'voted': False,
'poll_answer': ''
}
def test_bad_ajax_request(self):
response = self.ajax_request('bad_answer', {})
self.assertDictEqual(response, {'error': 'Unknown Command!'})
def test_good_ajax_request(self):
response = self.ajax_request('No', {})
poll_answers = response['poll_answers']
total = response['total']
callback = response['callback']
self.assertDictEqual(poll_answers, {'Yes': 1, 'Dont_know': 0, 'No': 1})
self.assertEqual(total, 2)
self.assertDictEqual(callback, {'objectName': 'Conditional'})
self.assertEqual(self.xmodule.poll_answer, 'No')
class ConditionalModuleTest(LogicTest):
descriptor_class = ConditionalDescriptor
def test_ajax_request(self):
# Mock is_condition_satisfied
self.xmodule.is_condition_satisfied = lambda: True
setattr(self.xmodule.descriptor, 'get_children', lambda: [])
response = self.ajax_request('No', {})
html = response['html']
self.assertEqual(html, [])

View File

@@ -13,7 +13,7 @@ COURSE = 'test_course'
START = '2013-01-01T01:00:00'
from test_course_module import DummySystem as DummyImportSystem
from .test_course_module import DummySystem as DummyImportSystem
from . import test_system

View File

@@ -29,8 +29,6 @@ class SelfAssessmentTest(unittest.TestCase):
location = Location(["i4x", "edX", "sa_test", "selfassessment",
"SampleQuestion"])
metadata = {'attempts': '10'}
descriptor = Mock()
def setUp(self):
@@ -54,9 +52,9 @@ class SelfAssessmentTest(unittest.TestCase):
}
self.module = SelfAssessmentModule(test_system(), self.location,
self.definition, self.descriptor,
static_data,
state, metadata=self.metadata)
self.definition,
self.descriptor,
static_data)
def test_get_html(self):
html = self.module.get_html(self.module.system)
@@ -85,18 +83,18 @@ class SelfAssessmentTest(unittest.TestCase):
self.module.save_answer({'student_answer': "I am an answer"},
self.module.system)
self.assertEqual(self.module.state, self.module.ASSESSING)
self.assertEqual(self.module.child_state, self.module.ASSESSING)
self.module.save_assessment(mock_query_dict, self.module.system)
self.assertEqual(self.module.state, self.module.DONE)
self.assertEqual(self.module.child_state, self.module.DONE)
d = self.module.reset({})
self.assertTrue(d['success'])
self.assertEqual(self.module.state, self.module.INITIAL)
self.assertEqual(self.module.child_state, self.module.INITIAL)
# if we now assess as right, skip the REQUEST_HINT state
self.module.save_answer({'student_answer': 'answer 4'},
self.module.system)
responses['assessment'] = '1'
self.module.save_assessment(mock_query_dict, self.module.system)
self.assertEqual(self.module.state, self.module.DONE)
self.assertEqual(self.module.child_state, self.module.DONE)

View File

@@ -1,7 +1,7 @@
import dateutil
import dateutil.parser
import datetime
from timeparse import parse_timedelta
from .timeparse import parse_timedelta
import logging
log = logging.getLogger(__name__)

View File

@@ -9,35 +9,31 @@ from xmodule.xml_module import XmlDescriptor
from xmodule.x_module import XModule
from xmodule.progress import Progress
from xmodule.exceptions import NotFoundError
from xblock.core import Float, String, Boolean, Scope
log = logging.getLogger(__name__)
class TimeLimitModule(XModule):
'''
class TimeLimitFields(object):
beginning_at = Float(help="The time this timer was started", scope=Scope.student_state)
ending_at = Float(help="The time this timer will end", scope=Scope.student_state)
accomodation_code = String(help="A code indicating accommodations to be given the student", scope=Scope.student_state)
time_expired_redirect_url = String(help="Url to redirect users to after the timelimit has expired", scope=Scope.settings)
duration = Float(help="The length of this timer", scope=Scope.settings)
suppress_toplevel_navigation = Boolean(help="Whether the toplevel navigation should be suppressed when viewing this module", scope=Scope.settings)
class TimeLimitModule(TimeLimitFields, XModule):
'''
Wrapper module which imposes a time constraint for the completion of its child.
'''
def __init__(self, system, location, definition, descriptor, instance_state=None,
shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs)
self.rendered = False
self.beginning_at = None
self.ending_at = None
self.accommodation_code = None
if instance_state is not None:
state = json.loads(instance_state)
if 'beginning_at' in state:
self.beginning_at = state['beginning_at']
if 'ending_at' in state:
self.ending_at = state['ending_at']
if 'accommodation_code' in state:
self.accommodation_code = state['accommodation_code']
# For a timed activity, we are only interested here
# in time-related accommodations, and these should be disjoint.
# (For proctored exams, it is possible to have multiple accommodations
@@ -50,7 +46,7 @@ class TimeLimitModule(XModule):
)
def _get_accommodated_duration(self, duration):
'''
'''
Get duration for activity, as adjusted for accommodations.
Input and output are expressed in seconds.
'''
@@ -70,35 +66,25 @@ class TimeLimitModule(XModule):
@property
def has_begun(self):
return self.beginning_at is not None
@property
@property
def has_ended(self):
if not self.ending_at:
return False
return self.ending_at < time()
def begin(self, duration):
'''
'''
Sets the starting time and ending time for the activity,
based on the duration provided (in seconds).
'''
self.beginning_at = time()
modified_duration = self._get_accommodated_duration(duration)
self.ending_at = self.beginning_at + modified_duration
def get_remaining_time_in_ms(self):
return int((self.ending_at - time()) * 1000)
def get_instance_state(self):
state = {}
if self.beginning_at:
state['beginning_at'] = self.beginning_at
if self.ending_at:
state['ending_at'] = self.ending_at
if self.accommodation_code:
state['accommodation_code'] = self.accommodation_code
return json.dumps(state)
def get_html(self):
self.render()
return self.content
@@ -133,12 +119,12 @@ class TimeLimitModule(XModule):
else:
return "other"
class TimeLimitDescriptor(XMLEditingDescriptor, XmlDescriptor):
class TimeLimitDescriptor(TimeLimitFields, XMLEditingDescriptor, XmlDescriptor):
module_class = TimeLimitModule
# For remembering when a student started, and when they should end
stores_state = True
stores_state = True
@classmethod
def definition_from_xml(cls, xml_object, system):
@@ -151,7 +137,7 @@ class TimeLimitDescriptor(XMLEditingDescriptor, XmlDescriptor):
if system.error_tracker is not None:
system.error_tracker("ERROR: " + str(e))
continue
return {'children': children}
return {}, children
def definition_to_xml(self, resource_fs):
xml_object = etree.Element('timelimit')

View File

@@ -8,11 +8,15 @@ from pkg_resources import resource_string
class_priority = ['video', 'problem']
class VerticalModule(XModule):
class VerticalFields(object):
has_children = True
class VerticalModule(VerticalFields, XModule):
''' Layout module for laying out submodules vertically.'''
def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs)
def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs)
self.contents = None
def get_html(self):
@@ -42,7 +46,7 @@ class VerticalModule(XModule):
return new_class
class VerticalDescriptor(SequenceDescriptor):
class VerticalDescriptor(VerticalFields, SequenceDescriptor):
module_class = VerticalModule
js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}

View File

@@ -8,9 +8,8 @@ from django.http import Http404
from xmodule.x_module import XModule
from xmodule.raw_module import RawDescriptor
from xmodule.modulestore.xml import XMLModuleStore
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xblock.core import Integer, Scope, String
import datetime
import time
@@ -18,7 +17,13 @@ import time
log = logging.getLogger(__name__)
class VideoModule(XModule):
class VideoFields(object):
data = String(help="XML data for the problem", scope=Scope.content)
position = Integer(help="Current position in the video", scope=Scope.student_state, default=0)
display_name = String(help="Display name for this module", scope=Scope.settings)
class VideoModule(VideoFields, XModule):
video_time = 0
icon_class = 'video'
@@ -32,23 +37,16 @@ class VideoModule(XModule):
css = {'scss': [resource_string(__name__, 'css/video/display.scss')]}
js_module_name = "Video"
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
xmltree = etree.fromstring(self.definition['data'])
def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs)
xmltree = etree.fromstring(self.data)
self.youtube = xmltree.get('youtube')
self.position = 0
self.show_captions = xmltree.get('show_captions', 'true')
self.source = self._get_source(xmltree)
self.track = self._get_track(xmltree)
self.start_time, self.end_time = self._get_timeframe(xmltree)
if instance_state is not None:
state = json.loads(instance_state)
if 'position' in state:
self.position = int(float(state['position']))
def _get_source(self, xmltree):
# find the first valid source
return self._get_first_external(xmltree, 'source')
@@ -120,13 +118,6 @@ class VideoModule(XModule):
return self.youtube
def get_html(self):
if isinstance(modulestore(), XMLModuleStore):
# VS[compat]
# cdodge: filesystem static content support.
caption_asset_path = "/static/{0}/subs/".format(self.metadata['data_dir'])
else:
caption_asset_path = StaticContent.get_base_url_path_for_course_assets(self.location) + '/subs_'
# We normally let JS parse this, but in the case that we need a hacked
# out <object> player because YouTube has broken their <iframe> API for
# the third time in a year, we need to extract it server side.
@@ -144,10 +135,8 @@ class VideoModule(XModule):
'position': self.position,
'source': self.source,
'track': self.track,
'display_name': self.display_name,
# TODO (cpennington): This won't work when we move to data that isn't on the filesystem
'data_dir': self.metadata['data_dir'],
'caption_asset_path': caption_asset_path,
'display_name': self.display_name_with_default,
'caption_asset_path': "/static/subs/",
'show_captions': self.show_captions,
'start': self.start_time,
'end': self.end_time,
@@ -155,7 +144,7 @@ class VideoModule(XModule):
})
class VideoDescriptor(RawDescriptor):
class VideoDescriptor(VideoFields, RawDescriptor):
module_class = VideoModule
stores_state = True
template_dir_name = "video"

View File

@@ -11,6 +11,7 @@ from xmodule.raw_module import RawDescriptor
from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.django import modulestore
from xmodule.contentstore.content import StaticContent
from xblock.core import Integer, Scope, String
import datetime
import time
@@ -18,7 +19,13 @@ import time
log = logging.getLogger(__name__)
class VideoAlphaModule(XModule):
class VideoAlphaFields(object):
data = String(help="XML data for the problem", scope=Scope.content)
position = Integer(help="Current position in the video", scope=Scope.student_state, default=0)
display_name = String(help="Display name for this module", scope=Scope.settings)
class VideoAlphaModule(VideoAlphaFields, XModule):
"""
XML source example:
@@ -46,11 +53,9 @@ class VideoAlphaModule(XModule):
css = {'scss': [resource_string(__name__, 'css/videoalpha/display.scss')]}
js_module_name = "VideoAlpha"
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
XModule.__init__(self, system, location, definition, descriptor,
instance_state, shared_state, **kwargs)
xmltree = etree.fromstring(self.definition['data'])
def __init__(self, *args, **kwargs):
XModule.__init__(self, *args, **kwargs)
xmltree = etree.fromstring(self.data)
self.youtube_streams = xmltree.get('youtube')
self.sub = xmltree.get('sub')
self.position = 0
@@ -64,11 +69,6 @@ class VideoAlphaModule(XModule):
self.track = self._get_track(xmltree)
self.start_time, self.end_time = self._get_timeframe(xmltree)
if instance_state is not None:
state = json.loads(instance_state)
if 'position' in state:
self.position = int(float(state['position']))
def _get_source(self, xmltree, exts=None):
"""Find the first valid source, which ends with one of `exts`."""
exts = ['mp4', 'ogv', 'avi', 'webm'] if exts is None else exts
@@ -131,7 +131,7 @@ class VideoAlphaModule(XModule):
else:
# VS[compat]
# cdodge: filesystem static content support.
caption_asset_path = "/static/{0}/subs/".format(self.metadata['data_dir'])
caption_asset_path = "/static/{0}/subs/".format(getattr(self, 'data_dir', None))
return self.system.render_template('videoalpha.html', {
'youtube_streams': self.youtube_streams,
@@ -139,9 +139,9 @@ class VideoAlphaModule(XModule):
'sub': self.sub,
'sources': self.sources,
'track': self.track,
'display_name': self.display_name,
'display_name': self.display_name_with_default,
# TODO (cpennington): This won't work when we move to data that isn't on the filesystem
'data_dir': self.metadata['data_dir'],
'data_dir': getattr(self, 'data_dir', None),
'caption_asset_path': caption_asset_path,
'show_captions': self.show_captions,
'start': self.start_time,
@@ -149,7 +149,7 @@ class VideoAlphaModule(XModule):
})
class VideoAlphaDescriptor(RawDescriptor):
class VideoAlphaDescriptor(VideoAlphaFields, RawDescriptor):
module_class = VideoAlphaModule
stores_state = True
template_dir_name = "videoalpha"

View File

@@ -0,0 +1,26 @@
# Same as vertical,
# But w/o css delimiters between children
from xmodule.vertical_module import VerticalModule, VerticalDescriptor
from pkg_resources import resource_string
# HACK: This shouldn't be hard-coded to two types
# OBSOLETE: This obsoletes 'type'
class_priority = ['video', 'problem']
class WrapperModule(VerticalModule):
''' Layout module for laying out submodules vertically w/o css delimiters'''
has_children = True
css = {'scss': [resource_string(__name__, 'css/wrapper/display.scss')]}
class WrapperDescriptor(VerticalDescriptor):
module_class = WrapperModule
has_children = True
js = {'coffee': [resource_string(__name__, 'js/src/vertical/edit.coffee')]}
js_module_name = "VerticalDescriptor"

View File

@@ -1,89 +1,23 @@
import logging
import pkg_resources
import yaml
import os
from functools import partial
from lxml import etree
from pprint import pprint
from collections import namedtuple
from pkg_resources import resource_listdir, resource_string, resource_isdir
from xmodule.modulestore import Location
from xmodule.timeparse import parse_time, stringify_time
from xmodule.contentstore.content import StaticContent, XASSET_SRCREF_PREFIX
from xmodule.modulestore.exceptions import ItemNotFoundError
import time
log = logging.getLogger('mitx.' + __name__)
from xblock.core import XBlock, Scope, String
log = logging.getLogger(__name__)
def dummy_track(event_type, event):
pass
class ModuleMissingError(Exception):
pass
class Plugin(object):
"""
Base class for a system that uses entry_points to load plugins.
Implementing classes are expected to have the following attributes:
entry_point: The name of the entry point to load plugins from
"""
_plugin_cache = None
@classmethod
def load_class(cls, identifier, default=None):
"""
Loads a single class instance specified by identifier. If identifier
specifies more than a single class, then logs a warning and returns the
first class identified.
If default is not None, will return default if no entry_point matching
identifier is found. Otherwise, will raise a ModuleMissingError
"""
if cls._plugin_cache is None:
cls._plugin_cache = {}
if identifier not in cls._plugin_cache:
identifier = identifier.lower()
classes = list(pkg_resources.iter_entry_points(
cls.entry_point, name=identifier))
if len(classes) > 1:
log.warning("Found multiple classes for {entry_point} with "
"identifier {id}: {classes}. "
"Returning the first one.".format(
entry_point=cls.entry_point,
id=identifier,
classes=", ".join(
class_.module_name for class_ in classes)))
if len(classes) == 0:
if default is not None:
return default
raise ModuleMissingError(identifier)
cls._plugin_cache[identifier] = classes[0].load()
return cls._plugin_cache[identifier]
@classmethod
def load_classes(cls):
"""
Returns a list of containing the identifiers and their corresponding classes for all
of the available instances of this plugin
"""
return [(class_.name, class_.load())
for class_
in pkg_resources.iter_entry_points(cls.entry_point)]
class HTMLSnippet(object):
"""
A base class defining an interface for an object that is able to present an
@@ -148,7 +82,15 @@ class HTMLSnippet(object):
.format(self.__class__))
class XModule(HTMLSnippet):
class XModuleFields(object):
display_name = String(
help="Display name for this module",
scope=Scope.settings,
default=None,
)
class XModule(XModuleFields, HTMLSnippet, XBlock):
''' Implements a generic learning module.
Subclasses must at a minimum provide a definition for get_html in order
@@ -165,8 +107,8 @@ class XModule(HTMLSnippet):
# in the module
icon_class = 'other'
def __init__(self, system, location, definition, descriptor,
instance_state=None, shared_state=None, **kwargs):
def __init__(self, system, location, descriptor, model_data):
'''
Construct a new xmodule
@@ -174,67 +116,35 @@ class XModule(HTMLSnippet):
location: Something Location-like that identifies this xmodule
definition: A dictionary containing 'data' and 'children'. Both are
optional
'data': is JSON-like (string, dictionary, list, bool, or None,
optionally nested).
This defines all of the data necessary for a problem to display
that is intrinsic to the problem. It should not include any
data that would vary between two courses using the same problem
(due dates, grading policy, randomization, etc.)
'children': is a list of Location-like values for child modules that
this module depends on
descriptor: the XModuleDescriptor that this module is an instance of.
TODO (vshnayder): remove the definition parameter and location--they
can come from the descriptor.
instance_state: A string of serialized json that contains the state of
this module for current student accessing the system, or None if
no state has been saved
shared_state: A string of serialized json that contains the state that
is shared between this module and any modules of the same type with
the same shared_state_key. This state is only shared per-student,
not across different students
kwargs: Optional arguments. Subclasses should always accept kwargs and
pass them to the parent class constructor.
Current known uses of kwargs:
metadata: SCAFFOLDING - This dictionary will be split into
several different types of metadata in the future (course
policy, modification history, etc). A dictionary containing
data that specifies information that is particular to a
problem in the context of a course
model_data: A dictionary-like object that maps field names to values
for those fields.
'''
self._model_data = model_data
self.system = system
self.location = Location(location)
self.definition = definition
self.descriptor = descriptor
self.instance_state = instance_state
self.shared_state = shared_state
self.id = self.location.url()
self.url_name = self.location.name
self.category = self.location.category
self.metadata = kwargs.get('metadata', {})
self._loaded_children = None
@property
def display_name(self):
def id(self):
return self.location.url()
@property
def display_name_with_default(self):
'''
Return a display name for the module: use display_name if defined in
metadata, otherwise convert the url name.
'''
return self.metadata.get('display_name',
self.url_name.replace('_', ' '))
def __unicode__(self):
return '<x_module(id={0})>'.format(self.id)
name = self.display_name
if name is None:
name = self.url_name.replace('_', ' ')
return name
def get_children(self):
'''
@@ -249,6 +159,9 @@ class XModule(HTMLSnippet):
return self._loaded_children
def __unicode__(self):
return '<x_module(id={0})>'.format(self.id)
def get_child_descriptors(self):
'''
Returns the descriptors of the child modules
@@ -299,18 +212,6 @@ class XModule(HTMLSnippet):
### Functions used in the LMS
def get_instance_state(self):
''' State of the object, as stored in the database
'''
return '{}'
def get_shared_state(self):
'''
Get state that should be shared with other instances
using the same 'shared_state_key' attribute.
'''
return '{}'
def get_score(self):
"""
Score the student received on the problem, or None if there is no
@@ -411,7 +312,8 @@ class ResourceTemplates(object):
return templates
class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock):
"""
An XModuleDescriptor is a specification for an element of a course. This
could be a problem, an organizational element (a group of content), or a
@@ -426,25 +328,15 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
module_class = XModule
# Attributes for inspection of the descriptor
stores_state = False # Indicates whether the xmodule state should be
# Indicates whether the xmodule state should be
# stored in a database (independent of shared state)
has_score = False # This indicates whether the xmodule is a problem-type.
stores_state = False
# This indicates whether the xmodule is a problem-type.
# It should respond to max_score() and grade(). It can be graded or ungraded
# (like a practice problem).
# A list of metadata that this module can inherit from its parent module
inheritable_metadata = (
'graded', 'start', 'due', 'graceperiod', 'showanswer', 'rerandomize',
# TODO (ichuang): used for Fall 2012 xqa server access
'xqa_key',
# TODO: This is used by the XMLModuleStore to provide for locations for
# static files, and will need to be removed when that code is removed
'data_dir',
# How many days early to show a course element to beta testers (float)
# intended to be set per-course, but can be overridden in for specific
# elements. Can be a float.
'days_early_for_beta'
)
has_score = False
# cdodge: this is a list of metadata names which are 'system' metadata
# and should not be edited by an end-user
@@ -452,17 +344,33 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
# A list of descriptor attributes that must be equal for the descriptors to
# be equal
equality_attributes = ('definition', 'metadata', 'location',
'shared_state_key', '_inherited_metadata')
equality_attributes = ('_model_data', 'location')
# Name of resource directory to load templates from
template_dir_name = "default"
# Class level variable
always_recalculate_grades = False
"""
Return whether this descriptor always requires recalculation of grades, for
example if the score can change via an extrnal service, not just when the
student interacts with the module on the page. A specific example is
FoldIt, which posts grade-changing updates through a separate API.
"""
# VS[compat]. Backwards compatibility code that can go away after
# importing 2012 courses.
# A set of metadata key conversions that we want to make
metadata_translations = {
'slug': 'url_name',
'name': 'display_name',
}
# ============================= STRUCTURAL MANIPULATION ===================
def __init__(self,
system,
definition=None,
**kwargs):
location,
model_data):
"""
Construct a new XModuleDescriptor. The only required arguments are the
system, used for interaction with external resources, and the
@@ -475,124 +383,33 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
system: A DescriptorSystem for interacting with external resources
definition: A dict containing `data` and `children` representing the
problem definition
location: Something Location-like that identifies this xmodule
Current arguments passed in kwargs:
location: A xmodule.modulestore.Location object indicating the name
and ownership of this problem
shared_state_key: The key to use for sharing StudentModules with
other modules of this type
metadata: A dictionary containing the following optional keys:
goals: A list of strings of learning goals associated with this
module
display_name: The name to use for displaying this module to the
user
url_name: The name to use for this module in urls and other places
where a unique name is needed.
format: The format of this module ('Homework', 'Lab', etc)
graded (bool): Whether this module is should be graded or not
start (string): The date for which this module will be available
due (string): The due date for this module
graceperiod (string): The amount of grace period to allow when
enforcing the due date
showanswer (string): When to show answers for this module
rerandomize (string): When to generate a newly randomized
instance of the module data
model_data: A dictionary-like object that maps field names to values
for those fields.
"""
self.system = system
self.metadata = kwargs.get('metadata', {})
self.definition = definition if definition is not None else {}
self.location = Location(kwargs.get('location'))
self.location = Location(location)
self.url_name = self.location.name
self.category = self.location.category
self.shared_state_key = kwargs.get('shared_state_key')
self._model_data = model_data
self._child_instances = None
self._inherited_metadata = set()
# Class level variable
always_recalculate_grades = False
"""
Return whether this descriptor always requires recalculation of grades, for
example if the score can change via an extrnal service, not just when the
student interacts with the module on the page. A specific example is
FoldIt, which posts grade-changing updates through a separate API.
"""
@property
def display_name(self):
def id(self):
return self.location.url()
@property
def display_name_with_default(self):
'''
Return a display name for the module: use display_name if defined in
metadata, otherwise convert the url name.
'''
return self.metadata.get('display_name',
self.url_name.replace('_', ' '))
@property
def start(self):
"""
If self.metadata contains a valid start time, return it as a time struct.
Else return None.
"""
if 'start' not in self.metadata:
return None
return self._try_parse_time('start')
@start.setter
def start(self, value):
if isinstance(value, time.struct_time):
self.metadata['start'] = stringify_time(value)
@property
def days_early_for_beta(self):
"""
If self.metadata contains start, return the number, as a float. Else return None.
"""
if 'days_early_for_beta' not in self.metadata:
return None
try:
return float(self.metadata['days_early_for_beta'])
except ValueError:
return None
@property
def own_metadata(self):
"""
Return the metadata that is not inherited, but was defined on this module.
"""
return dict((k, v) for k, v in self.metadata.items()
if k not in self._inherited_metadata)
@staticmethod
def compute_inherited_metadata(node):
"""Given a descriptor, traverse all of its descendants and do metadata
inheritance. Should be called on a CourseDescriptor after importing a
course.
NOTE: This means that there is no such thing as lazy loading at the
moment--this accesses all the children."""
for c in node.get_children():
c.inherit_metadata(node.metadata)
XModuleDescriptor.compute_inherited_metadata(c)
def inherit_metadata(self, metadata):
"""
Updates this module with metadata inherited from a containing module.
Only metadata specified in inheritable_metadata will
be inherited
"""
# Set all inheritable metadata from kwargs that are
# in inheritable_metadata and aren't already set in metadata
for attr in self.inheritable_metadata:
if attr not in self.metadata and attr in metadata:
self._inherited_metadata.add(attr)
self.metadata[attr] = metadata[attr]
name = self.display_name
if name is None:
name = self.url_name.replace('_', ' ')
return name
def get_required_module_descriptors(self):
"""Returns a list of XModuleDescritpor instances upon which this module depends, but are
@@ -602,18 +419,17 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
def get_children(self):
"""Returns a list of XModuleDescriptor instances for the children of
this module"""
if not self.has_children:
return []
if self._child_instances is None:
self._child_instances = []
for child_loc in self.definition.get('children', []):
for child_loc in self.children:
try:
child = self.system.load_item(child_loc)
except ItemNotFoundError:
log.exception('Unable to load item {loc}, skipping'.format(loc=child_loc))
continue
# TODO (vshnayder): this should go away once we have
# proper inheritance support in mongo. The xml
# datastore does all inheritance on course load.
child.inherit_metadata(self.metadata)
self._child_instances.append(child)
return self._child_instances
@@ -627,22 +443,19 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
return child
return None
def xmodule_constructor(self, system):
def xmodule(self, system):
"""
Returns a constructor for an XModule. This constructor takes two
arguments: instance_state and shared_state, and returns a fully
instantiated XModule
Returns an XModule.
system: Module system
"""
return partial(
self.module_class,
return self.module_class(
system,
self.location,
self.definition,
self,
metadata=self.metadata
system.xblock_model_data(self),
)
def has_dynamic_children(self):
"""
Returns True if this descriptor has dynamic children for a given
@@ -662,7 +475,7 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
on the contents of json_data.
json_data must contain a 'location' element, and must be suitable to be
passed into the subclasses `from_json` method.
passed into the subclasses `from_json` method as model_data
"""
class_ = XModuleDescriptor.load_class(
json_data['location']['category'],
@@ -676,12 +489,44 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
Creates an instance of this descriptor from the supplied json_data.
This may be overridden by subclasses
json_data: A json object specifying the definition and any optional
keyword arguments for the XModuleDescriptor
json_data: A json object with the keys 'definition' and 'metadata',
definition: A json object with the keys 'data' and 'children'
data: A json value
children: A list of edX Location urls
metadata: A json object with any keys
This json_data is transformed to model_data using the following rules:
1) The model data contains all of the fields from metadata
2) The model data contains the 'children' array
3) If 'definition.data' is a json object, model data contains all of its fields
Otherwise, it contains the single field 'data'
4) Any value later in this list overrides a value earlier in this list
system: A DescriptorSystem for interacting with external resources
"""
return cls(system=system, **json_data)
model_data = {}
for key, value in json_data.get('metadata', {}).items():
model_data[cls._translate(key)] = value
model_data.update(json_data.get('metadata', {}))
definition = json_data.get('definition', {})
if 'children' in definition:
model_data['children'] = definition['children']
if 'data' in definition:
if isinstance(definition['data'], dict):
model_data.update(definition['data'])
else:
model_data['data'] = definition['data']
return cls(system=system, location=json_data['location'], model_data=model_data)
@classmethod
def _translate(cls, key):
'VS[compat]'
return cls.metadata_translations.get(key, key)
# ================================= XML PARSING ============================
@staticmethod
@@ -758,42 +603,17 @@ class XModuleDescriptor(Plugin, HTMLSnippet, ResourceTemplates):
all(getattr(self, attr, None) == getattr(other, attr, None)
for attr in self.equality_attributes))
if not eq:
for attr in self.equality_attributes:
pprint((getattr(self, attr, None),
getattr(other, attr, None),
getattr(self, attr, None) == getattr(other, attr, None)))
return eq
def __repr__(self):
return ("{class_}({system!r}, {definition!r}, location={location!r},"
" metadata={metadata!r})".format(
return ("{class_}({system!r}, location={location!r},"
" model_data={model_data!r})".format(
class_=self.__class__.__name__,
system=self.system,
definition=self.definition,
location=self.location,
metadata=self.metadata
model_data=self._model_data,
))
# ================================ Internal helpers =======================
def _try_parse_time(self, key):
"""
Parse an optional metadata key containing a time: if present, complain
if it doesn't parse.
Returns a time_struct, or None if metadata key is not present or is invalid.
"""
if key in self.metadata:
try:
return parse_time(self.metadata[key])
except ValueError as e:
msg = "Descriptor {0} loaded with a bad metadata key '{1}': '{2}'".format(
self.location.url(), self.metadata[key], e)
log.warning(msg)
return None
class DescriptorSystem(object):
def __init__(self, load_item, resources_fs, error_tracker, **kwargs):
@@ -872,10 +692,12 @@ class ModuleSystem(object):
get_module,
render_template,
replace_urls,
xblock_model_data,
user=None,
filestore=None,
debug=False,
xqueue=None,
publish=None,
node_path="",
anonymous_student_id='',
course_id=None,
@@ -917,6 +739,11 @@ class ModuleSystem(object):
anonymous_student_id - Used for tracking modules with student id
course_id - the course_id containing this module
publish(event) - A function that allows XModules to publish events (such as grade changes)
xblock_model_data - A dict-like object containing the all data available to this
xblock
'''
self.ajax_url = ajax_url
self.xqueue = xqueue
@@ -931,6 +758,13 @@ class ModuleSystem(object):
self.anonymous_student_id = anonymous_student_id
self.course_id = course_id
self.user_is_staff = user is not None and user.is_staff
self.xblock_model_data = xblock_model_data
if publish is None:
publish = lambda e: None
self.publish = publish
self.open_ended_grading_interface = open_ended_grading_interface
self.s3_interface = s3_interface

View File

@@ -6,8 +6,10 @@ import sys
from collections import namedtuple
from lxml import etree
from xblock.core import Object, Scope
from xmodule.x_module import (XModuleDescriptor, policy_key)
from xmodule.modulestore import Location
from xmodule.modulestore.inheritance import own_metadata
log = logging.getLogger(__name__)
@@ -82,6 +84,8 @@ class XmlDescriptor(XModuleDescriptor):
Mixin class for standardized parsing of from xml
"""
xml_attributes = Object(help="Map of unhandled xml attributes, used only for storage between import and export", default={}, scope=Scope.settings)
# Extension to append to filename paths
filename_extension = 'xml'
@@ -109,7 +113,9 @@ class XmlDescriptor(XModuleDescriptor):
'tabs', 'grading_policy', 'is_draft', 'published_by', 'published_date',
'discussion_blackouts', 'testcenter_info',
# VS[compat] -- remove the below attrs once everything is in the CMS
'course', 'org', 'url_name', 'filename')
'course', 'org', 'url_name', 'filename',
# Used for storing xml attributes between import and export, for roundtrips
'xml_attributes')
metadata_to_export_to_policy = ('discussion_topics')
@@ -132,20 +138,6 @@ class XmlDescriptor(XModuleDescriptor):
}
# VS[compat]. Backwards compatibility code that can go away after
# importing 2012 courses.
# A set of metadata key conversions that we want to make
metadata_translations = {
'slug': 'url_name',
'name': 'display_name',
}
@classmethod
def _translate(cls, key):
'VS[compat]'
return cls.metadata_translations.get(key, key)
@classmethod
def definition_from_xml(cls, xml_object, system):
"""
@@ -227,15 +219,11 @@ class XmlDescriptor(XModuleDescriptor):
definition_metadata = get_metadata_from_xml(definition_xml)
cls.clean_metadata_from_xml(definition_xml)
definition = cls.definition_from_xml(definition_xml, system)
definition, children = cls.definition_from_xml(definition_xml, system)
if definition_metadata:
definition['definition_metadata'] = definition_metadata
# TODO (ichuang): remove this after migration
# for Fall 2012 LMS migration: keep filename (and unmangled filename)
definition['filename'] = [filepath, filename]
return definition
return definition, children
@classmethod
def load_metadata(cls, xml_object):
@@ -268,7 +256,7 @@ class XmlDescriptor(XModuleDescriptor):
"""
for attr in policy:
attr_map = cls.xml_attribute_map.get(attr, AttrMap())
metadata[attr] = attr_map.from_xml(policy[attr])
metadata[cls._translate(attr)] = attr_map.from_xml(policy[attr])
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
@@ -294,9 +282,10 @@ class XmlDescriptor(XModuleDescriptor):
filepath = cls._format_filepath(xml_object.tag, name_to_pathname(url_name))
definition_xml = cls.load_file(filepath, system.resources_fs, location)
else:
definition_xml = xml_object # this is just a pointer, not the real definition content
definition_xml = xml_object # this is just a pointer, not the real definition content
definition, children = cls.load_definition(definition_xml, system, location) # note this removes metadata
definition = cls.load_definition(definition_xml, system, location) # note this removes metadata
# VS[compat] -- make Ike's github preview links work in both old and
# new file layouts
if is_pointer_tag(xml_object):
@@ -320,11 +309,20 @@ class XmlDescriptor(XModuleDescriptor):
if k in system.policy:
cls.apply_policy(metadata, system.policy[k])
model_data = {}
model_data.update(metadata)
model_data.update(definition)
model_data['children'] = children
model_data['xml_attributes'] = {}
for key, value in metadata.items():
if key not in set(f.name for f in cls.fields + cls.lms.fields):
model_data['xml_attributes'][key] = value
return cls(
system,
definition,
location=location,
metadata=metadata,
location,
model_data,
)
@classmethod
@@ -345,7 +343,7 @@ class XmlDescriptor(XModuleDescriptor):
def export_to_xml(self, resource_fs):
"""
Returns an xml string representing this module, and all modules
Returns an xml string representign this module, and all modules
underneath it. May also write required resources out to resource_fs
Assumes that modules have single parentage (that no module appears twice
@@ -371,10 +369,10 @@ class XmlDescriptor(XModuleDescriptor):
(Possible format conversion through an AttrMap).
"""
attr_map = self.xml_attribute_map.get(attr, AttrMap())
return attr_map.to_xml(self.own_metadata[attr])
return attr_map.to_xml(self._model_data[attr])
# Add the non-inherited metadata
for attr in sorted(self.own_metadata):
for attr in sorted(own_metadata(self)):
# don't want e.g. data_dir
if attr not in self.metadata_to_strip and attr not in self.metadata_to_export_to_policy:
val = val_for_xml(attr)
@@ -385,6 +383,10 @@ class XmlDescriptor(XModuleDescriptor):
logging.exception('Failed to serialize metadata attribute {0} with value {1}. This could mean data loss!!! Exception: {2}'.format(attr, val, e))
pass
for key, value in self.xml_attributes.items():
if key not in self.metadata_to_strip:
xml_object.set(key, value)
if self.export_to_file():
# Write the definition to a file
url_path = name_to_pathname(self.url_name)

View File

@@ -79,6 +79,17 @@ if Backbone?
@getContent(id).updateInfo(info)
$.extend @contentInfos, infos
pinThread: ->
pinned = @get("pinned")
@set("pinned",pinned)
@trigger "change", @
unPinThread: ->
pinned = @get("pinned")
@set("pinned",pinned)
@trigger "change", @
class @Thread extends @Content
urlMappers:
'retrieve' : -> DiscussionUtil.urlFor('retrieve_single_thread', @discussion.id, @id)
@@ -91,6 +102,8 @@ if Backbone?
'delete' : -> DiscussionUtil.urlFor('delete_thread', @id)
'follow' : -> DiscussionUtil.urlFor('follow_thread', @id)
'unfollow' : -> DiscussionUtil.urlFor('unfollow_thread', @id)
'pinThread' : -> DiscussionUtil.urlFor("pin_thread", @id)
'unPinThread' : -> DiscussionUtil.urlFor("un_pin_thread", @id)
initialize: ->
@set('thread', @)

View File

@@ -58,10 +58,31 @@ if Backbone?
@current_page = response.page
sortByDate: (thread) ->
thread.get("created_at")
#
#The comment client asks each thread for a value by which to sort the collection
#and calls this sort routine regardless of the order returned from the LMS/comments service
#so, this takes advantage of this per-thread value and returns tomorrow's date
#for pinned threads, ensuring that they appear first, (which is the intent of pinned threads)
#
if thread.get('pinned')
#use tomorrow's date
today = new Date();
new Date(today.getTime() + (24 * 60 * 60 * 1000));
else
thread.get("created_at")
sortByDateRecentFirst: (thread) ->
-(new Date(thread.get("created_at")).getTime())
#
#Same as above
#but negative to flip the order (newest first)
#
if thread.get('pinned')
#use tomorrow's date
today = new Date();
-(new Date(today.getTime() + (24 * 60 * 60 * 1000)));
else
-(new Date(thread.get("created_at")).getTime())
#return String.fromCharCode.apply(String,
# _.map(thread.get("created_at").split(""),
# ((c) -> return 0xffff - c.charChodeAt()))

View File

@@ -50,6 +50,8 @@ class @DiscussionUtil
delete_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/delete"
upvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/upvote"
downvote_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/downvote"
pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/pin"
un_pin_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unpin"
undo_vote_for_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unvote"
follow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/follow"
unfollow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unfollow"

View File

@@ -3,6 +3,7 @@ if Backbone?
events:
"click .discussion-vote": "toggleVote"
"click .admin-pin": "togglePin"
"click .action-follow": "toggleFollowing"
"click .action-edit": "edit"
"click .action-delete": "delete"
@@ -24,6 +25,7 @@ if Backbone?
@delegateEvents()
@renderDogear()
@renderVoted()
@renderPinned()
@renderAttrs()
@$("span.timeago").timeago()
@convertMath()
@@ -41,8 +43,20 @@ if Backbone?
else
@$("[data-role=discussion-vote]").removeClass("is-cast")
renderPinned: =>
if @model.get("pinned")
@$("[data-role=thread-pin]").addClass("pinned")
@$("[data-role=thread-pin]").removeClass("notpinned")
@$(".discussion-pin .pin-label").html("Pinned")
else
@$("[data-role=thread-pin]").removeClass("pinned")
@$("[data-role=thread-pin]").addClass("notpinned")
@$(".discussion-pin .pin-label").html("Pin Thread")
updateModelDetails: =>
@renderVoted()
@renderPinned()
@$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"])
convertMath: ->
@@ -99,6 +113,34 @@ if Backbone?
delete: (event) ->
@trigger "thread:delete", event
togglePin: (event) ->
event.preventDefault()
if @model.get('pinned')
@unPin()
else
@pin()
pin: ->
url = @model.urlFor("pinThread")
DiscussionUtil.safeAjax
$elem: @$(".discussion-pin")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set('pinned', true)
unPin: ->
url = @model.urlFor("unPinThread")
DiscussionUtil.safeAjax
$elem: @$(".discussion-pin")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set('pinned', false)
toggleClosed: (event) ->
$elem = $(event.target)
url = @model.urlFor('close')
@@ -137,3 +179,5 @@ if Backbone?
if @model.get('username')?
params = $.extend(params, user:{username: @model.username, user_url: @model.user_url})
Mustache.render(@template, params)

View File

@@ -1,9 +1,4 @@
// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
// define() functions from Require JS available inside the anonymous function.
//
// See https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system
(function (requirejs, require, define) {
define(['logme'], function (logme) {
return BaseImage;
@@ -50,10 +45,5 @@ define(['logme'], function (logme) {
baseImageElContainer.appendTo(state.containerEl);
});
}
});
// End of wrapper for RequireJS. As you can see, we are passing
// namespaced Require JS variables to an anonymous function. Within
// it, you can use the standard requirejs(), require(), and define()
// functions as if they were in the global namespace.
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
}); // End-of: define(['logme'], function (logme) {
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) {

View File

@@ -1,9 +1,4 @@
// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
// define() functions from Require JS available inside the anonymous function.
//
// See https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system
(function (requirejs, require, define) {
define(['logme'], function (logme) {
return configParser;
@@ -16,7 +11,7 @@ define(['logme'], function (logme) {
'targetOutline': true,
'labelBgColor': '#d6d6d6',
'individualTargets': null, // Depends on 'targets'.
'errors': 0 // Number of errors found while parsing config.
'foundErrors': false // Whether or not we find errors while processing the config.
};
getDraggables(state, config);
@@ -28,7 +23,7 @@ define(['logme'], function (logme) {
setIndividualTargets(state);
if (state.config.errors !== 0) {
if (state.config.foundErrors !== false) {
return false;
}
@@ -38,35 +33,34 @@ define(['logme'], function (logme) {
function getDraggables(state, config) {
if (config.hasOwnProperty('draggables') === false) {
logme('ERROR: "config" does not have a property "draggables".');
state.config.errors += 1;
state.config.foundErrors = true;
} else if ($.isArray(config.draggables) === true) {
(function (i) {
while (i < config.draggables.length) {
if (processDraggable(state, config.draggables[i]) !== true) {
state.config.errors += 1;
}
i += 1;
config.draggables.every(function (draggable) {
if (processDraggable(state, draggable) !== true) {
state.config.foundErrors = true;
// Exit immediately from .every() call.
return false;
}
}(0));
} else if ($.isPlainObject(config.draggables) === true) {
if (processDraggable(state, config.draggables) !== true) {
state.config.errors += 1;
}
// Continue to next .every() call.
return true;
});
} else {
logme('ERROR: The type of config.draggables is no supported.');
state.config.errors += 1;
state.config.foundErrors = true;
}
}
function getBaseImage(state, config) {
if (config.hasOwnProperty('base_image') === false) {
logme('ERROR: "config" does not have a property "base_image".');
state.config.errors += 1;
state.config.foundErrors = true;
} else if (typeof config.base_image === 'string') {
state.config.baseImage = config.base_image;
} else {
logme('ERROR: Property config.base_image is not of type "string".');
state.config.errors += 1;
state.config.foundErrors = true;
}
}
@@ -77,28 +71,27 @@ define(['logme'], function (logme) {
// Draggables can be positioned anywhere on the image, and the server will
// get an answer in the form of (x, y) coordinates for each draggable.
} else if ($.isArray(config.targets) === true) {
(function (i) {
while (i < config.targets.length) {
if (processTarget(state, config.targets[i]) !== true) {
state.config.errors += 1;
}
i += 1;
config.targets.every(function (target) {
if (processTarget(state, target) !== true) {
state.config.foundErrors = true;
// Exit immediately from .every() call.
return false;
}
}(0));
} else if ($.isPlainObject(config.targets) === true) {
if (processTarget(state, config.targets) !== true) {
state.config.errors += 1;
}
// Continue to next .every() call.
return true;
});
} else {
logme('ERROR: Property config.targets is not of a supported type.');
state.config.errors += 1;
state.config.foundErrors = true;
}
}
function getOnePerTarget(state, config) {
if (config.hasOwnProperty('one_per_target') === false) {
logme('ERROR: "config" does not have a property "one_per_target".');
state.config.errors += 1;
state.config.foundErrors = true;
} else if (typeof config.one_per_target === 'string') {
if (config.one_per_target.toLowerCase() === 'true') {
state.config.onePerTarget = true;
@@ -106,42 +99,45 @@ define(['logme'], function (logme) {
state.config.onePerTarget = false;
} else {
logme('ERROR: Property config.one_per_target can either be "true", or "false".');
state.config.errors += 1;
state.config.foundErrors = true;
}
} else {
logme('ERROR: Property config.one_per_target is not of a supported type.');
state.config.errors += 1;
state.config.foundErrors = true;
}
}
function getTargetOutline(state, config) {
if (config.hasOwnProperty('target_outline') === false) {
// It is possible that no "target_outline" was specified. This is not an error.
// In this case the default value of 'true' (boolean) will be used.
} else if (typeof config.target_outline === 'string') {
if (config.target_outline.toLowerCase() === 'true') {
state.config.targetOutline = true;
} else if (config.target_outline.toLowerCase() === 'false') {
state.config.targetOutline = false;
// It is possible that no "target_outline" was specified. This is not an error.
// In this case the default value of 'true' (boolean) will be used.
if (config.hasOwnProperty('target_outline') === true) {
if (typeof config.target_outline === 'string') {
if (config.target_outline.toLowerCase() === 'true') {
state.config.targetOutline = true;
} else if (config.target_outline.toLowerCase() === 'false') {
state.config.targetOutline = false;
} else {
logme('ERROR: Property config.target_outline can either be "true", or "false".');
state.config.foundErrors = true;
}
} else {
logme('ERROR: Property config.target_outline can either be "true", or "false".');
state.config.errors += 1;
logme('ERROR: Property config.target_outline is not of a supported type.');
state.config.foundErrors = true;
}
} else {
logme('ERROR: Property config.target_outline is not of a supported type.');
state.config.errors += 1;
}
}
function getLabelBgColor(state, config) {
if (config.hasOwnProperty('label_bg_color') === false) {
// It is possible that no "label_bg_color" was specified. This is not an error.
// In this case the default value of '#d6d6d6' (string) will be used.
} else if (typeof config.label_bg_color === 'string') {
state.config.labelBgColor = config.label_bg_color;
} else {
logme('ERROR: Property config.label_bg_color is not of a supported type.');
returnStatus = false;
// It is possible that no "label_bg_color" was specified. This is not an error.
// In this case the default value of '#d6d6d6' (string) will be used.
if (config.hasOwnProperty('label_bg_color') === true) {
if (typeof config.label_bg_color === 'string') {
state.config.labelBgColor = config.label_bg_color;
} else {
logme('ERROR: Property config.label_bg_color is not of a supported type.');
}
}
}
@@ -159,17 +155,36 @@ define(['logme'], function (logme) {
(attrIsString(obj, 'icon') === false) ||
(attrIsString(obj, 'label') === false) ||
(attrIsBoolean(obj, 'can_reuse', false) === false)
(attrIsBoolean(obj, 'can_reuse', false) === false) ||
(obj.hasOwnProperty('target_fields') === false)
) {
return false;
}
// Check that all targets in the 'target_fields' property are proper target objects.
// We will be testing the return value from .every() call (it can be 'true' or 'false').
if (obj.target_fields.every(
function (targetObj) {
return processTarget(state, targetObj, false);
}
) === false) {
return false;
}
state.config.draggables.push(obj);
return true;
}
function processTarget(state, obj) {
// We need 'pushToState' parameter in order to simply test an object for the fact that it is a
// proper target (without pushing it to the 'state' object). When
//
// pushToState === false
//
// the object being tested is not going to be pushed to 'state'. The function will onyl return
// 'true' or 'false.
function processTarget(state, obj, pushToState) {
if (
(attrIsString(obj, 'id') === false) ||
@@ -182,7 +197,9 @@ define(['logme'], function (logme) {
return false;
}
state.config.targets.push(obj);
if (pushToState !== false) {
state.config.targets.push(obj);
}
return true;
}
@@ -250,10 +267,5 @@ define(['logme'], function (logme) {
return true;
}
});
// End of wrapper for RequireJS. As you can see, we are passing
// namespaced Require JS variables to an anonymous function. Within
// it, you can use the standard requirejs(), require(), and define()
// functions as if they were in the global namespace.
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
}); // End-of: define(['logme'], function (logme) {
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) {

View File

@@ -1,9 +1,4 @@
// Wrapper for RequireJS. It will make the standard requirejs(), require(), and
// define() functions from Require JS available inside the anonymous function.
//
// See https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system
(function (requirejs, require, define) {
define(['logme'], function (logme) {
return Container;
@@ -21,10 +16,5 @@ define(['logme'], function (logme) {
$('#inputtype_' + state.problemId).before(state.containerEl);
}
});
// End of wrapper for RequireJS. As you can see, we are passing
// namespaced Require JS variables to an anonymous function. Within
// it, you can use the standard requirejs(), require(), and define()
// functions as if they were in the global namespace.
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define)
}); // End-of: define(['logme'], function (logme) {
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) {

View File

@@ -0,0 +1,131 @@
(function (requirejs, require, define) {
define(['logme'], function (logme) {
return {
'attachMouseEventsTo': function (element) {
var self;
self = this;
this[element].mousedown(function (event) {
self.mouseDown(event);
});
this[element].mouseup(function (event) {
self.mouseUp(event);
});
this[element].mousemove(function (event) {
self.mouseMove(event);
});
},
'mouseDown': function (event) {
if (this.mousePressed === false) {
// So that the browser does not perform a default drag.
// If we don't do this, each drag operation will
// potentially cause the highlghting of the dragged element.
event.preventDefault();
event.stopPropagation();
if (this.numDraggablesOnMe > 0) {
return;
}
// If this draggable is just being dragged out of the
// container, we must perform some additional tasks.
if (this.inContainer === true) {
if ((this.isReusable === true) && (this.isOriginal === true)) {
this.makeDraggableCopy(function (draggableCopy) {
draggableCopy.mouseDown(event);
});
return;
}
if (this.isOriginal === true) {
this.containerEl.hide();
this.iconEl.detach();
}
if (this.iconImgEl !== null) {
this.iconImgEl.css({
'width': this.iconWidth,
'height': this.iconHeight
});
}
this.iconEl.css({
'background-color': this.iconElBGColor,
'padding-left': this.iconElPadding,
'padding-right': this.iconElPadding,
'border': this.iconElBorder,
'width': this.iconWidth,
'height': this.iconHeight,
'left': event.pageX - this.state.baseImageEl.offset().left - this.iconWidth * 0.5 - this.iconElLeftOffset,
'top': event.pageY - this.state.baseImageEl.offset().top - this.iconHeight * 0.5
});
this.iconEl.appendTo(this.state.baseImageEl.parent());
if (this.labelEl !== null) {
if (this.isOriginal === true) {
this.labelEl.detach();
}
this.labelEl.css({
'background-color': this.state.config.labelBgColor,
'padding-left': 8,
'padding-right': 8,
'border': '1px solid black',
'left': event.pageX - this.state.baseImageEl.offset().left - this.labelWidth * 0.5 - 9, // Account for padding, border.
'top': event.pageY - this.state.baseImageEl.offset().top + this.iconHeight * 0.5 + 5
});
this.labelEl.appendTo(this.state.baseImageEl.parent());
}
this.inContainer = false;
if (this.isOriginal === true) {
this.state.numDraggablesInSlider -= 1;
}
}
this.zIndex = 1000;
this.iconEl.css('z-index', '1000');
if (this.labelEl !== null) {
this.labelEl.css('z-index', '1000');
}
this.mousePressed = true;
this.state.currentMovingDraggable = this;
}
},
'mouseUp': function () {
if (this.mousePressed === true) {
this.state.currentMovingDraggable = null;
this.checkLandingElement();
}
},
'mouseMove': function (event) {
if (this.mousePressed === true) {
// Because we have also attached a 'mousemove' event to the
// 'document' (that will do the same thing), let's tell the
// browser not to bubble up this event. The attached event
// on the 'document' will only be triggered when the mouse
// pointer leaves the draggable while it is in the middle
// of a drag operation (user moves the mouse very quickly).
event.stopPropagation();
this.iconEl.css({
'left': event.pageX - this.state.baseImageEl.offset().left - this.iconWidth * 0.5 - this.iconElLeftOffset,
'top': event.pageY - this.state.baseImageEl.offset().top - this.iconHeight * 0.5
});
if (this.labelEl !== null) {
this.labelEl.css({
'left': event.pageX - this.state.baseImageEl.offset().left - this.labelWidth * 0.5 - 9, // Acoount for padding, border.
'top': event.pageY - this.state.baseImageEl.offset().top + this.iconHeight * 0.5 + 5
});
}
}
}
}; // End-of: return {
}); // End-of: define(['logme'], function (logme) {
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) {

View File

@@ -0,0 +1,387 @@
(function (requirejs, require, define) {
define(['logme', 'update_input', 'targets'], function (logme, updateInput, Targets) {
return {
'moveDraggableTo': function (moveType, target, funcCallback) {
var self, offset;
if (this.hasLoaded === false) {
self = this;
setTimeout(function () {
self.moveDraggableTo(moveType, target, funcCallback);
}, 50);
return;
}
if ((this.isReusable === true) && (this.isOriginal === true)) {
this.makeDraggableCopy(function (draggableCopy) {
draggableCopy.moveDraggableTo(moveType, target, funcCallback);
});
return;
}
offset = 0;
if (this.state.config.targetOutline === true) {
offset = 1;
}
this.inContainer = false;
if (this.isOriginal === true) {
this.containerEl.hide();
this.iconEl.detach();
}
if (this.iconImgEl !== null) {
this.iconImgEl.css({
'width': this.iconWidth,
'height': this.iconHeight
});
}
this.iconEl.css({
'background-color': this.iconElBGColor,
'padding-left': this.iconElPadding,
'padding-right': this.iconElPadding,
'border': this.iconElBorder,
'width': this.iconWidth,
'height': this.iconHeight
});
if (moveType === 'target') {
this.iconEl.css({
'left': target.offset.left + 0.5 * target.w - this.iconWidth * 0.5 + offset - this.iconElLeftOffset,
'top': target.offset.top + 0.5 * target.h - this.iconHeight * 0.5 + offset
});
} else {
this.iconEl.css({
'left': target.x - this.iconWidth * 0.5 + offset - this.iconElLeftOffset,
'top': target.y - this.iconHeight * 0.5 + offset
});
}
this.iconEl.appendTo(this.state.baseImageEl.parent());
if (this.labelEl !== null) {
if (this.isOriginal === true) {
this.labelEl.detach();
}
this.labelEl.css({
'background-color': this.state.config.labelBgColor,
'padding-left': 8,
'padding-right': 8,
'border': '1px solid black'
});
if (moveType === 'target') {
this.labelEl.css({
'left': target.offset.left + 0.5 * target.w - this.labelWidth * 0.5 + offset - 9, // Account for padding, border.
'top': target.offset.top + 0.5 * target.h + this.iconHeight * 0.5 + 5 + offset
});
} else {
this.labelEl.css({
'left': target.x - this.labelWidth * 0.5 + offset - 9, // Account for padding, border.
'top': target.y - this.iconHeight * 0.5 + this.iconHeight + 5 + offset
});
}
this.labelEl.appendTo(this.state.baseImageEl.parent());
}
if (moveType === 'target') {
target.addDraggable(this);
} else {
this.x = target.x;
this.y = target.y;
}
this.zIndex = 1000;
this.correctZIndexes();
Targets.initializeTargetField(this);
if (this.isOriginal === true) {
this.state.numDraggablesInSlider -= 1;
this.state.updateArrowOpacity();
}
if ($.isFunction(funcCallback) === true) {
funcCallback();
}
},
// At this point the mouse was realeased, and we need to check
// where the draggable eneded up. Based on several things, we
// will either move the draggable back to the slider, or update
// the input with the user's answer (X-Y position of the draggable,
// or the ID of the target where it landed.
'checkLandingElement': function () {
var positionIE;
this.mousePressed = false;
positionIE = this.iconEl.position();
if (this.state.config.individualTargets === true) {
if (this.checkIfOnTarget(positionIE) === true) {
this.correctZIndexes();
Targets.initializeTargetField(this);
} else {
if (this.onTarget !== null) {
this.onTarget.removeDraggable(this);
}
this.moveBackToSlider();
if (this.isOriginal === true) {
this.state.numDraggablesInSlider += 1;
}
}
} else {
if (
(positionIE.left < 0) ||
(positionIE.left + this.iconWidth > this.state.baseImageEl.width()) ||
(positionIE.top < 0) ||
(positionIE.top + this.iconHeight > this.state.baseImageEl.height())
) {
this.moveBackToSlider();
this.x = -1;
this.y = -1;
if (this.isOriginal === true) {
this.state.numDraggablesInSlider += 1;
}
} else {
this.correctZIndexes();
this.x = positionIE.left + this.iconWidth * 0.5;
this.y = positionIE.top + this.iconHeight * 0.5;
Targets.initializeTargetField(this);
}
}
if (this.isOriginal === true) {
this.state.updateArrowOpacity();
}
updateInput.update(this.state);
},
// Determine if a draggable, after it was relased, ends up on a
// target. We do this by iterating over all of the targets, and
// for each one we check whether the draggable's center is
// within the target's dimensions.
//
// positionIE is the object as returned by
//
// this.iconEl.position()
'checkIfOnTarget': function (positionIE) {
var c1, target;
for (c1 = 0; c1 < this.state.targets.length; c1 += 1) {
target = this.state.targets[c1];
// If only one draggable per target is allowed, and
// the current target already has a draggable on it
// (with an ID different from the one we are checking
// against), then go to next target.
if (
(this.state.config.onePerTarget === true) &&
(target.draggableList.length === 1) &&
(target.draggableList[0].uniqueId !== this.uniqueId)
) {
continue;
}
// If the target is on a draggable (from target field), we must make sure that
// this draggable is not the same as "this" one.
if ((target.type === 'on_drag') && (target.draggableObj.uniqueId === this.uniqueId)) {
continue;
}
// Check if the draggable's center coordinate is within
// the target's dimensions. If not, go to next target.
if (
(positionIE.top + this.iconHeight * 0.5 < target.offset.top) ||
(positionIE.top + this.iconHeight * 0.5 > target.offset.top + target.h) ||
(positionIE.left + this.iconWidth * 0.5 < target.offset.left) ||
(positionIE.left + this.iconWidth * 0.5 > target.offset.left + target.w)
) {
continue;
}
// If the draggable was moved from one target to
// another, then we need to remove it from the
// previous target's draggables list, and add it to the
// new target's draggables list.
if ((this.onTarget !== null) && (this.onTarget.uniqueId !== target.uniqueId)) {
this.onTarget.removeDraggable(this);
target.addDraggable(this);
}
// If the draggable was moved from the slider to a
// target, remember the target, and add ID to the
// target's draggables list.
else if (this.onTarget === null) {
target.addDraggable(this);
}
// Reposition the draggable so that it's center
// coincides with the center of the target.
this.snapToTarget(target);
// Target was found.
return true;
}
// Target was not found.
return false;
},
'snapToTarget': function (target) {
var offset;
offset = 0;
if (this.state.config.targetOutline === true) {
offset = 1;
}
this.iconEl.css({
'left': target.offset.left + 0.5 * target.w - this.iconWidth * 0.5 + offset - this.iconElLeftOffset,
'top': target.offset.top + 0.5 * target.h - this.iconHeight * 0.5 + offset
});
if (this.labelEl !== null) {
this.labelEl.css({
'left': target.offset.left + 0.5 * target.w - this.labelWidth * 0.5 + offset - 9, // Acoount for padding, border.
'top': target.offset.top + 0.5 * target.h + this.iconHeight * 0.5 + 5 + offset
});
}
},
// Go through all of the draggables subtract 1 from the z-index
// of all whose z-index is higher than the old z-index of the
// current element. After, set the z-index of the current
// element to 1 + N (where N is the number of draggables - i.e.
// the highest z-index possible).
//
// This will make sure that after releasing a draggable, it
// will be on top of all of the other draggables. Also, the
// ordering of the visibility (z-index) of the other draggables
// will not change.
'correctZIndexes': function () {
var c1, highestZIndex;
highestZIndex = -10000;
if (this.state.config.individualTargets === true) {
if (this.onTarget.draggableList.length > 0) {
for (c1 = 0; c1 < this.onTarget.draggableList.length; c1 += 1) {
if (
(this.onTarget.draggableList[c1].zIndex > highestZIndex) &&
(this.onTarget.draggableList[c1].zIndex !== 1000)
) {
highestZIndex = this.onTarget.draggableList[c1].zIndex;
}
}
} else {
highestZIndex = 0;
}
} else {
for (c1 = 0; c1 < this.state.draggables.length; c1++) {
if (this.inContainer === false) {
if (
(this.state.draggables[c1].zIndex > highestZIndex) &&
(this.state.draggables[c1].zIndex !== 1000)
) {
highestZIndex = this.state.draggables[c1].zIndex;
}
}
}
}
if (highestZIndex === -10000) {
highestZIndex = 0;
}
this.zIndex = highestZIndex + 1;
this.iconEl.css('z-index', this.zIndex);
if (this.labelEl !== null) {
this.labelEl.css('z-index', this.zIndex);
}
},
// If a draggable was released in a wrong positione, we will
// move it back to the slider, placing it in the same position
// that it was dragged out of.
'moveBackToSlider': function () {
var c1;
Targets.destroyTargetField(this);
if (this.isOriginal === false) {
this.iconEl.remove();
if (this.labelEl !== null) {
this.labelEl.remove();
}
this.state.draggables.splice(this.stateDraggablesIndex, 1);
for (c1 = 0; c1 < this.state.draggables.length; c1 += 1) {
if (this.state.draggables[c1].stateDraggablesIndex > this.stateDraggablesIndex) {
this.state.draggables[c1].stateDraggablesIndex -= 1;
}
}
return;
}
this.containerEl.show();
this.zIndex = 1;
this.iconEl.detach();
if (this.iconImgEl !== null) {
this.iconImgEl.css({
'width': this.iconWidthSmall,
'height': this.iconHeightSmall
});
}
this.iconEl.css({
'border': 'none',
'background-color': 'transparent',
'padding-left': 0,
'padding-right': 0,
'z-index': this.zIndex,
'width': this.iconWidthSmall,
'height': this.iconHeightSmall,
'left': 50 - this.iconWidthSmall * 0.5,
// Before:
// 'top': ((this.labelEl !== null) ? (100 - this.iconHeightSmall - 25) * 0.5 : 50 - this.iconHeightSmall * 0.5)
// After:
'top': ((this.labelEl !== null) ? 37.5 : 50.0) - 0.5 * this.iconHeightSmall
});
this.iconEl.appendTo(this.containerEl);
if (this.labelEl !== null) {
this.labelEl.detach();
this.labelEl.css({
'border': 'none',
'background-color': 'transparent',
'padding-left': 0,
'padding-right': 0,
'z-index': this.zIndex,
'left': 50 - this.labelWidth * 0.5,
// Before:
// 'top': (100 - this.iconHeightSmall - 25) * 0.5 + this.iconHeightSmall + 5
// After:
'top': 42.5 + 0.5 * this.iconHeightSmall
});
this.labelEl.appendTo(this.containerEl);
}
this.inContainer = true;
}
}; // End-of: return {
}); // End-of: define(['logme', 'update_input', 'targets'], function (logme, updateInput, Targets) {
}(RequireJS.requirejs, RequireJS.require, RequireJS.define)); // End-of: (function (requirejs, require, define) {

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