Commit 7ad9b39b by Arjun Singh

Merge master

parents 042ab6d2 317e2c34
......@@ -215,6 +215,7 @@ def preview_module_system(request, preview_id, descriptor):
render_template=render_from_lms,
debug=True,
replace_urls=replace_urls,
user=request.user,
)
......
......@@ -61,7 +61,7 @@ class GithubSyncTestCase(TestCase):
self.assertIn(
Location('i4x://edX/toy/chapter/Overview'),
[child.location for child in self.import_course.get_children()])
self.assertEquals(1, len(self.import_course.get_children()))
self.assertEquals(2, len(self.import_course.get_children()))
@patch('github_sync.sync_with_github')
def test_sync_all_with_github(self, sync_with_github):
......
......@@ -35,6 +35,7 @@ from path import path
MITX_FEATURES = {
'USE_DJANGO_PIPELINE': True,
'GITHUB_PUSH': False,
'ENABLE_DISCUSSION_SERVICE': False
}
# needed to use lms student app
......
......@@ -4,7 +4,7 @@ Models for Student Information
Replication Notes
In our live deployment, we intend to run in a scenario where there is a pool of
Portal servers that hold the canoncial user information and that user
Portal servers that hold the canoncial user information and that user
information is replicated to slave Course server pools. Each Course has a set of
servers that serves only its content and has users that are relevant only to it.
......@@ -61,6 +61,7 @@ from xmodule.modulestore.django import modulestore
log = logging.getLogger(__name__)
class UserProfile(models.Model):
"""This is where we store all the user demographic fields. We have a
separate table for this rather than extending the built-in Django auth_user.
......@@ -175,6 +176,7 @@ class PendingEmailChange(models.Model):
new_email = models.CharField(blank=True, max_length=255, db_index=True)
activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)
class CourseEnrollment(models.Model):
user = models.ForeignKey(User)
course_id = models.CharField(max_length=255, db_index=True)
......@@ -184,6 +186,10 @@ class CourseEnrollment(models.Model):
class Meta:
unique_together = (('user', 'course_id'), )
def __unicode__(self):
return "[CourseEnrollment] %s: %s (%s)" % (self.user, self.course_id, self.created)
@receiver(post_save, sender=CourseEnrollment)
def assign_default_role(sender, instance, **kwargs):
if instance.user.is_staff:
......@@ -273,8 +279,12 @@ def add_user_to_default_group(user, group):
utg.users.add(User.objects.get(username=user))
utg.save()
@receiver(post_save, sender=User)
def update_user_information(sender, instance, created, **kwargs):
if not settings.MITX_FEATURES['ENABLE_DISCUSSION_SERVICE']:
# Don't try--it won't work, and it will fill the logs with lots of errors
return
try:
cc_user = cc.User.from_django_user(instance)
cc_user.save()
......@@ -283,6 +293,7 @@ def update_user_information(sender, instance, created, **kwargs):
log.error(unicode(e))
log.error("update user info to discussion failed for user with id: " + str(instance.id))
########################## REPLICATION SIGNALS #################################
# @receiver(post_save, sender=User)
def replicate_user_save(sender, **kwargs):
......@@ -292,6 +303,7 @@ def replicate_user_save(sender, **kwargs):
for course_db_name in db_names_to_replicate_to(user_obj.id):
replicate_user(user_obj, course_db_name)
# @receiver(post_save, sender=CourseEnrollment)
def replicate_enrollment_save(sender, **kwargs):
"""This is called when a Student enrolls in a course. It has to do the
......@@ -317,12 +329,14 @@ def replicate_enrollment_save(sender, **kwargs):
log.debug("Replicating user profile because of new enrollment")
user_profile = UserProfile.objects.get(user_id=enrollment_obj.user_id)
replicate_model(UserProfile.save, user_profile, enrollment_obj.user_id)
# @receiver(post_delete, sender=CourseEnrollment)
def replicate_enrollment_delete(sender, **kwargs):
enrollment_obj = kwargs['instance']
return replicate_model(CourseEnrollment.delete, enrollment_obj, enrollment_obj.user_id)
# @receiver(post_save, sender=UserProfile)
def replicate_userprofile_save(sender, **kwargs):
"""We just updated the UserProfile (say an update to the name), so push that
......@@ -330,12 +344,13 @@ def replicate_userprofile_save(sender, **kwargs):
user_profile_obj = kwargs['instance']
return replicate_model(UserProfile.save, user_profile_obj, user_profile_obj.user_id)
######### Replication functions #########
USER_FIELDS_TO_COPY = ["id", "username", "first_name", "last_name", "email",
"password", "is_staff", "is_active", "is_superuser",
"last_login", "date_joined"]
def replicate_user(portal_user, course_db_name):
"""Replicate a User to the correct Course DB. This is more complicated than
it should be because Askbot extends the auth_user table and adds its own
......@@ -359,9 +374,10 @@ def replicate_user(portal_user, course_db_name):
course_user.save(using=course_db_name)
unmark(course_user)
def replicate_model(model_method, instance, user_id):
"""
model_method is the model action that we want replicated. For instance,
model_method is the model action that we want replicated. For instance,
UserProfile.save
"""
if not should_replicate(instance):
......@@ -376,8 +392,10 @@ def replicate_model(model_method, instance, user_id):
model_method(instance, using=db_name)
unmark(instance)
######### Replication Helpers #########
def is_valid_course_id(course_id):
"""Right now, the only database that's not a course database is 'default'.
I had nicer checking in here originally -- it would scan the courses that
......@@ -387,26 +405,30 @@ def is_valid_course_id(course_id):
"""
return course_id != 'default'
def is_portal():
"""Are we in the portal pool? Only Portal servers are allowed to replicate
their changes. For now, only Portal servers see multiple DBs, so we use
that to decide."""
return len(settings.DATABASES) > 1
def db_names_to_replicate_to(user_id):
"""Return a list of DB names that this user_id is enrolled in."""
return [c.course_id
for c in CourseEnrollment.objects.filter(user_id=user_id)
if is_valid_course_id(c.course_id)]
def marked_handled(instance):
"""Have we marked this instance as being handled to avoid infinite loops
caused by saving models in post_save hooks for the same models?"""
return hasattr(instance, '_do_not_copy_to_course_db') and instance._do_not_copy_to_course_db
def mark_handled(instance):
"""You have to mark your instance with this function or else we'll go into
an infinite loop since we're putting listeners on Model saves/deletes and
an infinite loop since we're putting listeners on Model saves/deletes and
the act of replication requires us to call the same model method.
We create a _replicated attribute to differentiate the first save of this
......@@ -415,16 +437,18 @@ def mark_handled(instance):
"""
instance._do_not_copy_to_course_db = True
def unmark(instance):
"""If we don't unmark a model after we do replication, then consecutive
"""If we don't unmark a model after we do replication, then consecutive
save() calls won't be properly replicated."""
instance._do_not_copy_to_course_db = False
def should_replicate(instance):
"""Should this instance be replicated? We need to be a Portal server and
the instance has to not have been marked_handled."""
if marked_handled(instance):
# Basically, avoid an infinite loop. You should
# Basically, avoid an infinite loop. You should
log.debug("{0} should not be replicated because it's been marked"
.format(instance))
return False
......
......@@ -75,8 +75,11 @@ def index(request, extra_context={}, user=None):
entry.summary = soup.getText()
# The course selection work is done in courseware.courses.
domain = settings.MITX_FEATURES.get('FORCE_UNIVERSITY_DOMAIN') # normally False
if domain==False: # do explicit check, because domain=None is valid
domain = request.META.get('HTTP_HOST')
universities = get_courses_by_university(None,
domain=request.META.get('HTTP_HOST'))
domain=domain)
context = {'universities': universities, 'entries': entries}
context.update(extra_context)
return render_to_response('index.html', context)
......@@ -131,10 +134,14 @@ def dashboard(request):
staff_access = True
errored_courses = modulestore().get_errored_courses()
show_courseware_links_for = frozenset(course.id for course in courses
if has_access(request.user, course, 'load'))
context = {'courses': courses,
'message': message,
'staff_access': staff_access,
'errored_courses': errored_courses,}
'errored_courses': errored_courses,
'show_courseware_links_for' : show_courseware_links_for}
return render_to_response('dashboard.html', context)
......
import re
import json
import logging
import time
from django.conf import settings
from functools import wraps
......@@ -75,7 +76,7 @@ def grade_histogram(module_id):
grades = list(cursor.fetchall())
grades.sort(key=lambda x: x[0]) # Add ORDER BY to sql query?
if len(grades) == 1 and grades[0][0] is None:
if len(grades) >= 1 and grades[0][0] is None:
return []
return grades
......@@ -117,6 +118,14 @@ def add_histogram(get_html, module, user):
data_dir = ""
source_file = module.metadata.get('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')
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),
'location': module.location,
......@@ -130,7 +139,9 @@ def add_histogram(get_html, module, user):
'xqa_server' : settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu/xqa'),
'histogram': json.dumps(histogram),
'render_histogram': render_histogram,
'module_content': get_html()}
'module_content': get_html(),
'is_released': is_released,
}
return render_to_string("staff_problem_info.html", staff_context)
return _get_html
......
......@@ -14,6 +14,8 @@ This is used by capa_module.
from __future__ import division
from datetime import datetime
import json
import logging
import math
import numpy
......@@ -32,6 +34,7 @@ from correctmap import CorrectMap
import eia
import inputtypes
from util import contextualize_text, convert_files_to_filenames
import xqueue_interface
# to be replaced with auto-registering
import responsetypes
......@@ -202,11 +205,24 @@ class LoncapaProblem(object):
'''
Returns True if any part of the problem has been submitted to an external queue
'''
queued = False
for answer_id in self.correct_map:
if self.correct_map.is_queued(answer_id):
queued = True
return queued
return any(self.correct_map.is_queued(answer_id) for answer_id in self.correct_map)
def get_recentmost_queuetime(self):
'''
Returns a DateTime object that represents the timestamp of the most recent queueing request, or None if not queued
'''
if not self.is_queued():
return None
# Get a list of timestamps of all queueing requests, then convert it to a DateTime object
queuetime_strs = [self.correct_map.get_queuetime_str(answer_id)
for answer_id in self.correct_map
if self.correct_map.is_queued(answer_id)]
queuetimes = [datetime.strptime(qt_str, xqueue_interface.dateformat) for qt_str in queuetime_strs]
return max(queuetimes)
def grade_answers(self, answers):
'''
......
......@@ -15,7 +15,8 @@ class CorrectMap(object):
- msg : string (may have HTML) giving extra message response (displayed below textline or textbox)
- hint : string (may have HTML) giving optional hint (displayed below textline or textbox, above msg)
- hintmode : one of (None,'on_request','always') criteria for displaying hint
- queuekey : a random integer for xqueue_callback verification
- queuestate : Dict {key:'', time:''} where key is a secret string, and time is a string dump
of a DateTime object in the format '%Y%m%d%H%M%S'. Is None when not queued
Behaves as a dict.
'''
......@@ -31,14 +32,15 @@ class CorrectMap(object):
def __iter__(self):
return self.cmap.__iter__()
def set(self, answer_id=None, correctness=None, npoints=None, msg='', hint='', hintmode=None, queuekey=None):
# See the documentation for 'set_dict' for the use of kwargs
def set(self, answer_id=None, correctness=None, npoints=None, msg='', hint='', hintmode=None, queuestate=None, **kwargs):
if answer_id is not None:
self.cmap[answer_id] = {'correctness': correctness,
'npoints': npoints,
'msg': msg,
'hint': hint,
'hintmode': hintmode,
'queuekey': queuekey,
'queuestate': queuestate,
}
def __repr__(self):
......@@ -52,25 +54,39 @@ class CorrectMap(object):
def set_dict(self, correct_map):
'''
set internal dict to provided correct_map dict
for graceful migration, if correct_map is a one-level dict, then convert it to the new
dict of dicts format.
Set internal dict of CorrectMap to provided correct_map dict
correct_map is saved by LMS as a plaintext JSON dump of the correctmap dict. This means that
when the definition of CorrectMap (e.g. its properties) are altered, existing correct_map dict
not coincide with the newest CorrectMap format as defined by self.set.
For graceful migration, feed the contents of each correct map to self.set, rather than
making a direct copy of the given correct_map dict. This way, the common keys between
the incoming correct_map dict and the new CorrectMap instance will be written, while
mismatched keys will be gracefully ignored.
Special migration case:
If correct_map is a one-level dict, then convert it to the new dict of dicts format.
'''
if correct_map and not (type(correct_map[correct_map.keys()[0]]) == dict):
self.__init__() # empty current dict
for k in correct_map: self.set(k, correct_map[k]) # create new dict entries
self.__init__() # empty current dict
for k in correct_map: self.set(k, correct_map[k]) # create new dict entries
else:
self.cmap = correct_map
self.__init__()
for k in correct_map: self.set(k, **correct_map[k])
def is_correct(self, answer_id):
if answer_id in self.cmap: return self.cmap[answer_id]['correctness'] == 'correct'
return None
def is_queued(self, answer_id):
return answer_id in self.cmap and self.cmap[answer_id]['queuekey'] is not None
return answer_id in self.cmap and self.cmap[answer_id]['queuestate'] is not None
def is_right_queuekey(self, answer_id, test_key):
return answer_id in self.cmap and self.cmap[answer_id]['queuekey'] == test_key
return self.is_queued(answer_id) and self.cmap[answer_id]['queuestate']['key'] == test_key
def get_queuetime_str(self, answer_id):
return self.cmap[answer_id]['queuestate']['time']
def get_npoints(self, answer_id):
npoints = self.get_property(answer_id, 'npoints')
......
......@@ -351,7 +351,7 @@ def filesubmission(element, value, status, render_template, msg=''):
if status == 'incomplete': # Flag indicating that the problem has been queued, 'msg' is length of queue
status = 'queued'
queue_len = msg
msg = 'Submitted to grader. (Queue length: %s)' % queue_len
msg = 'Submitted to grader.'
context = { 'id': eid, 'state': status, 'msg': msg, 'value': value,
'queue_len': queue_len, 'allowed_files': allowed_files,
......@@ -384,7 +384,7 @@ def textbox(element, value, status, render_template, msg=''):
if status == 'incomplete': # Flag indicating that the problem has been queued, 'msg' is length of queue
status = 'queued'
queue_len = msg
msg = 'Submitted to grader. (Queue length: %s)' % queue_len
msg = 'Submitted to grader.'
# For CodeMirror
mode = element.get('mode','python')
......@@ -470,9 +470,9 @@ def math(element, value, status, render_template, msg=''):
xhtml = etree.XML(html)
except Exception as err:
if False: # TODO needs to be self.system.DEBUG - but can't access system
msg = "<html><font color='red'><p>Error %s</p>" % str(err).replace('<', '&lt;')
msg = '<html><div class="inline-error"><p>Error %s</p>' % str(err).replace('<', '&lt;')
msg += '<p>Failed to construct math expression from <pre>%s</pre></p>' % html.replace('<', '&lt;')
msg += "</font></html>"
msg += "</div></html>"
log.error(msg)
return etree.XML(msg)
else:
......
<form class="choicegroup">
<form class="choicegroup capa_inputtype" id="inputtype_${id}">
% for choice_id, choice_description in choices:
<label for="input_${id}_${choice_id}"> <input type="${input_type}" name="input_${id}${name_array_suffix}" id="input_${id}_${choice_id}" value="${choice_id}"
......
<section id="filesubmission_${id}" class="filesubmission">
<input type="file" name="input_${id}" id="input_${id}" value="${value}" multiple="multiple" data-required_files="${required_files}" data-allowed_files="${allowed_files}"/><br />
<div class="grader-status file">
% if state == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
% elif state == 'correct':
<span class="correct" id="status_${id}"></span>
<span class="correct" id="status_${id}">Correct</span>
% elif state == 'incorrect':
<span class="incorrect" id="status_${id}"></span>
<span class="incorrect" id="status_${id}">Incorrect</span>
% elif state == 'queued':
<span class="processing" id="status_${id}"></span>
<span class="processing" id="status_${id}">Queued</span>
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
% endif
<span class="debug">(${state})</span>
<br/>
<span class="message">${msg|n}</span>
<br/>
<p class="debug">${state}</p>
<input type="file" name="input_${id}" id="input_${id}" value="${value}" multiple="multiple" data-required_files="${required_files}" data-allowed_files="${allowed_files}"/>
</div>
<div class="message">${msg|n}</div>
</section>
<form class="javascriptinput capa_inputtype">
<form class="javascriptinput capa_inputtype" id="inputtype_${id}">
<input type="hidden" name="input_${id}" id="input_${id}" class="javascriptinput_input"/>
<div class="javascriptinput_data" data-display_class="${display_class}"
data-problem_state="${problem_state}" data-params="${params}"
......
<form class="option-input">
<select name="input_${id}" id="input_${id}" >
<option value="option_${id}_dummy_default"> </option>
% for option_id, option_description in options:
......
......@@ -7,26 +7,28 @@
<span id="answer_${id}"></span>
% if state == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}"></span>
% elif state == 'correct':
<span class="correct" id="status_${id}"></span>
% elif state == 'incorrect':
<span class="incorrect" id="status_${id}"></span>
% elif state == 'queued':
<span class="processing" id="status_${id}"></span>
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
% endif
% if hidden:
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
% endif
<br/>
<span class="debug">(${state})</span>
<br/>
<span class="message">${msg|n}</span>
<br/>
<div class="grader-status">
% if state == 'unsubmitted':
<span class="unanswered" style="display:inline-block;" id="status_${id}">Unanswered</span>
% elif state == 'correct':
<span class="correct" id="status_${id}">Correct</span>
% elif state == 'incorrect':
<span class="incorrect" id="status_${id}">Incorrect</span>
% elif state == 'queued':
<span class="processing" id="status_${id}">Queued</span>
<span style="display:none;" class="xqueue" id="${id}" >${queue_len}</span>
% endif
<br/>
% if hidden:
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
% endif
<p class="debug">${state}</p>
</div>
<div class="external-grader-message">
${msg|n}
</div>
<script>
// Note: We need to make the area follow the CodeMirror for this to work.
......@@ -45,12 +47,4 @@
});
});
</script>
<style type="text/css">
.CodeMirror {
border: 1px solid black;
font-size: 14px;
line-height: 18px;
resize: both;
}
</style>
</section>
......@@ -5,20 +5,17 @@ import hashlib
import json
import logging
import requests
import time
log = logging.getLogger('mitx.' + __name__)
dateformat = '%Y%m%d%H%M%S'
def make_hashkey(seed=None):
def make_hashkey(seed):
'''
Generate a string key by hashing
'''
h = hashlib.md5()
if seed is not None:
h.update(str(seed))
h.update(str(time.time()))
h.update(str(seed))
return h.hexdigest()
......
......@@ -155,8 +155,8 @@ class CapaModule(XModule):
msg = '<p>%s</p>' % msg.replace('<', '&lt;')
msg += '<p><pre>%s</pre></p>' % traceback.format_exc().replace('<', '&lt;')
# create a dummy problem with error message instead of failing
problem_text = ('<problem><text><font color="red" size="+2">'
'Problem %s has an error:</font>%s</text></problem>' %
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(),
......@@ -202,10 +202,8 @@ class CapaModule(XModule):
try:
return Progress(score, total)
except Exception as err:
# TODO (vshnayder): why is this still here? still needed?
if self.system.DEBUG:
return None
raise
log.exception("Got bad progress")
return None
return None
def get_html(self):
......@@ -347,6 +345,10 @@ class CapaModule(XModule):
if self.show_answer == "never":
return False
# Admins can see the answer, unless the problem explicitly prevents it
if self.system.user_is_staff:
return True
if self.show_answer == 'attempted':
return self.attempts > 0
......@@ -462,6 +464,15 @@ class CapaModule(XModule):
self.system.track_function('save_problem_check_fail', event_info)
raise NotFoundError('Problem must be reset before it can be checked again')
# Problem queued. Students must wait a specified waittime before they are allowed to submit
if self.lcp.is_queued():
current_time = datetime.datetime.now()
prev_submit_time = self.lcp.get_recentmost_queuetime()
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
try:
old_state = self.lcp.get_state()
lcp_id = self.lcp.problem_id
......
......@@ -61,7 +61,7 @@ class CourseDescriptor(SequenceDescriptor):
def __init__(self, system, definition=None, **kwargs):
super(CourseDescriptor, self).__init__(system, definition, **kwargs)
self.textbooks = self.definition['data']['textbooks']
self.wiki_slug = self.definition['data']['wiki_slug'] or self.location.course
msg = None
......@@ -99,21 +99,28 @@ class CourseDescriptor(SequenceDescriptor):
def definition_from_xml(cls, xml_object, system):
textbooks = []
for textbook in xml_object.findall("textbook"):
textbooks.append(cls.Textbook.from_xml_object(textbook))
try:
txt = cls.Textbook.from_xml_object(textbook)
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")
continue
textbooks.append()
xml_object.remove(textbook)
#Load the wiki tag if it exists
wiki_slug = None
wiki_tag = xml_object.find("wiki")
if wiki_tag is not None:
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.setdefault('data', {})['textbooks'] = textbooks
definition['data']['wiki_slug'] = wiki_slug
return definition
def has_started(self):
......@@ -127,6 +134,10 @@ class CourseDescriptor(SequenceDescriptor):
def grade_cutoffs(self):
return self._grading_policy['GRADE_CUTOFFS']
@property
def show_calculator(self):
return self.metadata.get("show_calculator", None) == "Yes"
@lazyproperty
def grading_context(self):
"""
......@@ -219,7 +230,7 @@ class CourseDescriptor(SequenceDescriptor):
# 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
......@@ -233,6 +244,22 @@ class CourseDescriptor(SequenceDescriptor):
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 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 title(self):
return self.display_name
......
......@@ -16,6 +16,10 @@ h2 {
}
}
.inline-error {
color: darken($error-red, 10%);
}
section.problem {
@media print {
display: block;
......@@ -31,6 +35,23 @@ section.problem {
display: inline;
}
.choicegroup {
label.choicegroup_correct:after {
content: url('../images/correct-icon.png');
}
}
ol.enumerate {
li {
&:before {
content: " ";
display: block;
height: 0;
visibility: hidden;
}
}
}
div {
p {
&.answer {
......@@ -126,11 +147,19 @@ section.problem {
div.equation {
clear: both;
padding: 6px;
background: #eee;
margin-top: 3px;
span {
margin-bottom: 0;
&.math {
padding: 6px;
background: #f1f1f1;
border: 1px solid #e3e3e3;
@include inline-block;
@include border-radius(4px);
min-width: 300px;
}
}
}
......@@ -171,6 +200,61 @@ section.problem {
top: 6px;
}
}
.grader-status {
padding: 9px;
background: #F6F6F6;
border: 1px solid #ddd;
border-top: 0;
margin-bottom: 20px;
@include clearfix;
span {
text-indent: -9999px;
overflow: hidden;
display: block;
float: left;
margin: -7px 7px 0 0;
}
p {
line-height: 20px;
text-transform: capitalize;
margin-bottom: 0;
float: left;
}
&.file {
background: #FFF;
margin-top: 20px;
padding: 20px 0 0 0;
border: {
top: 1px solid #eee;
right: 0;
bottom: 0;
left: 0;
}
p.debug {
display: none;
}
input {
float: left;
}
}
}
}
form.option-input {
margin: -10px 0 20px;
padding-bottom: 20px;
border-bottom: 1px solid #e3e3e3;
select {
margin-right: flex-gutter();
}
}
ul {
......@@ -246,6 +330,69 @@ section.problem {
}
code {
margin: 0 2px;
padding: 0px 5px;
white-space: nowrap;
border: 1px solid #EAEAEA;
background-color: #F8F8F8;
@include border-radius(3px);
font-size: .9em;
}
pre {
background-color: #F8F8F8;
border: 1px solid #CCC;
font-size: .9em;
line-height: 1.4;
overflow: auto;
padding: 6px 10px;
@include border-radius(3px);
> code {
margin: 0;
padding: 0;
white-space: pre;
border: none;
background: transparent;
}
}
.CodeMirror {
border: 1px solid black;
font-size: 14px;
line-height: 18px;
resize: both;
pre {
@include border-radius(0);
border-radius: 0;
border-width: 0;
margin: 0;
padding: 0;
background: transparent;
font-family: inherit;
font-size: inherit;
white-space: pre;
word-wrap: normal;
overflow: hidden;
resize: none;
&.CodeMirror-cursor {
z-index: 10;
position: absolute;
visibility: hidden;
border-left: 1px solid black;
border-right: none;
width: 0;
}
}
}
.CodeMirror-focused pre.CodeMirror-cursor {
visibility: visible;
}
hr {
background: #ddd;
border: none;
......@@ -276,8 +423,124 @@ section.problem {
}
section.action {
margin-top: 20px;
input.save {
@extend .blue-button;
}
}
.detailed-solution {
border: 1px solid #ddd;
padding: 9px 9px 20px;
margin-bottom: 10px;
background: #FFF;
position: relative;
@include box-shadow(inset 0 0 0 1px #eee);
@include border-radius(3px);
p:first-child {
font-size: 0.9em;
font-weight: bold;
font-style: normal;
text-transform: uppercase;
color: #AAA;
}
p:last-child {
margin-bottom: 0;
}
}
div.capa_alert {
padding: 8px 12px;
border: 1px solid #EBE8BF;
border-radius: 3px;
background: #FFFCDD;
font-size: 0.9em;
margin-top: 10px;
}
.hints {
border: 1px solid #ccc;
h3 {
border-bottom: 1px solid #e3e3e3;
text-shadow: 0 1px 0 #fff;
padding: 9px;
background: #eee;
font-weight: bold;
font-size: em(16);
}
div {
border-bottom: 1px solid #ddd;
&:last-child {
border-bottom: none;
}
p {
margin-bottom: 0;
}
header {
a {
display: block;
padding: 9px;
background: #F6F6F6;
@include box-shadow(inset 0 0 0 1px #fff);
}
}
section {
padding: 9px;
}
}
}
.test {
padding-top: 18px;
header {
margin-bottom: 12px;
h3 {
font-size: 0.9em;
font-weight: bold;
font-style: normal;
text-transform: uppercase;
color: #AAA;
}
}
> section {
border: 1px solid #ddd;
padding: 9px 9px 20px;
margin-bottom: 10px;
background: #FFF;
position: relative;
@include box-shadow(inset 0 0 0 1px #eee);
@include border-radius(3px);
p:last-of-type {
margin-bottom: 0;
}
.shortform {
margin-bottom: .6em;
}
a.full {
@include position(absolute, 0 0 1px 0px);
font-size: .8em;
padding: 4px;
text-align: right;
width: 100%;
display: block;
background: #F3F3F3;
@include box-sizing(border-box);
}
}
}
}
......@@ -37,7 +37,6 @@ nav.sequence-nav {
height: 44px;
margin: 0 30px;
@include linear-gradient(top, #ddd, #eee);
overflow: hidden;
@include box-shadow(0 1px 3px rgba(0, 0, 0, .1) inset);
}
......
......@@ -35,6 +35,11 @@ def make_error_tracker():
if in_exception_handler():
exc_str = exc_info_to_str(sys.exc_info())
# don't display irrelevant gunicorn sync error
if (('python2.7/site-packages/gunicorn/workers/sync.py' in exc_str) and
('[Errno 11] Resource temporarily unavailable' in exc_str)):
exc_str = ''
errors.append((msg, exc_str))
return ErrorLog(error_tracker, errors)
......
......@@ -4,9 +4,10 @@ import logging
import os
import sys
from lxml import etree
from path import path
from .x_module import XModule
from .xml_module import XmlDescriptor
from .xml_module import XmlDescriptor, name_to_pathname
from .editing_module import EditingDescriptor
from .stringify import stringify_children
from .html_checker import check_html
......@@ -75,9 +76,19 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
cls.clean_metadata_from_xml(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 from .html
filepath = "{category}/{name}.html".format(category='html', name=filename)
# html is special. cls.filename_extension is 'xml', but
# if 'filename' is in the definition, that means to load
# from .html
# 'filename' in html pointers is a relative path
# (not same as 'html/blah.html' when the pointer is in a directory itself)
pointer_path = "{category}/{url_path}".format(category='html',
url_path=name_to_pathname(location.name))
base = path(pointer_path).dirname()
#log.debug("base = {0}, base.dirname={1}, filename={2}".format(base, base.dirname(), filename))
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,
......@@ -128,13 +139,18 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
pass
# Not proper format. Write html to file, return an empty tag
filepath = u'{category}/{name}.html'.format(category=self.category,
name=self.url_name)
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), allow_recreate=True)
with resource_fs.open(filepath, 'w') as file:
file.write(self.definition['data'])
# write out the relative name
relname = path(pathname).basename()
elt = etree.Element('html')
elt.set("filename", self.url_name)
elt.set("filename", relname)
return elt
......@@ -84,11 +84,14 @@ class @Problem
# stuff if a div w a class is found
setupInputTypes: =>
@inputtypeDisplays = {}
@el.find(".capa_inputtype").each (index, inputtype) =>
classes = $(inputtype).attr('class').split(' ')
id = $(inputtype).attr('id')
for cls in classes
setupMethod = @inputtypeSetupMethods[cls]
setupMethod(inputtype) if setupMethod?
if setupMethod?
@inputtypeDisplays[id] = setupMethod(inputtype)
executeProblemScripts: (callback=null) ->
......@@ -192,8 +195,11 @@ class @Problem
if file_not_selected
errors.push 'You did not select any files to submit'
if errors.length > 0
alert errors.join("\n")
error_html = '<ul>\n'
for error in errors
error_html += '<li>' + error + '</li>\n'
error_html += '</ul>'
@gentle_alert error_html
abort_submission = file_too_large or file_not_selected or unallowed_file_submitted or required_files_not_submitted
......@@ -208,7 +214,7 @@ class @Problem
@render(response.contents)
@updateProgress response
else
alert(response.success)
@gentle_alert response.success
if not abort_submission
$.ajaxWithPrefix("#{@url}/problem_check", settings)
......@@ -220,8 +226,10 @@ class @Problem
when 'incorrect', 'correct'
@render(response.contents)
@updateProgress response
if @el.hasClass 'showed'
@el.removeClass 'showed'
else
alert(response.success)
@gentle_alert response.success
reset: =>
Logger.log 'problem_reset', @answers
......@@ -243,6 +251,17 @@ class @Problem
@$("label[for='input_#{key}_#{choice}']").attr correct_answer: 'true'
else
@$("#answer_#{key}, #solution_#{key}").html(value)
# TODO remove the above once everything is extracted into its own
# inputtype functions.
@el.find(".capa_inputtype").each (index, inputtype) =>
classes = $(inputtype).attr('class').split(' ')
for cls in classes
display = @inputtypeDisplays[$(inputtype).attr('id')]
showMethod = @inputtypeShowAnswerMethods[cls]
showMethod(inputtype, display, answers) if showMethod?
MathJax.Hub.Queue ["Typeset", MathJax.Hub]
@$('.show').val 'Hide Answer'
@el.addClass 'showed'
......@@ -253,11 +272,26 @@ class @Problem
@el.removeClass 'showed'
@$('.show').val 'Show Answer'
@el.find(".capa_inputtype").each (index, inputtype) =>
display = @inputtypeDisplays[$(inputtype).attr('id')]
classes = $(inputtype).attr('class').split(' ')
for cls in classes
hideMethod = @inputtypeHideAnswerMethods[cls]
hideMethod(inputtype, display) if hideMethod?
gentle_alert: (msg) =>
if @el.find('.capa_alert').length
@el.find('.capa_alert').remove()
alert_elem = "<div class='capa_alert'>" + msg + "</div>"
@el.find('.action').after(alert_elem)
@el.find('.capa_alert').css(opacity: 0).animate(opacity: 1, 700)
save: =>
Logger.log 'problem_save', @answers
$.postWithPrefix "#{@url}/problem_save", @answers, (response) =>
if response.success
alert 'Saved'
saveMessage = "Your answers have been saved but not graded. Hit 'Check' to grade them."
@gentle_alert saveMessage
@updateProgress response
refreshMath: (event, element) =>
......@@ -293,8 +327,35 @@ class @Problem
problemState = data.data("problem_state")
displayClass = window[data.data('display_class')]
if evaluation == ''
evaluation = null
container = $(element).find(".javascriptinput_container")
submissionField = $(element).find(".javascriptinput_input")
display = new displayClass(problemState, submission, evaluation, container, submissionField, params)
display.render()
return display
inputtypeShowAnswerMethods:
choicegroup: (element, display, answers) =>
element = $(element)
for key, value of answers
element.find('input').attr('disabled', 'disabled')
for choice in value
element.find("label[for='input_#{key}_#{choice}']").addClass 'choicegroup_correct'
javascriptinput: (element, display, answers) =>
answer_id = $(element).attr('id').split("_")[1...].join("_")
answer = JSON.parse(answers[answer_id])
display.showAnswer(answer)
inputtypeHideAnswerMethods:
choicegroup: (element, display) =>
element = $(element)
element.find('input').attr('disabled', null)
element.find('label').removeClass('choicegroup_correct')
javascriptinput: (element, display) =>
display.hideAnswer()
......@@ -2,6 +2,7 @@ class @Sequence
constructor: (element) ->
@el = $(element).find('.sequence')
@contents = @$('.seq_contents')
@num_contents = @contents.length
@id = @el.data('id')
@modx_url = @el.data('course_modx_root')
@initProgress()
......@@ -86,22 +87,34 @@ class @Sequence
XModule.loadModules('display', @$('#seq_content'))
MathJax.Hub.Queue(["Typeset", MathJax.Hub, "seq_content"]) # NOTE: Actually redundant. Some other MathJax call also being performed
window.update_schematics() # For embedded circuit simulator exercises in 6.002x
@position = new_position
@toggleArrows()
@hookUpProgressEvent()
sequence_links = @$('#seq_content a.seqnav')
sequence_links.click @goto
goto: (event) =>
event.preventDefault()
new_position = $(event.target).data('element')
Logger.log "seq_goto", old: @position, new: new_position, id: @id
if $(event.target).hasClass 'seqnav' # Links from courseware <a class='seqnav' href='n'>...</a>
new_position = $(event.target).attr('href')
else # Tab links generated by backend template
new_position = $(event.target).data('element')
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
# for queued submissions, see ../capa/display.coffee
if window.queuePollerID
window.clearTimeout(window.queuePollerID)
delete window.queuePollerID
# On Sequence chage, destroy any existing polling thread
# for queued submissions, see ../capa/display.coffee
if window.queuePollerID
window.clearTimeout(window.queuePollerID)
delete window.queuePollerID
@render new_position
@render new_position
else
alert 'Sequence error! Cannot navigate to tab ' + new_position + 'in the current SequenceModule. Please contact the course staff.'
next: (event) =>
event.preventDefault()
......
......@@ -3,6 +3,7 @@ class @Video
@el = $(element).find('.video')
@id = @el.attr('id').replace(/video_/, '')
@caption_data_dir = @el.data('caption-data-dir')
@show_captions = @el.data('show-captions') == "true"
window.player = null
@el = $("#video_#{@id}")
@parseVideos @el.data('streams')
......
class @VideoCaption extends Subview
initialize: ->
@loaded = false
bind: ->
$(window).bind('resize', @resize)
@$('.hide-subtitles').click @toggle
......@@ -10,8 +13,12 @@ class @VideoCaption extends Subview
"/static/#{@captionDataDir}/subs/#{@youtubeId}.srt.sjson"
render: ->
# TODO: make it so you can have a video with no captions.
#@$('.video-wrapper').after """
# <ol class="subtitles"><li>Attempting to load captions...</li></ol>
# """
@$('.video-wrapper').after """
<ol class="subtitles"><li>Attempting to load captions...</li></ol>
<ol class="subtitles"></ol>
"""
@$('.video-controls .secondary-controls').append """
<a href="#" class="hide-subtitles" title="Turn off captions">Captions</a>
......@@ -24,6 +31,8 @@ class @VideoCaption extends Subview
@captions = captions.text
@start = captions.start
@loaded = true
if onTouchBasedDevice()
$('.subtitles li').html "Caption will be displayed when you start playing the video."
else
......@@ -47,37 +56,40 @@ class @VideoCaption extends Subview
@rendered = true
search: (time) ->
min = 0
max = @start.length - 1
while min < max
index = Math.ceil((max + min) / 2)
if time < @start[index]
max = index - 1
if time >= @start[index]
min = index
return min
if @loaded
min = 0
max = @start.length - 1
while min < max
index = Math.ceil((max + min) / 2)
if time < @start[index]
max = index - 1
if time >= @start[index]
min = index
return min
play: ->
@renderCaption() unless @rendered
@playing = true
if @loaded
@renderCaption() unless @rendered
@playing = true
pause: ->
@playing = false
if @loaded
@playing = false
updatePlayTime: (time) ->
# This 250ms offset is required to match the video speed
time = Math.round(Time.convert(time, @currentSpeed, '1.0') * 1000 + 250)
newIndex = @search time
if newIndex != undefined && @currentIndex != newIndex
if @currentIndex
@$(".subtitles li.current").removeClass('current')
@$(".subtitles li[data-index='#{newIndex}']").addClass('current')
@currentIndex = newIndex
@scrollCaption()
if @loaded
# This 250ms offset is required to match the video speed
time = Math.round(Time.convert(time, @currentSpeed, '1.0') * 1000 + 250)
newIndex = @search time
if newIndex != undefined && @currentIndex != newIndex
if @currentIndex
@$(".subtitles li.current").removeClass('current')
@$(".subtitles li[data-index='#{newIndex}']").addClass('current')
@currentIndex = newIndex
@scrollCaption()
resize: =>
@$('.subtitles').css maxHeight: @captionHeight()
......
......@@ -13,18 +13,21 @@ from xmodule.errortracker import ErrorLog, make_error_tracker
log = logging.getLogger('mitx.' + 'modulestore')
URL_RE = re.compile("""
(?P<tag>[^:]+)://
(?P<org>[^/]+)/
(?P<course>[^/]+)/
(?P<category>[^/]+)/
(?P<name>[^/]+)
(/(?P<revision>[^/]+))?
(?P<name>[^@]+)
(@(?P<revision>[^/]+))?
""", re.VERBOSE)
# TODO (cpennington): We should decide whether we want to expand the
# list of valid characters in a location
INVALID_CHARS = re.compile(r"[^\w.-]")
# Names are allowed to have colons.
INVALID_CHARS_NAME = re.compile(r"[^\w.:-]")
_LocationBase = namedtuple('LocationBase', 'tag org course category name revision')
......@@ -34,7 +37,7 @@ class Location(_LocationBase):
Encodes a location.
Locations representations of URLs of the
form {tag}://{org}/{course}/{category}/{name}[/{revision}]
form {tag}://{org}/{course}/{category}/{name}[@{revision}]
However, they can also be represented a dictionaries (specifying each component),
tuples or list (specified in order), or as strings of the url
......@@ -81,7 +84,7 @@ class Location(_LocationBase):
location - Can be any of the following types:
string: should be of the form
{tag}://{org}/{course}/{category}/{name}[/{revision}]
{tag}://{org}/{course}/{category}/{name}[@{revision}]
list: should be of the form [tag, org, course, category, name, revision]
......@@ -99,10 +102,11 @@ class Location(_LocationBase):
ommitted.
Components must be composed of alphanumeric characters, or the
characters '_', '-', and '.'
characters '_', '-', and '.'. The name component is additionally allowed to have ':',
which is interpreted specially for xml storage.
Components may be set to None, which may be interpreted by some contexts
to mean wildcard selection
Components may be set to None, which may be interpreted in some contexts
to mean wildcard selection.
"""
......@@ -116,14 +120,23 @@ class Location(_LocationBase):
return _LocationBase.__new__(_cls, *([None] * 6))
def check_dict(dict_):
check_list(dict_.itervalues())
# Order matters, so flatten out into a list
keys = ['tag', 'org', 'course', 'category', 'name', 'revision']
list_ = [dict_[k] for k in keys]
check_list(list_)
def check_list(list_):
for val in list_:
if val is not None and INVALID_CHARS.search(val) is not None:
def check(val, regexp):
if val is not None and regexp.search(val) is not None:
log.debug('invalid characters val="%s", list_="%s"' % (val, list_))
raise InvalidLocationError(location)
list_ = list(list_)
for val in list_[:4] + [list_[5]]:
check(val, INVALID_CHARS)
# names allow colons
check(list_[4], INVALID_CHARS_NAME)
if isinstance(location, basestring):
match = URL_RE.match(location)
if match is None:
......@@ -162,7 +175,7 @@ class Location(_LocationBase):
"""
url = "{tag}://{org}/{course}/{category}/{name}".format(**self.dict())
if self.revision:
url += "/" + self.revision
url += "@" + self.revision
return url
def html_id(self):
......@@ -170,6 +183,7 @@ class Location(_LocationBase):
Return a string with a version of the location that is safe for use in
html id attributes
"""
# TODO: is ':' ok in html ids?
return "-".join(str(v) for v in self.list()
if v is not None).replace('.', '_')
......
......@@ -5,8 +5,8 @@ Passes settings.MODULESTORE as kwargs to MongoModuleStore
"""
from __future__ import absolute_import
from importlib import import_module
from os import environ
from django.conf import settings
......@@ -43,3 +43,8 @@ def modulestore(name='default'):
)
return _MODULESTORES[name]
# if 'DJANGO_SETTINGS_MODULE' in environ:
# # Initialize the modulestores immediately
# for store_name in settings.MODULESTORE:
# modulestore(store_name)
......@@ -6,15 +6,14 @@ from .exceptions import (ItemNotFoundError, NoPathToItem)
from . import ModuleStore, Location
def path_to_location(modulestore, location, course_name=None):
def path_to_location(modulestore, course_id, location):
'''
Try to find a course_id/chapter/section[/position] path to location in
modulestore. The courseware insists that the first level in the course is
chapter, but any kind of module can be a "section".
location: something that can be passed to Location
course_name: [optional]. If not None, restrict search to paths
in that course.
course_id: Search for paths in this course.
raise ItemNotFoundError if the location doesn't exist.
......@@ -27,7 +26,7 @@ def path_to_location(modulestore, location, course_name=None):
A location may be accessible via many paths. This method may
return any valid path.
If the section is a sequence, position will be the position
If the section is a sequential or vertical, position will be the position
of this location in that sequence. Otherwise, position will
be None. TODO (vshnayder): Not true yet.
'''
......@@ -41,7 +40,7 @@ def path_to_location(modulestore, location, course_name=None):
xs = xs[1]
return p
def find_path_to_course(location, course_name=None):
def find_path_to_course():
'''Find a path up the location graph to a node with the
specified category.
......@@ -69,7 +68,8 @@ def path_to_location(modulestore, location, course_name=None):
# print 'Processing loc={0}, path={1}'.format(loc, path)
if loc.category == "course":
if course_name is None or course_name == loc.name:
# confirm that this is the right course
if course_id == CourseDescriptor.location_to_id(loc):
# Found it!
path = (loc, path)
return flatten(path)
......@@ -81,17 +81,34 @@ def path_to_location(modulestore, location, course_name=None):
# If we're here, there is no path
return None
path = find_path_to_course(location, course_name)
path = find_path_to_course()
if path is None:
raise(NoPathToItem(location))
raise NoPathToItem(location)
n = len(path)
course_id = CourseDescriptor.location_to_id(path[0])
# pull out the location names
chapter = path[1].name if n > 1 else None
section = path[2].name if n > 2 else None
# TODO (vshnayder): not handling position at all yet...
# Figure out the position
position = None
# This block of code will find the position of a module within a nested tree
# of modules. If a problem is on tab 2 of a sequence that's on tab 3 of a
# sequence, the resulting position is 3_2. However, no positional modules
# (e.g. sequential and videosequence) currently deal with this form of
# representing nested positions. This needs to happen before jumping to a
# module nested in more than one positional module will work.
if n > 3:
position_list = []
for path_index in range(2, n-1):
category = path[path_index].category
if category == 'sequential' or category == 'videosequence':
section_desc = modulestore.get_instance(course_id, path[path_index])
child_locs = [c.location for c in section_desc.get_children()]
# positions are 1-indexed, and should be strings to be consistent with
# url parsing.
position_list.append(str(child_locs.index(path[path_index+1]) + 1))
position = "_".join(position_list)
return (course_id, chapter, section, position)
from path import path
# from ~/mitx_all/mitx/common/lib/xmodule/xmodule/modulestore/tests/
# to ~/mitx_all/mitx/common/test
TEST_DIR = path(__file__).abspath().dirname()
for i in range(5):
TEST_DIR = TEST_DIR.dirname()
TEST_DIR = TEST_DIR / 'test'
DATA_DIR = TEST_DIR / 'data'
......@@ -10,7 +10,7 @@ def check_string_roundtrip(url):
def test_string_roundtrip():
check_string_roundtrip("tag://org/course/category/name")
check_string_roundtrip("tag://org/course/category/name/revision")
check_string_roundtrip("tag://org/course/category/name@revision")
input_dict = {
......@@ -21,18 +21,28 @@ input_dict = {
'org': 'org'
}
also_valid_dict = {
'tag': 'tag',
'course': 'course',
'category': 'category',
'name': 'name:more_name',
'org': 'org'
}
input_list = ['tag', 'org', 'course', 'category', 'name']
input_str = "tag://org/course/category/name"
input_str_rev = "tag://org/course/category/name/revision"
input_str_rev = "tag://org/course/category/name@revision"
valid = (input_list, input_dict, input_str, input_str_rev)
valid = (input_list, input_dict, input_str, input_str_rev, also_valid_dict)
invalid_dict = {
'tag': 'tag',
'course': 'course',
'category': 'category',
'name': 'name/more_name',
'name': 'name@more_name',
'org': 'org'
}
......@@ -45,8 +55,9 @@ invalid_dict2 = {
}
invalid = ("foo", ["foo"], ["foo", "bar"],
["foo", "bar", "baz", "blat", "foo/bar"],
"tag://org/course/category/name with spaces/revision",
["foo", "bar", "baz", "blat:blat", "foo:bar"], # ':' ok in name, not in category
"tag://org/course/category/name with spaces@revision",
"tag://org/course/category/name/with/slashes@revision",
invalid_dict,
invalid_dict2)
......@@ -62,16 +73,15 @@ def test_dict():
assert_equals(dict(revision=None, **input_dict), Location(input_dict).dict())
input_dict['revision'] = 'revision'
assert_equals("tag://org/course/category/name/revision", Location(input_dict).url())
assert_equals("tag://org/course/category/name@revision", Location(input_dict).url())
assert_equals(input_dict, Location(input_dict).dict())
def test_list():
assert_equals("tag://org/course/category/name", Location(input_list).url())
assert_equals(input_list + [None], Location(input_list).list())
input_list.append('revision')
assert_equals("tag://org/course/category/name/revision", Location(input_list).url())
assert_equals("tag://org/course/category/name@revision", Location(input_list).url())
assert_equals(input_list, Location(input_list).list())
......@@ -87,8 +97,10 @@ def test_none():
def test_invalid_locations():
assert_raises(InvalidLocationError, Location, "foo")
assert_raises(InvalidLocationError, Location, ["foo", "bar"])
assert_raises(InvalidLocationError, Location, ["foo", "bar", "baz", "blat/blat", "foo"])
assert_raises(InvalidLocationError, Location, ["foo", "bar", "baz", "blat", "foo/bar"])
assert_raises(InvalidLocationError, Location, "tag://org/course/category/name with spaces/revision")
assert_raises(InvalidLocationError, Location, "tag://org/course/category/name with spaces@revision")
assert_raises(InvalidLocationError, Location, "tag://org/course/category/name/revision")
def test_equality():
......
from nose.tools import assert_equals, assert_raises, assert_not_equals, with_setup
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule.modulestore.search import path_to_location
def check_path_to_location(modulestore):
'''Make sure that path_to_location works: should be passed a modulestore
with the toy and simple courses loaded.'''
should_work = (
("i4x://edX/toy/video/Welcome",
("edX/toy/2012_Fall", "Overview", "Welcome", None)),
("i4x://edX/toy/chapter/Overview",
("edX/toy/2012_Fall", "Overview", None, None)),
)
course_id = "edX/toy/2012_Fall"
for location, expected in should_work:
assert_equals(path_to_location(modulestore, course_id, location), expected)
not_found = (
"i4x://edX/toy/video/WelcomeX", "i4x://edX/toy/course/NotHome"
)
for location in not_found:
assert_raises(ItemNotFoundError, path_to_location, modulestore, course_id, location)
# Since our test files are valid, there shouldn't be any
# elements with no path to them. But we can look for them in
# another course.
no_path = (
"i4x://edX/simple/video/Lost_Video",
)
for location in no_path:
assert_raises(NoPathToItem, path_to_location, modulestore, course_id, location)
import pymongo
from nose.tools import assert_equals, assert_raises, assert_not_equals, with_setup
from path import path
from pprint import pprint
from xmodule.modulestore import Location
from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
from xmodule.modulestore.mongo import MongoModuleStore
from xmodule.modulestore.xml_importer import import_from_xml
from xmodule.modulestore.search import path_to_location
# from ~/mitx_all/mitx/common/lib/xmodule/xmodule/modulestore/tests/
# to ~/mitx_all/mitx/common/test
TEST_DIR = path(__file__).abspath().dirname()
for i in range(5):
TEST_DIR = TEST_DIR.dirname()
TEST_DIR = TEST_DIR / 'test'
DATA_DIR = TEST_DIR / 'data'
from .test_modulestore import check_path_to_location
from . import DATA_DIR
HOST = 'localhost'
......@@ -110,27 +101,5 @@ class TestMongoModuleStore(object):
def test_path_to_location(self):
'''Make sure that path_to_location works'''
should_work = (
("i4x://edX/toy/video/Welcome",
("edX/toy/2012_Fall", "Overview", "Welcome", None)),
("i4x://edX/toy/chapter/Overview",
("edX/toy/2012_Fall", "Overview", None, None)),
)
for location, expected in should_work:
assert_equals(path_to_location(self.store, location), expected)
not_found = (
"i4x://edX/toy/video/WelcomeX", "i4x://edX/toy/course/NotHome"
)
for location in not_found:
assert_raises(ItemNotFoundError, path_to_location, self.store, location)
# Since our test files are valid, there shouldn't be any
# elements with no path to them. But we can look for them in
# another course.
no_path = (
"i4x://edX/simple/video/Lost_Video",
)
for location in no_path:
assert_raises(NoPathToItem, path_to_location, self.store, location, "toy")
check_path_to_location(self.store)
from xmodule.modulestore import Location
from xmodule.modulestore.xml import XMLModuleStore
from xmodule.modulestore.xml_importer import import_from_xml
from .test_modulestore import check_path_to_location
from . import DATA_DIR
class TestXMLModuleStore(object):
def test_path_to_location(self):
"""Make sure that path_to_location works properly"""
print "Starting import"
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy', 'simple'])
print "finished import"
check_path_to_location(modulestore)
......@@ -6,7 +6,7 @@ from .exceptions import DuplicateItemError
log = logging.getLogger(__name__)
def import_from_xml(store, data_dir, course_dirs=None, eager=True,
def import_from_xml(store, data_dir, course_dirs=None,
default_class='xmodule.raw_module.RawDescriptor'):
"""
Import the specified xml data_dir into the "store" modulestore,
......@@ -19,7 +19,6 @@ def import_from_xml(store, data_dir, course_dirs=None, eager=True,
module_store = XMLModuleStore(
data_dir,
default_class=default_class,
eager=eager,
course_dirs=course_dirs
)
for course_id in module_store.modules.keys():
......
......@@ -39,9 +39,14 @@ class Progress(object):
isinstance(b, numbers.Number)):
raise TypeError('a and b must be numbers. Passed {0}/{1}'.format(a, b))
if not (0 <= a <= b and b > 0):
raise ValueError(
'fraction a/b = {0}/{1} must have 0 <= a <= b and b > 0'.format(a, b))
if a > b:
a = b
if a < 0:
a = 0
if b <= 0:
raise ValueError('fraction a/b = {0}/{1} must have b > 0'.format(a, b))
self._a = a
self._b = b
......
......@@ -29,6 +29,8 @@ class SequenceModule(XModule):
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:
......
......@@ -12,9 +12,17 @@ def stringify_children(node):
fixed from
http://stackoverflow.com/questions/4624062/get-all-text-inside-a-tag-in-lxml
'''
parts = ([node.text] +
list(chain(*([etree.tostring(c), c.tail]
for c in node.getchildren())
)))
# Useful things to know:
# node.tostring() -- generates xml for the node, including start
# and end tags. We'll use this for the children.
# node.text -- the text after the end of a start tag to the start
# of the first child
# node.tail -- the text after the end this tag to the start of the
# next element.
parts = [node.text]
for c in node.getchildren():
parts.append(etree.tostring(c, with_tail=True))
# filter removes possible Nones in texts and tails
return ''.join(filter(None, parts))
......@@ -49,7 +49,7 @@ class RoundTripTestCase(unittest.TestCase):
copytree(data_dir / course_dir, root_dir / course_dir)
print "Starting import"
initial_import = XMLModuleStore(root_dir, eager=True, course_dirs=[course_dir])
initial_import = XMLModuleStore(root_dir, course_dirs=[course_dir])
courses = initial_import.get_courses()
self.assertEquals(len(courses), 1)
......@@ -66,7 +66,7 @@ class RoundTripTestCase(unittest.TestCase):
course_xml.write(xml)
print "Starting second import"
second_import = XMLModuleStore(root_dir, eager=True, course_dirs=[course_dir])
second_import = XMLModuleStore(root_dir, course_dirs=[course_dir])
courses2 = second_import.get_courses()
self.assertEquals(len(courses2), 1)
......
......@@ -9,91 +9,23 @@
Write a program to compute the square of a number
<coderesponse tests="repeat:2,generate">
<textbox rows="10" cols="70" mode="python"/>
<answer><![CDATA[
initial_display = """
def square(n):
"""
answer = """
def square(n):
return n**2
"""
preamble = """
import sys, time
"""
test_program = """
import random
import operator
def testSquare(n = None):
if n is None:
n = random.randint(2, 20)
print 'Test is: square(%d)'%n
return str(square(n))
def main():
f = os.fdopen(3,'w')
test = int(sys.argv[1])
rndlist = map(int,os.getenv('rndlist').split(','))
random.seed(rndlist[0])
if test == 1: f.write(testSquare(0))
elif test == 2: f.write(testSquare(1))
else: f.write(testSquare())
f.close()
main()
sys.exit(0)
"""
]]>
</answer>
<codeparam>
<initial_display>def square(x):</initial_display>
<answer_display>answer</answer_display>
<grader_payload>grader stuff</grader_payload>
</codeparam>
</coderesponse>
</text>
<text>
Write a program to compute the cube of a number
Write a program to compute the square of a number
<coderesponse tests="repeat:2,generate">
<textbox rows="10" cols="70" mode="python"/>
<answer><![CDATA[
initial_display = """
def cube(n):
"""
answer = """
def cube(n):
return n**3
"""
preamble = """
import sys, time
"""
test_program = """
import random
import operator
def testCube(n = None):
if n is None:
n = random.randint(2, 20)
print 'Test is: cube(%d)'%n
return str(cube(n))
def main():
f = os.fdopen(3,'w')
test = int(sys.argv[1])
rndlist = map(int,os.getenv('rndlist').split(','))
random.seed(rndlist[0])
if test == 1: f.write(testCube(0))
elif test == 2: f.write(testCube(1))
else: f.write(testCube())
f.close()
main()
sys.exit(0)
"""
]]>
</answer>
<codeparam>
<initial_display>def square(x):</initial_display>
<answer_display>answer</answer_display>
<grader_payload>grader stuff</grader_payload>
</codeparam>
</coderesponse>
</text>
......
<problem>
<text>
<h2>Code response</h2>
<p>
</p>
<text>
Write a program to compute the square of a number
<coderesponse tests="repeat:2,generate">
<textbox rows="10" cols="70" mode="python"/>
<answer><![CDATA[
initial_display = """
def square(n):
"""
answer = """
def square(n):
return n**2
"""
preamble = """
import sys, time
"""
test_program = """
import random
import operator
def testSquare(n = None):
if n is None:
n = random.randint(2, 20)
print 'Test is: square(%d)'%n
return str(square(n))
def main():
f = os.fdopen(3,'w')
test = int(sys.argv[1])
rndlist = map(int,os.getenv('rndlist').split(','))
random.seed(rndlist[0])
if test == 1: f.write(testSquare(0))
elif test == 2: f.write(testSquare(1))
else: f.write(testSquare())
f.close()
main()
sys.exit(0)
"""
]]>
</answer>
</coderesponse>
</text>
<text>
Write a program to compute the cube of a number
<coderesponse tests="repeat:2,generate">
<textbox rows="10" cols="70" mode="python"/>
<answer><![CDATA[
initial_display = """
def cube(n):
"""
answer = """
def cube(n):
return n**3
"""
preamble = """
import sys, time
"""
test_program = """
import random
import operator
def testCube(n = None):
if n is None:
n = random.randint(2, 20)
print 'Test is: cube(%d)'%n
return str(cube(n))
def main():
f = os.fdopen(3,'w')
test = int(sys.argv[1])
rndlist = map(int,os.getenv('rndlist').split(','))
random.seed(rndlist[0])
if test == 1: f.write(testCube(0))
elif test == 2: f.write(testCube(1))
else: f.write(testCube())
f.close()
main()
sys.exit(0)
"""
]]>
</answer>
</coderesponse>
</text>
</text>
</problem>
// Generated by CoffeeScript 1.3.3
(function() {
var MinimaxProblemDisplay, root,
__hasProp = {}.hasOwnProperty,
__extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; };
MinimaxProblemDisplay = (function(_super) {
__extends(MinimaxProblemDisplay, _super);
function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) {
this.state = state;
this.submission = submission;
this.evaluation = evaluation;
this.container = container;
this.submissionField = submissionField;
this.parameters = parameters != null ? parameters : {};
MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters);
}
MinimaxProblemDisplay.prototype.render = function() {};
MinimaxProblemDisplay.prototype.createSubmission = function() {
var id, value, _ref, _results;
this.newSubmission = {};
if (this.submission != null) {
_ref = this.submission;
_results = [];
for (id in _ref) {
value = _ref[id];
_results.push(this.newSubmission[id] = value);
}
return _results;
}
};
MinimaxProblemDisplay.prototype.getCurrentSubmission = function() {
return this.newSubmission;
};
return MinimaxProblemDisplay;
})(XProblemDisplay);
root = typeof exports !== "undefined" && exports !== null ? exports : this;
root.TestProblemDisplay = TestProblemDisplay;
}).call(this);
;
......@@ -193,7 +193,7 @@ class ImportTestCase(unittest.TestCase):
"""Make sure that metadata is inherited properly"""
print "Starting import"
initial_import = XMLModuleStore(DATA_DIR, eager=True, course_dirs=['toy'])
initial_import = XMLModuleStore(DATA_DIR, course_dirs=['toy'])
courses = initial_import.get_courses()
self.assertEquals(len(courses), 1)
......@@ -216,7 +216,7 @@ class ImportTestCase(unittest.TestCase):
def get_course(name):
print "Importing {0}".format(name)
modulestore = XMLModuleStore(DATA_DIR, eager=True, course_dirs=[name])
modulestore = XMLModuleStore(DATA_DIR, course_dirs=[name])
courses = modulestore.get_courses()
self.assertEquals(len(courses), 1)
return courses[0]
......@@ -236,6 +236,10 @@ class ImportTestCase(unittest.TestCase):
# Also check that the grading policy loaded
self.assertEqual(two_toys.grade_cutoffs['C'], 0.5999)
# 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)
def test_definition_loading(self):
"""When two courses share the same org and course name and
......@@ -245,7 +249,7 @@ class ImportTestCase(unittest.TestCase):
happen--locations should uniquely name definitions. But in
our imperfect XML world, it can (and likely will) happen."""
modulestore = XMLModuleStore(DATA_DIR, eager=True, course_dirs=['toy', 'two_toys'])
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy', 'two_toys'])
toy_id = "edX/toy/2012_Fall"
two_toy_id = "edX/toy/TT_2012_Fall"
......@@ -255,3 +259,61 @@ class ImportTestCase(unittest.TestCase):
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")
def test_colon_in_url_name(self):
"""Ensure that colons in url_names convert to file paths properly"""
print "Starting import"
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy'])
courses = modulestore.get_courses()
self.assertEquals(len(courses), 1)
course = courses[0]
course_id = course.id
print "course errors:"
for (msg, err) in modulestore.get_item_errors(course.location):
print msg
print err
chapters = course.get_children()
self.assertEquals(len(chapters), 2)
ch2 = chapters[1]
self.assertEquals(ch2.url_name, "secret:magic")
print "Ch2 location: ", ch2.location
also_ch2 = modulestore.get_instance(course_id, ch2.location)
self.assertEquals(ch2, also_ch2)
print "making sure html loaded"
cloc = course.location
loc = Location(cloc.tag, cloc.org, cloc.course, 'html', 'secret:toylab')
html = modulestore.get_instance(course_id, loc)
self.assertEquals(html.display_name, "Toy lab")
def test_url_name_mangling(self):
"""
Make sure that url_names are only mangled once.
"""
modulestore = XMLModuleStore(DATA_DIR, course_dirs=['toy'])
toy_id = "edX/toy/2012_Fall"
course = modulestore.get_courses()[0]
chapters = course.get_children()
ch1 = chapters[0]
sections = ch1.get_children()
self.assertEqual(len(sections), 4)
for i in (2,3):
video = sections[i]
# Name should be 'video_{hash}'
print "video {0} url_name: {1}".format(i, video.url_name)
self.assertEqual(len(video.url_name), len('video_') + 12)
from nose.tools import assert_equals
from nose.tools import assert_equals, assert_true, assert_false
from lxml import etree
from xmodule.stringify import stringify_children
......@@ -8,3 +8,32 @@ def test_stringify():
xml = etree.fromstring(html)
out = stringify_children(xml)
assert_equals(out, text)
def test_stringify_again():
html = """<html name="Voltage Source Answer" >A voltage source is non-linear!
<div align="center">
<img src="/static/images/circuits/voltage-source.png"/>
\(V=V_C\)
</div>
But it is <a href="http://mathworld.wolfram.com/AffineFunction.html">affine</a>,
which means linear except for an offset.
</html>
"""
html = """<html>A voltage source is non-linear!
<div align="center">
</div>
But it is <a href="http://mathworld.wolfram.com/AffineFunction.html">affine</a>,
which means linear except for an offset.
</html>
"""
xml = etree.fromstring(html)
out = stringify_children(xml)
print "output:"
print out
# Tracking strange content repeating bug
# Should appear once
assert_equals(out.count("But it is "), 1)
......@@ -30,6 +30,7 @@ class VideoModule(XModule):
xmltree = etree.fromstring(self.definition['data'])
self.youtube = xmltree.get('youtube')
self.position = 0
self.show_captions = xmltree.get('show_captions', 'true')
if instance_state is not None:
state = json.loads(instance_state)
......@@ -75,6 +76,7 @@ class VideoModule(XModule):
'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'],
'show_captions': self.show_captions
})
......
......@@ -212,7 +212,7 @@ class XModule(HTMLSnippet):
return self.metadata.get('display_name',
self.url_name.replace('_', ' '))
def __unicode__(self):
return '<x_module(name=%s, category=%s, id=%s)>' % (self.name, self.category, self.id)
return '<x_module(id={0})>'.format(self.id)
def get_children(self):
'''
......@@ -465,6 +465,16 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
return self._child_instances
def get_child_by_url_name(self, url_name):
"""
Return a child XModuleDescriptor with the specified url_name, if it exists, and None otherwise.
"""
for c in self.get_children():
if c.url_name == url_name:
return c
return None
def xmodule_constructor(self, system):
"""
Returns a constructor for an XModule. This constructor takes two
......@@ -544,7 +554,13 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
# Put import here to avoid circular import errors
from xmodule.error_module import ErrorDescriptor
msg = "Error loading from xml."
log.warning(msg + " " + str(err))
log.warning(msg + " " + str(err)[:200])
# 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)
system.error_tracker(msg)
err_msg = msg + "\n" + exc_info_to_str(sys.exc_info())
descriptor = ErrorDescriptor.from_xml(xml_data, system, org, course,
......@@ -717,7 +733,8 @@ class ModuleSystem(object):
filestore=None,
debug=False,
xqueue=None,
node_path=""):
node_path="",
anonymous_student_id=''):
'''
Create a closure around the system environment.
......@@ -742,11 +759,16 @@ class ModuleSystem(object):
at settings.DATA_DIR.
xqueue - Dict containing XqueueInterface object, as well as parameters
for the specific StudentModule
for the specific StudentModule:
xqueue = {'interface': XQueueInterface object,
'callback_url': Callback into the LMS,
'queue_name': Target queuename in Xqueue}
replace_urls - TEMPORARY - A function like static_replace.replace_urls
that capa_module can use to fix up the static urls in
ajax results.
anonymous_student_id - Used for tracking modules with student id
'''
self.ajax_url = ajax_url
self.xqueue = xqueue
......@@ -758,6 +780,8 @@ class ModuleSystem(object):
self.seed = user.id if user is not None else 0
self.replace_urls = replace_urls
self.node_path = node_path
self.anonymous_student_id = anonymous_student_id
self.user_is_staff = user is not None and user.is_staff
def get(self, attr):
''' provide uniform access to attributes (like etree).'''
......
......@@ -12,6 +12,15 @@ import sys
log = logging.getLogger(__name__)
edx_xml_parser = etree.XMLParser(dtd_validation=False, load_dtd=False,
remove_comments=True, remove_blank_text=True)
def name_to_pathname(name):
"""
Convert a location name for use in a path: replace ':' with '/'.
This allows users of the xml format to organize content into directories
"""
return name.replace(':', '/')
def is_pointer_tag(xml_obj):
"""
......@@ -90,10 +99,14 @@ class XmlDescriptor(XModuleDescriptor):
# A dictionary mapping xml attribute names AttrMaps that describe how
# to import and export them
# Allow json to specify either the string "true", or the bool True. The string is preferred.
to_bool = lambda val: val == 'true' or val == True
from_bool = lambda val: str(val).lower()
bool_map = AttrMap(to_bool, from_bool)
xml_attribute_map = {
# type conversion: want True/False in python, "true"/"false" in xml
'graded': AttrMap(lambda val: val == 'true',
lambda val: str(val).lower()),
'graded': bool_map,
'hide_progress_tab': bool_map,
}
......@@ -140,7 +153,7 @@ class XmlDescriptor(XModuleDescriptor):
Returns an lxml Element
"""
return etree.parse(file_object).getroot()
return etree.parse(file_object, parser=edx_xml_parser).getroot()
@classmethod
def load_file(cls, filepath, fs, location):
......@@ -226,6 +239,16 @@ class XmlDescriptor(XModuleDescriptor):
@classmethod
def apply_policy(cls, metadata, policy):
"""
Add the keys in policy to metadata, after processing them
through the attrmap. Updates the metadata dict in place.
"""
for attr in policy:
attr_map = cls.xml_attribute_map.get(attr, AttrMap())
metadata[attr] = attr_map.from_xml(policy[attr])
@classmethod
def from_xml(cls, xml_data, system, org=None, course=None):
"""
Creates an instance of this descriptor from the supplied xml_data.
......@@ -245,8 +268,8 @@ class XmlDescriptor(XModuleDescriptor):
# VS[compat] -- detect new-style each-in-a-file mode
if is_pointer_tag(xml_object):
# new style:
# read the actual definition file--named using url_name
filepath = cls._format_filepath(xml_object.tag, url_name)
# read the actual definition file--named using url_name.replace(':','/')
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
......@@ -273,7 +296,7 @@ class XmlDescriptor(XModuleDescriptor):
# Set/override any metadata specified by policy
k = policy_key(location)
if k in system.policy:
metadata.update(system.policy[k])
cls.apply_policy(metadata, system.policy[k])
return cls(
system,
......@@ -292,7 +315,8 @@ class XmlDescriptor(XModuleDescriptor):
"""If this returns True, write the definition of this descriptor to a separate
file.
NOTE: Do not override this without a good reason. It is here specifically for customtag...
NOTE: Do not override this without a good reason. It is here
specifically for customtag...
"""
return True
......@@ -335,7 +359,8 @@ class XmlDescriptor(XModuleDescriptor):
if self.export_to_file():
# Write the definition to a file
filepath = self.__class__._format_filepath(self.category, self.url_name)
url_path = name_to_pathname(self.url_name)
filepath = self.__class__._format_filepath(self.category, url_path)
resource_fs.makedir(os.path.dirname(filepath), allow_recreate=True)
with resource_fs.open(filepath, 'w') as file:
file.write(etree.tostring(xml_object, pretty_print=True))
......
<sequential>
<video youtube="0.75:izygArpw-Qo,1.0:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" slug="Welcome" format="Video" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="Welcome"/>
<video url_name="welcome"/>
<sequential filename="System_Usage_Sequence" slug="System_Usage_Sequence" format="Lecture Sequence" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="System Usage Sequence"/>
<vertical slug="Lab0_Using_the_tools" format="Lab" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="Lab0: Using the tools">
<html slug="html_19" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never"> See the <a href="/section/labintro"> Lab Introduction </a> or <a href="/static/handouts/schematic_tutorial.pdf">Interactive Lab Usage Handout </a> for information on how to do the lab </html>
......
<course filename="6.002_Spring_2012" slug="6.002_Spring_2012" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="6.002 Spring 2012" start="2015-07-17T12:00" course="full" org="edx"/>
<course filename="6.002_Spring_2012" slug="6.002_Spring_2012" graceperiod="1 day 12 hours 59 minutes 59 seconds" showanswer="attempted" rerandomize="never" name="6.002 Spring 2012" start="2015-07-17T12:00" course="full" org="edX"/>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/sound.js"></script>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/plotter.js"></script>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/circuit.js"></script>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/mosfet_amplifier.js"></script>
<h2>LAB 5B: MOSFET AMPLIFIER EXPERIMENT</h2>
<section class="problem">
<startouttext />
<p>Note: This part of the lab is just to develop your intuition about
amplifiers and biasing, and to have fun with music! There are no responses
that need to be checked.</p>
<p>The graph plots the selected voltages from the amplifier circuit below. You
can also listen to various signals by selecting from the radio buttons to
the right of the graph. This way you can both see and hear various signals.
You can use the sliders to the right of the amplifier circuit to control
various parameters of the MOSFET and the amplifier. The parameter \(V_{MAX}\)
sets the maximum range on the plots. You can also select an input voltage
type (e.g., sine wave, square wave, various types of music) using the drop
down menu to the right of the graph. When describing AC signals, the
voltages on the sliders refer to peak-to-peak values.</p>
<p>1. To begin your first experiment, go ahead and use the pull down menu to
select a sine wave input. Then, adjust the sliders to an approximate
baseline setting shown below.</p>
<p>Baseline setting of sliders:
<br />
\(V_{S}=1.6V\), \(v_{IN}=3V\), \(Frequency=1000Hz\), \(V_{BIAS}=2.5V\), \(R=10K\Omega\), \(k=1mA/V^{2}\), \(V_{T}=1V\), \(V_{MAX}=2V\).</p>
<p>You will observe in the plot that the baseline setting of the sliders for
the various amplifiers parameters produces a distorted sine wave signal for
\(v_{OUT}\). Next, go ahead and select one of the music signals as the input and
listen to each of \(v_{IN}\) and \(v_{OUT}\), and confirm for yourself that the
output sounds distorted for the chosen slider settings. You will notice
that the graph now plots the music signal waveforms. Think about all the
reasons why the amplifier is producing a distorted output.</p>
<p>2. For the second experiment, we will study the amplifier's small signal
behavior. Select a sine wave as the input signal. To study the small
signal behavior, reduce the value of \(v_{IN}\) to 0.1V (peak-to-peak) by
using the \(v_{IN}\) slider. Keeping the rest of the parameters at their
baseline settings, derive an appropriate value of \(V_{BIAS}\) that will ensure
saturation region operation for the MOSFET for the 0.1V peak-to-peak swing
for \(v_{IN}\). Make sure to think about both positive and negative excursions
of the signals.</p>
</p>Next, use the \(V_{BIAS}\) slider to choose your computed value for \(V_{BIAS}\) and
see if the observed plot of \(v_{OUT}\) is more or less distortion free. If
your calculation was right, then the output will indeed be distortion free.</p>
<p>Next, select one of the music signals as the input and listen to each of
\(v_{IN}\) and \(v_{OUT}\), and confirm for yourself that the output sounds much
better than in Step 1. Also, based on sound volume, confirm that \(v_{OUT}\) is
an amplified version of \(v_{IN}\).</p>
<p>3. Now go ahead and experiment with various other settings while listening
to the music signal at \(v_{OUT}\). Observe the plots and listen to \(v_{OUT}\) as
you change, for example, the bias voltage \(V_{BIAS}\). You will notice that
the amplifier distorts the input signal when \(V_{BIAS}\) becomes too small, or
when it becomes too large. You can also experiment with various values of
\(v_{IN}\), \(R_{L}\), etc., and see how they affect the amplification and distortion.</p>
<endouttext />
</section>
<section class="tool-wrapper">
<div id="controlls-container">
<div class="graph-controls">
<div class="music-wrapper">
<select id="musicTypeSelect" size="1">
<option value = "0">Zero Input</option>
<option value = "1">Unit Impulse</option>
<option value = "2">Unit Step</option>
<option selected="selected" value = "3">Sine Wave</option>
<option value = "4">Square Wave</option>
<option value = "5">Classical Music</option>
<option value = "6">Folk Music</option>
<option value = "7">Jazz Music</option>
<option value = "8">Reggae Music</option>
</select>
<input id="playButton" type="button" value="Play" />
</div>
<div class="inputs-wrapper">
<div id="graph-output">
<p>Graph:</p>
<ul>
<li><label for="vinCheckbox"><input id="vinCheckbox" type="checkbox" checked="yes"/>v<sub>IN</sub></label></li>
<li><label for="voutCheckbox"><input id="voutCheckbox" type="checkbox" checked="yes"/>v<sub>OUT</sub></label> </li>
<li><label for="vrCheckbox"><input id="vrCheckbox" type="checkbox"/>v<sub>R</sub></label></li>
</ul>
</div>
<div id="graph-listen">
<p>Listen to:</p>
<ul>
<li><label for="vinRadioButton"><input id="vinRadioButton" type="radio" checked="yes" name="listenToWhat"/>v<sub>IN</sub></label></li>
<li><label for="voutRadioButton"><input id="voutRadioButton" type="radio" name="listenToWhat"/>v<sub>OUT</sub></label></li>
<li><label for="vrRadioButton"><input id="vrRadioButton" type="radio" name="listenToWhat"/>v<sub>R</sub></label></li>
</ul>
</div>
</div>
</div>
<div class="schematic-sliders">
<div class="slider-label" id="vs"></div>
<div class="slider" id="vsSlider"></div>
<div class="slider-label" id="vin"></div>
<div class="slider" id="vinSlider"></div>
<div class="slider-label" id="freq"></div>
<div class="slider" id="freqSlider"></div>
<div class="slider-label" id="vbias"></div>
<div class="slider" id="vbiasSlider"></div>
<div class="slider-label" id="r"></div>
<div class="slider" id="rSlider"></div>
<div class="slider-label" id="k"></div>
<div class="slider" id="kSlider"></div>
<div class="slider-label" id="vt"></div>
<div class="slider" id="vtSlider"></div>
<div class="slider-label" id="vmax"></div>
<div class="slider" id="vmaxSlider"></div>
</div>
</div>
<div id="graph-container">
<canvas id="graph" width="500" height="500">Your browser must support the Canvas element and have JavaScript enabled to view this tool.</canvas>
<canvas id="diag1" width="500" height="500">Your browser must support the Canvas element and have JavaScript enabled to view this tool.</canvas>
</div>
</section>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/sound.js"></script>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/plotter.js"></script>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/circuit.js"></script>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/rc_filters.js"></script>
<h2>LAB 10B: RC FILTERS WITH FREQUENCY RESPONSE EXPERIMENT</h2>
<section class="problem">
<startouttext />
<p>Note: Use this part of the lab to build your intuition about filters and frequency response, and to have fun with music! There are no responses that need to be checked.</p>
<p>Recall from the audio lab in Week 5 that the graph plots the selected voltages from the circuit shown below. This week the circuit is an RC filter. You can also listen to various signals by selecting from the radio buttons to the right of the graph. This way you can both see and hear various signals. You can use the sliders to the right of the circuit to control various circuit and input signal parameters. (Note that you can get finer control of some of the slider values by clicking on the slider and using the arrow keys). Recall that the parameter \(V_{MAX}\) sets the maximum range on the graph. You can also select an input voltage type (e.g., sine wave, square wave, various types of music) using the drop down menu to the right of the graph. When describing AC signals, the voltages on the sliders refer to peak-to-peak values.</p>
<p>1. To begin your first experiment, use the pull down menu to select a sine wave input. Then, adjust the sliders to these approximate baseline settings:
<br />
\(v_{IN} = 3V\), \(Frequency = 1000 Hz\), \(V_{BIAS} = 0V\), \(R = 1K\Omega\), \(v_C(0) = 0V\), \(C = 110nF\), \(V_{MAX} = 2V\).
<br />
Observe the waveforms for \(v_{IN}\) and \(v_C\) in the graph. You can also listen to \(v_{IN}\) and \(v_C\). You will observe that the amplitude of \(v_C\) is slightly smaller than the amplitude of \(v_{IN}\).
<br />
Compute the break frequency of the filter circuit for the given circuit parameters. (Note that the break frequency is also called the cutoff frequency or the corner frequency).
<br />
Change the frequency of the sinusoid so that it is approximately 3 times the break frequency.
<br />
Observe the waveforms for \(v_{IN}\) and \(v_C\) in the graph. Also listen to \(v_{IN}\) and \(v_C\). Think about why the sinusoid at \(v_C\) is significantly more attenuated than the original 1KHz sinusoid.
<br />
Keeping the input signal unchanged, observe the waveforms for \(v_{IN}\) and \(v_R\) in the graph. Also listen to \(v_{IN}\) and \(v_R\). Think about why the sinusoid at \(v_R\) is significantly louder than the sinusoid at \(v_C\).</p>
<p>2. Next, use the pull down menu to select a music signal of your choice. Adjust the sliders to the approximate baseline settings:
<br />
\(v_{IN} = 3V\), \(V_{BIAS} = 0V\), \(R = 1K\Omega\), \(v_C(0) = 0V\), \(C = 110nF\), \(V_{MAX} = 2V\).
<br />
Listen to the signals at \(v_{IN}\) and \(v_C\). Notice any difference between the signals?
<br />
Next, increase the capacitance value and observe the difference in the sound of \(v_{IN}\) and \(v_C\) as the capacitance increases. You should notice that the higher frequency components of \(v_C\) are attenuated as the capacitance is increased.
Convince yourself that when the signal is taken at \(v_C\), the circuit behaves like a low-pass filter.</p>
<p>3. Re-adjust the sliders to the approximate baseline settings:
<br />
\(v_{IN} = 3V\), \(V_{BIAS} = 0V\), \(R = 1K\Omega\), \(v_C(0) = 0V\), \(C = 110nF\), \(V_{MAX} = 2V\).
<br />
Try to create a high-pass filter from the same circuit by taking the signal output across a different element and possibly changing some of the element values.
</p>
<endouttext />
</section>
<section class="tool-wrapper">
<div id="controlls-container">
<div class="graph-controls">
<div class="music-wrapper">
<select id="musicTypeSelect" size="1">
<option value = "0">Zero Input</option>
<option value = "1">Unit Impulse</option>
<option value = "2">Unit Step</option>
<option selected="selected" value = "3">Sine Wave</option>
<option value = "4">Square Wave</option>
<option value = "5">Classical Music</option>
<option value = "6">Folk Music</option>
<option value = "7">Jazz Music</option>
<option value = "8">Reggae Music</option>
</select>
<input id="playButton" type="button" value="Play" />
</div>
<div class="inputs-wrapper">
<div id="graph-output">
<p>Graph:</p>
<ul>
<li><label for="vinCheckbox"><input id="vinCheckbox" type="checkbox" checked="yes"/>v<sub>IN</sub></label></li>
<li><label for="vcCheckbox"><input id="vcCheckbox" type="checkbox" checked="yes"/>v<sub>C</sub></label> </li>
<li><label for="vrCheckbox"><input id="vrCheckbox" type="checkbox"/>v<sub>R</sub></label></li>
</ul>
</div>
<div id="graph-listen">
<p>Listen to:</p>
<ul>
<li><label for="vinRadioButton"><input id="vinRadioButton" type="radio" checked="yes" name="listenToWhat"/>v<sub>IN</sub></label></li>
<li><label for="vcRadioButton"><input id="vcRadioButton" type="radio" name="listenToWhat"/>v<sub>C</sub></label></li>
<li><label for="vrRadioButton"><input id="vrRadioButton" type="radio" name="listenToWhat"/>v<sub>R</sub></label></li>
</ul>
</div>
</div>
</div>
<div class="schematic-sliders">
<div class="slider-label" id="fc">f<sub>C</sub> = </div>
<div class="slider-label" id="vin"></div>
<div class="slider" id="vinSlider"></div>
<div class="slider-label" id="freq"></div>
<div class="slider" id="freqSlider"></div>
<div class="slider-label" id="vbias"></div>
<div class="slider" id="vbiasSlider"></div>
<div class="slider-label" id="r"></div>
<div class="slider" id="rSlider"></div>
<div class="slider-label" id="vc0"></div>
<div class="slider" id="vc0Slider"></div>
<div class="slider-label" id="c"></div>
<div class="slider" id="cSlider"></div>
<div class="slider-label" id="vmax"></div>
<div class="slider" id="vmaxSlider"></div>
</div>
</div>
<div id="graph-container">
<div id="graphTabs">
<ul>
<li><a href="#time">Time</a></li>
<li><a href="#magnitude">Magnitude</a></li>
<li><a href="#phase">Phase</a></li>
</ul>
<canvas id="time" width="500" height="500">Your browser must support the Canvas element and have JavaScript enabled to view this tool.</canvas>
<canvas id="magnitude" width="500" height="500">Your browser must support the Canvas element and have JavaScript enabled to view this tool.</canvas>
<canvas id="phase" width="500" height="500">Your browser must support the Canvas element and have JavaScript enabled to view this tool.</canvas>
</div>
<canvas id="diag2" width="500" height="500">Your browser must support the Canvas element and have JavaScript enabled to view this tool.</canvas>
</div>
</section>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/sound.js"></script>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/plotter.js"></script>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/circuit.js"></script>
<script type="text/javascript" src="/static/courses/6002/js/sound_labs/series_rlc.js"></script>
<h2>SERIES RLC CIRCUIT WITH FREQUENCY RESPONSE EXPERIMENT</h2>
<section class="problem">
<startouttext />
<p>\(I(s) = \frac{1}{R + Ls + 1/Cs}V_{in}(s) = \frac{s/L}{s^2 + sR/L + 1/LC}V_{in}(s)\)</p>
<p>\(I(s) = \frac{s/L}{s^2 + 2\alpha s + \omega_0^2}V_{in}(s)\)</p>
<p>\(\omega_0 = \frac{1}{\sqrt{LC}} , \alpha = \frac{R}{2L}\)</p>
<p>Band-Pass Filter:</p>
<p>\(V_r(s) = RI(s) = \frac{sR/L}{s^2 + 2\alpha s + \omega_0^2}V_{in}(s) = \frac{2\alpha s}{s^2 + 2\alpha s + \omega_0^2}V_{in}(s) = \frac{2\alpha s}{(s-s_1)(s-s_2)}V_{in}(s)\)</p>
<p>Gain magnitude: \(G_R = \frac{2\alpha w}{|j\omega - s_1||j\omega - s_2|}\)</p>
<p>Phase: \(\Phi_R = \pi/2-\Phi(j\omega - s_1) -\Phi(j\omega - s_2)\)</p>
<p>Low-Pass Filter:</p>
<p>\(V_c(s) = I(s)/sC = \frac{1/LC}{s^2 + 2\alpha s + \omega_0^2}V_{in}(s) = \frac{\omega_0^2}{s^2 + 2\alpha s + \omega_0^2}V_{in}(s) = \frac{\omega_0^2}{(s-s_1)(s-s_2)}V_{in}(s)\)</p>
<p>Gain magnitude: \(G_C = \frac{\omega_0^2}{|j\omega - s_1||j\omega - s_2|}\)</p>
<p>Phase: \(\Phi_C = -\Phi(j\omega - s_1) -\Phi(j\omega - s_2)\)</p>
<p>High-Pass Filter:</p>
<p>\(V_l(s) = sLI(s) = \frac{s^2}{s^2 + 2\alpha s + \omega_0^2}V_{in}(s) = \frac{s^2}{(s-s_1)(s-s_2)}V_{in}(s)\)</p>
<p>Gain magnitude: \(G_L = \frac{\omega^2}{|j\omega - s_1||j\omega - s_2|}\)</p>
<p>Phase: \(\Phi_L = -\Phi(j\omega - s_1) -\Phi(j\omega - s_2)\)</p>
<br />
<p>Under-Damped: \(\alpha < \omega_0\)</p>
<p>Complex roots: \(s_{1,2} = -\alpha \pm j\sqrt{\omega_0^2 - \alpha^2}\)</p>
<p>Critically-Damped: \(\alpha = \omega_0\)</p>
<p>Double real root: \(s_{1,2} = -\alpha\)</p>
<p>Over-Damped: \(\alpha > \omega_0\)</p>
<p>Real roots: \(s_{1,2} = -\alpha \pm\sqrt{\alpha^2 - \omega_0^2}\)</p>
<endouttext />
</section>
<section class="tool-wrapper">
<div id="controlls-container">
<div class="graph-controls">
<div class="music-wrapper">
<select id="musicTypeSelect" size="1">
<option value = "0">Zero Input</option>
<option value = "1">Unit Impulse</option>
<option value = "2">Unit Step</option>
<option selected="selected" value = "3">Sine Wave</option>
<option value = "4">Square Wave</option>
<option value = "5">Classical Music</option>
<option value = "6">Folk Music</option>
<option value = "7">Jazz Music</option>
<option value = "8">Reggae Music</option>
</select>
<input id="playButton" type="button" value="Play" />
</div>
<div class="inputs-wrapper">
<div id="graph-output">
<p>Graph:</p>
<ul>
<li><label for="vinCheckbox"><input id="vinCheckbox" type="checkbox" checked="yes"/>v<sub>IN</sub></label></li>
<li><label for="vrCheckbox"><input id="vrCheckbox" type="checkbox"/>v<sub>R</sub></label></li>
<li><label for="vlCheckbox"><input id="vlCheckbox" type="checkbox"/>v<sub>L</sub></label></li>
<li><label for="vcCheckbox"><input id="vcCheckbox" type="checkbox" checked="yes"/>v<sub>C</sub></label> </li>
</ul>
</div>
<div id="graph-listen">
<p>Listen to:</p>
<ul>
<li><label for="vinRadioButton"><input id="vinRadioButton" type="radio" checked="yes" name="listenToWhat"/>v<sub>IN</sub></label></li>
<li><label for="vrRadioButton"><input id="vrRadioButton" type="radio" name="listenToWhat"/>v<sub>R</sub></label></li>
<li><label for="vlRadioButton"><input id="vlRadioButton" type="radio" name="listenToWhat"/>v<sub>L</sub></label></li>
<li><label for="vcRadioButton"><input id="vcRadioButton" type="radio" name="listenToWhat"/>v<sub>C</sub></label></li>
</ul>
</div>
</div>
</div>
<div class="schematic-sliders">
<div class="slider-label" id="vin"></div>
<div class="slider" id="vinSlider"></div>
<div class="slider-label" id="freq"></div>
<div class="slider" id="freqSlider"></div>
<div class="slider-label" id="vbias"></div>
<div class="slider" id="vbiasSlider"></div>
<div class="slider-label" id="r"></div>
<div class="slider" id="rSlider"></div>
<div class="slider-label" id="l"></div>
<div class="slider" id="lSlider"></div>
<div class="slider-label" id="c"></div>
<div class="slider" id="cSlider"></div>
<div class="slider-label" id="vc0"></div>
<div class="slider" id="vc0Slider"></div>
<div class="slider-label" id="i0"></div>
<div class="slider" id="i0Slider"></div>
<div class="slider-label" id="vmax"></div>
<div class="slider" id="vmaxSlider"></div>
</div>
</div>
<div id="graph-container">
<div id="graphTabs">
<ul>
<li><a href="#time">Time</a></li>
<li><a href="#magnitude">Magnitude</a></li>
<li><a href="#phase">Phase</a></li>
</ul>
<canvas id="time" width="500" height="500">Your browser must support the Canvas element and have JavaScript enabled to view this tool.</canvas>
<canvas id="magnitude" width="500" height="500">Your browser must support the Canvas element and have JavaScript enabled to view this tool.</canvas>
<canvas id="phase" width="500" height="500">Your browser must support the Canvas element and have JavaScript enabled to view this tool.</canvas>
</div>
<canvas id="diag3" width="500" height="500">Your browser must support the Canvas element and have JavaScript enabled to view this tool.</canvas>
</div>
</section>
<video youtube="0.75:izygArpw-Qo,1.0:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8" format="Video" display_name="Welcome"/>
<chapter>
<video url_name="toyvideo" youtube="blahblah"/>
</chapter>
<course>
<chapter url_name="Overview">
<videosequence url_name="Toy_Videos">
<html url_name="toylab"/>
<html url_name="secret:toylab"/>
<video url_name="Video_Resources" youtube="1.0:1bK-WdDi6Qw"/>
</videosequence>
<video url_name="Welcome" youtube="1.0:p2Q6BrNhdh8"/>
<video url_name="video_123456789012" youtube="1.0:p2Q6BrNhdh8"/>
<video url_name="video_123456789012" youtube="1.0:p2Q6BrNhdh8"/>
</chapter>
<chapter url_name="secret:magic"/>
</course>
......@@ -2,7 +2,8 @@
"course/2012_Fall": {
"graceperiod": "2 days 5 hours 59 minutes 59 seconds",
"start": "2015-07-17T12:00",
"display_name": "Toy Course"
"display_name": "Toy Course",
"graded": "true"
},
"chapter/Overview": {
"display_name": "Overview"
......@@ -11,7 +12,7 @@
"display_name": "Toy Videos",
"format": "Lecture Sequence"
},
"html/toylab": {
"html/secret:toylab": {
"display_name": "Toy lab"
},
"video/Video_Resources": {
......
......@@ -105,7 +105,7 @@ NUMPY_VER="1.6.2"
SCIPY_VER="0.10.1"
BREW_FILE="$BASE/mitx/brew-formulas.txt"
LOG="/var/tmp/install-$(date +%Y%m%d-%H%M%S).log"
APT_PKGS="curl git python-virtualenv build-essential python-dev gfortran liblapack-dev libfreetype6-dev libpng12-dev libxml2-dev libxslt-dev yui-compressor coffeescript graphviz"
APT_PKGS="curl git python-virtualenv build-essential python-dev gfortran liblapack-dev libfreetype6-dev libpng12-dev libxml2-dev libxslt-dev yui-compressor coffeescript graphviz libgraphviz-dev"
if [[ $EUID -eq 0 ]]; then
error "This script should not be run using sudo or as the root user"
......
......@@ -72,3 +72,30 @@ Very handy: if you uncomment the `--pdb` argument in `NOSE_ARGS` in `lms/envs/te
If you change course content, while running the LMS in dev mode, it is unnecessary to restart to refresh the modulestore.
Instead, hit /migrate/modules to see a list of all modules loaded, and click on links (eg /migrate/reload/edx4edx) to reload a course.
### Gitreload-based workflow
github (or other equivalent git-based repository systems) used for
course content can be setup to trigger an automatic reload when changes are pushed. Here is how:
1. Each content directory in mitx_all/data should be a clone of a git repo
2. The user running the mitx gunicorn process should have its ssh key registered with the git repo
3. The list settings.ALLOWED_GITRELOAD_IPS should contain the IP address of the git repo originating the gitreload request.
By default, this list is ['207.97.227.253', '50.57.128.197', '108.171.174.178'] (the github IPs).
The list can be overridden in the startup file used, eg lms/envs/dev*.py
4. The git post-receive-hook should POST to /gitreload with a JSON payload. This payload should define at least
{ "repository" : { "name" : reload_dir }
where reload_dir is the directory name of the content to reload (ie mitx_all/data/reload_dir should exist)
The mitx server will then do "git reset --hard HEAD; git clean -f -d; git pull origin" in that directory. After the pull,
it will reload the modulestore for that course.
Note that the gitreload-based workflow is not meant for deployments on AWS (or elsewhere) which use collectstatic, since collectstatic is not run by a gitreload event.
Also, the gitreload feature needs MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True in the django settings.
......@@ -20,8 +20,8 @@ def index(request):
return redirect(reverse('dashboard'))
if settings.MITX_FEATURES.get('AUTH_USE_MIT_CERTIFICATES'):
from external_auth.views import edXauth_ssl_login
return edXauth_ssl_login(request)
from external_auth.views import ssl_login
return ssl_login(request)
university = branding.get_university(request.META.get('HTTP_HOST'))
if university is None:
......
......@@ -4,9 +4,12 @@ from django.utils.encoding import force_unicode
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe
from django.template.loader import render_to_string
from wiki.editors.base import BaseEditor
from wiki.editors.markitup import MarkItUpAdminWidget
class CodeMirrorWidget(forms.Widget):
def __init__(self, attrs=None):
# The 'rows' and 'cols' attributes are required for HTML correctness.
......@@ -18,9 +21,15 @@ class CodeMirrorWidget(forms.Widget):
def render(self, name, value, attrs=None):
if value is None: value = ''
final_attrs = self.build_attrs(attrs, name=name)
return mark_safe(u'<div><textarea%s>%s</textarea></div>' % (flatatt(final_attrs),
conditional_escape(force_unicode(value))))
# TODO use the help_text field of edit form instead of rendering a template
return render_to_string('wiki/includes/editor_widget.html',
{'attrs': mark_safe(flatatt(final_attrs)),
'content': conditional_escape(force_unicode(value)),
})
class CodeMirror(BaseEditor):
......@@ -50,5 +59,6 @@ class CodeMirror(BaseEditor):
"js/vendor/CodeMirror/xml.js",
"js/vendor/CodeMirror/mitx_markdown.js",
"js/wiki/CodeMirror.init.js",
"js/wiki/cheatsheet.js",
)
......@@ -30,7 +30,7 @@ def has_access(user, obj, action):
Things this module understands:
- start dates for modules
- DISABLE_START_DATES
- different access for staff, course staff, and students.
- different access for instructor, staff, course staff, and students.
user: a Django user object. May be anonymous.
......@@ -70,6 +70,20 @@ def has_access(user, obj, action):
raise TypeError("Unknown object type in has_access(): '{0}'"
.format(type(obj)))
def get_access_group_name(obj,action):
'''
Returns group name for user group which has "action" access to the given object.
Used in managing access lists.
'''
if isinstance(obj, CourseDescriptor):
return _get_access_group_name_course_desc(obj, action)
# Passing an unknown object here is a coding error, so rather than
# returning a default, complain.
raise TypeError("Unknown object type in get_access_group_name(): '{0}'"
.format(type(obj)))
# ================ Implementation helpers ================================
......@@ -138,11 +152,19 @@ def _has_access_course_desc(user, course, action):
'load': can_load,
'enroll': can_enroll,
'see_exists': see_exists,
'staff': lambda: _has_staff_access_to_descriptor(user, course)
'staff': lambda: _has_staff_access_to_descriptor(user, course),
'instructor': lambda: _has_instructor_access_to_descriptor(user, course),
}
return _dispatch(checkers, action, user, course)
def _get_access_group_name_course_desc(course, action):
'''
Return name of group which gives staff access to course. Only understands action = 'staff'
'''
if not action=='staff':
return []
return _course_staff_group_name(course.location)
def _has_access_error_desc(user, descriptor, action):
"""
......@@ -292,6 +314,17 @@ def _course_staff_group_name(location):
"""
return 'staff_%s' % Location(location).course
def _course_instructor_group_name(location):
"""
Get the name of the instructor group for a location. Right now, that's instructor_COURSE.
A course instructor has all staff privileges, but also can manage list of course staff (add, remove, list).
location: something that can passed to Location.
"""
return 'instructor_%s' % Location(location).course
def _has_global_staff_access(user):
if user.is_staff:
debug("Allow: user.is_staff")
......@@ -301,17 +334,28 @@ def _has_global_staff_access(user):
return False
def _has_instructor_access_to_location(user, location):
return _has_access_to_location(user, location, 'instructor')
def _has_staff_access_to_location(user, location):
return _has_access_to_location(user, location, 'staff')
def _has_access_to_location(user, location, access_level):
'''
Returns True if the given user has staff access to a location. For now this
is equivalent to having staff access to the course location.course.
Returns True if the given user has access_level (= staff or
instructor) access to a location. For now this is equivalent to
having staff / instructor access to the course location.course.
This means that user is in the staff_* group, or is an overall admin.
This means that user is in the staff_* group or instructor_* group, or is an overall admin.
TODO (vshnayder): this needs to be changed to allow per-course_id permissions, not per-course
(e.g. staff in 2012 is different from 2013, but maybe some people always have access)
course is a string: the course field of the location being accessed.
location = location
access_level = string, either "staff" or "instructor"
'''
if user is None or (not user.is_authenticated()):
debug("Deny: no user or anon user")
......@@ -321,25 +365,47 @@ def _has_staff_access_to_location(user, location):
return True
# If not global staff, is the user in the Auth group for this class?
user_groups = [x[1] for x in user.groups.values_list()]
staff_group = _course_staff_group_name(location)
if staff_group in user_groups:
debug("Allow: user in group %s", staff_group)
return True
debug("Deny: user not in group %s", staff_group)
user_groups = [g.name for g in user.groups.all()]
if access_level == 'staff':
staff_group = _course_staff_group_name(location)
if staff_group in user_groups:
debug("Allow: user in group %s", staff_group)
return True
debug("Deny: user not in group %s", staff_group)
if access_level == 'instructor' or access_level == 'staff': # instructors get staff privileges
instructor_group = _course_instructor_group_name(location)
if instructor_group in user_groups:
debug("Allow: user in group %s", instructor_group)
return True
debug("Deny: user not in group %s", instructor_group)
else:
log.debug("Error in access._has_access_to_location access_level=%s unknown" % access_level)
return False
def _has_staff_access_to_course_id(user, course_id):
"""Helper method that takes a course_id instead of a course name"""
loc = CourseDescriptor.id_to_location(course_id)
return _has_staff_access_to_location(user, loc)
def _has_instructor_access_to_descriptor(user, descriptor):
"""Helper method that checks whether the user has staff access to
the course of the location.
descriptor: something that has a location attribute
"""
return _has_instructor_access_to_location(user, descriptor.location)
def _has_staff_access_to_descriptor(user, descriptor):
"""Helper method that checks whether the user has staff access to
the course of the location.
location: something that can be passed to Location
descriptor: something that has a location attribute
"""
return _has_staff_access_to_location(user, descriptor.location)
......@@ -64,6 +64,22 @@ def course_image_url(course):
path = course.metadata['data_dir'] + "/images/course_image.jpg"
return try_staticfiles_lookup(path)
def find_file(fs, dirs, filename):
"""
Looks for a filename in a list of dirs on a filesystem, in the specified order.
fs: an OSFS filesystem
dirs: a list of path objects
filename: a string
Returns d / filename if found in dir d, else raises ResourceNotFoundError.
"""
for d in dirs:
filepath = path(d) / filename
if fs.exists(filepath):
return filepath
raise ResourceNotFoundError("Could not find {0}".format(filename))
def get_course_about_section(course, section_key):
"""
This returns the snippet of html to be rendered on the course about page,
......@@ -97,8 +113,13 @@ def get_course_about_section(course, section_key):
'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:
fs = course.system.resources_fs
# first look for a run-specific version
dirs = [path("about") / course.url_name, path("about")]
filepath = find_file(fs, dirs, section_key + ".html")
with fs.open(filepath) as htmlFile:
return replace_urls(htmlFile.read().decode('utf-8'),
course.metadata['data_dir'])
except ResourceNotFoundError:
......@@ -133,7 +154,12 @@ def get_course_info_section(course, section_key):
if section_key in ['handouts', 'guest_handouts', 'updates', 'guest_updates']:
try:
with course.system.resources_fs.open(path("info") / section_key + ".html") as htmlFile:
fs = course.system.resources_fs
# first look for a run-specific version
dirs = [path("info") / course.url_name, path("info")]
filepath = find_file(fs, dirs, section_key + ".html")
with fs.open(filepath) as htmlFile:
# Replace '/static/' urls
info_html = replace_urls(htmlFile.read().decode('utf-8'), course.metadata['data_dir'])
......
......@@ -4,11 +4,14 @@ from __future__ import division
import random
import logging
from collections import defaultdict
from django.conf import settings
from django.contrib.auth.models import User
from models import StudentModuleCache
from module_render import get_module, get_instance_module
from xmodule import graders
from xmodule.capa_module import CapaModule
from xmodule.course_module import CourseDescriptor
from xmodule.graders import Score
from models import StudentModule
......@@ -17,13 +20,80 @@ log = logging.getLogger("mitx.courseware")
def yield_module_descendents(module):
stack = module.get_display_items()
stack.reverse()
while len(stack) > 0:
next_module = stack.pop()
stack.extend( next_module.get_display_items() )
yield next_module
def grade(student, request, course, student_module_cache=None):
def yield_problems(request, course, student):
"""
Return an iterator over capa_modules that this student has
potentially answered. (all that student has answered will definitely be in
the list, but there may be others as well).
"""
grading_context = course.grading_context
student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors'])
for section_format, sections in grading_context['graded_sections'].iteritems():
for section in sections:
section_descriptor = section['section_descriptor']
# If the student hasn't seen a single problem in the section, skip it.
skip = True
for moduledescriptor in section['xmoduledescriptors']:
if student_module_cache.lookup(
course.id, moduledescriptor.category, moduledescriptor.location.url()):
skip = False
break
if skip:
continue
section_module = get_module(student, request,
section_descriptor.location, student_module_cache,
course.id)
if section_module is None:
# student doesn't have access to this module, or something else
# went wrong.
# log.debug("couldn't get module for student {0} for section location {1}"
# .format(student.username, section_descriptor.location))
continue
for problem in yield_module_descendents(section_module):
if isinstance(problem, CapaModule):
yield problem
def answer_distributions(request, course):
"""
Given a course_descriptor, compute frequencies of answers for each problem:
Format is:
dict: (problem url_name, problem display_name, problem_id) -> (dict : answer -> count)
TODO (vshnayder): this is currently doing a full linear pass through all
students and all problems. This will be just a little slow.
"""
counts = defaultdict(lambda: defaultdict(int))
enrolled_students = User.objects.filter(courseenrollment__course_id=course.id)
for student in enrolled_students:
for capa_module in yield_problems(request, course, student):
for problem_id in capa_module.lcp.student_answers:
# Answer can be a list or some other unhashable element. Convert to string.
answer = str(capa_module.lcp.student_answers[problem_id])
key = (capa_module.url_name, capa_module.display_name, problem_id)
counts[key][answer] += 1
return counts
def grade(student, request, course, student_module_cache=None, keep_raw_scores=False):
"""
This grades a student as quickly as possible. It retuns the
output from the course grader, augmented with the final letter
......@@ -37,11 +107,13 @@ def grade(student, request, course, student_module_cache=None):
up the grade. (For display)
- grade_breakdown : A breakdown of the major components that
make up the final grade. (For display)
- keep_raw_scores : if True, then value for key 'raw_scores' contains scores for every graded module
More information on the format is in the docstring for CourseGrader.
"""
grading_context = course.grading_context
raw_scores = []
if student_module_cache == None:
student_module_cache = StudentModuleCache(course.id, student, grading_context['all_descriptors'])
......@@ -82,7 +154,7 @@ def grade(student, request, course, student_module_cache=None):
if correct is None and total is None:
continue
if settings.GENERATE_PROFILE_SCORES:
if settings.GENERATE_PROFILE_SCORES: # for debugging!
if total > 1:
correct = random.randrange(max(total - 2, 1), total + 1)
else:
......@@ -96,6 +168,8 @@ def grade(student, request, course, student_module_cache=None):
scores.append(Score(correct, total, graded, module.metadata.get('display_name')))
section_total, graded_total = graders.aggregate_scores(scores, section_name)
if keep_raw_scores:
raw_scores += scores
else:
section_total = Score(0.0, 1.0, False, section_name)
graded_total = Score(0.0, 1.0, True, section_name)
......@@ -116,7 +190,10 @@ def grade(student, request, course, student_module_cache=None):
letter_grade = grade_for_percentage(course.grade_cutoffs, grade_summary['percent'])
grade_summary['grade'] = letter_grade
grade_summary['totaled_scores'] = totaled_scores # make this available, eg for instructor download & debugging
if keep_raw_scores:
grade_summary['raw_scores'] = raw_scores # way to get all RAW scores out to instructor
# so grader can be double-checked
return grade_summary
def grade_for_percentage(grade_cutoffs, percentage):
......
......@@ -57,7 +57,6 @@ def import_with_checks(course_dir, verbose=True):
# module.
modulestore = XMLModuleStore(data_dir,
default_class=None,
eager=True,
course_dirs=course_dirs)
def str_of_err(tpl):
......
......@@ -23,7 +23,6 @@ def import_course(course_dir, verbose=True):
# module.
modulestore = XMLModuleStore(data_dir,
default_class=None,
eager=True,
course_dirs=course_dirs)
def str_of_err(tpl):
......
import hashlib
import json
import logging
import sys
......@@ -51,7 +52,7 @@ def make_track_function(request):
return f
def toc_for_course(user, request, course, active_chapter, active_section, course_id=None):
def toc_for_course(user, request, course, active_chapter, active_section):
'''
Create a table of contents from the module store
......@@ -61,7 +62,7 @@ def toc_for_course(user, request, course, active_chapter, active_section, course
where SECTIONS is a list
[ {'display_name': name, 'url_name': url_name,
'format': format, 'due': due, 'active' : bool}, ...]
'format': format, 'due': due, 'active' : bool, 'graded': bool}, ...]
active is set for the section and chapter corresponding to the passed
parameters, which are expected to be url_names of the chapter+section.
......@@ -74,13 +75,13 @@ def toc_for_course(user, request, course, active_chapter, active_section, course
'''
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
course_id, user, course, depth=2)
course = get_module(user, request, course.location, student_module_cache, course_id)
if course is None:
course.id, user, course, depth=2)
course_module = get_module(user, request, course.location, student_module_cache, course.id)
if course_module is None:
return None
chapters = list()
for chapter in course.get_display_items():
for chapter in course_module.get_display_items():
hide_from_toc = chapter.metadata.get('hide_from_toc','false').lower() == 'true'
if hide_from_toc:
continue
......@@ -97,7 +98,9 @@ def toc_for_course(user, request, course, active_chapter, active_section, course
'url_name': section.url_name,
'format': section.metadata.get('format', ''),
'due': section.metadata.get('due', ''),
'active': active})
'active': active,
'graded': section.metadata.get('graded', False),
})
chapters.append({'display_name': chapter.display_name,
'url_name': chapter.url_name,
......@@ -106,36 +109,6 @@ def toc_for_course(user, request, course, active_chapter, active_section, course
return chapters
def get_section(course_module, chapter, section):
"""
Returns the xmodule descriptor for the name course > chapter > section,
or None if this doesn't specify a valid section
course: Course url
chapter: Chapter url_name
section: Section url_name
"""
if course_module is None:
return
chapter_module = None
for _chapter in course_module.get_children():
if _chapter.url_name == chapter:
chapter_module = _chapter
break
if chapter_module is None:
return
section_module = None
for _section in chapter_module.get_children():
if _section.url_name == section:
section_module = _section
break
return section_module
def get_module(user, request, location, student_module_cache, course_id, position=None):
"""
Get an instance of the xmodule class identified by location,
......@@ -144,8 +117,8 @@ def get_module(user, request, location, student_module_cache, course_id, positio
Arguments:
- user : User for whom we're getting the module
- request : current django HTTPrequest -- used in particular for auth
(This is important e.g. for prof impersonation of students in progress view)
- request : current django HTTPrequest. Note: request.user isn't used for anything--all auth
and such works based on user.
- location : A Location-like object identifying the module to load
- student_module_cache : a StudentModuleCache
- course_id : the course_id in the context of which to load module
......@@ -171,12 +144,16 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
descriptor = modulestore().get_instance(course_id, location)
# Short circuit--if the user shouldn't have access, bail without doing any work
# NOTE: Do access check on request.user -- that's who actually needs access (e.g. could be prof
# impersonating a user)
if not has_access(request.user, descriptor, 'load'):
if not has_access(user, descriptor, 'load'):
return None
#TODO Only check the cache if this module can possibly have state
# Anonymized student identifier
h = hashlib.md5()
h.update(settings.SECRET_KEY)
h.update(str(user.id))
anonymous_student_id = h.hexdigest()
# Only check the cache if this module can possibly have state
instance_module = None
shared_module = None
if user.is_authenticated():
......@@ -190,7 +167,6 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
descriptor.category,
shared_state_key)
instance_state = instance_module.state if instance_module is not None else None
shared_state = shared_module.state if shared_module is not None else None
......@@ -200,6 +176,8 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
location=descriptor.location.url(),
dispatch=''),
)
# Intended use is as {ajax_url}/{dispatch_command}, so get rid of the trailing slash.
ajax_url = ajax_url.rstrip('/')
# Fully qualified callback URL for external queueing system
xqueue_callback_url = '{proto}://{host}'.format(
......@@ -220,7 +198,9 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
xqueue = {'interface': xqueue_interface,
'callback_url': xqueue_callback_url,
'default_queuename': xqueue_default_queuename.replace(' ', '_')}
'default_queuename': xqueue_default_queuename.replace(' ', '_'),
'waittime': settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS
}
def inner_get_module(location):
"""
......@@ -244,7 +224,8 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
# a module is coming through get_html and is therefore covered
# by the replace_static_urls code below
replace_urls=replace_urls,
node_path=settings.NODE_PATH
node_path=settings.NODE_PATH,
anonymous_student_id=anonymous_student_id,
)
# pass position specified in URL to module through ModuleSystem
system.set('position', position)
......@@ -282,9 +263,10 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
return module
# TODO (vshnayder): Rename this? It's very confusing.
def get_instance_module(course_id, user, module, student_module_cache):
"""
Returns instance_module is a StudentModule specific to this module for this student,
Returns the StudentModule specific to this module for this student,
or None if this is an anonymous user
"""
if user.is_authenticated():
......@@ -412,6 +394,10 @@ def modx_dispatch(request, dispatch, location, course_id):
'''
# ''' (fix emacs broken parsing)
# Check parameters and fail fast if there's a problem
if not Location.is_valid(location):
raise Http404("Invalid location")
# Check for submitted files and basic file size checks
p = request.POST.copy()
if request.FILES:
......
......@@ -21,11 +21,17 @@ def dashboard(request):
if not request.user.is_staff:
raise Http404
query = "select count(user_id) as students, course_id from student_courseenrollment group by course_id order by students desc"
queries=[]
queries.append("select count(user_id) as students, course_id from student_courseenrollment group by course_id order by students desc;")
queries.append("select count(distinct user_id) as unique_students from student_courseenrollment;")
queries.append("select registrations, count(registrations) from (select count(user_id) as registrations from student_courseenrollment group by user_id) as registrations_per_user group by registrations;")
from django.db import connection
cursor = connection.cursor()
cursor.execute(query)
results = dictfetchall(cursor)
results =[]
for query in queries:
cursor.execute(query)
results.append(dictfetchall(cursor))
return HttpResponse(json.dumps(results, indent=4))
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
from django.http import HttpResponse
from django.http import HttpResponse, Http404
from django.utils import simplejson
from django.core.context_processors import csrf
from django.core.urlresolvers import reverse
......@@ -20,7 +20,7 @@ import django_comment_client.utils as utils
import comment_client as cc
import xml.sax.saxutils as saxutils
THREADS_PER_PAGE = 50000
THREADS_PER_PAGE = 200
INLINE_THREADS_PER_PAGE = 5
PAGES_NEARBY_DELTA = 2
......@@ -108,6 +108,10 @@ def render_user_discussion(*args, **kwargs):
return render_discussion(discussion_type='user', *args, **kwargs)
def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAGE):
"""
This may raise cc.utils.CommentClientError or
cc.utils.CommentClientUnknownError if something goes wrong.
"""
default_query_params = {
'page': 1,
......@@ -120,6 +124,18 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
'course_id': course_id,
}
if not request.GET.get('sort_key'):
# If the user did not select a sort key, use their last used sort key
user = cc.User.from_django_user(request.user)
user.retrieve()
# TODO: After the comment service is updated this can just be user.default_sort_key because the service returns the default value
default_query_params['sort_key'] = user.get('default_sort_key') or default_query_params['sort_key']
else:
# If the user clicked a sort key, update their default sort key
user = cc.User.from_django_user(request.user)
user.default_sort_key = request.GET.get('sort_key')
user.save()
query_params = merge_dict(default_query_params,
strip_none(extract(request.GET, ['page', 'sort_key', 'sort_order', 'text', 'tags'])))
......@@ -134,16 +150,25 @@ def inline_discussion(request, course_id, discussion_id):
"""
Renders JSON for DiscussionModules
"""
threads, query_params = get_threads(request, course_id, discussion_id, per_page=INLINE_THREADS_PER_PAGE)
try:
threads, query_params = get_threads(request, course_id, discussion_id, per_page=INLINE_THREADS_PER_PAGE)
user_info = cc.User.from_django_user(request.user).to_dict()
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
# TODO (vshnayder): since none of this code seems to be aware of the fact that
# sometimes things go wrong, I suspect that the js client is also not
# checking for errors on request. Check and fix as needed.
raise Http404
# TODO: Remove all of this stuff or switch back to server side rendering once templates are mustache again
# html = render_inline_discussion(request, course_id, threads, discussion_id=discussion_id, \
# query_params=query_params)
user_info = cc.User.from_django_user(request.user).to_dict()
#html = render_inline_discussion(request, course_id, threads, discussion_id=discussion_id, \
# query_params=query_params)
def infogetter(thread):
return utils.get_annotated_content_infos(course_id, thread, request.user, user_info)
annotated_content_info = reduce(merge_dict, map(infogetter, threads), {})
return utils.JsonResponse({
# 'html': html,
'discussion_data': map(utils.safe_content, threads),
......@@ -169,7 +194,12 @@ def forum_form_discussion(request, course_id):
"""
course = get_course_with_access(request.user, course_id, 'load')
category_map = utils.get_discussion_category_map(course)
threads, query_params = get_threads(request, course_id)
try:
threads, query_params = get_threads(request, course_id)
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
raise Http404
content = render_forum_discussion(request, course_id, threads, discussion_id=_general_discussion_id(course_id), query_params=query_params)
user_info = cc.User.from_django_user(request.user).to_dict()
......@@ -237,7 +267,12 @@ def single_thread(request, course_id, discussion_id, thread_id):
if request.is_ajax():
user_info = cc.User.from_django_user(request.user).to_dict()
thread = cc.Thread.find(thread_id).retrieve(recursive=True)
try:
thread = cc.Thread.find(thread_id).retrieve(recursive=True)
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
raise Http404
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info)
context = {'thread': thread.to_dict(), 'course_id': course_id}
# TODO: Remove completely or switch back to server side rendering
......@@ -252,7 +287,10 @@ def single_thread(request, course_id, discussion_id, thread_id):
else:
course = get_course_with_access(request.user, course_id, 'load')
category_map = utils.get_discussion_category_map(course)
threads, query_params = get_threads(request, course_id)
try:
threads, query_params = get_threads(request, course_id)
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
raise Http404
course = get_course_with_access(request.user, course_id, 'load')
......@@ -300,32 +338,28 @@ def single_thread(request, course_id, discussion_id, thread_id):
def user_profile(request, course_id, user_id):
course = get_course_with_access(request.user, course_id, 'load')
profiled_user = cc.User(id=user_id, course_id=course_id)
query_params = {
'page': request.GET.get('page', 1),
'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities
}
threads, page, num_pages = profiled_user.active_threads(query_params)
query_params['page'] = page
query_params['num_pages'] = num_pages
content = render_user_discussion(request, course_id, threads, user_id=user_id, query_params=query_params)
if request.is_ajax():
return utils.JsonResponse({
'html': content,
'discussion_data': map(utils.safe_content, threads),
})
else:
context = {
'course': course,
'user': request.user,
'django_user': User.objects.get(id=user_id),
'profiled_user': profiled_user.to_dict(),
'content': content,
}
return render_to_response('discussion/user_profile.html', context)
try:
profiled_user = cc.User(id=user_id, course_id=course_id)
query_params['page'] = page
query_params['num_pages'] = num_pages
content = render_user_discussion(request, course_id, threads, user_id=user_id, query_params=query_params)
if request.is_ajax():
return utils.JsonResponse({
'html': content,
'discussion_data': map(utils.safe_content, threads),
})
else:
context = {
'course': course,
'user': request.user,
'django_user': User.objects.get(id=user_id),
'profiled_user': profiled_user.to_dict(),
'content': content,
}
return render_to_response('discussion/user_profile.html', context)
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
raise Http404
......@@ -9,7 +9,9 @@ from django.utils import simplejson
from django.db import connection
from django.conf import settings
from django.core.urlresolvers import reverse
from django.contrib.auth.models import User
from django_comment_client.permissions import check_permissions_by_view
from django_comment_client.models import Role
from mitxmako import middleware
import logging
......@@ -19,6 +21,7 @@ import urllib
import pystache_custom as pystache
# TODO these should be cached via django's caching rather than in-memory globals
_FULLMODULES = None
_DISCUSSIONINFO = None
......@@ -55,7 +58,7 @@ def get_discussion_title(course, discussion_id):
global _DISCUSSIONINFO
if not _DISCUSSIONINFO:
initialize_discussion_info(course)
title = _DISCUSSIONINFO['by_id'].get(discussion_id, {}).get('title', '(no title)')
title = _DISCUSSIONINFO['id_map'].get(discussion_id, {}).get('title', '(no title)')
return title
def get_discussion_category_map(course):
......@@ -129,7 +132,6 @@ def initialize_discussion_info(course):
_DISCUSSIONINFO = {}
_DISCUSSIONINFO['id_map'] = discussion_id_map
_DISCUSSIONINFO['category_map'] = category_map
def get_courseware_context(content, course):
......@@ -239,14 +241,29 @@ def permalink(content):
args=[content['course_id'], content['commentable_id'], content['thread_id']]) + '#' + content['id']
def extend_content(content):
user = User.objects.get(pk=content['user_id'])
roles = dict(('name', role.name.lower()) for role in user.roles.filter(course_id=content['course_id']))
content_info = {
'displayed_title': content.get('highlighted_title') or content.get('title', ''),
'displayed_body': content.get('highlighted_body') or content.get('body', ''),
'raw_tags': ','.join(content.get('tags', [])),
'permalink': permalink(content),
'roles': roles,
'updated': content['created_at']!=content['updated_at'],
}
return merge_dict(content, content_info)
def get_courseware_context(content, course):
id_map = get_discussion_id_map(course)
id = content['commentable_id']
content_info = None
if id in id_map:
location = id_map[id]["location"].url()
title = id_map[id]["title"]
content_info = { "courseware_location": location, "courseware_title": title}
return content_info
def safe_content(content):
fields = [
'id', 'title', 'body', 'course_id', 'anonymous', 'endorsed',
......
"""
Unit tests for instructor dashboard
Based on (and depends on) unit tests for courseware.
Notes for running by hand:
django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/instructor
"""
import courseware.tests.tests as ct
from nose import SkipTest
from mock import patch, Mock
from override_settings import override_settings
# Need access to internal func to put users in the right group
from courseware.access import _course_staff_group_name
from django.contrib.auth.models import User, Group
from django.conf import settings
from django.core.urlresolvers import reverse
import xmodule.modulestore.django
from xmodule.modulestore.django import modulestore
@override_settings(MODULESTORE=ct.TEST_DATA_XML_MODULESTORE)
class TestInstructorDashboardGradeDownloadCSV(ct.PageLoader):
'''
Check for download of csv
'''
def setUp(self):
xmodule.modulestore.django._MODULESTORES = {}
courses = modulestore().get_courses()
def find_course(name):
"""Assumes the course is present"""
return [c for c in courses if c.location.course==name][0]
self.full = find_course("full")
self.toy = find_course("toy")
# Create two accounts
self.student = 'view@test.com'
self.instructor = 'view2@test.com'
self.password = 'foo'
self.create_account('u1', self.student, self.password)
self.create_account('u2', self.instructor, self.password)
self.activate_user(self.student)
self.activate_user(self.instructor)
group_name = _course_staff_group_name(self.toy.location)
g = Group.objects.create(name=group_name)
g.user_set.add(ct.user(self.instructor))
self.logout()
self.login(self.instructor, self.password)
self.enroll(self.toy)
def test_download_grades_csv(self):
print "running test_download_grades_csv"
course = self.toy
url = reverse('instructor_dashboard', kwargs={'course_id': course.id})
msg = "url = %s\n" % url
response = self.client.post(url, {'action': 'Download CSV of all student grades for this course',
})
msg += "instructor dashboard download csv grades: response = '%s'\n" % response
self.assertEqual(response['Content-Type'],'text/csv',msg)
cdisp = response['Content-Disposition'].replace('TT_2012','2012') # jenkins course_id is TT_2012_Fall instead of 2012_Fall?
msg += "cdisp = '%s'\n" % cdisp
self.assertEqual(cdisp,'attachment; filename=grades_edX/toy/2012_Fall.csv',msg)
body = response.content.replace('\r','')
msg += "body = '%s'\n" % body
expected_body = '''"ID","Username","Full Name","edX email","External email","HW 01","HW 02","HW 03","HW 04","HW 05","HW 06","HW 07","HW 08","HW 09","HW 10","HW 11","HW 12","HW Avg","Lab 01","Lab 02","Lab 03","Lab 04","Lab 05","Lab 06","Lab 07","Lab 08","Lab 09","Lab 10","Lab 11","Lab 12","Lab Avg","Midterm","Final"
"2","u2","Fred Weasley","view2@test.com","","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0.0","0.0"
'''
self.assertEqual(body, expected_body, msg)
......@@ -67,7 +67,10 @@ class Command(BaseCommand):
password = GenPasswd(12)
# get name from kerberos
kname = os.popen("finger %s | grep 'name:'" % email).read().strip().split('name: ')[1].strip()
try:
kname = os.popen("finger %s | grep 'name:'" % email).read().strip().split('name: ')[1].strip()
except:
kname = ''
name = raw_input('Full name: [%s] ' % kname).strip()
if name=='':
name = kname
......
#!/usr/bin/python
#
# File: manage_course_groups
#
# interactively list and edit membership in course staff and instructor groups
import os, sys, string, re
import datetime
from getpass import getpass
import json
import readline
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.auth.models import User, Group
#-----------------------------------------------------------------------------
# get all staff groups
class Command(BaseCommand):
help = "Manage course group membership, interactively."
def handle(self, *args, **options):
gset = Group.objects.all()
print "Groups:"
for cnt,g in zip(range(len(gset)), gset):
print "%d. %s" % (cnt,g)
gnum = int(raw_input('Choose group to manage (enter #): '))
group = gset[gnum]
#-----------------------------------------------------------------------------
# users in group
uall = User.objects.all()
if uall.count()<50:
print "----"
print "List of All Users: %s" % [str(x.username) for x in uall]
print "----"
else:
print "----"
print "There are %d users, which is too many to list" % uall.count()
print "----"
while True:
print "Users in the group:"
uset = group.user_set.all()
for cnt, u in zip(range(len(uset)), uset):
print "%d. %s" % (cnt, u)
action = raw_input('Choose user to delete (enter #) or enter usernames (comma delim) to add: ')
m = re.match('^[0-9]+$',action)
if m:
unum = int(action)
u = uset[unum]
print "Deleting user %s" % u
u.groups.remove(group)
else:
for uname in action.split(','):
try:
user = User.objects.get(username=action)
except Exception as err:
print "Error %s" % err
continue
print "adding %s to group %s" % (user, group)
user.groups.add(group)
......@@ -70,9 +70,9 @@ def manage_modulestores(request,reload_dir=None):
if reload_dir is not None:
if reload_dir not in def_ms.courses:
html += "<h2><font color='red'>Error: '%s' is not a valid course directory</font></h2>" % reload_dir
html += '<h2 class="inline-error">Error: "%s" is not a valid course directory</h2>' % reload_dir
else:
html += "<h2><font color='blue'>Reloaded course directory '%s'</font></h2>" % reload_dir
html += '<h2>Reloaded course directory "%s"</h2>' % reload_dir
def_ms.try_load_course(reload_dir)
#----------------------------------------
......@@ -139,7 +139,7 @@ def gitreload(request, reload_dir=None):
ALLOWED_IPS = [] # allow none by default
if hasattr(settings,'ALLOWED_GITRELOAD_IPS'): # allow override in settings
ALLOWED_IPS = ALLOWED_GITRELOAD_IPS
ALLOWED_IPS = settings.ALLOWED_GITRELOAD_IPS
if not (ip in ALLOWED_IPS or 'any' in ALLOWED_IPS):
if request.user and request.user.is_staff:
......@@ -179,9 +179,9 @@ def gitreload(request, reload_dir=None):
if reload_dir is not None:
def_ms = modulestore()
if reload_dir not in def_ms.courses:
html += "<h2><font color='red'>Error: '%s' is not a valid course directory</font></h2>" % reload_dir
html += '<h2 class="inline-error">Error: "%s" is not a valid course directory</font></h2>' % reload_dir
else:
html += "<h2><font color='blue'>Reloaded course directory '%s'</font></h2>" % reload_dir
html += "<h2>Reloaded course directory '%s'</h2>" % reload_dir
def_ms.try_load_course(reload_dir)
track.views.server_track(request, 'reloaded %s' % reload_dir, {}, page='migrate')
......
......@@ -6,6 +6,7 @@
from mitxmako.shortcuts import render_to_response, render_to_string
from django.shortcuts import redirect
from django.conf import settings
from django.http import HttpResponseNotFound, HttpResponseServerError
from django_future.csrf import ensure_csrf_cookie
from util.cache import cache_if_anonymous
......@@ -40,9 +41,9 @@ def render(request, template):
def render_404(request):
return render_to_response('static_templates/404.html', {})
return HttpResponseNotFound(render_to_string('static_templates/404.html', {}))
def render_500(request):
return render_to_response('static_templates/server-error.html', {})
return HttpResponseServerError(render_to_string('static_templates/server-error.html', {}))
......@@ -48,10 +48,12 @@ for feature, value in ENV_TOKENS.get('MITX_FEATURES', {}).items():
MITX_FEATURES[feature] = value
WIKI_ENABLED = ENV_TOKENS.get('WIKI_ENABLED', WIKI_ENABLED)
local_loglevel = ENV_TOKENS.get('LOCAL_LOGLEVEL', 'INFO')
LOGGING = get_logger_config(LOG_DIR,
logging_env=ENV_TOKENS['LOGGING_ENV'],
syslog_addr=(ENV_TOKENS['SYSLOG_SERVER'], 514),
local_loglevel=local_loglevel,
debug=False)
COURSE_LISTINGS = ENV_TOKENS.get('COURSE_LISTINGS', {})
......
This diff is collapsed. Click to expand it.
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