studio - alerts: resolving local master merge conflcits
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>⁄<sub>2</sub>SO<sub>4</sub><sup>2-</sup></span>'
|
||||
log(out + ' ------- ' + correct, 'html')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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?:
|
||||
|
||||
@@ -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) ])
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from calc import evaluator, UndefinedVariable
|
||||
from .calc import evaluator, UndefinedVariable
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
#
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
import requests
|
||||
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
log = logging.getLogger(__name__)
|
||||
dateformat = '%Y%m%d%H%M%S'
|
||||
|
||||
|
||||
|
||||
@@ -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'],
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
221
common/lib/xmodule/xmodule/css/poll/display.scss
Normal file
221
common/lib/xmodule/xmodule/css/poll/display.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
69
common/lib/xmodule/xmodule/fields.py
Normal file
69
common/lib/xmodule/xmodule/fields.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
54
common/lib/xmodule/xmodule/js/src/poll/logme.js
Normal file
54
common/lib/xmodule/xmodule/js/src/poll/logme.js
Normal 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)
|
||||
5
common/lib/xmodule/xmodule/js/src/poll/poll.js
Normal file
5
common/lib/xmodule/xmodule/js/src/poll/poll.js
Normal file
@@ -0,0 +1,5 @@
|
||||
window.Poll = function (el) {
|
||||
RequireJS.require(['PollMain'], function (PollMain) {
|
||||
new PollMain(el);
|
||||
});
|
||||
};
|
||||
323
common/lib/xmodule/xmodule/js/src/poll/poll_main.js
Normal file
323
common/lib/xmodule/xmodule/js/src/poll/poll_main.js
Normal 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));
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
10
common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee
Normal file
10
common/lib/xmodule/xmodule/js/src/wrapper/edit.coffee
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
67
common/lib/xmodule/xmodule/modulestore/inheritance.py
Normal file
67
common/lib/xmodule/xmodule/modulestore/inheritance.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from grading_service_module import GradingService
|
||||
from .grading_service_module import GradingService
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from grading_service_module import GradingService
|
||||
from .grading_service_module import GradingService
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
64
common/lib/xmodule/xmodule/plugin.py
Normal file
64
common/lib/xmodule/xmodule/plugin.py
Normal 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)]
|
||||
205
common/lib/xmodule/xmodule/poll_module.py
Normal file
205
common/lib/xmodule/xmodule/poll_module.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from x_module import XModule, XModuleDescriptor
|
||||
from .x_module import XModule, XModuleDescriptor
|
||||
|
||||
|
||||
class ModuleDescriptor(XModuleDescriptor):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from itertools import chain
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from lxml import etree
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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>')
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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>")
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
66
common/lib/xmodule/xmodule/tests/test_logic.py
Normal file
66
common/lib/xmodule/xmodule/tests/test_logic.py
Normal 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, [])
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')]}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
26
common/lib/xmodule/xmodule/wrapper_module.py
Normal file
26
common/lib/xmodule/xmodule/wrapper_module.py
Normal 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', @)
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
131
common/static/js/capa/drag_and_drop/draggable_events.js
Normal file
131
common/static/js/capa/drag_and_drop/draggable_events.js
Normal 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) {
|
||||
387
common/static/js/capa/drag_and_drop/draggable_logic.js
Normal file
387
common/static/js/capa/drag_and_drop/draggable_logic.js
Normal 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
Reference in New Issue
Block a user