Commit 5e6261eb by Mike Chen

courseware still not working..

parents 8627a5f3 80b8529f
......@@ -121,6 +121,7 @@ MIDDLEWARE_CLASSES = (
)
############################ SIGNAL HANDLERS ################################
# This is imported to register the exception signal handling that logs exceptions
import monitoring.exceptions # noqa
############################ DJANGO_BUILTINS ################################
......
import logging
from django.conf import settings
from django.http import HttpResponseServerError
log = logging.getLogger("mitx")
class ExceptionLoggingMiddleware(object):
"""Just here to log unchecked exceptions that go all the way up the Django
stack"""
if not settings.TEMPLATE_DEBUG:
def process_exception(self, request, exception):
log.exception(exception)
return HttpResponseServerError("Server Error - Please try again later.")
......@@ -90,10 +90,12 @@ def add_histogram(get_html, module):
# TODO (ichuang): Remove after fall 2012 LMS migration done
if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'):
[filepath, filename] = module.definition.get('filename','')
[filepath, filename] = module.definition.get('filename', ['', None])
osfs = module.system.filestore
if filename is not None and osfs.exists(filename):
filepath = filename # if original, unmangled filename exists then use it (github doesn't like symlinks)
# if original, unmangled filename exists then use it (github
# doesn't like symlinks)
filepath = filename
data_dir = osfs.root_path.rsplit('/')[-1]
edit_link = "https://github.com/MITx/%s/tree/master/%s" % (data_dir,filepath)
else:
......
......@@ -204,7 +204,7 @@ def extract_choices(element):
raise Exception("[courseware.capa.inputtypes.extract_choices] \
Expected a <choice> tag; got %s instead"
% choice.tag)
choice_text = ''.join([etree.tostring(x) for x in choice])
choice_text = ''.join([x.text for x in choice])
choices.append((choice.get("name"), choice_text))
......
......@@ -800,6 +800,12 @@ class CodeResponse(LoncapaResponse):
'''
Grade student code using an external queueing server, called 'xqueue'
Expects 'xqueue' dict in ModuleSystem with the following keys:
system.xqueue = { 'interface': XqueueInterface object,
'callback_url': Per-StudentModule callback URL where results are posted (string),
'default_queuename': Default queuename to submit request (string)
}
External requests are only submitted for student submission grading
(i.e. and not for getting reference answers)
'''
......@@ -873,15 +879,16 @@ class CodeResponse(LoncapaResponse):
'edX_cmd': 'get_score',
'edX_tests': self.tests,
'processor': self.code,
'edX_student_response': unicode(submission), # unicode on File object returns its filename
}
# Submit request
if hasattr(submission, 'read'): # Test for whether submission is a file
if is_file(submission):
contents.update({'edX_student_response': submission.name})
(error, msg) = qinterface.send_to_queue(header=xheader,
body=json.dumps(contents),
file_to_upload=submission)
else:
contents.update({'edX_student_response': submission})
(error, msg) = qinterface.send_to_queue(header=xheader,
body=json.dumps(contents))
......
......@@ -39,5 +39,18 @@ def convert_files_to_filenames(answers):
'''
new_answers = dict()
for answer_id in answers.keys():
new_answers[answer_id] = unicode(answers[answer_id])
if is_file(answers[answer_id]):
new_answers[answer_id] = answers[answer_id].name
else:
new_answers[answer_id] = answers[answer_id]
return new_answers
def is_file(file_to_test):
'''
Duck typing to check if 'file_to_test' is a File object
'''
is_file = True
for method in ['read', 'name']:
if not hasattr(file_to_test, method):
is_file = False
return is_file
......@@ -103,7 +103,9 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
experiment = xml_object.get('experiment')
if experiment is None:
raise InvalidDefinitionError("ABTests must specify an experiment. Not found in:\n{xml}".format(xml=etree.tostring(xml_object, pretty_print=True)))
raise InvalidDefinitionError(
"ABTests must specify an experiment. Not found in:\n{xml}"
.format(xml=etree.tostring(xml_object, pretty_print=True)))
definition = {
'data': {
......@@ -127,7 +129,9 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
definition['data']['group_content'][name] = child_content_urls
definition['children'].extend(child_content_urls)
default_portion = 1 - sum(portion for (name, portion) in definition['data']['group_portions'].items())
default_portion = 1 - sum(
portion for (name, portion) in definition['data']['group_portions'].items())
if default_portion < 0:
raise InvalidDefinitionError("ABTest portions must add up to less than or equal to 1")
......
......@@ -119,9 +119,9 @@ class CapaModule(XModule):
if self.show_answer == "":
self.show_answer = "closed"
if instance_state != None:
if instance_state is not None:
instance_state = json.loads(instance_state)
if instance_state != None and 'attempts' in 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'))
......@@ -130,7 +130,7 @@ class CapaModule(XModule):
if weight_string:
self.weight = float(weight_string)
else:
self.weight = 1
self.weight = None
if self.rerandomize == 'never':
seed = 1
......@@ -238,7 +238,7 @@ class CapaModule(XModule):
content = {'name': self.metadata['display_name'],
'html': html,
'weight': self.weight,
}
}
# We using strings as truthy values, because the terminology of the
# check button is context-specific.
......@@ -563,6 +563,11 @@ class CapaDescriptor(RawDescriptor):
module_class = CapaModule
# Capa modules have some additional metadata:
# TODO (vshnayder): do problems have any other metadata? Do they
# actually use type and points?
metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points')
# VS[compat]
# TODO (cpennington): Delete this method once all fall 2012 course are being
# edited in the cms
......@@ -572,8 +577,3 @@ class CapaDescriptor(RawDescriptor):
'problems/' + path[8:],
path[8:],
]
@classmethod
def split_to_file(cls, xml_object):
'''Problems always written in their own files'''
return True
......@@ -3,6 +3,7 @@ import time
import dateutil.parser
import logging
from xmodule.util.decorators import lazyproperty
from xmodule.graders import load_grading_policy
from xmodule.modulestore import Location
from xmodule.seq_module import SequenceDescriptor, SequenceModule
......@@ -12,13 +13,9 @@ log = logging.getLogger(__name__)
class CourseDescriptor(SequenceDescriptor):
module_class = SequenceModule
metadata_attributes = SequenceDescriptor.metadata_attributes + ('org', 'course')
def __init__(self, system, definition=None, **kwargs):
super(CourseDescriptor, self).__init__(system, definition, **kwargs)
self._grader = None
self._grade_cutoffs = None
msg = None
try:
......@@ -39,34 +36,84 @@ class CourseDescriptor(SequenceDescriptor):
def has_started(self):
return time.gmtime() > self.start
@property
def grader(self):
self.__load_grading_policy()
return self._grader
return self.__grading_policy['GRADER']
@property
def grade_cutoffs(self):
self.__load_grading_policy()
return self._grade_cutoffs
def __load_grading_policy(self):
if not self._grader or not self._grade_cutoffs:
policy_string = ""
try:
with self.system.resources_fs.open("grading_policy.json") as grading_policy_file:
policy_string = grading_policy_file.read()
except (IOError, ResourceNotFoundError):
log.warning("Unable to load course settings file from grading_policy.json in course " + self.id)
grading_policy = load_grading_policy(policy_string)
self._grader = grading_policy['GRADER']
self._grade_cutoffs = grading_policy['GRADE_CUTOFFS']
return self.__grading_policy['GRADE_CUTOFFS']
@lazyproperty
def __grading_policy(self):
policy_string = ""
try:
with self.system.resources_fs.open("grading_policy.json") as grading_policy_file:
policy_string = grading_policy_file.read()
except (IOError, ResourceNotFoundError):
log.warning("Unable to load course settings file from grading_policy.json in course " + self.id)
grading_policy = load_grading_policy(policy_string)
return grading_policy
@lazyproperty
def grading_context(self):
"""
This returns a dictionary with keys necessary for quickly grading
a student. They are used by grades.grade()
The grading context has two keys:
graded_sections - This contains the sections that are graded, as
well as all possible children modules that can affect the
grading. This allows some sections to be skipped if the student
hasn't seen any part of it.
The format is a dictionary keyed by section-type. The values are
arrays of dictionaries containing
"section_descriptor" : The section descriptor
"xmoduledescriptors" : An array of xmoduledescriptors that
could possibly be in the section, for any student
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
the descriptor tree again.
"""
all_descriptors = []
graded_sections = {}
def yield_descriptor_descendents(module_descriptor):
for child in module_descriptor.get_children():
yield child
for module_descriptor in yield_descriptor_descendents(child):
yield module_descriptor
for c in self.get_children():
sections = []
for s in c.get_children():
if s.metadata.get('graded', False):
# TODO: Only include modules that have a score here
xmoduledescriptors = [child for child in yield_descriptor_descendents(s)]
section_description = { 'section_descriptor' : s, 'xmoduledescriptors' : xmoduledescriptors}
section_format = s.metadata.get('format', "")
graded_sections[ section_format ] = graded_sections.get( section_format, [] ) + [section_description]
all_descriptors.extend(xmoduledescriptors)
all_descriptors.append(s)
return { 'graded_sections' : graded_sections,
'all_descriptors' : all_descriptors,}
@staticmethod
def id_to_location(course_id):
'''Convert the given course_id (org/course/name) to a location object.
......
......@@ -207,7 +207,7 @@ div.video {
h3 {
color: #999;
float: left;
font-size: 12px;
font-size: em(14);
font-weight: normal;
letter-spacing: 1px;
padding: 0 lh(.25) 0 lh(.5);
......@@ -221,6 +221,7 @@ div.video {
margin-bottom: 0;
padding: 0 lh(.5) 0 0;
line-height: 46px;
color: #fff;
}
&:hover, &:active, &:focus {
......
import sys
import hashlib
import logging
import random
import string
import sys
from pkg_resources import resource_string
from lxml import etree
......@@ -35,7 +38,8 @@ class ErrorDescriptor(EditingDescriptor):
error_msg='Error not available'):
'''Create an instance of this descriptor from the supplied data.
Does not try to parse the data--just stores it.
Does not require that xml_data be parseable--just stores it and exports
as-is if not.
Takes an extra, optional, parameter--the error that caused an
issue. (should be a string, or convert usefully into one).
......@@ -45,6 +49,13 @@ class ErrorDescriptor(EditingDescriptor):
definition = {'data': inner}
inner['error_msg'] = str(error_msg)
# Pick a unique url_name -- the sha1 hash of the xml_data.
# NOTE: We could try to pull out the url_name of the errored descriptor,
# but url_names aren't guaranteed to be unique between descriptor types,
# and ErrorDescriptor can wrap any type. When the wrapped module is fixed,
# it will be written out with the original url_name.
url_name = hashlib.sha1(xml_data).hexdigest()
try:
# If this is already an error tag, don't want to re-wrap it.
xml_obj = etree.fromstring(xml_data)
......@@ -63,7 +74,7 @@ class ErrorDescriptor(EditingDescriptor):
inner['contents'] = xml_data
# TODO (vshnayder): Do we need a unique slug here? Just pick a random
# 64-bit num?
location = ['i4x', org, course, 'error', 'slug']
location = ['i4x', org, course, 'error', url_name]
metadata = {} # stays in the xml_data
return cls(system, definition, location=location, metadata=metadata)
......
......@@ -13,6 +13,7 @@ from .html_checker import check_html
log = logging.getLogger("mitx.courseware")
class HtmlModule(XModule):
def get_html(self):
return self.html
......@@ -36,18 +37,19 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
# are being edited in the cms
@classmethod
def backcompat_paths(cls, path):
origpath = path
if path.endswith('.html.xml'):
path = path[:-9] + '.html' #backcompat--look for html instead of xml
path = path[:-9] + '.html' # backcompat--look for html instead of xml
candidates = []
while os.sep in path:
candidates.append(path)
_, _, path = path.partition(os.sep)
# also look for .html versions instead of .xml
if origpath.endswith('.xml'):
candidates.append(origpath[:-4] + '.html')
return candidates
nc = []
for candidate in candidates:
if candidate.endswith('.xml'):
nc.append(candidate[:-4] + '.html')
return candidates + nc
# NOTE: html descriptors are special. We do not want to parse and
# export them ourselves, because that can break things (e.g. lxml
......@@ -69,7 +71,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:
filepath = cls._format_filepath(xml_object.tag, filename)
......@@ -80,7 +82,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
# online and has imported all current (fall 2012) courses from xml
if not system.resources_fs.exists(filepath):
candidates = cls.backcompat_paths(filepath)
#log.debug("candidates = {0}".format(candidates))
log.debug("candidates = {0}".format(candidates))
for candidate in candidates:
if system.resources_fs.exists(candidate):
filepath = candidate
......@@ -95,7 +97,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
log.warning(msg)
system.error_tracker("Warning: " + msg)
definition = {'data' : html}
definition = {'data': html}
# TODO (ichuang): remove this after migration
# for Fall 2012 LMS migration: keep filename (and unmangled filename)
......@@ -109,17 +111,11 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
# add more info and re-raise
raise Exception(msg), None, sys.exc_info()[2]
@classmethod
def split_to_file(cls, xml_object):
'''Never include inline html'''
return True
# TODO (vshnayder): make export put things in the right places.
def definition_to_xml(self, resource_fs):
'''If the contents are valid xml, write them to filename.xml. Otherwise,
write just the <html filename=""> tag to filename.xml, and the html
write just <html filename="" [meta-attrs="..."]> to filename.xml, and the html
string to filename.html.
'''
try:
......@@ -138,4 +134,3 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
elt = etree.Element('html')
elt.set("filename", self.url_name)
return elt
......@@ -188,21 +188,26 @@ class XMLModuleStore(ModuleStoreBase):
course_file = StringIO(clean_out_mako_templating(course_file.read()))
course_data = etree.parse(course_file).getroot()
org = course_data.get('org')
if org is None:
log.error("No 'org' attribute set for course in {dir}. "
msg = ("No 'org' attribute set for course in {dir}. "
"Using default 'edx'".format(dir=course_dir))
log.error(msg)
tracker(msg)
org = 'edx'
course = course_data.get('course')
if course is None:
log.error("No 'course' attribute set for course in {dir}."
msg = ("No 'course' attribute set for course in {dir}."
" Using default '{default}'".format(
dir=course_dir,
default=course_dir
))
log.error(msg)
tracker(msg)
course = course_dir
system = ImportSystem(self, org, course, course_dir, tracker)
......
......@@ -122,16 +122,3 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor):
etree.fromstring(child.export_to_xml(resource_fs)))
return xml_object
@classmethod
def split_to_file(cls, xml_object):
# Note: if we end up needing subclasses, can port this logic there.
yes = ('chapter',)
no = ('course',)
if xml_object.tag in yes:
return True
elif xml_object.tag in no:
return False
# otherwise maybe--delegate to superclass.
return XmlDescriptor.split_to_file(xml_object)
......@@ -15,6 +15,7 @@ import xmodule
import capa.calc as calc
import capa.capa_problem as lcp
from capa.correctmap import CorrectMap
from capa.util import convert_files_to_filenames
from xmodule import graders, x_module
from xmodule.x_module import ModuleSystem
from xmodule.graders import Score, aggregate_scores
......@@ -31,7 +32,7 @@ i4xs = ModuleSystem(
user=Mock(),
filestore=fs.osfs.OSFS(os.path.dirname(os.path.realpath(__file__))),
debug=True,
xqueue_callback_url='/',
xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue'},
is_staff=False
)
......@@ -278,7 +279,6 @@ class StringResponseWithHintTest(unittest.TestCase):
class CodeResponseTest(unittest.TestCase):
'''
Test CodeResponse
'''
def test_update_score(self):
problem_file = os.path.dirname(__file__) + "/test_files/coderesponse.xml"
......@@ -327,7 +327,18 @@ class CodeResponseTest(unittest.TestCase):
self.assertFalse(test_lcp.correct_map.is_queued(answer_ids[j])) # Should be dequeued, message delivered
else:
self.assertTrue(test_lcp.correct_map.is_queued(answer_ids[j])) # Should be queued, message undelivered
def test_convert_files_to_filenames(self):
problem_file = os.path.dirname(__file__) + "/test_files/coderesponse.xml"
fp = open(problem_file)
answers_with_file = {'1_2_1': 'String-based answer',
'1_3_1': ['answer1', 'answer2', 'answer3'],
'1_4_1': fp}
answers_converted = convert_files_to_filenames(answers_with_file)
self.assertEquals(answers_converted['1_2_1'], 'String-based answer')
self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3'])
self.assertEquals(answers_converted['1_4_1'], fp.name)
class ChoiceResponseTest(unittest.TestCase):
......
from xmodule.modulestore.xml import XMLModuleStore
from nose.tools import assert_equals
from nose import SkipTest
from tempfile import mkdtemp
import unittest
from fs.osfs import OSFS
from nose.tools import assert_equals, assert_true
from path import path
from tempfile import mkdtemp
from xmodule.modulestore.xml import XMLModuleStore
# from ~/mitx_all/mitx/common/lib/xmodule/xmodule/tests/
# to ~/mitx_all/mitx/common/test
TEST_DIR = path(__file__).abspath().dirname()
for i in range(4):
TEST_DIR = TEST_DIR.dirname()
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)
for d in descriptor.get_children():
strip_filenames(d)
class RoundTripTestCase(unittest.TestCase):
'''Check that our test courses roundtrip properly'''
def check_export_roundtrip(self, data_dir, course_dir):
print "Starting import"
initial_import = XMLModuleStore(data_dir, eager=True, course_dirs=[course_dir])
courses = initial_import.get_courses()
self.assertEquals(len(courses), 1)
initial_course = courses[0]
print "Starting export"
export_dir = mkdtemp()
print "export_dir: {0}".format(export_dir)
fs = OSFS(export_dir)
export_course_dir = 'export'
export_fs = fs.makeopendir(export_course_dir)
xml = initial_course.export_to_xml(export_fs)
with export_fs.open('course.xml', 'w') as course_xml:
course_xml.write(xml)
print "Starting second import"
second_import = XMLModuleStore(export_dir, eager=True,
course_dirs=[export_course_dir])
courses2 = second_import.get_courses()
self.assertEquals(len(courses2), 1)
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.
strip_filenames(initial_course)
strip_filenames(exported_course)
self.assertEquals(initial_course, exported_course)
def check_export_roundtrip(data_dir):
print "Starting import"
initial_import = XMLModuleStore('org', 'course', data_dir, eager=True)
initial_course = initial_import.course
print "Checking key equality"
self.assertEquals(sorted(initial_import.modules.keys()),
sorted(second_import.modules.keys()))
print "Starting export"
export_dir = mkdtemp()
fs = OSFS(export_dir)
xml = initial_course.export_to_xml(fs)
with fs.open('course.xml', 'w') as course_xml:
course_xml.write(xml)
print "Checking module equality"
for location in initial_import.modules.keys():
print "Checking", location
if location.category == 'html':
print ("Skipping html modules--they can't import in"
" final form without writing files...")
continue
self.assertEquals(initial_import.modules[location],
second_import.modules[location])
print "Starting second import"
second_import = XMLModuleStore('org', 'course', export_dir, eager=True)
print "Checking key equality"
assert_equals(initial_import.modules.keys(), second_import.modules.keys())
def setUp(self):
self.maxDiff = None
print "Checking module equality"
for location in initial_import.modules.keys():
print "Checking", location
assert_equals(initial_import.modules[location], second_import.modules[location])
def test_toy_roundtrip(self):
self.check_export_roundtrip(DATA_DIR, "toy")
def test_simple_roundtrip(self):
self.check_export_roundtrip(DATA_DIR, "simple")
def test_toy_roundtrip():
dir = ""
# TODO: add paths and make this run.
raise SkipTest()
check_export_roundtrip(dir)
def test_full_roundtrip(self):
self.check_export_roundtrip(DATA_DIR, "full")
......@@ -5,6 +5,7 @@ from fs.memoryfs import MemoryFS
from lxml import etree
from xmodule.x_module import XMLParsingSystem, XModuleDescriptor
from xmodule.xml_module import is_pointer_tag
from xmodule.errortracker import make_error_tracker
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import ItemNotFoundError
......@@ -46,22 +47,17 @@ class DummySystem(XMLParsingSystem):
raise Exception("Shouldn't be called")
class ImportTestCase(unittest.TestCase):
'''Make sure module imports work properly, including for malformed inputs'''
@staticmethod
def get_system():
'''Get a dummy system'''
return DummySystem()
def test_fallback(self):
'''Make sure that malformed xml loads as an ErrorDescriptor.'''
'''Check that malformed xml loads as an ErrorDescriptor.'''
bad_xml = '''<sequential display_name="oops"><video url="hi"></sequential>'''
system = self.get_system()
descriptor = XModuleDescriptor.load_from_xml(bad_xml, system, 'org', 'course',
......@@ -70,6 +66,22 @@ class ImportTestCase(unittest.TestCase):
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>'''
bad_xml2 = '''<sequential url_name="oops"><video url="hi"></sequential>'''
system = self.get_system()
descriptor1 = XModuleDescriptor.load_from_xml(bad_xml, system, 'org',
'course', None)
descriptor2 = XModuleDescriptor.load_from_xml(bad_xml2, system, 'org',
'course', None)
self.assertNotEqual(descriptor1.location, descriptor2.location)
def test_reimport(self):
'''Make sure an already-exported error xml tag loads properly'''
......@@ -111,30 +123,65 @@ class ImportTestCase(unittest.TestCase):
xml_out = etree.fromstring(xml_str_out)
self.assertEqual(xml_out.tag, 'sequential')
def test_metadata_inherit(self):
"""Make sure metadata inherits properly"""
def test_metadata_import_export(self):
"""Two checks:
- unknown metadata is preserved across import-export
- inherited metadata doesn't leak to children.
"""
system = self.get_system()
v = "1 hour"
start_xml = '''<course graceperiod="{grace}" url_name="test1" display_name="myseq">
<chapter url="hi" url_name="ch" display_name="CH">
<html url_name="h" display_name="H">Two houses, ...</html></chapter>
</course>'''.format(grace=v)
v = '1 hour'
org = 'foo'
course = 'bbhh'
url_name = 'test1'
start_xml = '''
<course org="{org}" course="{course}"
graceperiod="{grace}" 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)
descriptor = XModuleDescriptor.load_from_xml(start_xml, system,
'org', 'course')
org, course)
print "Errors: {0}".format(system.errorlog.errors)
print descriptor, descriptor.metadata
self.assertEqual(descriptor.metadata['graceperiod'], v)
self.assertEqual(descriptor.metadata['unicorn'], 'purple')
# Check that the child inherits correctly
# Check that the child inherits graceperiod correctly
child = descriptor.get_children()[0]
self.assertEqual(child.metadata['graceperiod'], v)
# Now export and see if the chapter tag has a graceperiod attribute
# check that the child does _not_ inherit any unicorns
self.assertTrue('unicorn' not in child.metadata)
# Now export and check things
resource_fs = MemoryFS()
exported_xml = descriptor.export_to_xml(resource_fs)
# Check that the exported xml is just a pointer
print "Exported xml:", exported_xml
root = etree.fromstring(exported_xml)
chapter_tag = root[0]
self.assertEqual(chapter_tag.tag, 'chapter')
self.assertFalse('graceperiod' in chapter_tag.attrib)
pointer = etree.fromstring(exported_xml)
self.assertTrue(is_pointer_tag(pointer))
# but it's a special case course pointer
self.assertEqual(pointer.attrib['course'], course)
self.assertEqual(pointer.attrib['org'], org)
# Does the course still have unicorns?
with resource_fs.open('course/{url_name}.xml'.format(url_name=url_name)) as f:
course_xml = etree.fromstring(f.read())
self.assertEqual(course_xml.attrib['unicorn'], 'purple')
# the course and org tags should be _only_ in the pointer
self.assertTrue('course' not in course_xml.attrib)
self.assertTrue('org' not in course_xml.attrib)
# 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?
# 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)
def lazyproperty(fn):
"""
Use this decorator for lazy generation of properties that
are expensive to compute. From http://stackoverflow.com/a/3013910/86828
Example:
class Test(object):
@lazyproperty
def a(self):
print 'generating "a"'
return range(5)
Interactive Session:
>>> t = Test()
>>> t.__dict__
{}
>>> t.a
generating "a"
[0, 1, 2, 3, 4]
>>> t.__dict__
{'_lazy_a': [0, 1, 2, 3, 4]}
>>> t.a
[0, 1, 2, 3, 4]
"""
attr_name = '_lazy_' + fn.__name__
@property
def _lazyprop(self):
if not hasattr(self, attr_name):
setattr(self, attr_name, fn(self))
return getattr(self, attr_name)
return _lazyprop
\ No newline at end of file
......@@ -6,6 +6,7 @@ from fs.errors import ResourceNotFoundError
from functools import partial
from lxml import etree
from lxml.etree import XMLSyntaxError
from pprint import pprint
from xmodule.modulestore import Location
from xmodule.errortracker import exc_info_to_str
......@@ -550,9 +551,9 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
if not eq:
for attr in self.equality_attributes:
print(getattr(self, attr, None),
getattr(other, attr, None),
getattr(self, attr, None) == getattr(other, attr, None))
pprint((getattr(self, attr, None),
getattr(other, attr, None),
getattr(self, attr, None) == getattr(other, attr, None)))
return eq
......@@ -643,7 +644,7 @@ class ModuleSystem(object):
user=None,
filestore=None,
debug=False,
xqueue = None,
xqueue=None,
is_staff=False):
'''
Create a closure around the system environment.
......
......@@ -2,7 +2,7 @@
<chapter name="Overview">
<video name="Welcome" youtube="0.75:izygArpw-Qo,1.0:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8"/>
<videosequence format="Lecture Sequence" name="A simple sequence">
<html id="toylab" filename="toylab"/>
<html name="toylab" filename="toylab"/>
<video name="S0V1: Video Resources" youtube="0.75:EuzkdzfR0i8,1.0:1bK-WdDi6Qw,1.25:0v1VzoDVUTM,1.50:Bxk_-ZJb240"/>
</videosequence>
<section name="Lecture 2">
......@@ -15,7 +15,7 @@
<chapter name="Chapter 2">
<section name="Problem Set 1">
<sequential>
<problem type="lecture" showanswer="attempted" rerandomize="true" title="A simple coding problem" name="Simple coding problem" filename="ps01-simple"/>
<problem type="lecture" showanswer="attempted" rerandomize="true" display_name="A simple coding problem" name="Simple coding problem" filename="ps01-simple"/>
</sequential>
</section>
<video name="Lost Video" youtube="1.0:TBvX7HzxexQ"/>
......
......@@ -28,3 +28,18 @@ Check out the course data directories that you want to work with into the
Replace `../data` with your `GITHUB_REPO_ROOT` if it's not the default value.
This will import all courses in your data directory into mongodb
## Unit tests
This runs all the tests (long, uses collectstatic):
rake test
xmodule can be tested independently, with this:
rake test_common/lib/xmodule
To see all available rake commands, do this:
rake -T
\ No newline at end of file
......@@ -52,7 +52,7 @@ def certificate_request(request):
return return_error(survey_response['error'])
grade = None
student_gradesheet = grades.grade_sheet(request.user)
student_gradesheet = grades.grade(request.user, request, course)
grade = student_gradesheet['grade']
if not grade:
......@@ -65,7 +65,7 @@ def certificate_request(request):
else:
#This is not a POST, we should render the page with the form
grade_sheet = grades.grade_sheet(request.user)
student_gradesheet = grades.grade(request.user, request, course)
certificate_state = certificate_state_for_student(request.user, grade_sheet['grade'])
if certificate_state['state'] != "requestable":
......
......@@ -46,12 +46,15 @@ def check_course(course_id, course_must_be_open=True, course_required=True):
def course_image_url(course):
return staticfiles_storage.url(course.metadata['data_dir'] + "/images/course_image.jpg")
return staticfiles_storage.url(course.metadata['data_dir'] +
"/images/course_image.jpg")
def get_course_about_section(course, section_key):
"""
This returns the snippet of html to be rendered on the course about page, given the key for the section.
This returns the snippet of html to be rendered on the course about page,
given the key for the section.
Valid keys:
- overview
- title
......@@ -70,18 +73,23 @@ def get_course_about_section(course, section_key):
- more_info
"""
# Many of these are stored as html files instead of some semantic markup. This can change without effecting
# this interface when we find a good format for defining so many snippets of text/html.
# Many of these are stored as html files instead of some semantic
# markup. This can change without effecting this interface when we find a
# good format for defining so many snippets of text/html.
# TODO: Remove number, instructors from this list
if section_key in ['short_description', 'description', 'key_dates', 'video', 'course_staff_short', 'course_staff_extended',
'requirements', 'syllabus', 'textbook', 'faq', 'more_info', 'number', 'instructors', 'overview',
'effort', 'end_date', 'prerequisites']:
if section_key in ['short_description', 'description', 'key_dates', 'video',
'course_staff_short', 'course_staff_extended',
'requirements', 'syllabus', 'textbook', 'faq', 'more_info',
'number', 'instructors', 'overview',
'effort', 'end_date', 'prerequisites']:
try:
with course.system.resources_fs.open(path("about") / section_key + ".html") as htmlFile:
return replace_urls(htmlFile.read().decode('utf-8'), course.metadata['data_dir'])
return replace_urls(htmlFile.read().decode('utf-8'),
course.metadata['data_dir'])
except ResourceNotFoundError:
log.warning("Missing about section {key} in course {url}".format(key=section_key, url=course.location.url()))
log.warning("Missing about section {key} in course {url}".format(
key=section_key, url=course.location.url()))
return None
elif section_key == "title":
return course.metadata.get('display_name', course.url_name)
......@@ -95,7 +103,9 @@ def get_course_about_section(course, section_key):
def get_course_info_section(course, section_key):
"""
This returns the snippet of html to be rendered on the course info page, given the key for the section.
This returns the snippet of html to be rendered on the course info page,
given the key for the section.
Valid keys:
- handouts
- guest_handouts
......@@ -103,43 +113,51 @@ def get_course_info_section(course, section_key):
- guest_updates
"""
# Many of these are stored as html files instead of some semantic markup. This can change without effecting
# this interface when we find a good format for defining so many snippets of text/html.
# Many of these are stored as html files instead of some semantic
# markup. This can change without effecting this interface when we find a
# good format for defining so many snippets of text/html.
if section_key in ['handouts', 'guest_handouts', 'updates', 'guest_updates']:
try:
with course.system.resources_fs.open(path("info") / section_key + ".html") as htmlFile:
return replace_urls(htmlFile.read().decode('utf-8'), course.metadata['data_dir'])
return replace_urls(htmlFile.read().decode('utf-8'),
course.metadata['data_dir'])
except ResourceNotFoundError:
log.exception("Missing info section {key} in course {url}".format(key=section_key, url=course.location.url()))
log.exception("Missing info section {key} in course {url}".format(
key=section_key, url=course.location.url()))
return "! Info section missing !"
raise KeyError("Invalid about key " + str(section_key))
def course_staff_group_name(course):
'''
course should be either a CourseDescriptor instance, or a string (the .course entry of a Location)
course should be either a CourseDescriptor instance, or a string (the
.course entry of a Location)
'''
if isinstance(course,str):
if isinstance(course, str) or isinstance(course, unicode):
coursename = course
else:
coursename = course.metadata.get('data_dir','UnknownCourseName')
if not coursename: # Fall 2012: not all course.xml have metadata correct yet
coursename = course.metadata.get('course','')
# should be a CourseDescriptor, so grab its location.course:
coursename = course.location.course
return 'staff_%s' % coursename
def has_staff_access_to_course(user,course):
def has_staff_access_to_course(user, course):
'''
Returns True if the given user has staff access to the course.
This means that user is in the staff_* group, or is an overall admin.
course is the course field of the location being accessed.
'''
if user is None or (not user.is_authenticated()) or course is None:
return False
if user.is_staff:
return True
user_groups = [x[1] for x in user.groups.values_list()] # note this is the Auth group, not UserTestGroup
# note this is the Auth group, not UserTestGroup
user_groups = [x[1] for x in user.groups.values_list()]
staff_group = course_staff_group_name(course)
log.debug('course %s user %s groups %s' % (staff_group, user, user_groups))
log.debug('course %s, staff_group %s, user %s, groups %s' % (
course, staff_group, user, user_groups))
if staff_group in user_groups:
return True
return False
......@@ -154,7 +172,8 @@ def get_courses_by_university(user):
Returns dict of lists of courses available, keyed by course.org (ie university).
Courses are sorted by course.number.
if ACCESS_REQUIRE_STAFF_FOR_COURSE then list only includes those accessible to user.
if ACCESS_REQUIRE_STAFF_FOR_COURSE then list only includes those accessible
to user.
'''
# TODO: Clean up how 'error' is done.
# filter out any courses that errored.
......@@ -163,9 +182,9 @@ def get_courses_by_university(user):
courses = sorted(courses, key=lambda course: course.number)
universities = defaultdict(list)
for course in courses:
if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'):
if settings.MITX_FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'):
if not has_access_to_course(user,course):
continue
universities[course.org].append(course)
return universities
......@@ -78,8 +78,8 @@ class Command(BaseCommand):
# TODO (cpennington): Get coursename in a legitimate way
course_location = 'i4x://edx/6002xs12/course/6.002_Spring_2012'
student_module_cache = StudentModuleCache(sample_user, modulestore().get_item(course_location))
(course, _, _, _) = get_module(sample_user, None, course_location, student_module_cache)
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(sample_user, modulestore().get_item(course_location))
course = get_module(sample_user, None, course_location, student_module_cache)
to_run = [
#TODO (vshnayder) : make check_rendering work (use module_render.py),
......
......@@ -67,17 +67,19 @@ class StudentModuleCache(object):
"""
A cache of StudentModules for a specific student
"""
def __init__(self, user, descriptor, depth=None):
def __init__(self, user, descriptors):
'''
Find any StudentModule objects that are needed by any child modules of the
supplied descriptor. Avoids making multiple queries to the database
descriptor: An XModuleDescriptor
depth is the number of levels of descendent modules to load StudentModules for, in addition to
the supplied descriptor. If depth is None, load all descendent StudentModules
supplied descriptor, or caches only the StudentModule objects specifically
for every descriptor in descriptors. Avoids making multiple queries to the
database.
Arguments
user: The user for which to fetch maching StudentModules
descriptors: An array of XModuleDescriptors.
'''
if user.is_authenticated():
module_ids = self._get_module_state_keys(descriptor, depth)
module_ids = self._get_module_state_keys(descriptors)
# This works around a limitation in sqlite3 on the number of parameters
# that can be put into a single query
......@@ -91,27 +93,52 @@ class StudentModuleCache(object):
else:
self.cache = []
def _get_module_state_keys(self, descriptor, depth):
'''
Get a list of the state_keys needed for StudentModules
required for this module descriptor
@classmethod
def cache_for_descriptor_descendents(cls, user, descriptor, depth=None, descriptor_filter=lambda descriptor: True):
"""
descriptor: An XModuleDescriptor
depth is the number of levels of descendent modules to load StudentModules for, in addition to
the supplied descriptor. If depth is None, load all descendent StudentModules
descriptor_filter is a function that accepts a descriptor and return wether the StudentModule
should be cached
"""
def get_child_descriptors(descriptor, depth, descriptor_filter):
if descriptor_filter(descriptor):
descriptors = [descriptor]
else:
descriptors = []
if depth is None or depth > 0:
new_depth = depth - 1 if depth is not None else depth
for child in descriptor.get_children():
descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter))
return descriptors
descriptors = get_child_descriptors(descriptor, depth, descriptor_filter)
return StudentModuleCache(user, descriptors)
def _get_module_state_keys(self, descriptors):
'''
keys = [descriptor.location.url()]
shared_state_key = getattr(descriptor, 'shared_state_key', None)
if shared_state_key is not None:
keys.append(shared_state_key)
if depth is None or depth > 0:
new_depth = depth - 1 if depth is not None else depth
for child in descriptor.get_children():
keys.extend(self._get_module_state_keys(child, new_depth))
Get a list of the state_keys needed for StudentModules
required for this module descriptor
descriptor_filter is a function that accepts a descriptor and return wether the StudentModule
should be cached
'''
keys = []
for descriptor in descriptors:
keys.append(descriptor.location.url())
shared_state_key = getattr(descriptor, 'shared_state_key', None)
if shared_state_key is not None:
keys.append(shared_state_key)
return keys
......
......@@ -50,9 +50,9 @@ def toc_for_course(user, request, course, active_chapter, active_section):
chapters with name 'hidden' are skipped.
'''
student_module_cache = StudentModuleCache(user, course, depth=2)
(course, _, _, _) = get_module(user, request, course.location, student_module_cache)
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(user, course, depth=2)
course = get_module(user, request, course.location, student_module_cache)
chapters = list()
for chapter in course.get_display_items():
......@@ -121,25 +121,26 @@ def get_module(user, request, location, student_module_cache, position=None):
- position : extra information from URL for user-specified
position within module
Returns:
- a tuple (xmodule instance, instance_module, shared_module, module category).
instance_module is a StudentModule specific to this module for this student,
or None if this is an anonymous user
shared_module is a StudentModule specific to all modules with the same
'shared_state_key' attribute, or None if the module does not elect to
share state
Returns: xmodule instance
'''
descriptor = modulestore().get_item(location)
instance_module = student_module_cache.lookup(descriptor.category,
descriptor.location.url())
shared_state_key = getattr(descriptor, 'shared_state_key', None)
if shared_state_key is not None:
shared_module = student_module_cache.lookup(descriptor.category,
shared_state_key)
#TODO Only check the cache if this module can possibly have state
if user.is_authenticated():
instance_module = student_module_cache.lookup(descriptor.category,
descriptor.location.url())
shared_state_key = getattr(descriptor, 'shared_state_key', None)
if shared_state_key is not None:
shared_module = student_module_cache.lookup(descriptor.category,
shared_state_key)
else:
shared_module = None
else:
instance_module = None
shared_module = None
instance_state = instance_module.state if instance_module is not None else {}
shared_state = shared_module.state if shared_module is not None else None
......@@ -163,9 +164,8 @@ def get_module(user, request, location, student_module_cache, position=None):
'default_queuename': xqueue_default_queuename.replace(' ','_') }
def _get_module(location):
(module, _, _, _) = get_module(user, request, location,
return get_module(user, request, location,
student_module_cache, position)
return module
# TODO (cpennington): When modules are shared between courses, the static
# prefix is going to have to be specific to the module, not the directory
......@@ -198,31 +198,59 @@ def get_module(user, request, location, student_module_cache, position=None):
if has_staff_access_to_course(user, module.location.course):
module.get_html = add_histogram(module.get_html, module)
# If StudentModule for this instance wasn't already in the database,
# and this isn't a guest user, create it.
return module
def get_instance_module(user, module, student_module_cache):
"""
Returns instance_module is a StudentModule specific to this module for this student,
or None if this is an anonymous user
"""
if user.is_authenticated():
instance_module = student_module_cache.lookup(module.category,
module.location.url())
if not instance_module:
instance_module = StudentModule(
student=user,
module_type=descriptor.category,
module_type=module.category,
module_state_key=module.id,
state=module.get_instance_state(),
max_grade=module.max_score())
instance_module.save()
# Add to cache. The caller and the system context have references
# to it, so the change persists past the return
student_module_cache.append(instance_module)
if not shared_module and shared_state_key is not None:
shared_module = StudentModule(
student=user,
module_type=descriptor.category,
module_state_key=shared_state_key,
state=module.get_shared_state())
shared_module.save()
student_module_cache.append(shared_module)
return (module, instance_module, shared_module, descriptor.category)
return instance_module
else:
return None
def get_shared_instance_module(user, module, student_module_cache):
"""
Return shared_module is a StudentModule specific to all modules with the same
'shared_state_key' attribute, or None if the module does not elect to
share state
"""
if user.is_authenticated():
# To get the shared_state_key, we need to descriptor
descriptor = modulestore().get_item(module.location)
shared_state_key = getattr(module, 'shared_state_key', None)
if shared_state_key is not None:
shared_module = student_module_cache.lookup(module.category,
shared_state_key)
if not shared_module:
shared_module = StudentModule(
student=user,
module_type=descriptor.category,
module_state_key=shared_state_key,
state=module.get_shared_state())
shared_module.save()
student_module_cache.append(shared_module)
else:
shared_module = None
return shared_module
else:
return None
@csrf_exempt
def xqueue_callback(request, userid, id, dispatch):
......@@ -240,12 +268,13 @@ def xqueue_callback(request, userid, id, dispatch):
# Retrieve target StudentModule
user = User.objects.get(id=userid)
student_module_cache = StudentModuleCache(user, modulestore().get_item(id))
instance, instance_module, shared_module, module_type = get_module(request.user, request, id, student_module_cache)
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(user, modulestore().get_item(id))
instance = get_module(user, request, id, student_module_cache)
instance_module = get_instance_module(user, instance, student_module_cache)
if instance_module is None:
log.debug("Couldn't find module '%s' for user '%s'",
id, request.user)
id, user)
raise Http404
oldgrade = instance_module.grade
......@@ -285,16 +314,18 @@ def modx_dispatch(request, dispatch=None, id=None):
- id -- the module id. Used to look up the XModule instance
'''
# ''' (fix emacs broken parsing)
# Check for submitted files
p = request.POST.copy()
if request.FILES:
for inputfile_id in request.FILES.keys():
p[inputfile_id] = request.FILES[inputfile_id]
student_module_cache = StudentModuleCache(request.user, modulestore().get_item(id))
instance, instance_module, shared_module, module_type = get_module(request.user, request, id, student_module_cache)
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, modulestore().get_item(id))
instance = get_module(request.user, request, id, student_module_cache)
instance_module = get_instance_module(request.user, instance, student_module_cache)
shared_module = get_shared_instance_module(request.user, instance, student_module_cache)
# Don't track state for anonymous users (who don't have student modules)
if instance_module is not None:
oldgrade = instance_module.grade
......
......@@ -135,10 +135,25 @@ class ActivateLoginTestCase(TestCase):
class PageLoader(ActivateLoginTestCase):
''' Base class that adds a function to load all pages in a modulestore '''
def enroll(self, course):
resp = self.client.post('/change_enrollment', {
'enrollment_action': 'enroll',
'course_id': course.id,
})
data = parse_json(resp)
self.assertTrue(data['success'])
def check_pages_load(self, course_name, data_dir, modstore):
print "Checking course {0} in {1}".format(course_name, data_dir)
import_from_xml(modstore, data_dir, [course_name])
# enroll in the course before trying to access pages
courses = modstore.get_courses()
self.assertEqual(len(courses), 1)
course = courses[0]
self.enroll(course)
n = 0
num_bad = 0
all_ok = True
......
......@@ -48,7 +48,6 @@ log = logging.getLogger("mitx.courseware")
template_imports = {'urllib': urllib}
def user_groups(user):
if not user.is_authenticated():
return []
......@@ -59,6 +58,8 @@ def user_groups(user):
# Kill caching on dev machines -- we switch groups a lot
group_names = cache.get(key)
if settings.DEBUG:
group_names = None
if group_names is None:
group_names = [u.name for u in UserTestGroup.objects.filter(users=user)]
......@@ -82,18 +83,17 @@ def gradebook(request, course_id):
if 'course_admin' not in user_groups(request.user):
raise Http404
course = check_course(course_id)
student_objects = User.objects.all()[:100]
student_info = []
for student in student_objects:
student_module_cache = StudentModuleCache(student, course)
course, _, _, _ = get_module(request.user, request, course.location, student_module_cache)
#TODO: Only select students who are in the course
for student in student_objects:
student_info.append({
'username': student.username,
'id': student.id,
'email': student.email,
'grade_info': grades.grade_sheet(student, course, student_module_cache),
'grade_summary': grades.grade(student, request, course),
'realname': UserProfile.objects.get(user=student).name
})
......@@ -116,18 +116,23 @@ def profile(request, course_id, student_id=None):
user_info = UserProfile.objects.get(user=student)
student_module_cache = StudentModuleCache(request.user, course)
course_module, _, _, _ = get_module(request.user, request, course.location, student_module_cache)
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, course)
course_module = get_module(request.user, request, course.location, student_module_cache)
courseware_summary = grades.progress_summary(student, course_module, course.grader, student_module_cache)
grade_summary = grades.grade(request.user, request, course, student_module_cache)
context = {'name': user_info.name,
'username': student.username,
'location': user_info.location,
'language': user_info.language,
'email': student.email,
'course': course,
'csrf': csrf(request)['csrf_token']
'csrf': csrf(request)['csrf_token'],
'courseware_summary' : courseware_summary,
'grade_summary' : grade_summary
}
context.update(grades.grade_sheet(student, course_module, course.grader, student_module_cache))
context.update()
return render_to_response('profile.html', context)
......@@ -198,11 +203,12 @@ def index(request, course_id, chapter=None, section=None,
if look_for_module:
section_descriptor = get_section(course, chapter, section)
if section_descriptor is not None:
student_module_cache = StudentModuleCache(request.user,
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
request.user,
section_descriptor)
module, _, _, _ = get_module(request.user, request,
section_descriptor.location,
student_module_cache)
module = get_module(request.user, request,
section_descriptor.location,
student_module_cache)
context['content'] = module.get_html()
else:
log.warning("Couldn't find a section descriptor for course_id '{0}',"
......
......@@ -158,6 +158,9 @@ COURSE_SETTINGS = {'6.002x_Fall_2012': {'number' : '6.002x',
}
}
# IP addresses that are allowed to reload the course, etc.
# TODO (vshnayder): Will probably need to change as we get real access control in.
LMS_MIGRATION_ALLOWED_IPS = []
############################### XModule Store ##################################
MODULESTORE = {
......@@ -171,6 +174,9 @@ MODULESTORE = {
}
}
############################ SIGNAL HANDLERS ################################
# This is imported to register the exception signal handling that logs exceptions
import monitoring.exceptions # noqa
############################### DJANGO BUILT-INS ###############################
# Change DEBUG/TEMPLATE_DEBUG in your environment settings files, not here
......@@ -283,7 +289,6 @@ TEMPLATE_LOADERS = (
)
MIDDLEWARE_CLASSES = (
'util.middleware.ExceptionLoggingMiddleware',
'django_comment_client.middleware.AjaxExceptionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
......
......@@ -16,3 +16,8 @@ MITX_FEATURES['ENABLE_TEXTBOOK'] = False
MITX_FEATURES['ENABLE_DISCUSSION'] = False
MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = True # require that user be in the staff_* group to be able to enroll
#-----------------------------------------------------------------------------
# disable django debug toolbars
INSTALLED_APPS = tuple([ app for app in INSTALLED_APPS if not app.startswith('debug_toolbar') ])
MIDDLEWARE_CLASSES = tuple([ mcl for mcl in MIDDLEWARE_CLASSES if not mcl.startswith('debug_toolbar') ])
......@@ -20,7 +20,7 @@ div.info-wrapper {
> li {
@extend .clearfix;
border-bottom: 1px solid #e3e3e3;
border-bottom: 1px solid lighten($border-color, 10%);
margin-bottom: lh();
padding-bottom: lh(.5);
list-style-type: disk;
......@@ -76,42 +76,29 @@ div.info-wrapper {
h1 {
@extend .bottom-border;
padding: lh(.5) lh(.5);
}
header {
// h1 {
// font-weight: 100;
// font-style: italic;
// }
p {
color: #666;
font-size: 12px;
margin-bottom: 0;
margin-top: 4px;
}
margin-bottom: 0;
}
ol {
background: none;
list-style: none;
padding-left: 0;
margin: 0;
li {
@extend .clearfix;
background: none;
border-bottom: 1px solid #d3d3d3;
@include box-shadow(0 1px 0 #eee);
border-bottom: 1px solid $border-color;
@include box-sizing(border-box);
padding: em(7) lh(.75);
position: relative;
font-size: 1em;
&.expandable,
&.collapsable {
h4 {
font-style: $body-font-size;
font-weight: normal;
font-size: 1em;
padding-left: 18px;
}
}
......@@ -122,16 +109,12 @@ div.info-wrapper {
li {
border-bottom: 0;
border-top: 1px solid #d3d3d3;
border-top: 1px solid $border-color;
@include box-shadow(inset 0 1px 0 #eee);
padding-left: lh(1.5);
font-size: 1em;
}
}
&:hover {
background-color: #e9e9e9;
}
div.hitarea {
background-image: url('../images/treeview-default.gif');
display: block;
......@@ -159,14 +142,12 @@ div.info-wrapper {
h3 {
border-bottom: 0;
@include box-shadow(none);
color: #999;
font-size: $body-font-size;
font-weight: bold;
text-transform: uppercase;
color: #aaa;
font-size: 1em;
margin-bottom: em(6);
}
p {
font-size: $body-font-size;
letter-spacing: 0;
margin: 0;
text-transform: none;
......@@ -191,14 +172,8 @@ div.info-wrapper {
}
a {
color: lighten($text-color, 10%);
@include inline-block();
text-decoration: none;
@include transition();
&:hover {
color: $mit-red;
}
line-height: lh();
}
}
}
......
......@@ -10,38 +10,26 @@ div.profile-wrapper {
header {
@extend .bottom-border;
margin: 0 ;
padding: lh(.5) lh();
margin: 0;
padding: lh(.5);
h1 {
font-size: 18px;
margin: 0;
padding-right: 30px;
}
a {
color: #999;
font-size: 12px;
position: absolute;
right: lh(.5);
text-transform: uppercase;
top: 13px;
&:hover {
color: #555;
}
}
}
ul {
list-style: none;
padding: 0;
margin: 0;
li {
border-bottom: 1px solid #d3d3d3;
@include box-shadow(0 1px 0 #eee);
color: lighten($text-color, 10%);
display: block;
padding: 7px lh();
padding: lh(.5) 0 lh(.5) lh(.5);
position: relative;
text-decoration: none;
@include transition();
......@@ -144,11 +132,14 @@ div.profile-wrapper {
@extend .content;
header {
@extend h1.top-header;
@extend .clearfix;
@extend h1.top-header;
margin-bottom: lh();
h1 {
float: left;
font-size: 1em;
font-weight: 100;
margin: 0;
}
}
......@@ -162,6 +153,7 @@ div.profile-wrapper {
border-top: 1px solid #e3e3e3;
list-style: none;
margin-top: lh();
padding-left: 0;
> li {
@extend .clearfix;
......@@ -178,9 +170,11 @@ div.profile-wrapper {
border-right: 1px dashed #ddd;
@include box-sizing(border-box);
display: table-cell;
letter-spacing: 0;
margin: 0;
padding: 0;
padding-right: flex-gutter(9);
text-transform: none;
width: flex-grid(2, 9);
}
......@@ -203,14 +197,39 @@ div.profile-wrapper {
h3 {
color: #666;
span {
color: #999;
font-size: em(14);
font-weight: 100;
}
}
p {
color: #999;
font-size: em(14);
}
ol {
list-style: none;
section.scores {
margin: lh(.5) 0;
h3 {
font-size: em(14);
@include inline-block;
}
li {
display: inline-block;
padding-right: 1em;
ol {
list-style: none;
margin: 0;
padding: 0;
@include inline-block;
li {
@include inline-block;
font-size: em(14);
font-weight: normal;
padding-right: 1em;
}
}
}
}
......
......@@ -7,7 +7,7 @@ div.book-wrapper {
@include box-sizing(border-box);
ul#booknav {
font-size: 12px;
font-size: $body-font-size;
a {
color: #000;
......@@ -39,8 +39,7 @@ div.book-wrapper {
}
> li {
border-bottom: 1px solid #d3d3d3;
@include box-shadow(0 1px 0 #eee);
border-bottom: 1px solid $border-color;
padding: 7px 7px 7px 30px;
}
}
......@@ -48,9 +47,11 @@ div.book-wrapper {
section.book {
@extend .content;
padding-right: 0;
padding-bottom: 0;
padding-top: 0;
nav {
@extend .topbar;
@extend .clearfix;
a {
......@@ -62,32 +63,57 @@ div.book-wrapper {
@extend .clearfix;
li {
position: absolute;
height: 100%;
width: flex-grid(2, 8);
a {
display: table;
@include box-sizing(border-box);
height: 100%;
width: 100%;
vertical-align: middle;
@include transition;
background-color: rgba(#000, .7);
background-repeat: no-repeat;
background-position: center;
opacity: 0;
filter: alpha(opacity=0);
text-indent: -9999px;
&:hover {
opacity: 1;
filter: alpha(opacity=100);
&.last {
}
&.next {
}
}
}
&.last {
display: block;
float: left;
left: 0;
a {
border-left: 0;
border-right: 1px solid darken(#f6efd4, 20%);
@include box-shadow(inset -1px 0 0 lighten(#f6efd4, 5%));
background-image: url('../images/textbook/textbook-left.png');
}
}
&.next {
display: block;
float: right;
}
right: 0;
&:hover {
background: none;
a {
background-image: url('../images/textbook/textbook-right.png');
}
}
}
}
&.bottom-nav {
border-bottom: 0;
border-top: 1px solid #EDDFAA;
margin-bottom: -(lh());
margin-top: lh();
}
......@@ -95,9 +121,10 @@ div.book-wrapper {
section.page {
text-align: center;
position: relative;
border: 1px solid $border-color;
img {
border: 1px solid $border-color;
max-width: 100%;
}
}
......
body {
font-family: $sans-serif;
}
h1, h2, h3, h4, h5, h6 {
body, h1, h2, h3, h4, h5, h6, p, p a:link, p a:visited, a {
font-family: $sans-serif;
}
......@@ -19,3 +15,9 @@ table {
table-layout: fixed;
}
}
form {
label {
display: block;
}
}
h1.top-header {
border-bottom: 1px solid #e3e3e3;
text-align: left;
font-size: 24px;
font-size: em(24);
font-weight: 100;
padding-bottom: lh();
}
......@@ -51,7 +51,6 @@ h1.top-header {
.sidebar {
border-right: 1px solid #C8C8C8;
@include box-shadow(inset -1px 0 0 #e6e6e6);
@include box-sizing(border-box);
display: table-cell;
font-family: $sans-serif;
......@@ -75,7 +74,7 @@ h1.top-header {
}
.bottom-border {
border-bottom: 1px solid #d3d3d3;
border-bottom: 1px solid $border-color;
}
@media print {
......
......@@ -4,78 +4,70 @@ div#wiki_panel {
h2 {
@extend .bottom-border;
font-size: 18px;
margin: 0 ;
padding: lh(.5) lh();
}
input[type="button"] {
background: transparent;
border: none;
@include box-shadow(none);
color: #666;
font-size: 14px;
font-weight: bold;
margin: 0px;
padding: 7px lh();
text-align: left;
@include transition();
width: 100%;
padding: lh(.5) lh() lh(.5) 0;
color: #000;
}
ul {
li {
@include box-shadow(inset 0 1px 0 0 #eee);
border-top: 1px solid #d3d3d3;
&:hover {
background: #efefef;
@include background-image(linear-gradient(-90deg, rgb(245,245,245), rgb(225,225,225)));
}
padding-left: 0;
margin: 0;
&:first-child {
border: none;
}
li {
@extend .bottom-border;
&.search {
padding: 10px lh();
padding: 10px lh() 10px 0;
label {
display: none;
}
}
&.create-article {
h3 {
}
}
a {
color: #666;
font-size: 14px;
padding: 7px lh();
padding: 7px lh() 7px 0;
&:hover {
background: #efefef;
}
}
}
form {
input[type="submit"]{
@extend .light-button;
text-transform: none;
text-shadow: none;
}
}
}
div#wiki_create_form {
@extend .clearfix;
background: #dadada;
border-bottom: 1px solid #d3d3d3;
padding: 15px;
padding: lh(.5) lh() lh(.5) 0;
label {
font-family: $sans-serif;
margin-bottom: lh(.5);
}
input[type="text"] {
@include box-sizing(border-box);
display: block;
margin-bottom: 6px;
width: 100%;
margin-bottom: lh(.5);
}
ul {
list-style: none;
margin: 0;
li {
float: left;
border-bottom: 0;
&#cancel {
float: right;
......
body {
margin: 0;
padding: 0; }
.wrapper, .subpage, section.copyright, section.tos, section.privacy-policy, section.honor-code, header.announcement div, section.index-content, footer {
margin: 0;
overflow: hidden; }
div#enroll form {
display: none; }
......@@ -299,3 +299,7 @@
}
}
}
.leanModal_box {
@extend .modal;
}
......@@ -8,6 +8,7 @@
</%block>
<%block name="headextra">
<%static:css group='course'/>
<style type="text/css">
.grade_a {color:green;}
......@@ -19,7 +20,8 @@
</%block>
<%include file="navigation.html" args="active_page=''" />
<%include file="course_navigation.html" args="active_page=''" />
<section class="container">
<div class="gradebook-wrapper">
<section class="gradebook-content">
......@@ -28,7 +30,7 @@
%if len(students) > 0:
<table>
<%
templateSummary = students[0]['grade_info']['grade_summary']
templateSummary = students[0]['grade_summary']
%>
......@@ -42,15 +44,15 @@
<%def name="percent_data(percentage)">
<%
data_class = "grade_none"
if percentage > .87:
data_class = "grade_a"
elif percentage > .70:
data_class = "grade_b"
elif percentage > .6:
data_class = "grade_c"
elif percentage > 0:
data_class = "grade_f"
letter_grade = 'None'
if percentage > 0:
letter_grade = 'F'
for grade in ['A', 'B', 'C']:
if percentage >= course.grade_cutoffs[grade]:
letter_grade = grade
break
data_class = "grade_" + letter_grade
%>
<td class="${data_class}">${ "{0:.0%}".format( percentage ) }</td>
</%def>
......@@ -58,10 +60,10 @@
%for student in students:
<tr>
<td><a href="/profile/${student['id']}/">${student['username']}</a></td>
%for section in student['grade_info']['grade_summary']['section_breakdown']:
%for section in student['grade_summary']['section_breakdown']:
${percent_data( section['percent'] )}
%endfor
<th>${percent_data( student['grade_info']['grade_summary']['percent'])}</th>
<th>${percent_data( student['grade_summary']['percent'])}</th>
</tr>
%endfor
</table>
......
......@@ -26,9 +26,9 @@
<%include file="navigation.html" />
<section class="content-wrapper">
${self.body()}
<%block name="bodyextra"/>
</section>
<%block name="bodyextra"/>
<%include file="footer.html" />
<%static:js group='application'/>
......
......@@ -20,7 +20,7 @@
if(json.success) {
location.href="${reverse('dashboard')}";
}else{
$('#register_message).html("<p><font color='red'>" + json.error + "</font></p>")
$('#register_message').html("<p><font color='red'>" + json.error + "</font></p>");
}
});
})(this)
......
<%page args="grade_summary, graph_div_id, **kwargs"/>
<%page args="grade_summary, grade_cutoffs, graph_div_id, **kwargs"/>
<%!
import json
import math
%>
$(function () {
......@@ -89,8 +90,16 @@ $(function () {
ticks += [ [overviewBarX, "Total"] ]
tickIndex += 1 + sectionSpacer
totalScore = grade_summary['percent']
totalScore = math.floor(grade_summary['percent'] * 100) / 100 #We floor it to the nearest percent, 80.9 won't show up like a 90 (an A)
detail_tooltips['Dropped Scores'] = dropped_score_tooltips
## ----------------------------- Grade cutoffs ------------------------- ##
grade_cutoff_ticks = [ [1, "100%"], [0, "0%"] ]
for grade in ['A', 'B', 'C']:
percent = grade_cutoffs[grade]
grade_cutoff_ticks.append( [ percent, "{0} {1:.0%}".format(grade, percent) ] )
%>
var series = ${ json.dumps( series ) };
......@@ -98,6 +107,7 @@ $(function () {
var bottomTicks = ${ json.dumps(bottomTicks) };
var detail_tooltips = ${ json.dumps(detail_tooltips) };
var droppedScores = ${ json.dumps(droppedScores) };
var grade_cutoff_ticks = ${ json.dumps(grade_cutoff_ticks) }
//Alwasy be sure that one series has the xaxis set to 2, or the second xaxis labels won't show up
series.push( {label: 'Dropped Scores', data: droppedScores, points: {symbol: "cross", show: true, radius: 3}, bars: {show: false}, color: "#333"} );
......@@ -107,10 +117,10 @@ $(function () {
lines: {show: false, steps: false },
bars: {show: true, barWidth: 0.8, align: 'center', lineWidth: 0, fill: .8 },},
xaxis: {tickLength: 0, min: 0.0, max: ${tickIndex - sectionSpacer}, ticks: ticks, labelAngle: 90},
yaxis: {ticks: [[1, "100%"], [0.87, "A 87%"], [0.7, "B 70%"], [0.6, "C 60%"], [0, "0%"]], min: 0.0, max: 1.0, labelWidth: 50},
yaxis: {ticks: grade_cutoff_ticks, min: 0.0, max: 1.0, labelWidth: 50},
grid: { hoverable: true, clickable: true, borderWidth: 1,
markings: [ {yaxis: {from: 0.87, to: 1 }, color: "#ddd"}, {yaxis: {from: 0.7, to: 0.87 }, color: "#e9e9e9"},
{yaxis: {from: 0.6, to: 0.7 }, color: "#f3f3f3"}, ] },
markings: [ {yaxis: {from: ${grade_cutoffs['A']}, to: 1 }, color: "#ddd"}, {yaxis: {from: ${grade_cutoffs['B']}, to: ${grade_cutoffs['A']} }, color: "#e9e9e9"},
{yaxis: {from: ${grade_cutoffs['C']}, to: ${grade_cutoffs['B']} }, color: "#f3f3f3"}, ] },
legend: {show: false},
};
......
......@@ -71,9 +71,9 @@
});
</script>
<%block name="wiki_head"/>
</%block>
<%block name="bodyextra">
......@@ -86,7 +86,7 @@
<div class="wiki-wrapper">
<%block name="wiki_panel">
<div aria-label="Wiki Navigation" id="wiki_panel">
<h2>Course Wiki</h2>
<h2>Course Wiki</h2>
<ul class="action">
<li>
<h3>
......@@ -101,12 +101,12 @@
<div id="wiki_create_form">
<%
baseURL = wiki_reverse("wiki_create", course=course, kwargs={"article_path" : namespace + "/" })
baseURL = wiki_reverse("wiki_create", course=course, kwargs={"article_path" : namespace + "/" })
%>
<form method="GET" onsubmit="this.action='${baseURL}' + this.wiki_article_name.value.replace(/([^a-zA-Z0-9\-])/g, '');">
<div>
<label for="id_wiki_article_name">Title of article</label>
<input type="text" name="wiki_article_name" id="id_wiki_article_name" /><br/>
<input type="text" name="wiki_article_name" id="id_wiki_article_name" />
</div>
<ul>
<li>
......@@ -130,31 +130,31 @@
</%block>
<section class="wiki-body">
%if wiki_article is not UNDEFINED:
<header>
%if wiki_article.locked:
<p><strong>This article has been locked</strong></p>
%endif
<p>Last modified: ${wiki_article.modified_on.strftime("%b %d, %Y, %I:%M %p")}</p>
%endif
%if wiki_article is not UNDEFINED:
<ul>
<li>
<a href="${ wiki_reverse('wiki_view', wiki_article, course)}" class="view">View</a>
</li>
<li>
<a href="${ wiki_reverse('wiki_edit', wiki_article, course)}" class="edit">Edit</a>
</li>
<li>
<a href="${ wiki_reverse('wiki_history', wiki_article, course)}" class="history">History</a>
</li>
</ul>
</header>
%if wiki_article is not UNDEFINED:
<header>
%if wiki_article.locked:
<p><strong>This article has been locked</strong></p>
%endif
<p>Last modified: ${wiki_article.modified_on.strftime("%b %d, %Y, %I:%M %p")}</p>
%endif
%if wiki_article is not UNDEFINED:
<ul>
<li>
<a href="${ wiki_reverse('wiki_view', wiki_article, course)}" class="view">View</a>
</li>
<li>
<a href="${ wiki_reverse('wiki_edit', wiki_article, course)}" class="edit">Edit</a>
</li>
<li>
<a href="${ wiki_reverse('wiki_history', wiki_article, course)}" class="history">History</a>
</li>
</ul>
</header>
%endif
<%block name="wiki_page_title"/>
<%block name="wiki_body"/>
......
......@@ -65,12 +65,10 @@ ${"Edit " + wiki_title + " - " if wiki_title is not UNDEFINED else ""}MITx 6.002
</div>
${wiki_form}
%if create_article:
<input type="submit" id="submit_edit" value="Create article" /></td>
<input type="submit" id="submit_edit" value="Create article" />
%else:
<input type="submit" id="submit_edit" name="edit" value="Save Changes" />
<input type="submit" id="submit_delete" name="delete" value="Delete article" />
%endif
</form>
<%include file="simplewiki_instructions.html"/>
......
......@@ -26,7 +26,7 @@ Displaying all articles
<li><h3><a href="${wiki_reverse("wiki_view", article, course)}">${article.title} ${'(Deleted)' if article_deleted else ''}</a></h3></li>
%endfor
%if not wiki_search_results:
%if not wiki_search_results:
No articles matching <b>${wiki_search_query if wiki_search_query is not UNDEFINED else ""} </b>!
%endif
</ul>
......
......@@ -2,6 +2,9 @@ ${module_content}
%if edit_link:
<div><a href="${edit_link}">Edit</a></div>
% endif
<div><a href="javascript:void(0)" onclick="javascript:$('#${element_id}_debug').slideToggle()">Staff Debug Info</a></div>
<span style="display:none" id="${element_id}_debug">
<div class="staff_info">
definition = <pre>${definition | h}</pre>
metadata = ${metadata | h}
......@@ -9,3 +12,4 @@ metadata = ${metadata | h}
%if render_histogram:
<div id="histogram_${element_id}" class="histogram" data-histogram="${histogram}"></div>
%endif
</span>
......@@ -89,17 +89,6 @@ $("#open_close_accordion a").click(function(){
</nav>
<img id="bookpage" src="${ settings.BOOK_URL }p${ "%03i"%(page) }.png">
<nav class="bottom-nav">
<ul>
<li class="last">
<a href="javascript:prev_page()">Previous page</a>
</li>
<li class="next">
<a href="javascript:next_page()">Next page</a>
</li>
</ul>
</nav>
</section>
</section>
</div>
......
......@@ -107,7 +107,6 @@ if settings.COURSEWARE_ENABLED:
# TODO: These views need to be updated before they work
# url(r'^calculate$', 'util.views.calculate'),
# url(r'^gradebook$', 'courseware.views.gradebook'),
# TODO: We should probably remove the circuit package. I believe it was only used in the old way of saving wiki circuits for the wiki
# url(r'^edit_circuit/(?P<circuit>[^/]*)$', 'circuit.views.edit_circuit'),
# url(r'^save_circuit/(?P<circuit>[^/]*)$', 'circuit.views.save_circuit'),
......@@ -119,7 +118,7 @@ if settings.COURSEWARE_ENABLED:
#About the course
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/about$',
'courseware.views.course_about', name="about_course"),
#Inside the course
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/info$',
'courseware.views.course_info', name="info"),
......@@ -142,7 +141,10 @@ if settings.COURSEWARE_ENABLED:
# discussion
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/discussion/',
include('django_comment_client.urls')),
include('django_comment_client.urls')),
# For the instructor
url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/gradebook$',
'courseware.views.gradebook'),
)
# Multicourse wiki
......
......@@ -83,13 +83,20 @@ end
task :pylint => "pylint_#{system}"
end
$failed_tests = 0
def run_tests(system, report_dir)
def run_tests(system, report_dir, stop_on_failure=true)
ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml")
ENV['NOSE_COVER_HTML_DIR'] = File.join(report_dir, "cover")
sh(django_admin(system, :test, 'test', *Dir["#{system}/djangoapps/*"].each))
sh(django_admin(system, :test, 'test', *Dir["#{system}/djangoapps/*"].each)) do |ok, res|
if !ok and stop_on_failure
abort "Test failed!"
end
$failed_tests += 1 unless ok
end
end
TEST_TASKS = []
[:lms, :cms].each do |system|
report_dir = File.join(REPORT_DIR, system.to_s)
......@@ -97,15 +104,16 @@ end
# Per System tasks
desc "Run all django tests on our djangoapps for the #{system}"
task "test_#{system}" => ["clean_test_files", "#{system}:collectstatic:test", "fasttest_#{system}"]
task "test_#{system}", [:stop_on_failure] => ["clean_test_files", "#{system}:collectstatic:test", "fasttest_#{system}"]
# Have a way to run the tests without running collectstatic -- useful when debugging without
# messing with static files.
task "fasttest_#{system}" => [report_dir, :predjango] do
run_tests(system, report_dir)
task "fasttest_#{system}", [:stop_on_failure] => [report_dir, :predjango] do |t, args|
args.with_defaults(:stop_on_failure => 'true')
run_tests(system, report_dir, args.stop_on_failure)
end
task :test => "test_#{system}"
TEST_TASKS << "test_#{system}"
desc <<-desc
Start the #{system} locally with the specified environment (defaults to dev).
......@@ -142,7 +150,17 @@ Dir["common/lib/*"].each do |lib|
ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml")
sh("nosetests #{lib} --cover-erase --with-xunit --with-xcoverage --cover-html --cover-inclusive --cover-package #{File.basename(lib)} --cover-html-dir #{File.join(report_dir, "cover")}")
end
task :test => task_name
TEST_TASKS << task_name
end
task :test do
TEST_TASKS.each do |task|
Rake::Task[task].invoke(false)
end
if $failed_tests > 0
abort "Tests failed!"
end
end
task :runserver => :lms
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment