Merge remote-tracking branch 'origin/master' into feature/bridger/new_wiki
@@ -249,7 +249,7 @@ def load_preview_module(request, preview_id, descriptor, instance_state, shared_
|
||||
module = descriptor.xmodule_constructor(system)(instance_state, shared_state)
|
||||
module.get_html = replace_static_urls(
|
||||
wrap_xmodule(module.get_html, module, "xmodule_display.html"),
|
||||
module.metadata['data_dir']
|
||||
module.metadata['data_dir'], module
|
||||
)
|
||||
save_preview_state(request, preview_id, descriptor.location.url(),
|
||||
module.get_instance_state(), module.get_shared_state())
|
||||
|
||||
@@ -55,6 +55,17 @@ DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ENV_ROOT / "db" / "cms.db",
|
||||
},
|
||||
|
||||
# The following are for testing purposes...
|
||||
'edX/toy/2012_Fall': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ENV_ROOT / "db" / "course1.db",
|
||||
},
|
||||
|
||||
'edx/full/6.002_Spring_2012': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ENV_ROOT / "db" / "course2.db",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ describe "CMS", ->
|
||||
beforeEach ->
|
||||
CMS.unbind()
|
||||
|
||||
it "should iniitalize Models", ->
|
||||
it "should initialize Models", ->
|
||||
expect(CMS.Models).toBeDefined()
|
||||
|
||||
it "should initialize Views", ->
|
||||
|
||||
@@ -11,14 +11,25 @@ describe "CMS.Models.Module", ->
|
||||
@fakeModule = jasmine.createSpy("fakeModuleObject")
|
||||
window.FakeModule = jasmine.createSpy("FakeModule").andReturn(@fakeModule)
|
||||
@module = new CMS.Models.Module(type: "FakeModule")
|
||||
@stubElement = $("<div>")
|
||||
@module.loadModule(@stubElement)
|
||||
@stubDiv = $('<div />')
|
||||
@stubElement = $('<div class="xmodule_edit" />')
|
||||
@stubElement.data('type', "FakeModule")
|
||||
|
||||
@stubDiv.append(@stubElement)
|
||||
@module.loadModule(@stubDiv)
|
||||
|
||||
afterEach ->
|
||||
window.FakeModule = undefined
|
||||
|
||||
it "initialize the module", ->
|
||||
expect(window.FakeModule).toHaveBeenCalledWith(@stubElement)
|
||||
expect(window.FakeModule).toHaveBeenCalled()
|
||||
# Need to compare underlying nodes, because jquery selectors
|
||||
# aren't equal even when they point to the same node.
|
||||
# http://stackoverflow.com/questions/9505437/how-to-test-jquery-with-jasmine-for-element-id-if-used-as-this
|
||||
expectedNode = @stubElement[0]
|
||||
actualNode = window.FakeModule.mostRecentCall.args[0][0]
|
||||
|
||||
expect(actualNode).toEqual(expectedNode)
|
||||
expect(@module.module).toEqual(@fakeModule)
|
||||
|
||||
describe "when the module does not exists", ->
|
||||
@@ -32,7 +43,8 @@ describe "CMS.Models.Module", ->
|
||||
window.console = @previousConsole
|
||||
|
||||
it "print out error to log", ->
|
||||
expect(window.console.error).toHaveBeenCalledWith("Unable to load HTML.")
|
||||
expect(window.console.error).toHaveBeenCalled()
|
||||
expect(window.console.error.mostRecentCall.args[0]).toMatch("^Unable to load")
|
||||
|
||||
|
||||
describe "editUrl", ->
|
||||
|
||||
@@ -8,11 +8,11 @@ describe "CMS.Views.ModuleEdit", ->
|
||||
<a href="#" class="cancel">cancel</a>
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#" class="module-edit" data-id="i4x://mitx.edu/course/module" data-type="html">submodule</a>
|
||||
<a href="#" class="module-edit" data-id="i4x://mitx/course/html/module" data-type="html">submodule</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
"""
|
||||
""" #"
|
||||
CMS.unbind()
|
||||
|
||||
describe "defaults", ->
|
||||
@@ -27,7 +27,7 @@ describe "CMS.Views.ModuleEdit", ->
|
||||
@stubModule.editUrl.andReturn("/edit_item?id=stub_module")
|
||||
new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule)
|
||||
|
||||
it "load the edit from via ajax and pass to the model", ->
|
||||
it "load the edit via ajax and pass to the model", ->
|
||||
expect($.fn.load).toHaveBeenCalledWith("/edit_item?id=stub_module", jasmine.any(Function))
|
||||
if $.fn.load.mostRecentCall
|
||||
$.fn.load.mostRecentCall.args[1]()
|
||||
@@ -37,9 +37,9 @@ describe "CMS.Views.ModuleEdit", ->
|
||||
beforeEach ->
|
||||
@stubJqXHR = jasmine.createSpy("stubJqXHR")
|
||||
@stubJqXHR.success = jasmine.createSpy("stubJqXHR.success").andReturn(@stubJqXHR)
|
||||
@stubJqXHR.error= jasmine.createSpy("stubJqXHR.success").andReturn(@stubJqXHR)
|
||||
@stubJqXHR.error = jasmine.createSpy("stubJqXHR.error").andReturn(@stubJqXHR)
|
||||
@stubModule.save = jasmine.createSpy("stubModule.save").andReturn(@stubJqXHR)
|
||||
new CMS.Views.ModuleEdit(el: $("#module-edit"), model: @stubModule)
|
||||
new CMS.Views.ModuleEdit(el: $(".module-edit"), model: @stubModule)
|
||||
spyOn(window, "alert")
|
||||
$(".save-update").click()
|
||||
|
||||
@@ -77,5 +77,5 @@ describe "CMS.Views.ModuleEdit", ->
|
||||
expect(CMS.pushView).toHaveBeenCalledWith @view
|
||||
expect(CMS.Views.ModuleEdit).toHaveBeenCalledWith model: @model
|
||||
expect(CMS.Models.Module).toHaveBeenCalledWith
|
||||
id: "i4x://mitx.edu/course/module"
|
||||
id: "i4x://mitx/course/html/module"
|
||||
type: "html"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
describe "CMS.Views.Module", ->
|
||||
beforeEach ->
|
||||
setFixtures """
|
||||
<div id="module" data-id="i4x://mitx.edu/course/module" data-type="html">
|
||||
<div id="module" data-id="i4x://mitx/course/html/module" data-type="html">
|
||||
<a href="#" class="module-edit">edit</a>
|
||||
</div>
|
||||
"""
|
||||
@@ -20,5 +20,5 @@ describe "CMS.Views.Module", ->
|
||||
expect(CMS.replaceView).toHaveBeenCalledWith @view
|
||||
expect(CMS.Views.ModuleEdit).toHaveBeenCalledWith model: @model
|
||||
expect(CMS.Models.Module).toHaveBeenCalledWith
|
||||
id: "i4x://mitx.edu/course/module"
|
||||
id: "i4x://mitx/course/html/module"
|
||||
type: "html"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
describe "CMS.Views.Week", ->
|
||||
beforeEach ->
|
||||
setFixtures """
|
||||
<div id="week" data-id="i4x://mitx.edu/course/week">
|
||||
<div id="week" data-id="i4x://mitx/course/chapter/week">
|
||||
<div class="editable"></div>
|
||||
<textarea class="editable-textarea"></textarea>
|
||||
<a href="#" class="week-edit" >edit</a>
|
||||
|
||||
@@ -4,7 +4,8 @@ class CMS.Models.Module extends Backbone.Model
|
||||
data: ''
|
||||
|
||||
loadModule: (element) ->
|
||||
@module = XModule.loadModule($(element).find('.xmodule_edit'))
|
||||
elt = $(element).find('.xmodule_edit').first()
|
||||
@module = XModule.loadModule(elt)
|
||||
|
||||
editUrl: ->
|
||||
"/edit_item?#{$.param(id: @get('id'))}"
|
||||
|
||||
@@ -13,6 +13,16 @@ class CMS.Views.ModuleEdit extends Backbone.View
|
||||
|
||||
# Load preview modules
|
||||
XModule.loadModules('display')
|
||||
@enableDrag()
|
||||
|
||||
enableDrag: ->
|
||||
# Enable dragging things in the #sortable div (if there is one)
|
||||
if $("#sortable").length > 0
|
||||
$("#sortable").sortable({
|
||||
placeholder: "ui-state-highlight"
|
||||
})
|
||||
$("#sortable").disableSelection();
|
||||
|
||||
|
||||
save: (event) ->
|
||||
event.preventDefault()
|
||||
@@ -32,6 +42,7 @@ class CMS.Views.ModuleEdit extends Backbone.View
|
||||
cancel: (event) ->
|
||||
event.preventDefault()
|
||||
CMS.popView()
|
||||
@enableDrag()
|
||||
|
||||
editSubmodule: (event) ->
|
||||
event.preventDefault()
|
||||
@@ -42,3 +53,4 @@ class CMS.Views.ModuleEdit extends Backbone.View
|
||||
id: $(event.target).data('id')
|
||||
type: if moduleType == 'None' then null else moduleType
|
||||
previewType: if previewType == 'None' then null else previewType
|
||||
@enableDrag()
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<section class="modules">
|
||||
<ol>
|
||||
<li>
|
||||
<ol>
|
||||
<ol id="sortable">
|
||||
% for child in module.get_children():
|
||||
<li class="${module.category}">
|
||||
<a href="#" class="module-edit"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from staticfiles.storage import staticfiles_storage
|
||||
|
||||
from mitxmako.shortcuts import render_to_string
|
||||
|
||||
from pipeline.conf import settings
|
||||
from pipeline.packager import Packager
|
||||
from pipeline.utils import guess_type
|
||||
from static_replace import try_staticfiles_lookup
|
||||
|
||||
|
||||
def compressed_css(package_name):
|
||||
@@ -25,9 +24,11 @@ def compressed_css(package_name):
|
||||
def render_css(package, path):
|
||||
template_name = package.template_name or "mako/css.html"
|
||||
context = package.extra_context
|
||||
|
||||
url = try_staticfiles_lookup(path)
|
||||
context.update({
|
||||
'type': guess_type(path, 'text/css'),
|
||||
'url': staticfiles_storage.url(path)
|
||||
'url': url,
|
||||
})
|
||||
return render_to_string(template_name, context)
|
||||
|
||||
@@ -58,7 +59,7 @@ def render_js(package, path):
|
||||
context = package.extra_context
|
||||
context.update({
|
||||
'type': guess_type(path, 'text/javascript'),
|
||||
'url': staticfiles_storage.url(path)
|
||||
'url': try_staticfiles_lookup(path)
|
||||
})
|
||||
return render_to_string(template_name, context)
|
||||
|
||||
|
||||
@@ -3,7 +3,13 @@ from staticfiles.storage import staticfiles_storage
|
||||
from pipeline_mako import compressed_css, compressed_js
|
||||
%>
|
||||
|
||||
<%def name='url(file)'>${staticfiles_storage.url(file)}</%def>
|
||||
<%def name='url(file)'>
|
||||
<%
|
||||
try:
|
||||
url = staticfiles_storage.url(file)
|
||||
except:
|
||||
url = file
|
||||
%>${url}</%def>
|
||||
|
||||
<%def name='css(group)'>
|
||||
% if settings.MITX_FEATURES['USE_DJANGO_PIPELINE']:
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
from staticfiles.storage import staticfiles_storage
|
||||
import logging
|
||||
import re
|
||||
|
||||
from staticfiles.storage import staticfiles_storage
|
||||
from staticfiles import finders
|
||||
from django.conf import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def try_staticfiles_lookup(path):
|
||||
"""
|
||||
Try to lookup a path in staticfiles_storage. If it fails, return
|
||||
a dead link instead of raising an exception.
|
||||
"""
|
||||
try:
|
||||
url = staticfiles_storage.url(path)
|
||||
except Exception as err:
|
||||
log.warning("staticfiles_storage couldn't find path {}: {}".format(
|
||||
path, str(err)))
|
||||
# Just return the original path; don't kill everything.
|
||||
url = path
|
||||
return url
|
||||
|
||||
|
||||
def replace(static_url, prefix=None):
|
||||
if prefix is None:
|
||||
@@ -9,10 +29,19 @@ def replace(static_url, prefix=None):
|
||||
prefix = prefix + '/'
|
||||
|
||||
quote = static_url.group('quote')
|
||||
if staticfiles_storage.exists(static_url.group('rest')):
|
||||
|
||||
servable = (
|
||||
# If in debug mode, we'll serve up anything that the finders can find
|
||||
(settings.DEBUG and finders.find(static_url.group('rest'), True)) or
|
||||
# Otherwise, we'll only serve up stuff that the storages can find
|
||||
staticfiles_storage.exists(static_url.group('rest'))
|
||||
)
|
||||
|
||||
if servable:
|
||||
return static_url.group(0)
|
||||
else:
|
||||
url = staticfiles_storage.url(prefix + static_url.group('rest'))
|
||||
# don't error if file can't be found
|
||||
url = try_staticfiles_lookup(prefix + static_url.group('rest'))
|
||||
return "".join([quote, url, quote])
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
##
|
||||
## A script to create some dummy users
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from student.models import UserProfile, CourseEnrollment
|
||||
|
||||
from student.views import _do_create_account, get_random_post_override
|
||||
|
||||
def create(n, course_id):
|
||||
"""Create n users, enrolling them in course_id if it's not None"""
|
||||
for i in range(n):
|
||||
(user, user_profile, _) = _do_create_account(get_random_post_override())
|
||||
if course_id is not None:
|
||||
CourseEnrollment.objects.create(user=user, course_id=course_id)
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Create N new users, with random parameters.
|
||||
|
||||
Usage: create_random_users.py N [course_id_to_enroll_in].
|
||||
|
||||
Examples:
|
||||
create_random_users.py 1
|
||||
create_random_users.py 10 MITx/6.002x/2012_Fall
|
||||
create_random_users.py 100 HarvardX/CS50x/2012
|
||||
"""
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if len(args) < 1 or len(args) > 2:
|
||||
print Command.help
|
||||
return
|
||||
|
||||
n = int(args[0])
|
||||
course_id = args[1] if len(args) == 2 else None
|
||||
create(n, course_id)
|
||||
@@ -1,5 +1,30 @@
|
||||
"""
|
||||
WE'RE USING MIGRATIONS!
|
||||
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
|
||||
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.
|
||||
|
||||
We replicate the following tables into the Course DBs where the user is
|
||||
enrolled. Only the Portal servers should ever write to these models.
|
||||
* UserProfile
|
||||
* CourseEnrollment
|
||||
|
||||
We do a partial replication of:
|
||||
* User -- Askbot extends this and uses the extra fields, so we replicate only
|
||||
the stuff that comes with basic django_auth and ignore the rest.)
|
||||
|
||||
There are a couple different scenarios:
|
||||
|
||||
1. There's an update of User or UserProfile -- replicate it to all Course DBs
|
||||
that the user is enrolled in (found via CourseEnrollment).
|
||||
2. There's a change in CourseEnrollment. We need to push copies of UserProfile,
|
||||
CourseEnrollment, and the base fields in User
|
||||
|
||||
Migration Notes
|
||||
|
||||
If you make changes to this model, be sure to create an appropriate migration
|
||||
file and check it in at the same time as your model changes. To do that,
|
||||
@@ -10,16 +35,41 @@ file and check it in at the same time as your model changes. To do that,
|
||||
"""
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django_countries import CountryField
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
#from cache_toolbox import cache_model, cache_relation
|
||||
|
||||
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.
|
||||
|
||||
Notes:
|
||||
* Some fields are legacy ones from the first run of 6.002, from which
|
||||
we imported many users.
|
||||
* Fields like name and address are intentionally open ended, to account
|
||||
for international variations. An unfortunate side-effect is that we
|
||||
cannot efficiently sort on last names for instance.
|
||||
|
||||
Replication:
|
||||
* Only the Portal servers should ever modify this information.
|
||||
* All fields are replicated into relevant Course databases
|
||||
|
||||
Some of the fields are legacy ones that were captured during the initial
|
||||
MITx fall prototype.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
db_table = "auth_userprofile"
|
||||
|
||||
@@ -203,3 +253,154 @@ def add_user_to_default_group(user, group):
|
||||
utg.save()
|
||||
utg.users.add(User.objects.get(username=user))
|
||||
utg.save()
|
||||
|
||||
########################## REPLICATION SIGNALS #################################
|
||||
@receiver(post_save, sender=User)
|
||||
def replicate_user_save(sender, **kwargs):
|
||||
user_obj = kwargs['instance']
|
||||
if not should_replicate(user_obj):
|
||||
return
|
||||
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
|
||||
following:
|
||||
|
||||
1. Make sure the User is copied into the Course DB. It may already exist
|
||||
(someone deleting and re-adding a course). This has to happen first or
|
||||
the foreign key constraint breaks.
|
||||
2. Replicate the CourseEnrollment.
|
||||
3. Replicate the UserProfile.
|
||||
"""
|
||||
if not is_portal():
|
||||
return
|
||||
|
||||
enrollment_obj = kwargs['instance']
|
||||
log.debug("Replicating user because of new enrollment")
|
||||
replicate_user(enrollment_obj.user, enrollment_obj.course_id)
|
||||
|
||||
log.debug("Replicating enrollment because of new enrollment")
|
||||
replicate_model(CourseEnrollment.save, enrollment_obj, enrollment_obj.user_id)
|
||||
|
||||
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
|
||||
change to all Course DBs that we're enrolled in."""
|
||||
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
|
||||
fields. So we need to only push changes to the standard fields and leave
|
||||
the rest alone so that Askbot changes at the Course DB level don't get
|
||||
overridden.
|
||||
"""
|
||||
try:
|
||||
course_user = User.objects.using(course_db_name).get(id=portal_user.id)
|
||||
log.debug("User {0} found in Course DB, replicating fields to {1}"
|
||||
.format(course_user, course_db_name))
|
||||
except User.DoesNotExist:
|
||||
log.debug("User {0} not found in Course DB, creating copy in {1}"
|
||||
.format(portal_user, course_db_name))
|
||||
course_user = User()
|
||||
|
||||
for field in USER_FIELDS_TO_COPY:
|
||||
setattr(course_user, field, getattr(portal_user, field))
|
||||
|
||||
mark_handled(course_user)
|
||||
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,
|
||||
UserProfile.save
|
||||
"""
|
||||
if not should_replicate(instance):
|
||||
return
|
||||
|
||||
course_db_names = db_names_to_replicate_to(user_id)
|
||||
log.debug("Replicating {0} for user {1} to DBs: {2}"
|
||||
.format(model_method, user_id, course_db_names))
|
||||
|
||||
mark_handled(instance)
|
||||
for db_name in course_db_names:
|
||||
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
|
||||
were in the system and only let you choose that. But it was annoying to run
|
||||
tests with, since we don't have course data for some for our course test
|
||||
databases. Hence the lazy version.
|
||||
"""
|
||||
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
|
||||
the act of replication requires us to call the same model method.
|
||||
|
||||
We create a _replicated attribute to differentiate the first save of this
|
||||
model vs. the duplicate save we force on to the course database. Kind of
|
||||
a hack -- suggestions welcome.
|
||||
"""
|
||||
instance._do_not_copy_to_course_db = True
|
||||
|
||||
def unmark(instance):
|
||||
"""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
|
||||
log.debug("{0} should not be replicated because it's been marked"
|
||||
.format(instance))
|
||||
return False
|
||||
if not is_portal():
|
||||
log.debug("{0} should not be replicated because we're not a portal."
|
||||
.format(instance))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -4,13 +4,195 @@ when you run "manage.py test".
|
||||
|
||||
Replace this with more appropriate tests for your application.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from .models import User, UserProfile, CourseEnrollment, replicate_user, USER_FIELDS_TO_COPY
|
||||
|
||||
COURSE_1 = 'edX/toy/2012_Fall'
|
||||
COURSE_2 = 'edx/full/6.002_Spring_2012'
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class ReplicationTest(TestCase):
|
||||
|
||||
multi_db = True
|
||||
|
||||
def test_user_replication(self):
|
||||
"""Test basic user replication."""
|
||||
portal_user = User.objects.create_user('rusty', 'rusty@edx.org', 'fakepass')
|
||||
portal_user.first_name='Rusty'
|
||||
portal_user.last_name='Skids'
|
||||
portal_user.is_staff=True
|
||||
portal_user.is_active=True
|
||||
portal_user.is_superuser=True
|
||||
portal_user.last_login=datetime(2012, 1, 1)
|
||||
portal_user.date_joined=datetime(2011, 1, 1)
|
||||
# This is an Askbot field and will break if askbot is not included
|
||||
|
||||
if hasattr(portal_user, 'seen_response_count'):
|
||||
portal_user.seen_response_count = 10
|
||||
|
||||
portal_user.save(using='default')
|
||||
|
||||
# We replicate this user to Course 1, then pull the same user and verify
|
||||
# that the fields copied over properly.
|
||||
replicate_user(portal_user, COURSE_1)
|
||||
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
|
||||
|
||||
# Make sure the fields we care about got copied over for this user.
|
||||
for field in USER_FIELDS_TO_COPY:
|
||||
self.assertEqual(getattr(portal_user, field),
|
||||
getattr(course_user, field),
|
||||
"{0} not copied from {1} to {2}".format(
|
||||
field, portal_user, course_user
|
||||
))
|
||||
|
||||
# This hasattr lameness is here because we don't want this test to be
|
||||
# triggered when we're being run by CMS tests (Askbot doesn't exist
|
||||
# there, so the test will fail).
|
||||
#
|
||||
# seen_response_count isn't a field we care about, so it shouldn't have
|
||||
# been copied over.
|
||||
if hasattr(portal_user, 'seen_response_count'):
|
||||
portal_user.seen_response_count = 20
|
||||
replicate_user(portal_user, COURSE_1)
|
||||
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
|
||||
self.assertEqual(portal_user.seen_response_count, 20)
|
||||
self.assertEqual(course_user.seen_response_count, 0)
|
||||
|
||||
# Another replication should work for an email change however, since
|
||||
# it's a field we care about.
|
||||
portal_user.email = "clyde@edx.org"
|
||||
replicate_user(portal_user, COURSE_1)
|
||||
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
|
||||
self.assertEqual(portal_user.email, course_user.email)
|
||||
|
||||
# During this entire time, the user data should never have made it over
|
||||
# to COURSE_2
|
||||
self.assertRaises(User.DoesNotExist,
|
||||
User.objects.using(COURSE_2).get,
|
||||
id=portal_user.id)
|
||||
|
||||
|
||||
def test_enrollment_for_existing_user_info(self):
|
||||
"""Test the effect of Enrolling in a class if you've already got user
|
||||
data to be copied over."""
|
||||
# Create our User
|
||||
portal_user = User.objects.create_user('jack', 'jack@edx.org', 'fakepass')
|
||||
portal_user.first_name = "Jack"
|
||||
portal_user.save()
|
||||
|
||||
# Set up our UserProfile info
|
||||
portal_user_profile = UserProfile.objects.create(
|
||||
user=portal_user,
|
||||
name="Jack Foo",
|
||||
level_of_education=None,
|
||||
gender='m',
|
||||
mailing_address=None,
|
||||
goals="World domination",
|
||||
)
|
||||
portal_user_profile.save()
|
||||
|
||||
# Now let's see if creating a CourseEnrollment copies all the relevant
|
||||
# data.
|
||||
portal_enrollment = CourseEnrollment.objects.create(user=portal_user,
|
||||
course_id=COURSE_1)
|
||||
portal_enrollment.save()
|
||||
|
||||
# Grab all the copies we expect
|
||||
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
|
||||
self.assertEquals(portal_user, course_user)
|
||||
self.assertRaises(User.DoesNotExist,
|
||||
User.objects.using(COURSE_2).get,
|
||||
id=portal_user.id)
|
||||
|
||||
course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id)
|
||||
self.assertEquals(portal_enrollment, course_enrollment)
|
||||
self.assertRaises(CourseEnrollment.DoesNotExist,
|
||||
CourseEnrollment.objects.using(COURSE_2).get,
|
||||
id=portal_enrollment.id)
|
||||
|
||||
course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id)
|
||||
self.assertEquals(portal_user_profile, course_user_profile)
|
||||
self.assertRaises(UserProfile.DoesNotExist,
|
||||
UserProfile.objects.using(COURSE_2).get,
|
||||
id=portal_user_profile.id)
|
||||
|
||||
log.debug("Make sure our seen_response_count is not replicated.")
|
||||
if hasattr(portal_user, 'seen_response_count'):
|
||||
portal_user.seen_response_count = 200
|
||||
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
|
||||
self.assertEqual(portal_user.seen_response_count, 200)
|
||||
self.assertEqual(course_user.seen_response_count, 0)
|
||||
portal_user.save()
|
||||
|
||||
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
|
||||
self.assertEqual(portal_user.seen_response_count, 200)
|
||||
self.assertEqual(course_user.seen_response_count, 0)
|
||||
|
||||
portal_user.email = 'jim@edx.org'
|
||||
portal_user.save()
|
||||
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
|
||||
self.assertEqual(portal_user.email, 'jim@edx.org')
|
||||
self.assertEqual(course_user.email, 'jim@edx.org')
|
||||
|
||||
|
||||
|
||||
def test_enrollment_for_user_info_after_enrollment(self):
|
||||
"""Test the effect of modifying User data after you've enrolled."""
|
||||
# Create our User
|
||||
portal_user = User.objects.create_user('patty', 'patty@edx.org', 'fakepass')
|
||||
portal_user.first_name = "Patty"
|
||||
portal_user.save()
|
||||
|
||||
# Set up our UserProfile info
|
||||
portal_user_profile = UserProfile.objects.create(
|
||||
user=portal_user,
|
||||
name="Patty Foo",
|
||||
level_of_education=None,
|
||||
gender='f',
|
||||
mailing_address=None,
|
||||
goals="World peace",
|
||||
)
|
||||
portal_user_profile.save()
|
||||
|
||||
# Now let's see if creating a CourseEnrollment copies all the relevant
|
||||
# data when things are saved.
|
||||
portal_enrollment = CourseEnrollment.objects.create(user=portal_user,
|
||||
course_id=COURSE_1)
|
||||
portal_enrollment.save()
|
||||
|
||||
portal_user.last_name = "Bar"
|
||||
portal_user.save()
|
||||
portal_user_profile.gender = 'm'
|
||||
portal_user_profile.save()
|
||||
|
||||
# Grab all the copies we expect, and make sure it doesn't end up in
|
||||
# places we don't expect.
|
||||
course_user = User.objects.using(COURSE_1).get(id=portal_user.id)
|
||||
self.assertEquals(portal_user, course_user)
|
||||
self.assertRaises(User.DoesNotExist,
|
||||
User.objects.using(COURSE_2).get,
|
||||
id=portal_user.id)
|
||||
|
||||
course_enrollment = CourseEnrollment.objects.using(COURSE_1).get(id=portal_enrollment.id)
|
||||
self.assertEquals(portal_enrollment, course_enrollment)
|
||||
self.assertRaises(CourseEnrollment.DoesNotExist,
|
||||
CourseEnrollment.objects.using(COURSE_2).get,
|
||||
id=portal_enrollment.id)
|
||||
|
||||
course_user_profile = UserProfile.objects.using(COURSE_1).get(id=portal_user_profile.id)
|
||||
self.assertEquals(portal_user_profile, course_user_profile)
|
||||
self.assertRaises(UserProfile.DoesNotExist,
|
||||
UserProfile.objects.using(COURSE_2).get,
|
||||
id=portal_user_profile.id)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class SimpleTest(TestCase):
|
||||
def test_basic_addition(self):
|
||||
"""
|
||||
Tests that 1 + 1 always equals 2.
|
||||
"""
|
||||
self.assertEqual(1 + 1, 2)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import datetime
|
||||
import feedparser
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
import uuid
|
||||
import feedparser
|
||||
import time
|
||||
import urllib
|
||||
import itertools
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import logout, authenticate, login
|
||||
@@ -26,17 +27,19 @@ from bs4 import BeautifulSoup
|
||||
from django.core.cache import cache
|
||||
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from student.models import Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment
|
||||
from student.models import (Registration, UserProfile,
|
||||
PendingNameChange, PendingEmailChange,
|
||||
CourseEnrollment)
|
||||
from util.cache import cache_if_anonymous
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from models import Registration, UserProfile, PendingNameChange, PendingEmailChange, CourseEnrollment
|
||||
from datetime import date
|
||||
from collections import namedtuple
|
||||
from courseware.courses import course_staff_group_name, has_staff_access_to_course, get_courses_by_university
|
||||
from courseware.courses import (course_staff_group_name, has_staff_access_to_course,
|
||||
get_courses_by_university)
|
||||
|
||||
log = logging.getLogger("mitx.student")
|
||||
Article = namedtuple('Article', 'title url author image deck publication publish_date')
|
||||
@@ -47,7 +50,8 @@ def csrf_token(context):
|
||||
csrf_token = context.get('csrf_token', '')
|
||||
if csrf_token == 'NOTPROVIDED':
|
||||
return ''
|
||||
return u'<div style="display:none"><input type="hidden" name="csrfmiddlewaretoken" value="%s" /></div>' % (csrf_token)
|
||||
return (u'<div style="display:none"><input type="hidden"'
|
||||
' name="csrfmiddlewaretoken" value="%s" /></div>' % (csrf_token))
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@@ -94,8 +98,9 @@ def main_index(extra_context = {}, user=None):
|
||||
context.update(extra_context)
|
||||
return render_to_response('index.html', context)
|
||||
|
||||
def course_from_id(id):
|
||||
course_loc = CourseDescriptor.id_to_location(id)
|
||||
def course_from_id(course_id):
|
||||
"""Return the CourseDescriptor corresponding to this course_id"""
|
||||
course_loc = CourseDescriptor.id_to_location(course_id)
|
||||
return modulestore().get_item(course_loc)
|
||||
|
||||
|
||||
@@ -127,7 +132,7 @@ def dashboard(request):
|
||||
try:
|
||||
courses.append(course_from_id(enrollment.course_id))
|
||||
except ItemNotFoundError:
|
||||
log.error("User {0} enrolled in non-existant course {1}"
|
||||
log.error("User {0} enrolled in non-existent course {1}"
|
||||
.format(user.username, enrollment.course_id))
|
||||
|
||||
message = ""
|
||||
@@ -158,18 +163,41 @@ def try_change_enrollment(request):
|
||||
|
||||
@login_required
|
||||
def change_enrollment_view(request):
|
||||
"""Delegate to change_enrollment to actually do the work."""
|
||||
return HttpResponse(json.dumps(change_enrollment(request)))
|
||||
|
||||
def enrollment_allowed(user, course):
|
||||
"""If the course has an enrollment period, check whether we are in it.
|
||||
Also respects the DARK_LAUNCH setting"""
|
||||
now = time.gmtime()
|
||||
start = course.enrollment_start
|
||||
end = course.enrollment_end
|
||||
|
||||
if (start is None or now > start) and (end is None or now < end):
|
||||
# in enrollment period.
|
||||
return True
|
||||
|
||||
if settings.MITX_FEATURES['DARK_LAUNCH']:
|
||||
if has_staff_access_to_course(user, course):
|
||||
# if dark launch, staff can enroll outside enrollment window
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def change_enrollment(request):
|
||||
if request.method != "POST":
|
||||
raise Http404
|
||||
|
||||
action = request.POST.get("enrollment_action", "")
|
||||
user = request.user
|
||||
if not user.is_authenticated():
|
||||
raise Http404
|
||||
|
||||
action = request.POST.get("enrollment_action", "")
|
||||
|
||||
course_id = request.POST.get("course_id", None)
|
||||
if course_id == None:
|
||||
return HttpResponse(json.dumps({'success': False, 'error': 'There was an error receiving the course id.'}))
|
||||
return HttpResponse(json.dumps({'success': False,
|
||||
'error': 'There was an error receiving the course id.'}))
|
||||
|
||||
if action == "enroll":
|
||||
# Make sure the course exists
|
||||
@@ -177,17 +205,25 @@ def change_enrollment(request):
|
||||
try:
|
||||
course = course_from_id(course_id)
|
||||
except ItemNotFoundError:
|
||||
log.error("User {0} tried to enroll in non-existant course {1}"
|
||||
log.warning("User {0} tried to enroll in non-existant course {1}"
|
||||
.format(user.username, enrollment.course_id))
|
||||
return {'success': False, 'error': 'The course requested does not exist.'}
|
||||
|
||||
if settings.MITX_FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'):
|
||||
# require that user be in the staff_* group (or be an overall admin) to be able to enroll
|
||||
# eg staff_6.002x or staff_6.00x
|
||||
if not has_staff_access_to_course(user,course):
|
||||
# require that user be in the staff_* group (or be an
|
||||
# overall admin) to be able to enroll eg staff_6.002x or
|
||||
# staff_6.00x
|
||||
if not has_staff_access_to_course(user, course):
|
||||
staff_group = course_staff_group_name(course)
|
||||
log.debug('user %s denied enrollment to %s ; not in %s' % (user,course.location.url(),staff_group))
|
||||
return {'success': False, 'error' : '%s membership required to access course.' % staff_group}
|
||||
log.debug('user %s denied enrollment to %s ; not in %s' % (
|
||||
user, course.location.url(), staff_group))
|
||||
return {'success': False,
|
||||
'error' : '%s membership required to access course.' % staff_group}
|
||||
|
||||
if not enrollment_allowed(user, course):
|
||||
return {'success': False,
|
||||
'error': 'enrollment in {} not allowed at this time'
|
||||
.format(course.display_name)}
|
||||
|
||||
enrollment, created = CourseEnrollment.objects.get_or_create(user=user, course_id=course.id)
|
||||
return {'success': True}
|
||||
@@ -264,6 +300,7 @@ def logout_user(request):
|
||||
def change_setting(request):
|
||||
''' JSON call to change a profile setting: Right now, location
|
||||
'''
|
||||
# TODO (vshnayder): location is no longer used
|
||||
up = UserProfile.objects.get(user=request.user) # request.user.profile_cache
|
||||
if 'location' in request.POST:
|
||||
up.location = request.POST['location']
|
||||
@@ -272,6 +309,59 @@ def change_setting(request):
|
||||
return HttpResponse(json.dumps({'success': True,
|
||||
'location': up.location, }))
|
||||
|
||||
def _do_create_account(post_vars):
|
||||
"""
|
||||
Given cleaned post variables, create the User and UserProfile objects, as well as the
|
||||
registration for this user.
|
||||
|
||||
Returns a tuple (User, UserProfile, Registration).
|
||||
|
||||
Note: this function is also used for creating test users.
|
||||
"""
|
||||
user = User(username=post_vars['username'],
|
||||
email=post_vars['email'],
|
||||
is_active=False)
|
||||
user.set_password(post_vars['password'])
|
||||
registration = Registration()
|
||||
# TODO: Rearrange so that if part of the process fails, the whole process fails.
|
||||
# Right now, we can have e.g. no registration e-mail sent out and a zombie account
|
||||
try:
|
||||
user.save()
|
||||
except IntegrityError:
|
||||
js = {'success': False}
|
||||
# Figure out the cause of the integrity error
|
||||
if len(User.objects.filter(username=post_vars['username'])) > 0:
|
||||
js['value'] = "An account with this username already exists."
|
||||
js['field'] = 'username'
|
||||
return HttpResponse(json.dumps(js))
|
||||
|
||||
if len(User.objects.filter(email=post_vars['email'])) > 0:
|
||||
js['value'] = "An account with this e-mail already exists."
|
||||
js['field'] = 'email'
|
||||
return HttpResponse(json.dumps(js))
|
||||
|
||||
raise
|
||||
|
||||
registration.register(user)
|
||||
|
||||
profile = UserProfile(user=user)
|
||||
profile.name = post_vars['name']
|
||||
profile.level_of_education = post_vars.get('level_of_education')
|
||||
profile.gender = post_vars.get('gender')
|
||||
profile.mailing_address = post_vars.get('mailing_address')
|
||||
profile.goals = post_vars.get('goals')
|
||||
|
||||
try:
|
||||
profile.year_of_birth = int(post_vars['year_of_birth'])
|
||||
except (ValueError, KeyError):
|
||||
profile.year_of_birth = None # If they give us garbage, just ignore it instead
|
||||
# of asking them to put an integer.
|
||||
try:
|
||||
profile.save()
|
||||
except Exception:
|
||||
log.exception("UserProfile creation failed for user {0}.".format(user.id))
|
||||
return (user, profile, registration)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def create_account(request, post_override=None):
|
||||
@@ -343,50 +433,14 @@ def create_account(request, post_override=None):
|
||||
js['field'] = 'username'
|
||||
return HttpResponse(json.dumps(js))
|
||||
|
||||
u = User(username=post_vars['username'],
|
||||
email=post_vars['email'],
|
||||
is_active=False)
|
||||
u.set_password(post_vars['password'])
|
||||
r = Registration()
|
||||
# TODO: Rearrange so that if part of the process fails, the whole process fails.
|
||||
# Right now, we can have e.g. no registration e-mail sent out and a zombie account
|
||||
try:
|
||||
u.save()
|
||||
except IntegrityError:
|
||||
# Figure out the cause of the integrity error
|
||||
if len(User.objects.filter(username=post_vars['username'])) > 0:
|
||||
js['value'] = "An account with this username already exists."
|
||||
js['field'] = 'username'
|
||||
return HttpResponse(json.dumps(js))
|
||||
|
||||
if len(User.objects.filter(email=post_vars['email'])) > 0:
|
||||
js['value'] = "An account with this e-mail already exists."
|
||||
js['field'] = 'email'
|
||||
return HttpResponse(json.dumps(js))
|
||||
|
||||
raise
|
||||
|
||||
r.register(u)
|
||||
|
||||
up = UserProfile(user=u)
|
||||
up.name = post_vars['name']
|
||||
up.level_of_education = post_vars.get('level_of_education')
|
||||
up.gender = post_vars.get('gender')
|
||||
up.mailing_address = post_vars.get('mailing_address')
|
||||
up.goals = post_vars.get('goals')
|
||||
|
||||
try:
|
||||
up.year_of_birth = int(post_vars['year_of_birth'])
|
||||
except (ValueError, KeyError):
|
||||
up.year_of_birth = None # If they give us garbage, just ignore it instead
|
||||
# of asking them to put an integer.
|
||||
try:
|
||||
up.save()
|
||||
except Exception:
|
||||
log.exception("UserProfile creation failed for user {0}.".format(u.id))
|
||||
# Ok, looks like everything is legit. Create the account.
|
||||
ret = _do_create_account(post_vars)
|
||||
if isinstance(ret,HttpResponse): # if there was an error then return that
|
||||
return ret
|
||||
(user, profile, registration) = ret
|
||||
|
||||
d = {'name': post_vars['name'],
|
||||
'key': r.activation_key,
|
||||
'key': registration.activation_key,
|
||||
}
|
||||
|
||||
# composes activation email
|
||||
@@ -398,10 +452,11 @@ def create_account(request, post_override=None):
|
||||
try:
|
||||
if settings.MITX_FEATURES.get('REROUTE_ACTIVATION_EMAIL'):
|
||||
dest_addr = settings.MITX_FEATURES['REROUTE_ACTIVATION_EMAIL']
|
||||
message = "Activation for %s (%s): %s\n" % (u, u.email, up.name) + '-' * 80 + '\n\n' + message
|
||||
message = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) +
|
||||
'-' * 80 + '\n\n' + message)
|
||||
send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [dest_addr], fail_silently=False)
|
||||
elif not settings.GENERATE_RANDOM_USER_CREDENTIALS:
|
||||
res = u.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
|
||||
res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
|
||||
except:
|
||||
log.exception(sys.exc_info())
|
||||
js['value'] = 'Could not send activation e-mail.'
|
||||
@@ -431,24 +486,30 @@ def create_account(request, post_override=None):
|
||||
return HttpResponse(json.dumps(js), mimetype="application/json")
|
||||
|
||||
|
||||
def create_random_account(create_account_function):
|
||||
|
||||
def get_random_post_override():
|
||||
"""
|
||||
Return a dictionary suitable for passing to post_vars of _do_create_account or post_override
|
||||
of create_account, with random user info.
|
||||
"""
|
||||
def id_generator(size=6, chars=string.ascii_uppercase + string.ascii_lowercase + string.digits):
|
||||
return ''.join(random.choice(chars) for x in range(size))
|
||||
|
||||
def inner_create_random_account(request):
|
||||
post_override = {'username': "random_" + id_generator(),
|
||||
'email': id_generator(size=10, chars=string.ascii_lowercase) + "_dummy_test@mitx.mit.edu",
|
||||
'password': id_generator(),
|
||||
'location': id_generator(size=5, chars=string.ascii_uppercase),
|
||||
'name': id_generator(size=5, chars=string.ascii_lowercase) + " " + id_generator(size=7, chars=string.ascii_lowercase),
|
||||
'honor_code': u'true',
|
||||
'terms_of_service': u'true', }
|
||||
return {'username': "random_" + id_generator(),
|
||||
'email': id_generator(size=10, chars=string.ascii_lowercase) + "_dummy_test@mitx.mit.edu",
|
||||
'password': id_generator(),
|
||||
'name': (id_generator(size=5, chars=string.ascii_lowercase) + " " +
|
||||
id_generator(size=7, chars=string.ascii_lowercase)),
|
||||
'honor_code': u'true',
|
||||
'terms_of_service': u'true', }
|
||||
|
||||
return create_account_function(request, post_override=post_override)
|
||||
|
||||
def create_random_account(create_account_function):
|
||||
def inner_create_random_account(request):
|
||||
return create_account_function(request, post_override=get_random_post_override())
|
||||
|
||||
return inner_create_random_account
|
||||
|
||||
# TODO (vshnayder): do we need GENERATE_RANDOM_USER_CREDENTIALS for anything?
|
||||
if settings.GENERATE_RANDOM_USER_CREDENTIALS:
|
||||
create_account = create_random_account(create_account)
|
||||
|
||||
@@ -514,7 +575,7 @@ def reactivation_email(request):
|
||||
subject = ''.join(subject.splitlines())
|
||||
message = render_to_string('reactivation_email.txt', d)
|
||||
|
||||
res = u.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
|
||||
res = user.email_user(subject, message, settings.DEFAULT_FROM_EMAIL)
|
||||
|
||||
return HttpResponse(json.dumps({'success': True}))
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ def wrap_xmodule(get_html, module, template):
|
||||
return _get_html
|
||||
|
||||
|
||||
def replace_static_urls(get_html, prefix):
|
||||
def replace_static_urls(get_html, prefix, module):
|
||||
"""
|
||||
Updates the supplied module with a new get_html function that wraps
|
||||
the old get_html function and substitutes urls of the form /static/...
|
||||
@@ -69,14 +69,14 @@ def grade_histogram(module_id):
|
||||
return grades
|
||||
|
||||
|
||||
def add_histogram(get_html, module):
|
||||
def add_histogram(get_html, module, user):
|
||||
"""
|
||||
Updates the supplied module with a new get_html function that wraps
|
||||
the output of the old get_html function with additional information
|
||||
for admin users only, including a histogram of student answers and the
|
||||
definition of the xmodule
|
||||
|
||||
Does nothing if module is a SequenceModule
|
||||
Does nothing if module is a SequenceModule or a VerticalModule.
|
||||
"""
|
||||
@wraps(get_html)
|
||||
def _get_html():
|
||||
@@ -97,14 +97,20 @@ def add_histogram(get_html, module):
|
||||
# doesn't like symlinks)
|
||||
filepath = filename
|
||||
data_dir = osfs.root_path.rsplit('/')[-1]
|
||||
edit_link = "https://github.com/MITx/%s/tree/master/%s" % (data_dir,filepath)
|
||||
giturl = module.metadata.get('giturl','https://github.com/MITx')
|
||||
edit_link = "%s/%s/tree/master/%s" % (giturl,data_dir,filepath)
|
||||
else:
|
||||
edit_link = False
|
||||
|
||||
staff_context = {'definition': module.definition.get('data'),
|
||||
'metadata': json.dumps(module.metadata, indent=4),
|
||||
'element_id': module.location.html_id(),
|
||||
'location': module.location,
|
||||
'xqa_key': module.metadata.get('xqa_key',''),
|
||||
'category': str(module.__class__.__name__),
|
||||
'element_id': module.location.html_id().replace('-','_'),
|
||||
'edit_link': edit_link,
|
||||
'user': user,
|
||||
'xqa_server' : settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu/xqa'),
|
||||
'histogram': json.dumps(histogram),
|
||||
'render_histogram': render_histogram,
|
||||
'module_content': get_html()}
|
||||
|
||||
@@ -39,9 +39,9 @@ import responsetypes
|
||||
# dict of tagname, Response Class -- this should come from auto-registering
|
||||
response_tag_dict = dict([(x.response_tag, x) for x in responsetypes.__all__])
|
||||
|
||||
entry_types = ['textline', 'schematic', 'textbox', 'imageinput', 'optioninput', 'choicegroup', 'radiogroup', 'checkboxgroup', 'filesubmission']
|
||||
entry_types = ['textline', 'schematic', 'textbox', 'imageinput', 'optioninput', 'choicegroup', 'radiogroup', 'checkboxgroup', 'filesubmission', 'javascriptinput']
|
||||
solution_types = ['solution'] # extra things displayed after "show answers" is pressed
|
||||
response_properties = ["responseparam", "answer"] # these get captured as student responses
|
||||
response_properties = ["codeparam", "responseparam", "answer"] # these get captured as student responses
|
||||
|
||||
# special problem tags which should be turned into innocuous HTML
|
||||
html_transforms = {'problem': {'tag': 'div'},
|
||||
@@ -57,7 +57,7 @@ global_context = {'random': random,
|
||||
'eia': eia}
|
||||
|
||||
# These should be removed from HTML output, including all subelements
|
||||
html_problem_semantics = ["responseparam", "answer", "script", "hintgroup"]
|
||||
html_problem_semantics = ["codeparam", "responseparam", "answer", "script", "hintgroup"]
|
||||
|
||||
log = logging.getLogger('mitx.' + __name__)
|
||||
|
||||
@@ -154,21 +154,10 @@ class LoncapaProblem(object):
|
||||
def get_max_score(self):
|
||||
'''
|
||||
Return maximum score for this problem.
|
||||
We do this by counting the number of answers available for each question
|
||||
in the problem. If the Response for a question has a get_max_score() method
|
||||
then we call that and add its return value to the count. That can be
|
||||
used to give complex problems (eg programming questions) multiple points.
|
||||
'''
|
||||
maxscore = 0
|
||||
for response, responder in self.responders.iteritems():
|
||||
if hasattr(responder, 'get_max_score'):
|
||||
try:
|
||||
maxscore += responder.get_max_score()
|
||||
except Exception:
|
||||
log.debug('responder %s failed to properly return from get_max_score()' % responder) # FIXME
|
||||
raise
|
||||
else:
|
||||
maxscore += len(self.responder_answers[response])
|
||||
maxscore += responder.get_max_score()
|
||||
return maxscore
|
||||
|
||||
def get_score(self):
|
||||
@@ -203,8 +192,9 @@ class LoncapaProblem(object):
|
||||
cmap.update(self.correct_map)
|
||||
for responder in self.responders.values():
|
||||
if hasattr(responder, 'update_score'):
|
||||
# Each LoncapaResponse will update the specific entries of 'cmap' that it's responsible for
|
||||
cmap = responder.update_score(score_msg, cmap, queuekey)
|
||||
# Each LoncapaResponse will update its specific entries in cmap
|
||||
# cmap is passed by reference
|
||||
responder.update_score(score_msg, cmap, queuekey)
|
||||
self.correct_map.set_dict(cmap.get_dict())
|
||||
return cmap
|
||||
|
||||
@@ -228,14 +218,14 @@ class LoncapaProblem(object):
|
||||
|
||||
Calls the Response for each question in this problem, to do the actual grading.
|
||||
'''
|
||||
|
||||
|
||||
self.student_answers = convert_files_to_filenames(answers)
|
||||
|
||||
|
||||
oldcmap = self.correct_map # old CorrectMap
|
||||
newcmap = CorrectMap() # start new with empty CorrectMap
|
||||
# log.debug('Responders: %s' % self.responders)
|
||||
for responder in self.responders.values(): # Call each responsetype instance to do actual grading
|
||||
if 'filesubmission' in responder.allowed_inputfields: # File objects are passed only if responsetype
|
||||
if 'filesubmission' in responder.allowed_inputfields: # File objects are passed only if responsetype
|
||||
# explicitly allows for file submissions
|
||||
results = responder.evaluate_answers(answers, oldcmap)
|
||||
else:
|
||||
@@ -294,9 +284,9 @@ class LoncapaProblem(object):
|
||||
try:
|
||||
ifp = self.system.filestore.open(file) # open using ModuleSystem OSFS filestore
|
||||
except Exception as err:
|
||||
log.error('Error %s in problem xml include: %s' % (
|
||||
log.warning('Error %s in problem xml include: %s' % (
|
||||
err, etree.tostring(inc, pretty_print=True)))
|
||||
log.error('Cannot find file %s in %s' % (
|
||||
log.warning('Cannot find file %s in %s' % (
|
||||
file, self.system.filestore))
|
||||
# if debugging, don't fail - just log error
|
||||
# TODO (vshnayder): need real error handling, display to users
|
||||
@@ -305,11 +295,11 @@ class LoncapaProblem(object):
|
||||
else:
|
||||
continue
|
||||
try:
|
||||
incxml = etree.XML(ifp.read()) # read in and convert to XML
|
||||
incxml = etree.XML(ifp.read()) # read in and convert to XML
|
||||
except Exception as err:
|
||||
log.error('Error %s in problem xml include: %s' % (
|
||||
log.warning('Error %s in problem xml include: %s' % (
|
||||
err, etree.tostring(inc, pretty_print=True)))
|
||||
log.error('Cannot parse XML in %s' % (file))
|
||||
log.warning('Cannot parse XML in %s' % (file))
|
||||
# if debugging, don't fail - just log error
|
||||
# TODO (vshnayder): same as above
|
||||
if not self.system.get('DEBUG'):
|
||||
@@ -392,9 +382,10 @@ class LoncapaProblem(object):
|
||||
context['script_code'] += code # store code source in context
|
||||
try:
|
||||
exec code in context, context # use "context" for global context; thus defs in code are global within code
|
||||
except Exception:
|
||||
except Exception as err:
|
||||
log.exception("Error while execing script code: " + code)
|
||||
raise responsetypes.LoncapaProblemError("Error while executing script code")
|
||||
msg = "Error while executing script code: %s" % str(err).replace('<','<')
|
||||
raise responsetypes.LoncapaProblemError(msg)
|
||||
finally:
|
||||
sys.path = original_path
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Module containing the problem elements which render into input objects
|
||||
- choicegroup
|
||||
- radiogroup
|
||||
- checkboxgroup
|
||||
- javascriptinput
|
||||
- imageinput (for clickable image)
|
||||
- optioninput (for option list)
|
||||
- filesubmission (upload a file)
|
||||
@@ -246,6 +247,34 @@ def checkboxgroup(element, value, status, render_template, msg=''):
|
||||
html = render_template("choicegroup.html", context)
|
||||
return etree.XML(html)
|
||||
|
||||
@register_render_function
|
||||
def javascriptinput(element, value, status, render_template, msg='null'):
|
||||
'''
|
||||
Hidden field for javascript to communicate via; also loads the required
|
||||
scripts for rendering the problem and passes data to the problem.
|
||||
'''
|
||||
eid = element.get('id')
|
||||
params = element.get('params')
|
||||
problem_state = element.get('problem_state')
|
||||
display_class = element.get('display_class')
|
||||
display_file = element.get('display_file')
|
||||
|
||||
# Need to provide a value that JSON can parse if there is no
|
||||
# student-supplied value yet.
|
||||
if value == "":
|
||||
value = 'null'
|
||||
|
||||
escapedict = {'"': '"'}
|
||||
value = saxutils.escape(value, escapedict)
|
||||
msg = saxutils.escape(msg, escapedict)
|
||||
context = {'id': eid, 'params': params, 'display_file': display_file,
|
||||
'display_class': display_class, 'problem_state': problem_state,
|
||||
'value': value, 'evaluation': msg,
|
||||
}
|
||||
html = render_template("javascriptinput.html", context)
|
||||
return etree.XML(html)
|
||||
|
||||
|
||||
|
||||
@register_render_function
|
||||
def textline(element, value, status, render_template, msg=""):
|
||||
@@ -307,9 +336,19 @@ def filesubmission(element, value, status, render_template, msg=''):
|
||||
Upload a single file (e.g. for programming assignments)
|
||||
'''
|
||||
eid = element.get('id')
|
||||
context = { 'id': eid, 'state': status, 'msg': msg, 'value': value, }
|
||||
|
||||
# Check if problem has been queued
|
||||
queue_len = 0
|
||||
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
|
||||
|
||||
context = { 'id': eid, 'state': status, 'msg': msg, 'value': value,
|
||||
'queue_len': queue_len
|
||||
}
|
||||
html = render_template("filesubmission.html", context)
|
||||
return etree.XML(html)
|
||||
return etree.XML(html)
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
@@ -330,9 +369,16 @@ def textbox(element, value, status, render_template, msg=''):
|
||||
|
||||
if not value: value = element.text # if no student input yet, then use the default input given by the problem
|
||||
|
||||
# Check if problem has been queued
|
||||
queue_len = 0
|
||||
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
|
||||
|
||||
# For CodeMirror
|
||||
mode = element.get('mode') or 'python' # mode, eg "python" or "xml"
|
||||
linenumbers = element.get('linenumbers','true') # for CodeMirror
|
||||
mode = element.get('mode','python')
|
||||
linenumbers = element.get('linenumbers','true')
|
||||
tabsize = element.get('tabsize','4')
|
||||
tabsize = int(tabsize)
|
||||
|
||||
@@ -340,6 +386,7 @@ def textbox(element, value, status, render_template, msg=''):
|
||||
'mode': mode, 'linenumbers': linenumbers,
|
||||
'rows': rows, 'cols': cols,
|
||||
'hidden': hidden, 'tabsize': tabsize,
|
||||
'queue_len': queue_len,
|
||||
}
|
||||
html = render_template("textbox.html", context)
|
||||
try:
|
||||
|
||||
30
common/lib/capa/capa/javascript_problem_generator.js
Normal file
@@ -0,0 +1,30 @@
|
||||
require('coffee-script');
|
||||
var importAll = function (modulePath) {
|
||||
module = require(modulePath);
|
||||
for(key in module){
|
||||
global[key] = module[key];
|
||||
}
|
||||
}
|
||||
|
||||
importAll("mersenne-twister-min");
|
||||
importAll("xproblem");
|
||||
|
||||
generatorModulePath = process.argv[2];
|
||||
dependencies = JSON.parse(process.argv[3]);
|
||||
seed = process.argv[4];
|
||||
params = JSON.parse(process.argv[5]);
|
||||
|
||||
if(seed==null){
|
||||
seed = 4;
|
||||
}else{
|
||||
seed = parseInt(seed);
|
||||
}
|
||||
|
||||
for(var i = 0; i < dependencies.length; i++){
|
||||
importAll(dependencies[i]);
|
||||
}
|
||||
|
||||
generatorModule = require(generatorModulePath);
|
||||
generatorClass = generatorModule.generatorClass;
|
||||
generator = new generatorClass(seed, params);
|
||||
console.log(JSON.stringify(generator.generate()));
|
||||
26
common/lib/capa/capa/javascript_problem_grader.js
Normal file
@@ -0,0 +1,26 @@
|
||||
require('coffee-script');
|
||||
var importAll = function (modulePath) {
|
||||
module = require(modulePath);
|
||||
for(key in module){
|
||||
global[key] = module[key];
|
||||
}
|
||||
}
|
||||
|
||||
importAll("xproblem");
|
||||
|
||||
graderModulePath = process.argv[2];
|
||||
dependencies = JSON.parse(process.argv[3]);
|
||||
submission = JSON.parse(process.argv[4]);
|
||||
problemState = JSON.parse(process.argv[5]);
|
||||
params = JSON.parse(process.argv[6]);
|
||||
|
||||
for(var i = 0; i < dependencies.length; i++){
|
||||
importAll(dependencies[i]);
|
||||
}
|
||||
|
||||
graderModule = require(graderModulePath);
|
||||
graderClass = graderModule.graderClass;
|
||||
grader = new graderClass(submission, problemState, params);
|
||||
console.log(JSON.stringify(grader.grade()));
|
||||
console.log(JSON.stringify(grader.evaluation));
|
||||
console.log(JSON.stringify(grader.solution));
|
||||
@@ -17,7 +17,11 @@ import random
|
||||
import re
|
||||
import requests
|
||||
import traceback
|
||||
import hashlib
|
||||
import abc
|
||||
import os
|
||||
import subprocess
|
||||
import xml.sax.saxutils as saxutils
|
||||
|
||||
# specific library imports
|
||||
from calc import evaluator, UndefinedVariable
|
||||
@@ -71,7 +75,6 @@ class LoncapaResponse(object):
|
||||
|
||||
In addition, these methods are optional:
|
||||
|
||||
- get_max_score : if defined, this is called to obtain the maximum score possible for this question
|
||||
- setup_response : find and note the answer input field IDs for the response; called by __init__
|
||||
- check_hint_condition : check to see if the student's answers satisfy a particular condition for a hint to be displayed
|
||||
- render_html : render this Response as HTML (must return XHTML compliant string)
|
||||
@@ -130,6 +133,11 @@ class LoncapaResponse(object):
|
||||
if self.max_inputfields == 1:
|
||||
self.answer_id = self.answer_ids[0] # for convenience
|
||||
|
||||
self.maxpoints = dict()
|
||||
for inputfield in self.inputfields:
|
||||
maxpoints = inputfield.get('points','1') # By default, each answerfield is worth 1 point
|
||||
self.maxpoints.update({inputfield.get('id'): int(maxpoints)})
|
||||
|
||||
self.default_answer_map = {} # dict for default answer map (provided in input elements)
|
||||
for entry in self.inputfields:
|
||||
answer = entry.get('correct_answer')
|
||||
@@ -139,6 +147,12 @@ class LoncapaResponse(object):
|
||||
if hasattr(self, 'setup_response'):
|
||||
self.setup_response()
|
||||
|
||||
def get_max_score(self):
|
||||
'''
|
||||
Return the total maximum points of all answer fields under this Response
|
||||
'''
|
||||
return sum(self.maxpoints.values())
|
||||
|
||||
def render_html(self, renderer):
|
||||
'''
|
||||
Return XHTML Element tree representation of this Response.
|
||||
@@ -272,9 +286,190 @@ class LoncapaResponse(object):
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class JavascriptResponse(LoncapaResponse):
|
||||
'''
|
||||
This response type is used when the student's answer is graded via
|
||||
Javascript using Node.js.
|
||||
'''
|
||||
|
||||
response_tag = 'javascriptresponse'
|
||||
max_inputfields = 1
|
||||
allowed_inputfields = ['javascriptinput']
|
||||
|
||||
def setup_response(self):
|
||||
|
||||
# Sets up generator, grader, display, and their dependencies.
|
||||
self.parse_xml()
|
||||
|
||||
self.compile_display_javascript()
|
||||
|
||||
self.params = self.extract_params()
|
||||
|
||||
if self.generator:
|
||||
self.problem_state = self.generate_problem_state()
|
||||
else:
|
||||
self.problem_state = None
|
||||
|
||||
self.solution = None
|
||||
|
||||
self.prepare_inputfield()
|
||||
|
||||
def compile_display_javascript(self):
|
||||
|
||||
latestTimestamp = 0
|
||||
basepath = self.system.filestore.root_path + '/js/'
|
||||
for filename in (self.display_dependencies + [self.display]):
|
||||
filepath = basepath + filename
|
||||
timestamp = os.stat(filepath).st_mtime
|
||||
if timestamp > latestTimestamp:
|
||||
latestTimestamp = timestamp
|
||||
|
||||
h = hashlib.md5()
|
||||
h.update(self.answer_id + str(self.display_dependencies))
|
||||
compiled_filename = 'compiled/' + h.hexdigest() + '.js'
|
||||
compiled_filepath = basepath + compiled_filename
|
||||
|
||||
if not os.path.exists(compiled_filepath) or os.stat(compiled_filepath).st_mtime < latestTimestamp:
|
||||
outfile = open(compiled_filepath, 'w')
|
||||
for filename in (self.display_dependencies + [self.display]):
|
||||
filepath = basepath + filename
|
||||
infile = open(filepath, 'r')
|
||||
outfile.write(infile.read())
|
||||
outfile.write(';\n')
|
||||
infile.close()
|
||||
outfile.close()
|
||||
|
||||
self.display_filename = compiled_filename
|
||||
|
||||
def parse_xml(self):
|
||||
self.generator_xml = self.xml.xpath('//*[@id=$id]//generator',
|
||||
id=self.xml.get('id'))[0]
|
||||
|
||||
self.grader_xml = self.xml.xpath('//*[@id=$id]//grader',
|
||||
id=self.xml.get('id'))[0]
|
||||
|
||||
self.display_xml = self.xml.xpath('//*[@id=$id]//display',
|
||||
id=self.xml.get('id'))[0]
|
||||
|
||||
self.xml.remove(self.generator_xml)
|
||||
self.xml.remove(self.grader_xml)
|
||||
self.xml.remove(self.display_xml)
|
||||
|
||||
self.generator = self.generator_xml.get("src")
|
||||
self.grader = self.grader_xml.get("src")
|
||||
self.display = self.display_xml.get("src")
|
||||
|
||||
if self.generator_xml.get("dependencies"):
|
||||
self.generator_dependencies = self.generator_xml.get("dependencies").split()
|
||||
else:
|
||||
self.generator_dependencies = []
|
||||
|
||||
if self.grader_xml.get("dependencies"):
|
||||
self.grader_dependencies = self.grader_xml.get("dependencies").split()
|
||||
else:
|
||||
self.grader_dependencies = []
|
||||
|
||||
if self.display_xml.get("dependencies"):
|
||||
self.display_dependencies = self.display_xml.get("dependencies").split()
|
||||
else:
|
||||
self.display_dependencies = []
|
||||
|
||||
self.display_class = self.display_xml.get("class")
|
||||
|
||||
def get_node_env(self):
|
||||
|
||||
js_dir = os.path.join(self.system.filestore.root_path, 'js')
|
||||
tmp_env = os.environ.copy()
|
||||
node_path = self.system.node_path + ":" + os.path.normpath(js_dir)
|
||||
tmp_env["NODE_PATH"] = node_path
|
||||
return tmp_env
|
||||
|
||||
|
||||
def generate_problem_state(self):
|
||||
|
||||
generator_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_generator.js'
|
||||
output = subprocess.check_output(["node",
|
||||
generator_file,
|
||||
self.generator,
|
||||
json.dumps(self.generator_dependencies),
|
||||
json.dumps(str(self.system.seed)),
|
||||
json.dumps(self.params)
|
||||
],
|
||||
env=self.get_node_env()).strip()
|
||||
|
||||
return json.loads(output)
|
||||
|
||||
def extract_params(self):
|
||||
|
||||
params = {}
|
||||
|
||||
for param in self.xml.xpath('//*[@id=$id]//responseparam',
|
||||
id=self.xml.get('id')):
|
||||
|
||||
params[param.get("name")] = json.loads(param.get("value"))
|
||||
|
||||
return params
|
||||
|
||||
def prepare_inputfield(self):
|
||||
|
||||
for inputfield in self.xml.xpath('//*[@id=$id]//javascriptinput',
|
||||
id=self.xml.get('id')):
|
||||
|
||||
escapedict = {'"': '"'}
|
||||
|
||||
encoded_params = json.dumps(self.params)
|
||||
encoded_params = saxutils.escape(encoded_params, escapedict)
|
||||
inputfield.set("params", encoded_params)
|
||||
|
||||
encoded_problem_state = json.dumps(self.problem_state)
|
||||
encoded_problem_state = saxutils.escape(encoded_problem_state,
|
||||
escapedict)
|
||||
inputfield.set("problem_state", encoded_problem_state)
|
||||
|
||||
inputfield.set("display_file", self.display_filename)
|
||||
inputfield.set("display_class", self.display_class)
|
||||
|
||||
def get_score(self, student_answers):
|
||||
json_submission = student_answers[self.answer_id]
|
||||
(all_correct, evaluation, solution) = self.run_grader(json_submission)
|
||||
self.solution = solution
|
||||
correctness = 'correct' if all_correct else 'incorrect'
|
||||
return CorrectMap(self.answer_id, correctness, msg=evaluation)
|
||||
|
||||
def run_grader(self, submission):
|
||||
if submission is None or submission == '':
|
||||
submission = json.dumps(None)
|
||||
|
||||
grader_file = os.path.dirname(os.path.normpath(__file__)) + '/javascript_problem_grader.js'
|
||||
outputs = subprocess.check_output(["node",
|
||||
grader_file,
|
||||
self.grader,
|
||||
json.dumps(self.grader_dependencies),
|
||||
submission,
|
||||
json.dumps(self.problem_state),
|
||||
json.dumps(self.params)
|
||||
],
|
||||
env=self.get_node_env()).split('\n')
|
||||
|
||||
all_correct = json.loads(outputs[0].strip())
|
||||
evaluation = outputs[1].strip()
|
||||
solution = outputs[2].strip()
|
||||
return (all_correct, evaluation, solution)
|
||||
|
||||
def get_answers(self):
|
||||
if self.solution is None:
|
||||
(_, _, self.solution) = self.run_grader(None)
|
||||
|
||||
return {self.answer_id: self.solution}
|
||||
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
class ChoiceResponse(LoncapaResponse):
|
||||
'''
|
||||
This Response type is used when the student chooses from a discrete set of
|
||||
This response type is used when the student chooses from a discrete set of
|
||||
choices. Currently, to be marked correct, all "correct" choices must be
|
||||
supplied by the student, and no extraneous choices may be included.
|
||||
|
||||
@@ -313,6 +508,11 @@ class ChoiceResponse(LoncapaResponse):
|
||||
In the above example, radiogroup can be replaced with checkboxgroup to allow
|
||||
the student to select more than one choice.
|
||||
|
||||
TODO: In order for the inputtypes to render properly, this response type
|
||||
must run setup_response prior to the input type rendering. Specifically, the
|
||||
choices must be given names. This behavior seems like a leaky abstraction,
|
||||
and it'd be nice to change this at some point.
|
||||
|
||||
'''
|
||||
|
||||
response_tag = 'choiceresponse'
|
||||
@@ -668,7 +868,10 @@ def sympy_check2():
|
||||
|
||||
# if there is only one box, and it's empty, then don't evaluate
|
||||
if len(idset) == 1 and not submission[0]:
|
||||
return CorrectMap(idset[0], 'incorrect', msg='<font color="red">No answer entered!</font>')
|
||||
# default to no error message on empty answer (to be consistent with other responsetypes)
|
||||
# but allow author to still have the old behavior by setting empty_answer_err attribute
|
||||
msg = '<font color="red">No answer entered!</font>' if self.xml.get('empty_answer_err') else ''
|
||||
return CorrectMap(idset[0], 'incorrect', msg=msg)
|
||||
|
||||
correct = ['unknown'] * len(idset)
|
||||
messages = [''] * len(idset)
|
||||
@@ -815,33 +1018,79 @@ class CodeResponse(LoncapaResponse):
|
||||
max_inputfields = 1
|
||||
|
||||
def setup_response(self):
|
||||
'''
|
||||
Configure CodeResponse from XML. Supports both CodeResponse and ExternalResponse XML
|
||||
|
||||
TODO: Determines whether in synchronous or asynchronous (queued) mode
|
||||
'''
|
||||
xml = self.xml
|
||||
self.url = xml.get('url', None) # XML can override external resource (grader/queue) URL
|
||||
self.queue_name = xml.get('queuename', self.system.xqueue['default_queuename'])
|
||||
|
||||
answer = xml.find('answer')
|
||||
# VS[compat]:
|
||||
# Check if XML uses the ExternalResponse format or the generic CodeResponse format
|
||||
codeparam = self.xml.find('codeparam')
|
||||
if codeparam is None:
|
||||
self._parse_externalresponse_xml()
|
||||
else:
|
||||
self._parse_coderesponse_xml(codeparam)
|
||||
|
||||
def _parse_coderesponse_xml(self,codeparam):
|
||||
'''
|
||||
Parse the new CodeResponse XML format. When successful, sets:
|
||||
self.initial_display
|
||||
self.answer (an answer to display to the student in the LMS)
|
||||
self.payload
|
||||
'''
|
||||
# Note that CodeResponse is agnostic to the specific contents of grader_payload
|
||||
grader_payload = codeparam.find('grader_payload')
|
||||
grader_payload = grader_payload.text if grader_payload is not None else ''
|
||||
self.payload = {'grader_payload': grader_payload}
|
||||
|
||||
answer_display = codeparam.find('answer_display')
|
||||
if answer_display is not None:
|
||||
self.answer = answer_display.text
|
||||
else:
|
||||
self.answer = 'No answer provided.'
|
||||
|
||||
initial_display = codeparam.find('initial_display')
|
||||
if initial_display is not None:
|
||||
self.initial_display = initial_display.text
|
||||
else:
|
||||
self.initial_display = ''
|
||||
|
||||
def _parse_externalresponse_xml(self):
|
||||
'''
|
||||
VS[compat]: Suppport for old ExternalResponse XML format. When successful, sets:
|
||||
self.initial_display
|
||||
self.answer (an answer to display to the student in the LMS)
|
||||
self.payload
|
||||
'''
|
||||
answer = self.xml.find('answer')
|
||||
|
||||
if answer is not None:
|
||||
answer_src = answer.get('src')
|
||||
if answer_src is not None:
|
||||
self.code = self.system.filesystem.open('src/' + answer_src).read()
|
||||
code = self.system.filesystem.open('src/' + answer_src).read()
|
||||
else:
|
||||
self.code = answer.text
|
||||
code = answer.text
|
||||
else: # no <answer> stanza; get code from <script>
|
||||
self.code = self.context['script_code']
|
||||
if not self.code:
|
||||
code = self.context['script_code']
|
||||
if not code:
|
||||
msg = '%s: Missing answer script code for coderesponse' % unicode(self)
|
||||
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>')
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
self.tests = xml.get('tests')
|
||||
tests = self.xml.get('tests')
|
||||
|
||||
# Extract 'answer' and 'initial_display' from XML. Note that the code to be exec'ed here is:
|
||||
# (1) Internal edX code, i.e. NOT student submissions, and
|
||||
# (2) The code should only define the strings 'initial_display', 'answer', 'preamble', 'test_program'
|
||||
# following the 6.01 problem definition convention
|
||||
# following the ExternalResponse XML format
|
||||
penv = {}
|
||||
penv['__builtins__'] = globals()['__builtins__']
|
||||
try:
|
||||
exec(self.code, penv, penv)
|
||||
exec(code, penv, penv)
|
||||
except Exception as err:
|
||||
log.error('Error in CodeResponse %s: Error in problem reference code' % err)
|
||||
raise Exception(err)
|
||||
@@ -852,6 +1101,14 @@ class CodeResponse(LoncapaResponse):
|
||||
log.error("Error in CodeResponse %s: Problem reference code does not define 'answer' and/or 'initial_display' in <answer>...</answer>" % err)
|
||||
raise Exception(err)
|
||||
|
||||
# Finally, make the ExternalResponse input XML format conform to the generic exteral grader interface
|
||||
# The XML tagging of grader_payload is pyxserver-specific
|
||||
grader_payload = '<pyxserver>'
|
||||
grader_payload += '<tests>' + tests + '</tests>\n'
|
||||
grader_payload += '<processor>' + code + '</processor>'
|
||||
grader_payload += '</pyxserver>'
|
||||
self.payload = {'grader_payload': grader_payload}
|
||||
|
||||
def get_score(self, student_answers):
|
||||
try:
|
||||
submission = student_answers[self.answer_id] # Note that submission can be a file
|
||||
@@ -860,7 +1117,10 @@ class CodeResponse(LoncapaResponse):
|
||||
(err, self.answer_id, convert_files_to_filenames(student_answers)))
|
||||
raise Exception(err)
|
||||
|
||||
self.context.update({'submission': unicode(submission)})
|
||||
if is_file(submission):
|
||||
self.context.update({'submission': submission.name})
|
||||
else:
|
||||
self.context.update({'submission': submission})
|
||||
|
||||
# Prepare xqueue request
|
||||
#------------------------------------------------------------
|
||||
@@ -873,22 +1133,16 @@ class CodeResponse(LoncapaResponse):
|
||||
queue_name=self.queue_name)
|
||||
|
||||
# Generate body
|
||||
# NOTE: Currently specialized to 6.00x's pyxserver, which follows the ExternalResponse interface
|
||||
# We should define a common interface for external code graders to CodeResponse
|
||||
contents = {'xml': etree.tostring(self.xml, pretty_print=True),
|
||||
'edX_cmd': 'get_score',
|
||||
'edX_tests': self.tests,
|
||||
'processor': self.code,
|
||||
}
|
||||
|
||||
# Submit request
|
||||
contents = self.payload.copy()
|
||||
|
||||
# Submit request. When successful, 'msg' is the prior length of the queue
|
||||
if is_file(submission):
|
||||
contents.update({'edX_student_response': submission.name})
|
||||
contents.update({'student_response': submission.name})
|
||||
(error, msg) = qinterface.send_to_queue(header=xheader,
|
||||
body=json.dumps(contents),
|
||||
file_to_upload=submission)
|
||||
else:
|
||||
contents.update({'edX_student_response': submission})
|
||||
contents.update({'student_response': submission})
|
||||
(error, msg) = qinterface.send_to_queue(header=xheader,
|
||||
body=json.dumps(contents))
|
||||
|
||||
@@ -897,33 +1151,34 @@ class CodeResponse(LoncapaResponse):
|
||||
cmap.set(self.answer_id, queuekey=None,
|
||||
msg='Unable to deliver your submission to grader. (Reason: %s.) Please try again later.' % msg)
|
||||
else:
|
||||
# Non-null CorrectMap['queuekey'] indicates that the problem has been queued
|
||||
cmap.set(self.answer_id, queuekey=queuekey, msg='Submitted to grader. (Queue length: %s)' % msg)
|
||||
# Queueing mechanism flags:
|
||||
# 1) Backend: Non-null CorrectMap['queuekey'] indicates that the problem has been queued
|
||||
# 2) Frontend: correctness='incomplete' eventually trickles down through inputtypes.textbox
|
||||
# and .filesubmission to inform the browser to poll the LMS
|
||||
cmap.set(self.answer_id, queuekey=queuekey, correctness='incomplete', msg=msg)
|
||||
|
||||
return cmap
|
||||
|
||||
def update_score(self, score_msg, oldcmap, queuekey):
|
||||
# Parse 'score_msg' as XML
|
||||
try:
|
||||
rxml = etree.fromstring(score_msg)
|
||||
except Exception as err:
|
||||
msg = 'Error in CodeResponse %s: cannot parse response from xworker r.text=%s' % (err, score_msg)
|
||||
raise Exception(err)
|
||||
|
||||
# The following process is lifted directly from ExternalResponse
|
||||
ad = rxml.find('awarddetail').text
|
||||
admap = {'EXACT_ANS': 'correct', # TODO: handle other loncapa responses
|
||||
'WRONG_FORMAT': 'incorrect',
|
||||
}
|
||||
self.context['correct'] = ['correct']
|
||||
if ad in admap:
|
||||
self.context['correct'][0] = admap[ad]
|
||||
(valid_score_msg, correct, points, msg) = self._parse_score_msg(score_msg)
|
||||
if not valid_score_msg:
|
||||
oldcmap.set(self.answer_id, msg='Error: Invalid grader reply.')
|
||||
return oldcmap
|
||||
|
||||
correctness = 'correct' if correct else 'incorrect'
|
||||
|
||||
self.context['correct'] = correctness # TODO: Find out how this is used elsewhere, if any
|
||||
|
||||
# Replace 'oldcmap' with new grading results if queuekey matches.
|
||||
# If queuekey does not match, we keep waiting for the score_msg whose key actually matches
|
||||
if oldcmap.is_right_queuekey(self.answer_id, queuekey):
|
||||
msg = rxml.find('message').text.replace(' ', ' ')
|
||||
oldcmap.set(self.answer_id, correctness=self.context['correct'][0], msg=msg, queuekey=None) # Queuekey is consumed
|
||||
# Sanity check on returned points
|
||||
if points < 0:
|
||||
points = 0
|
||||
elif points > self.maxpoints[self.answer_id]:
|
||||
points = self.maxpoints[self.answer_id]
|
||||
oldcmap.set(self.answer_id, npoints=points, correctness=correctness, msg=msg.replace(' ', ' '), queuekey=None) # Queuekey is consumed
|
||||
else:
|
||||
log.debug('CodeResponse: queuekey %s does not match for answer_id=%s.' % (queuekey, self.answer_id))
|
||||
|
||||
@@ -936,6 +1191,31 @@ class CodeResponse(LoncapaResponse):
|
||||
def get_initial_display(self):
|
||||
return {self.answer_id: self.initial_display}
|
||||
|
||||
def _parse_score_msg(self, score_msg):
|
||||
'''
|
||||
Grader reply is a JSON-dump of the following dict
|
||||
{ 'correct': True/False,
|
||||
'score': # TODO -- Partial grading
|
||||
'msg': grader_msg }
|
||||
|
||||
Returns (valid_score_msg, correct, score, msg):
|
||||
valid_score_msg: Flag indicating valid score_msg format (Boolean)
|
||||
correct: Correctness of submission (Boolean)
|
||||
score: # TODO: Implement partial grading
|
||||
msg: Message from grader to display to student (string)
|
||||
'''
|
||||
fail = (False, False, -1, '')
|
||||
try:
|
||||
score_result = json.loads(score_msg)
|
||||
except (TypeError, ValueError):
|
||||
return fail
|
||||
if not isinstance(score_result, dict):
|
||||
return fail
|
||||
for tag in ['correct', 'score', 'msg']:
|
||||
if not score_result.has_key(tag):
|
||||
return fail
|
||||
return (True, score_result['correct'], score_result['score'], score_result['msg'])
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
@@ -1321,4 +1601,4 @@ class ImageResponse(LoncapaResponse):
|
||||
# TEMPORARY: List of all response subclasses
|
||||
# FIXME: To be replaced by auto-registration
|
||||
|
||||
__all__ = [CodeResponse, NumericalResponse, FormulaResponse, CustomResponse, SchematicResponse, ExternalResponse, ImageResponse, OptionResponse, SymbolicResponse, StringResponse, ChoiceResponse, MultipleChoiceResponse, TrueFalseResponse]
|
||||
__all__ = [CodeResponse, NumericalResponse, FormulaResponse, CustomResponse, SchematicResponse, ExternalResponse, ImageResponse, OptionResponse, SymbolicResponse, StringResponse, ChoiceResponse, MultipleChoiceResponse, TrueFalseResponse, JavascriptResponse]
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
% elif state == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% elif state == 'incomplete':
|
||||
<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
|
||||
<span class="debug">(${state})</span>
|
||||
<br/>
|
||||
|
||||
10
common/lib/capa/capa/templates/javascriptinput.html
Normal file
@@ -0,0 +1,10 @@
|
||||
<form class="javascriptinput capa_inputtype">
|
||||
<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}"
|
||||
data-submission="${value}" data-evaluation="${evaluation}">
|
||||
</div>
|
||||
<div class="script_placeholder" data-src="/static/js/${display_file}"></div>
|
||||
<div class="javascriptinput_container"></div>
|
||||
</form>
|
||||
|
||||
@@ -13,11 +13,12 @@
|
||||
<span class="correct" id="status_${id}"></span>
|
||||
% elif state == 'incorrect':
|
||||
<span class="incorrect" id="status_${id}"></span>
|
||||
% elif state == 'incomplete':
|
||||
<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}" />
|
||||
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
|
||||
% endif
|
||||
<br/>
|
||||
<span class="debug">(${state})</span>
|
||||
|
||||
@@ -11,18 +11,19 @@ DEFAULT = "_DEFAULT_GROUP"
|
||||
|
||||
|
||||
def group_from_value(groups, v):
|
||||
''' Given group: (('a',0.3),('b',0.4),('c',0.3)) And random value
|
||||
"""
|
||||
Given group: (('a', 0.3), ('b', 0.4), ('c', 0.3)) and random value v
|
||||
in [0,1], return the associated group (in the above case, return
|
||||
'a' if v<0.3, 'b' if 0.3<=v<0.7, and 'c' if v>0.7
|
||||
'''
|
||||
'a' if v < 0.3, 'b' if 0.3 <= v < 0.7, and 'c' if v > 0.7
|
||||
"""
|
||||
sum = 0
|
||||
for (g, p) in groups:
|
||||
sum = sum + p
|
||||
if sum > v:
|
||||
return g
|
||||
|
||||
# Round off errors might cause us to run to the end of the list
|
||||
# If the do, return the last element
|
||||
# Round off errors might cause us to run to the end of the list.
|
||||
# If the do, return the last element.
|
||||
return g
|
||||
|
||||
|
||||
@@ -31,8 +32,8 @@ class ABTestModule(XModule):
|
||||
Implements an A/B test with an aribtrary number of competing groups
|
||||
"""
|
||||
|
||||
def __init__(self, system, location, definition, instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, instance_state, shared_state, **kwargs)
|
||||
def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs)
|
||||
|
||||
if shared_state is None:
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ from datetime import timedelta
|
||||
from lxml import etree
|
||||
from pkg_resources import resource_string
|
||||
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.exceptions import NotFoundError
|
||||
from progress import Progress
|
||||
from capa.capa_problem import LoncapaProblem
|
||||
from capa.responsetypes import StudentInputError
|
||||
from capa.util import convert_files_to_filenames
|
||||
from progress import Progress
|
||||
from xmodule.x_module import XModule
|
||||
from xmodule.raw_module import RawDescriptor
|
||||
from xmodule.exceptions import NotFoundError
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
@@ -80,9 +80,9 @@ class CapaModule(XModule):
|
||||
js_module_name = "Problem"
|
||||
css = {'scss': [resource_string(__name__, 'css/capa/display.scss')]}
|
||||
|
||||
def __init__(self, system, location, definition, instance_state=None,
|
||||
def __init__(self, system, location, definition, descriptor, instance_state=None,
|
||||
shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, instance_state,
|
||||
XModule.__init__(self, system, location, definition, descriptor, instance_state,
|
||||
shared_state, **kwargs)
|
||||
|
||||
self.attempts = 0
|
||||
@@ -134,12 +134,14 @@ class CapaModule(XModule):
|
||||
|
||||
if self.rerandomize == 'never':
|
||||
seed = 1
|
||||
elif self.rerandomize == "per_student" and hasattr(system, 'id'):
|
||||
elif self.rerandomize == "per_student" and hasattr(self.system, 'id'):
|
||||
seed = system.id
|
||||
else:
|
||||
seed = None
|
||||
|
||||
try:
|
||||
# TODO (vshnayder): move as much as possible of this work and error
|
||||
# checking to descriptor load time
|
||||
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
|
||||
instance_state, seed=seed, system=self.system)
|
||||
except Exception as err:
|
||||
@@ -148,7 +150,7 @@ class CapaModule(XModule):
|
||||
# TODO (vshnayder): do modules need error handlers too?
|
||||
# We shouldn't be switching on DEBUG.
|
||||
if self.system.DEBUG:
|
||||
log.error(msg)
|
||||
log.warning(msg)
|
||||
# TODO (vshnayder): This logic should be general, not here--and may
|
||||
# want to preserve the data instead of replacing it.
|
||||
# e.g. in the CMS
|
||||
@@ -426,7 +428,7 @@ class CapaModule(XModule):
|
||||
event_info = dict()
|
||||
event_info['state'] = self.lcp.get_state()
|
||||
event_info['problem_id'] = self.location.url()
|
||||
|
||||
|
||||
answers = self.make_dict_of_responses(get)
|
||||
event_info['answers'] = convert_files_to_filenames(answers)
|
||||
|
||||
@@ -462,7 +464,7 @@ class CapaModule(XModule):
|
||||
return {'success': msg}
|
||||
log.exception("Error in capa_module problem checking")
|
||||
raise Exception("error in capa_module")
|
||||
|
||||
|
||||
self.attempts = self.attempts + 1
|
||||
self.lcp.done = True
|
||||
|
||||
@@ -563,6 +565,9 @@ class CapaDescriptor(RawDescriptor):
|
||||
|
||||
module_class = CapaModule
|
||||
|
||||
stores_state = True
|
||||
has_score = True
|
||||
|
||||
# Capa modules have some additional metadata:
|
||||
# TODO (vshnayder): do problems have any other metadata? Do they
|
||||
# actually use type and points?
|
||||
|
||||
@@ -21,18 +21,35 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
try:
|
||||
self.start = time.strptime(self.metadata["start"], "%Y-%m-%dT%H:%M")
|
||||
except KeyError:
|
||||
self.start = time.gmtime(0) #The epoch
|
||||
msg = "Course loaded without a start date. id = %s" % self.id
|
||||
log.critical(msg)
|
||||
except ValueError as e:
|
||||
self.start = time.gmtime(0) #The epoch
|
||||
msg = "Course loaded with a bad start date. %s '%s'" % (self.id, e)
|
||||
log.critical(msg)
|
||||
|
||||
# Don't call the tracker from the exception handler.
|
||||
if msg is not None:
|
||||
self.start = time.gmtime(0) # The epoch
|
||||
log.critical(msg)
|
||||
system.error_tracker(msg)
|
||||
|
||||
def try_parse_time(key):
|
||||
"""
|
||||
Parse an optional metadata key: if present, must be valid.
|
||||
Return None if not present.
|
||||
"""
|
||||
if key in self.metadata:
|
||||
try:
|
||||
return time.strptime(self.metadata[key], "%Y-%m-%dT%H:%M")
|
||||
except ValueError as e:
|
||||
msg = "Course %s loaded with a bad metadata key %s '%s'" % (
|
||||
self.id, self.metadata[key], e)
|
||||
log.warning(msg)
|
||||
return None
|
||||
|
||||
self.enrollment_start = try_parse_time("enrollment_start")
|
||||
self.enrollment_end = try_parse_time("enrollment_end")
|
||||
|
||||
|
||||
|
||||
|
||||
def has_started(self):
|
||||
return time.gmtime() > self.start
|
||||
@@ -99,10 +116,10 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
sections = []
|
||||
for s in c.get_children():
|
||||
if s.metadata.get('graded', False):
|
||||
# TODO: Only include modules that have a score here
|
||||
xmoduledescriptors = [child for child in yield_descriptor_descendents(s)]
|
||||
xmoduledescriptors = list(yield_descriptor_descendents(s))
|
||||
|
||||
section_description = { 'section_descriptor' : s, 'xmoduledescriptors' : xmoduledescriptors}
|
||||
# The xmoduledescriptors included here are only the ones that have scores.
|
||||
section_description = { 'section_descriptor' : s, 'xmoduledescriptors' : filter(lambda child: child.has_score, xmoduledescriptors) }
|
||||
|
||||
section_format = s.metadata.get('format', "")
|
||||
graded_sections[ section_format ] = graded_sections.get( section_format, [] ) + [section_description]
|
||||
|
||||
@@ -49,6 +49,8 @@ padding-left: flex-gutter(9);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
div {
|
||||
p.status {
|
||||
text-indent: -9999px;
|
||||
@@ -64,6 +66,16 @@ div {
|
||||
}
|
||||
}
|
||||
|
||||
&.processing {
|
||||
p.status {
|
||||
@include inline-block();
|
||||
background: url('../images/spinner.gif') center center no-repeat;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
text-indent: -9999px;
|
||||
}
|
||||
}
|
||||
|
||||
&.correct, &.ui-icon-check {
|
||||
p.status {
|
||||
@include inline-block();
|
||||
@@ -77,6 +89,19 @@ div {
|
||||
}
|
||||
}
|
||||
|
||||
&.processing {
|
||||
p.status {
|
||||
@include inline-block();
|
||||
background: url('../images/spinner.gif') center center no-repeat;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
input {
|
||||
border-color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
&.incorrect, &.ui-icon-close {
|
||||
p.status {
|
||||
@include inline-block();
|
||||
@@ -134,6 +159,15 @@ div {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
&.processing, &.ui-icon-processing {
|
||||
@include inline-block();
|
||||
background: url('../images/spinner.gif') center center no-repeat;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
top: 6px;
|
||||
width: 25px;
|
||||
}
|
||||
|
||||
&.correct, &.ui-icon-check {
|
||||
@include inline-block();
|
||||
background: url('../images/correct-icon.png') center center no-repeat;
|
||||
|
||||
@@ -3,9 +3,9 @@ nav.sequence-nav {
|
||||
// import from external sources.
|
||||
@extend .topbar;
|
||||
border-bottom: 1px solid $border-color;
|
||||
@include border-top-right-radius(4px);
|
||||
margin: (-(lh())) (-(lh())) lh() (-(lh()));
|
||||
position: relative;
|
||||
@include border-top-right-radius(4px);
|
||||
|
||||
ol {
|
||||
@include box-sizing(border-box);
|
||||
@@ -242,9 +242,11 @@ nav.sequence-bottom {
|
||||
border: 1px solid $border-color;
|
||||
@include border-radius(3px);
|
||||
@include inline-block();
|
||||
width: 100px;
|
||||
|
||||
li {
|
||||
float: left;
|
||||
width: 50%;
|
||||
|
||||
&.prev, &.next {
|
||||
margin-bottom: 0;
|
||||
@@ -252,12 +254,11 @@ nav.sequence-bottom {
|
||||
a {
|
||||
background-position: center center;
|
||||
background-repeat: no-repeat;
|
||||
border-bottom: none;
|
||||
border: none;
|
||||
display: block;
|
||||
padding: lh(.5) 4px;
|
||||
text-indent: -9999px;
|
||||
@include transition(all, .2s, $ease-in-out-quad);
|
||||
width: 45px;
|
||||
|
||||
&:hover {
|
||||
background-color: #ddd;
|
||||
@@ -275,7 +276,7 @@ nav.sequence-bottom {
|
||||
&.prev {
|
||||
a {
|
||||
background-image: url('../images/sequence-nav/previous-icon.png');
|
||||
border-right: 1px solid lighten($border-color, 10%);
|
||||
border-right: 1px solid lighten(#c6c6c6, 10%);
|
||||
|
||||
&:hover {
|
||||
background-color: none;
|
||||
|
||||
@@ -14,9 +14,8 @@ div.video {
|
||||
|
||||
section.video-player {
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
// overflow: hidden;
|
||||
padding-bottom: 56.25%;
|
||||
padding-top: 30px;
|
||||
position: relative;
|
||||
|
||||
object, iframe {
|
||||
@@ -46,12 +45,13 @@ div.video {
|
||||
div.slider {
|
||||
@extend .clearfix;
|
||||
background: #c2c2c2;
|
||||
border: none;
|
||||
border-bottom: 1px solid #000;
|
||||
border: 1px solid #000;
|
||||
@include border-radius(0);
|
||||
border-top: 1px solid #000;
|
||||
@include box-shadow(inset 0 1px 0 #eee, 0 1px 0 #555);
|
||||
height: 7px;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
@include transition(height 2.0s ease-in-out);
|
||||
|
||||
div.ui-widget-header {
|
||||
@@ -59,43 +59,12 @@ div.video {
|
||||
@include box-shadow(inset 0 1px 0 #999);
|
||||
}
|
||||
|
||||
.ui-tooltip.qtip .ui-tooltip-content {
|
||||
background: $mit-red;
|
||||
border: 1px solid darken($mit-red, 20%);
|
||||
@include border-radius(2px);
|
||||
@include box-shadow(inset 0 1px 0 lighten($mit-red, 10%));
|
||||
color: #fff;
|
||||
font: bold 12px $body-font-family;
|
||||
margin-bottom: 6px;
|
||||
margin-right: 0;
|
||||
overflow: visible;
|
||||
padding: 4px;
|
||||
text-align: center;
|
||||
text-shadow: 0 -1px 0 darken($mit-red, 10%);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
&::after {
|
||||
background: $mit-red;
|
||||
border-bottom: 1px solid darken($mit-red, 20%);
|
||||
border-right: 1px solid darken($mit-red, 20%);
|
||||
bottom: -5px;
|
||||
content: " ";
|
||||
display: block;
|
||||
height: 7px;
|
||||
left: 50%;
|
||||
margin-left: -3px;
|
||||
position: absolute;
|
||||
@include transform(rotate(45deg));
|
||||
width: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
a.ui-slider-handle {
|
||||
background: $mit-red url(../images/slider-handle.png) center center no-repeat;
|
||||
background: $pink url(../images/slider-handle.png) center center no-repeat;
|
||||
@include background-size(50%);
|
||||
border: 1px solid darken($mit-red, 20%);
|
||||
border: 1px solid darken($pink, 20%);
|
||||
@include border-radius(15px);
|
||||
@include box-shadow(inset 0 1px 0 lighten($mit-red, 10%));
|
||||
@include box-shadow(inset 0 1px 0 lighten($pink, 10%));
|
||||
cursor: pointer;
|
||||
height: 15px;
|
||||
margin-left: -7px;
|
||||
@@ -104,7 +73,7 @@ div.video {
|
||||
width: 15px;
|
||||
|
||||
&:focus, &:hover {
|
||||
background-color: lighten($mit-red, 10%);
|
||||
background-color: lighten($pink, 10%);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@@ -463,7 +432,8 @@ div.video {
|
||||
}
|
||||
|
||||
ol.subtitles {
|
||||
width: 0px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,14 @@ class ErrorModule(XModule):
|
||||
'is_staff' : self.system.is_staff,
|
||||
})
|
||||
|
||||
def displayable_items(self):
|
||||
"""Hide errors in the profile and table of contents for non-staff
|
||||
users.
|
||||
"""
|
||||
if self.system.is_staff:
|
||||
return [self]
|
||||
return []
|
||||
|
||||
class ErrorDescriptor(EditingDescriptor):
|
||||
"""
|
||||
Module that provides a raw editing view of broken xml.
|
||||
@@ -75,7 +83,8 @@ class ErrorDescriptor(EditingDescriptor):
|
||||
# TODO (vshnayder): Do we need a unique slug here? Just pick a random
|
||||
# 64-bit num?
|
||||
location = ['i4x', org, course, 'error', url_name]
|
||||
metadata = {} # stays in the xml_data
|
||||
# real metadata stays in the xml_data, but add a display name
|
||||
metadata = {'display_name': 'Error ' + url_name}
|
||||
|
||||
return cls(system, definition, location=location, metadata=metadata)
|
||||
|
||||
|
||||
@@ -18,9 +18,9 @@ class HtmlModule(XModule):
|
||||
def get_html(self):
|
||||
return self.html
|
||||
|
||||
def __init__(self, system, location, definition,
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition,
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
self.html = self.definition['data']
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class @Problem
|
||||
|
||||
constructor: (element) ->
|
||||
@el = $(element).find('.problems-wrapper')
|
||||
@id = @el.data('problem-id')
|
||||
@@ -12,7 +13,10 @@ class @Problem
|
||||
bind: =>
|
||||
MathJax.Hub.Queue ["Typeset", MathJax.Hub]
|
||||
window.update_schematics()
|
||||
@inputs = @$("[id^=input_#{@element_id.replace(/problem_/, '')}_]")
|
||||
|
||||
problem_prefix = @element_id.replace(/problem_/,'')
|
||||
@inputs = @$("[id^=input_#{problem_prefix}_]")
|
||||
|
||||
@$('section.action input:button').click @refreshAnswers
|
||||
@$('section.action input.check').click @check_fd
|
||||
#@$('section.action input.check').click @check
|
||||
@@ -26,25 +30,94 @@ class @Problem
|
||||
@el.attr progress: response.progress_status
|
||||
@el.trigger('progressChanged')
|
||||
|
||||
queueing: =>
|
||||
@queued_items = @$(".xqueue")
|
||||
if @queued_items.length > 0
|
||||
if window.queuePollerID # Only one poller 'thread' per Problem
|
||||
window.clearTimeout(window.queuePollerID)
|
||||
window.queuePollerID = window.setTimeout(@poll, 100)
|
||||
|
||||
poll: =>
|
||||
$.postWithPrefix "#{@url}/problem_get", (response) =>
|
||||
@queued_items = $(response.html).find(".xqueue")
|
||||
if @queued_items.length == 0
|
||||
@el.html(response.html)
|
||||
@executeProblemScripts () =>
|
||||
@setupInputTypes()
|
||||
@bind()
|
||||
delete window.queuePollerID
|
||||
else
|
||||
# TODO: Dynamically adjust timeout interval based on @queued_items.value
|
||||
window.queuePollerID = window.setTimeout(@poll, 1000)
|
||||
|
||||
render: (content) ->
|
||||
if content
|
||||
@el.html(content)
|
||||
@bind()
|
||||
@executeProblemScripts () =>
|
||||
@setupInputTypes()
|
||||
@bind()
|
||||
@queueing()
|
||||
else
|
||||
$.postWithPrefix "#{@url}/problem_get", (response) =>
|
||||
@el.html(response.html)
|
||||
@executeProblemScripts()
|
||||
@bind()
|
||||
@executeProblemScripts () =>
|
||||
@setupInputTypes()
|
||||
@bind()
|
||||
@queueing()
|
||||
|
||||
executeProblemScripts: ->
|
||||
@el.find(".script_placeholder").each (index, placeholder) ->
|
||||
s = $("<script>")
|
||||
s.attr("type", "text/javascript")
|
||||
s.attr("src", $(placeholder).attr("data-src"))
|
||||
# TODO add hooks for problem types here by inspecting response.html and doing
|
||||
# stuff if a div w a class is found
|
||||
|
||||
setupInputTypes: =>
|
||||
@el.find(".capa_inputtype").each (index, inputtype) =>
|
||||
classes = $(inputtype).attr('class').split(' ')
|
||||
for cls in classes
|
||||
setupMethod = @inputtypeSetupMethods[cls]
|
||||
setupMethod(inputtype) if setupMethod?
|
||||
|
||||
executeProblemScripts: (callback=null) ->
|
||||
|
||||
placeholders = @el.find(".script_placeholder")
|
||||
|
||||
if placeholders.length == 0
|
||||
callback()
|
||||
return
|
||||
|
||||
completed = (false for i in [1..placeholders.length])
|
||||
callbackCalled = false
|
||||
|
||||
# This is required for IE8 support.
|
||||
completionHandlerGeneratorIE = (index) =>
|
||||
return () ->
|
||||
if (this.readyState == 'complete' || this.readyState == 'loaded')
|
||||
#completionHandlerGenerator.call(self, index)()
|
||||
completionHandlerGenerator(index)()
|
||||
|
||||
completionHandlerGenerator = (index) =>
|
||||
return () =>
|
||||
allComplete = true
|
||||
completed[index] = true
|
||||
for flag in completed
|
||||
if not flag
|
||||
allComplete = false
|
||||
break
|
||||
if allComplete and not callbackCalled
|
||||
callbackCalled = true
|
||||
callback() if callback?
|
||||
|
||||
placeholders.each (index, placeholder) ->
|
||||
s = document.createElement('script')
|
||||
s.setAttribute('src', $(placeholder).attr("data-src"))
|
||||
s.setAttribute('type', "text/javascript")
|
||||
|
||||
s.onload = completionHandlerGenerator(index)
|
||||
|
||||
# s.onload does not fire in IE8; this does.
|
||||
s.onreadystatechange = completionHandlerGeneratorIE(index)
|
||||
|
||||
# Need to use the DOM elements directly or the scripts won't execute
|
||||
# properly.
|
||||
$('head')[0].appendChild(s[0])
|
||||
$('head')[0].appendChild(s)
|
||||
$(placeholder).remove()
|
||||
|
||||
###
|
||||
@@ -108,6 +181,9 @@ class @Problem
|
||||
@render(response.html)
|
||||
@updateProgress response
|
||||
|
||||
# TODO this needs modification to deal with javascript responses; perhaps we
|
||||
# need something where responsetypes can define their own behavior when show
|
||||
# is called.
|
||||
show: =>
|
||||
if !@el.hasClass 'showed'
|
||||
Logger.log 'problem_show', problem: @id
|
||||
@@ -157,3 +233,20 @@ class @Problem
|
||||
@$(".CodeMirror").each (index, element) ->
|
||||
element.CodeMirror.save() if element.CodeMirror.save
|
||||
@answers = @inputs.serialize()
|
||||
|
||||
inputtypeSetupMethods:
|
||||
javascriptinput: (element) =>
|
||||
|
||||
data = $(element).find(".javascriptinput_data")
|
||||
|
||||
params = data.data("params")
|
||||
submission = data.data("submission")
|
||||
evaluation = data.data("evaluation")
|
||||
problemState = data.data("problem_state")
|
||||
displayClass = window[data.data('display_class')]
|
||||
|
||||
container = $(element).find(".javascriptinput_container")
|
||||
submissionField = $(element).find(".javascriptinput_input")
|
||||
|
||||
display = new displayClass(problemState, submission, evaluation, container, submissionField, params)
|
||||
display.render()
|
||||
|
||||
@@ -91,6 +91,13 @@ class @Sequence
|
||||
event.preventDefault()
|
||||
new_position = $(event.target).data('element')
|
||||
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
|
||||
|
||||
@render new_position
|
||||
|
||||
next: (event) =>
|
||||
|
||||
@@ -190,6 +190,13 @@ class Location(_LocationBase):
|
||||
return "Location%s" % repr(tuple(self))
|
||||
|
||||
|
||||
@property
|
||||
def course_id(self):
|
||||
"""Return the ID of the Course that this item belongs to by looking
|
||||
at the location URL hierachy"""
|
||||
return "/".join([self.org, self.course, self.name])
|
||||
|
||||
|
||||
class ModuleStore(object):
|
||||
"""
|
||||
An abstract interface for a database backend that stores XModuleDescriptor
|
||||
|
||||
@@ -55,6 +55,9 @@ class CachingDescriptorSystem(MakoDescriptorSystem):
|
||||
if json_data is None:
|
||||
return self.modulestore.get_item(location)
|
||||
else:
|
||||
# TODO (vshnayder): metadata inheritance is somewhat broken because mongo, doesn't
|
||||
# always load an entire course. We're punting on this until after launch, and then
|
||||
# will build a proper course policy framework.
|
||||
return XModuleDescriptor.load_from_json(json_data, self, self.default_class)
|
||||
|
||||
|
||||
|
||||
@@ -50,8 +50,9 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem):
|
||||
# have been imported into the cms from xml
|
||||
xml = clean_out_mako_templating(xml)
|
||||
xml_data = etree.fromstring(xml)
|
||||
except:
|
||||
log.exception("Unable to parse xml: {xml}".format(xml=xml))
|
||||
except Exception as err:
|
||||
log.warning("Unable to parse xml: {err}, xml: {xml}".format(
|
||||
err=str(err), xml=xml))
|
||||
raise
|
||||
|
||||
# VS[compat]. Take this out once course conversion is done
|
||||
@@ -194,7 +195,7 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
if org is None:
|
||||
msg = ("No 'org' attribute set for course in {dir}. "
|
||||
"Using default 'edx'".format(dir=course_dir))
|
||||
log.error(msg)
|
||||
log.warning(msg)
|
||||
tracker(msg)
|
||||
org = 'edx'
|
||||
|
||||
@@ -206,13 +207,19 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
dir=course_dir,
|
||||
default=course_dir
|
||||
))
|
||||
log.error(msg)
|
||||
log.warning(msg)
|
||||
tracker(msg)
|
||||
course = course_dir
|
||||
|
||||
system = ImportSystem(self, org, course, course_dir, tracker)
|
||||
|
||||
course_descriptor = system.process_xml(etree.tostring(course_data))
|
||||
# NOTE: The descriptors end up loading somewhat bottom up, which
|
||||
# breaks metadata inheritance via get_children(). Instead
|
||||
# (actually, in addition to, for now), we do a final inheritance pass
|
||||
# after we have the course descriptor.
|
||||
XModuleDescriptor.compute_inherited_metadata(course_descriptor)
|
||||
|
||||
log.debug('========> Done with course import from {0}'.format(course_dir))
|
||||
return course_descriptor
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ class SequenceModule(XModule):
|
||||
css = {'scss': [resource_string(__name__, 'css/sequence/display.scss')]}
|
||||
js_module_name = "Sequence"
|
||||
|
||||
def __init__(self, system, location, definition, instance_state=None,
|
||||
def __init__(self, system, location, definition, descriptor, instance_state=None,
|
||||
shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition,
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
self.position = 1
|
||||
|
||||
@@ -107,6 +107,8 @@ class SequenceModule(XModule):
|
||||
class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor):
|
||||
mako_template = 'widgets/sequence-edit.html'
|
||||
module_class = SequenceModule
|
||||
|
||||
stores_state = True # For remembering where in the sequence the student is
|
||||
|
||||
@classmethod
|
||||
def definition_from_xml(cls, xml_object, system):
|
||||
|
||||
@@ -26,12 +26,23 @@ class CustomTagModule(XModule):
|
||||
More information given in <a href="/book/234">the text</a>
|
||||
"""
|
||||
|
||||
def __init__(self, system, location, definition,
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition,
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
|
||||
xmltree = etree.fromstring(self.definition['data'])
|
||||
def get_html(self):
|
||||
return self.descriptor.rendered_html
|
||||
|
||||
|
||||
class CustomTagDescriptor(RawDescriptor):
|
||||
""" Descriptor for custom tags. Loads the template when created."""
|
||||
module_class = CustomTagModule
|
||||
|
||||
@staticmethod
|
||||
def render_template(system, xml_data):
|
||||
'''Render the template, given the definition xml_data'''
|
||||
xmltree = etree.fromstring(xml_data)
|
||||
if 'impl' in xmltree.attrib:
|
||||
template_name = xmltree.attrib['impl']
|
||||
else:
|
||||
@@ -45,13 +56,20 @@ class CustomTagModule(XModule):
|
||||
.format(location))
|
||||
|
||||
params = dict(xmltree.items())
|
||||
with self.system.filestore.open(
|
||||
'custom_tags/{name}'.format(name=template_name)) as template:
|
||||
self.html = Template(template.read()).render(**params)
|
||||
|
||||
def get_html(self):
|
||||
return self.html
|
||||
with system.resources_fs.open('custom_tags/{name}'
|
||||
.format(name=template_name)) as template:
|
||||
return Template(template.read()).render(**params)
|
||||
|
||||
|
||||
class CustomTagDescriptor(RawDescriptor):
|
||||
module_class = CustomTagModule
|
||||
def __init__(self, system, definition, **kwargs):
|
||||
'''Render and save the template for this descriptor instance'''
|
||||
super(CustomTagDescriptor, self).__init__(system, definition, **kwargs)
|
||||
self.rendered_html = self.render_template(system, definition['data'])
|
||||
|
||||
def export_to_file(self):
|
||||
"""
|
||||
Custom tags are special: since they're already pointers, we don't want
|
||||
to export them in a file with yet another layer of indirection.
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
import unittest
|
||||
import os
|
||||
import fs
|
||||
import json
|
||||
|
||||
import json
|
||||
import numpy
|
||||
|
||||
import xmodule
|
||||
@@ -30,10 +32,11 @@ i4xs = ModuleSystem(
|
||||
render_template=Mock(),
|
||||
replace_urls=Mock(),
|
||||
user=Mock(),
|
||||
filestore=fs.osfs.OSFS(os.path.dirname(os.path.realpath(__file__))),
|
||||
filestore=fs.osfs.OSFS(os.path.dirname(os.path.realpath(__file__))+"/test_files"),
|
||||
debug=True,
|
||||
xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue'},
|
||||
is_staff=False
|
||||
is_staff=False,
|
||||
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules")
|
||||
)
|
||||
|
||||
|
||||
@@ -291,9 +294,14 @@ class CodeResponseTest(unittest.TestCase):
|
||||
for i in range(numAnswers):
|
||||
old_cmap.update(CorrectMap(answer_id=answer_ids[i], queuekey=1000 + i))
|
||||
|
||||
# Message format inherited from ExternalResponse
|
||||
correct_score_msg = "<edxgrade><awarddetail>EXACT_ANS</awarddetail><message>MESSAGE</message></edxgrade>"
|
||||
incorrect_score_msg = "<edxgrade><awarddetail>WRONG_FORMAT</awarddetail><message>MESSAGE</message></edxgrade>"
|
||||
# TODO: Message format inherited from ExternalResponse
|
||||
#correct_score_msg = "<edxgrade><awarddetail>EXACT_ANS</awarddetail><message>MESSAGE</message></edxgrade>"
|
||||
#incorrect_score_msg = "<edxgrade><awarddetail>WRONG_FORMAT</awarddetail><message>MESSAGE</message></edxgrade>"
|
||||
|
||||
# New message format common to external graders
|
||||
correct_score_msg = json.dumps({'correct':True, 'score':1, 'msg':'MESSAGE'})
|
||||
incorrect_score_msg = json.dumps({'correct':False, 'score':0, 'msg':'MESSAGE'})
|
||||
|
||||
xserver_msgs = {'correct': correct_score_msg,
|
||||
'incorrect': incorrect_score_msg,
|
||||
}
|
||||
@@ -317,7 +325,8 @@ class CodeResponseTest(unittest.TestCase):
|
||||
|
||||
new_cmap = CorrectMap()
|
||||
new_cmap.update(old_cmap)
|
||||
new_cmap.set(answer_id=answer_ids[i], correctness=correctness, msg='MESSAGE', queuekey=None)
|
||||
npoints = 1 if correctness=='correct' else 0
|
||||
new_cmap.set(answer_id=answer_ids[i], npoints=npoints, correctness=correctness, msg='MESSAGE', queuekey=None)
|
||||
|
||||
test_lcp.update_score(xserver_msgs[correctness], queuekey=1000 + i)
|
||||
self.assertEquals(test_lcp.correct_map.get_dict(), new_cmap.get_dict())
|
||||
@@ -367,6 +376,19 @@ class ChoiceResponseTest(unittest.TestCase):
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_3_1'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(test_answers).get_correctness('1_4_1'), 'correct')
|
||||
|
||||
class JavascriptResponseTest(unittest.TestCase):
|
||||
|
||||
def test_jr_grade(self):
|
||||
problem_file = os.path.dirname(__file__) + "/test_files/javascriptresponse.xml"
|
||||
coffee_file_path = os.path.dirname(__file__) + "/test_files/js/*.coffee"
|
||||
os.system("coffee -c %s" % (coffee_file_path))
|
||||
test_lcp = lcp.LoncapaProblem(open(problem_file).read(), '1', system=i4xs)
|
||||
correct_answers = {'1_2_1': json.dumps({0: 4})}
|
||||
incorrect_answers = {'1_2_1': json.dumps({0: 5})}
|
||||
|
||||
self.assertEquals(test_lcp.grade_answers(incorrect_answers).get_correctness('1_2_1'), 'incorrect')
|
||||
self.assertEquals(test_lcp.grade_answers(correct_answers).get_correctness('1_2_1'), 'correct')
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
# Grading tests
|
||||
|
||||
@@ -708,6 +730,6 @@ class ModuleProgressTest(unittest.TestCase):
|
||||
'''
|
||||
def test_xmodule_default(self):
|
||||
'''Make sure default get_progress exists, returns None'''
|
||||
xm = x_module.XModule(i4xs, 'a://b/c/d/e', {})
|
||||
xm = x_module.XModule(i4xs, 'a://b/c/d/e', None, {})
|
||||
p = xm.get_progress()
|
||||
self.assertEqual(p, None)
|
||||
|
||||
@@ -4,6 +4,7 @@ from fs.osfs import OSFS
|
||||
from nose.tools import assert_equals, assert_true
|
||||
from path import path
|
||||
from tempfile import mkdtemp
|
||||
from shutil import copytree
|
||||
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
|
||||
@@ -40,27 +41,32 @@ def strip_filenames(descriptor):
|
||||
class RoundTripTestCase(unittest.TestCase):
|
||||
'''Check that our test courses roundtrip properly'''
|
||||
def check_export_roundtrip(self, data_dir, course_dir):
|
||||
|
||||
root_dir = path(mkdtemp())
|
||||
print "Copying test course to temp dir {0}".format(root_dir)
|
||||
|
||||
data_dir = path(data_dir)
|
||||
copytree(data_dir / course_dir, root_dir / course_dir)
|
||||
|
||||
print "Starting import"
|
||||
initial_import = XMLModuleStore(data_dir, eager=True, course_dirs=[course_dir])
|
||||
initial_import = XMLModuleStore(root_dir, eager=True, course_dirs=[course_dir])
|
||||
|
||||
courses = initial_import.get_courses()
|
||||
self.assertEquals(len(courses), 1)
|
||||
initial_course = courses[0]
|
||||
|
||||
# export to the same directory--that way things like the custom_tags/ folder
|
||||
# will still be there.
|
||||
print "Starting export"
|
||||
export_dir = mkdtemp()
|
||||
print "export_dir: {0}".format(export_dir)
|
||||
fs = OSFS(export_dir)
|
||||
export_course_dir = 'export'
|
||||
export_fs = fs.makeopendir(export_course_dir)
|
||||
fs = OSFS(root_dir)
|
||||
export_fs = fs.makeopendir(course_dir)
|
||||
|
||||
xml = initial_course.export_to_xml(export_fs)
|
||||
with export_fs.open('course.xml', 'w') as course_xml:
|
||||
course_xml.write(xml)
|
||||
|
||||
print "Starting second import"
|
||||
second_import = XMLModuleStore(export_dir, eager=True,
|
||||
course_dirs=[export_course_dir])
|
||||
second_import = XMLModuleStore(root_dir, eager=True, course_dirs=[course_dir])
|
||||
|
||||
courses2 = second_import.get_courses()
|
||||
self.assertEquals(len(courses2), 1)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<problem>
|
||||
|
||||
<javascriptresponse>
|
||||
<generator src="test_problem_generator.js"/>
|
||||
<grader src="test_problem_grader.js"/>
|
||||
<display class="TestProblemDisplay" src="test_problem_display.js"/>
|
||||
<responseparam name="value" value="4"/>
|
||||
<javascriptinput>
|
||||
</javascriptinput>
|
||||
</javascriptresponse>
|
||||
|
||||
</problem>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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);
|
||||
;
|
||||
202
common/lib/xmodule/xmodule/tests/test_files/js/mersenne-twister-min.js
vendored
Normal file
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
I've wrapped Makoto Matsumoto and Takuji Nishimura's code in a namespace
|
||||
so it's better encapsulated. Now you can have multiple random number generators
|
||||
and they won't stomp all over eachother's state.
|
||||
|
||||
If you want to use this as a substitute for Math.random(), use the random()
|
||||
method like so:
|
||||
|
||||
var m = new MersenneTwister();
|
||||
var randomNumber = m.random();
|
||||
|
||||
You can also call the other genrand_{foo}() methods on the instance.
|
||||
|
||||
If you want to use a specific seed in order to get a repeatable random
|
||||
sequence, pass an integer into the constructor:
|
||||
|
||||
var m = new MersenneTwister(123);
|
||||
|
||||
and that will always produce the same random sequence.
|
||||
|
||||
Sean McCullough (banksean@gmail.com)
|
||||
*/
|
||||
|
||||
/*
|
||||
A C-program for MT19937, with initialization improved 2002/1/26.
|
||||
Coded by Takuji Nishimura and Makoto Matsumoto.
|
||||
|
||||
Before using, initialize the state by using init_genrand(seed)
|
||||
or init_by_array(init_key, key_length).
|
||||
|
||||
Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura,
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. The names of its contributors may not be used to endorse or promote
|
||||
products derived from this software without specific prior written
|
||||
permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
Any feedback is very welcome.
|
||||
http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/emt.html
|
||||
email: m-mat @ math.sci.hiroshima-u.ac.jp (remove space)
|
||||
*/
|
||||
|
||||
var MersenneTwister = function(seed) {
|
||||
if (seed == undefined) {
|
||||
seed = new Date().getTime();
|
||||
}
|
||||
/* Period parameters */
|
||||
this.N = 624;
|
||||
this.M = 397;
|
||||
this.MATRIX_A = 0x9908b0df; /* constant vector a */
|
||||
this.UPPER_MASK = 0x80000000; /* most significant w-r bits */
|
||||
this.LOWER_MASK = 0x7fffffff; /* least significant r bits */
|
||||
|
||||
this.mt = new Array(this.N); /* the array for the state vector */
|
||||
this.mti=this.N+1; /* mti==N+1 means mt[N] is not initialized */
|
||||
|
||||
this.init_genrand(seed);
|
||||
}
|
||||
|
||||
/* initializes mt[N] with a seed */
|
||||
MersenneTwister.prototype.init_genrand = function(s) {
|
||||
this.mt[0] = s >>> 0;
|
||||
for (this.mti=1; this.mti<this.N; this.mti++) {
|
||||
var s = this.mt[this.mti-1] ^ (this.mt[this.mti-1] >>> 30);
|
||||
this.mt[this.mti] = (((((s & 0xffff0000) >>> 16) * 1812433253) << 16) + (s & 0x0000ffff) * 1812433253)
|
||||
+ this.mti;
|
||||
/* See Knuth TAOCP Vol2. 3rd Ed. P.106 for multiplier. */
|
||||
/* In the previous versions, MSBs of the seed affect */
|
||||
/* only MSBs of the array mt[]. */
|
||||
/* 2002/01/09 modified by Makoto Matsumoto */
|
||||
this.mt[this.mti] >>>= 0;
|
||||
/* for >32 bit machines */
|
||||
}
|
||||
}
|
||||
|
||||
/* initialize by an array with array-length */
|
||||
/* init_key is the array for initializing keys */
|
||||
/* key_length is its length */
|
||||
/* slight change for C++, 2004/2/26 */
|
||||
MersenneTwister.prototype.init_by_array = function(init_key, key_length) {
|
||||
var i, j, k;
|
||||
this.init_genrand(19650218);
|
||||
i=1; j=0;
|
||||
k = (this.N>key_length ? this.N : key_length);
|
||||
for (; k; k--) {
|
||||
var s = this.mt[i-1] ^ (this.mt[i-1] >>> 30)
|
||||
this.mt[i] = (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1664525) << 16) + ((s & 0x0000ffff) * 1664525)))
|
||||
+ init_key[j] + j; /* non linear */
|
||||
this.mt[i] >>>= 0; /* for WORDSIZE > 32 machines */
|
||||
i++; j++;
|
||||
if (i>=this.N) { this.mt[0] = this.mt[this.N-1]; i=1; }
|
||||
if (j>=key_length) j=0;
|
||||
}
|
||||
for (k=this.N-1; k; k--) {
|
||||
var s = this.mt[i-1] ^ (this.mt[i-1] >>> 30);
|
||||
this.mt[i] = (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1566083941) << 16) + (s & 0x0000ffff) * 1566083941))
|
||||
- i; /* non linear */
|
||||
this.mt[i] >>>= 0; /* for WORDSIZE > 32 machines */
|
||||
i++;
|
||||
if (i>=this.N) { this.mt[0] = this.mt[this.N-1]; i=1; }
|
||||
}
|
||||
|
||||
this.mt[0] = 0x80000000; /* MSB is 1; assuring non-zero initial array */
|
||||
}
|
||||
|
||||
/* generates a random number on [0,0xffffffff]-interval */
|
||||
MersenneTwister.prototype.genrand_int32 = function() {
|
||||
var y;
|
||||
var mag01 = new Array(0x0, this.MATRIX_A);
|
||||
/* mag01[x] = x * MATRIX_A for x=0,1 */
|
||||
|
||||
if (this.mti >= this.N) { /* generate N words at one time */
|
||||
var kk;
|
||||
|
||||
if (this.mti == this.N+1) /* if init_genrand() has not been called, */
|
||||
this.init_genrand(5489); /* a default initial seed is used */
|
||||
|
||||
for (kk=0;kk<this.N-this.M;kk++) {
|
||||
y = (this.mt[kk]&this.UPPER_MASK)|(this.mt[kk+1]&this.LOWER_MASK);
|
||||
this.mt[kk] = this.mt[kk+this.M] ^ (y >>> 1) ^ mag01[y & 0x1];
|
||||
}
|
||||
for (;kk<this.N-1;kk++) {
|
||||
y = (this.mt[kk]&this.UPPER_MASK)|(this.mt[kk+1]&this.LOWER_MASK);
|
||||
this.mt[kk] = this.mt[kk+(this.M-this.N)] ^ (y >>> 1) ^ mag01[y & 0x1];
|
||||
}
|
||||
y = (this.mt[this.N-1]&this.UPPER_MASK)|(this.mt[0]&this.LOWER_MASK);
|
||||
this.mt[this.N-1] = this.mt[this.M-1] ^ (y >>> 1) ^ mag01[y & 0x1];
|
||||
|
||||
this.mti = 0;
|
||||
}
|
||||
|
||||
y = this.mt[this.mti++];
|
||||
|
||||
/* Tempering */
|
||||
y ^= (y >>> 11);
|
||||
y ^= (y << 7) & 0x9d2c5680;
|
||||
y ^= (y << 15) & 0xefc60000;
|
||||
y ^= (y >>> 18);
|
||||
|
||||
return y >>> 0;
|
||||
}
|
||||
|
||||
/* generates a random number on [0,0x7fffffff]-interval */
|
||||
MersenneTwister.prototype.genrand_int31 = function() {
|
||||
return (this.genrand_int32()>>>1);
|
||||
}
|
||||
|
||||
/* generates a random number on [0,1]-real-interval */
|
||||
MersenneTwister.prototype.genrand_real1 = function() {
|
||||
return this.genrand_int32()*(1.0/4294967295.0);
|
||||
/* divided by 2^32-1 */
|
||||
}
|
||||
|
||||
/* generates a random number on [0,1)-real-interval */
|
||||
MersenneTwister.prototype.random = function() {
|
||||
return this.genrand_int32()*(1.0/4294967296.0);
|
||||
/* divided by 2^32 */
|
||||
}
|
||||
|
||||
/* generates a random number on (0,1)-real-interval */
|
||||
MersenneTwister.prototype.genrand_real3 = function() {
|
||||
return (this.genrand_int32() + 0.5)*(1.0/4294967296.0);
|
||||
/* divided by 2^32 */
|
||||
}
|
||||
|
||||
/* generates a random number on [0,1) with 53-bit resolution*/
|
||||
MersenneTwister.prototype.genrand_res53 = function() {
|
||||
var a=this.genrand_int32()>>>5, b=this.genrand_int32()>>>6;
|
||||
return(a*67108864.0+b)*(1.0/9007199254740992.0);
|
||||
}
|
||||
|
||||
/* These real versions are due to Isaku Wada, 2002/01/09 added */
|
||||
if(typeof exports == 'undefined'){
|
||||
var root = this;
|
||||
} else {
|
||||
var root = exports;
|
||||
}
|
||||
root.MersenneTwister = MersenneTwister;
|
||||
@@ -0,0 +1,21 @@
|
||||
class MinimaxProblemDisplay extends XProblemDisplay
|
||||
|
||||
constructor: (@state, @submission, @evaluation, @container, @submissionField, @parameters={}) ->
|
||||
|
||||
super(@state, @submission, @evaluation, @container, @submissionField, @parameters)
|
||||
|
||||
render: () ->
|
||||
|
||||
createSubmission: () ->
|
||||
|
||||
@newSubmission = {}
|
||||
|
||||
if @submission?
|
||||
for id, value of @submission
|
||||
@newSubmission[id] = value
|
||||
|
||||
getCurrentSubmission: () ->
|
||||
return @newSubmission
|
||||
|
||||
root = exports ? this
|
||||
root.TestProblemDisplay = TestProblemDisplay
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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);
|
||||
@@ -0,0 +1,14 @@
|
||||
class TestProblemGenerator extends XProblemGenerator
|
||||
|
||||
constructor: (seed, @parameters = {}) ->
|
||||
|
||||
super(seed, @parameters)
|
||||
|
||||
generate: () ->
|
||||
|
||||
@problemState.value = @parameters.value
|
||||
|
||||
return @problemState
|
||||
|
||||
root = exports ? this
|
||||
root.generatorClass = TestProblemGenerator
|
||||
@@ -0,0 +1,29 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var TestProblemGenerator, 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; };
|
||||
|
||||
TestProblemGenerator = (function(_super) {
|
||||
|
||||
__extends(TestProblemGenerator, _super);
|
||||
|
||||
function TestProblemGenerator(seed, parameters) {
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
TestProblemGenerator.__super__.constructor.call(this, seed, this.parameters);
|
||||
}
|
||||
|
||||
TestProblemGenerator.prototype.generate = function() {
|
||||
this.problemState.value = this.parameters.value;
|
||||
return this.problemState;
|
||||
};
|
||||
|
||||
return TestProblemGenerator;
|
||||
|
||||
})(XProblemGenerator);
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.generatorClass = TestProblemGenerator;
|
||||
|
||||
}).call(this);
|
||||
@@ -0,0 +1,27 @@
|
||||
class TestProblemGrader extends XProblemGrader
|
||||
|
||||
constructor: (@submission, @problemState, @parameters={}) ->
|
||||
|
||||
super(@submission, @problemState, @parameters)
|
||||
|
||||
solve: () ->
|
||||
|
||||
@solution = {0: @problemState.value}
|
||||
|
||||
grade: () ->
|
||||
|
||||
if not @solution?
|
||||
@solve()
|
||||
|
||||
allCorrect = true
|
||||
|
||||
for id, value of @solution
|
||||
valueCorrect = if @submission? then (value == @submission[id]) else false
|
||||
@evaluation[id] = valueCorrect
|
||||
if not valueCorrect
|
||||
allCorrect = false
|
||||
|
||||
return allCorrect
|
||||
|
||||
root = exports ? this
|
||||
root.graderClass = TestProblemGrader
|
||||
@@ -0,0 +1,50 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var TestProblemGrader, 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; };
|
||||
|
||||
TestProblemGrader = (function(_super) {
|
||||
|
||||
__extends(TestProblemGrader, _super);
|
||||
|
||||
function TestProblemGrader(submission, problemState, parameters) {
|
||||
this.submission = submission;
|
||||
this.problemState = problemState;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
TestProblemGrader.__super__.constructor.call(this, this.submission, this.problemState, this.parameters);
|
||||
}
|
||||
|
||||
TestProblemGrader.prototype.solve = function() {
|
||||
return this.solution = {
|
||||
0: this.problemState.value
|
||||
};
|
||||
};
|
||||
|
||||
TestProblemGrader.prototype.grade = function() {
|
||||
var allCorrect, id, value, valueCorrect, _ref;
|
||||
if (!(this.solution != null)) {
|
||||
this.solve();
|
||||
}
|
||||
allCorrect = true;
|
||||
_ref = this.solution;
|
||||
for (id in _ref) {
|
||||
value = _ref[id];
|
||||
valueCorrect = this.submission != null ? value === this.submission[id] : false;
|
||||
this.evaluation[id] = valueCorrect;
|
||||
if (!valueCorrect) {
|
||||
allCorrect = false;
|
||||
}
|
||||
}
|
||||
return allCorrect;
|
||||
};
|
||||
|
||||
return TestProblemGrader;
|
||||
|
||||
})(XProblemGrader);
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.graderClass = TestProblemGrader;
|
||||
|
||||
}).call(this);
|
||||
@@ -0,0 +1,47 @@
|
||||
class XProblemGenerator
|
||||
|
||||
constructor: (seed, @parameters={}) ->
|
||||
|
||||
@random = new MersenneTwister(seed)
|
||||
|
||||
@problemState = {}
|
||||
|
||||
generate: () ->
|
||||
|
||||
console.error("Abstract method called: XProblemGenerator.generate")
|
||||
|
||||
class XProblemDisplay
|
||||
|
||||
constructor: (@state, @submission, @evaluation, @container, @submissionField, @parameters={}) ->
|
||||
|
||||
render: () ->
|
||||
|
||||
console.error("Abstract method called: XProblemDisplay.render")
|
||||
|
||||
updateSubmission: () ->
|
||||
|
||||
@submissionField.val(JSON.stringify(@getCurrentSubmission()))
|
||||
|
||||
getCurrentSubmission: () ->
|
||||
console.error("Abstract method called: XProblemDisplay.getCurrentSubmission")
|
||||
|
||||
class XProblemGrader
|
||||
|
||||
constructor: (@submission, @problemState, @parameters={}) ->
|
||||
|
||||
@solution = null
|
||||
@evaluation = {}
|
||||
|
||||
solve: () ->
|
||||
|
||||
console.error("Abstract method called: XProblemGrader.solve")
|
||||
|
||||
grade: () ->
|
||||
|
||||
console.error("Abstract method called: XProblemGrader.grade")
|
||||
|
||||
root = exports ? this
|
||||
|
||||
root.XProblemGenerator = XProblemGenerator
|
||||
root.XProblemDisplay = XProblemDisplay
|
||||
root.XProblemGrader = XProblemGrader
|
||||
78
common/lib/xmodule/xmodule/tests/test_files/js/xproblem.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// Generated by CoffeeScript 1.3.3
|
||||
(function() {
|
||||
var XProblemDisplay, XProblemGenerator, XProblemGrader, root;
|
||||
|
||||
XProblemGenerator = (function() {
|
||||
|
||||
function XProblemGenerator(seed, parameters) {
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
this.random = new MersenneTwister(seed);
|
||||
this.problemState = {};
|
||||
}
|
||||
|
||||
XProblemGenerator.prototype.generate = function() {
|
||||
return console.error("Abstract method called: XProblemGenerator.generate");
|
||||
};
|
||||
|
||||
return XProblemGenerator;
|
||||
|
||||
})();
|
||||
|
||||
XProblemDisplay = (function() {
|
||||
|
||||
function XProblemDisplay(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 : {};
|
||||
}
|
||||
|
||||
XProblemDisplay.prototype.render = function() {
|
||||
return console.error("Abstract method called: XProblemDisplay.render");
|
||||
};
|
||||
|
||||
XProblemDisplay.prototype.updateSubmission = function() {
|
||||
return this.submissionField.val(JSON.stringify(this.getCurrentSubmission()));
|
||||
};
|
||||
|
||||
XProblemDisplay.prototype.getCurrentSubmission = function() {
|
||||
return console.error("Abstract method called: XProblemDisplay.getCurrentSubmission");
|
||||
};
|
||||
|
||||
return XProblemDisplay;
|
||||
|
||||
})();
|
||||
|
||||
XProblemGrader = (function() {
|
||||
|
||||
function XProblemGrader(submission, problemState, parameters) {
|
||||
this.submission = submission;
|
||||
this.problemState = problemState;
|
||||
this.parameters = parameters != null ? parameters : {};
|
||||
this.solution = null;
|
||||
this.evaluation = {};
|
||||
}
|
||||
|
||||
XProblemGrader.prototype.solve = function() {
|
||||
return console.error("Abstract method called: XProblemGrader.solve");
|
||||
};
|
||||
|
||||
XProblemGrader.prototype.grade = function() {
|
||||
return console.error("Abstract method called: XProblemGrader.grade");
|
||||
};
|
||||
|
||||
return XProblemGrader;
|
||||
|
||||
})();
|
||||
|
||||
root = typeof exports !== "undefined" && exports !== null ? exports : this;
|
||||
|
||||
root.XProblemGenerator = XProblemGenerator;
|
||||
|
||||
root.XProblemDisplay = XProblemDisplay;
|
||||
|
||||
root.XProblemGrader = XProblemGrader;
|
||||
|
||||
}).call(this);
|
||||
@@ -8,8 +8,11 @@ from xmodule.x_module import XMLParsingSystem, XModuleDescriptor
|
||||
from xmodule.xml_module import is_pointer_tag
|
||||
from xmodule.errortracker import make_error_tracker
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from .test_export import DATA_DIR
|
||||
|
||||
ORG = 'test_org'
|
||||
COURSE = 'test_course'
|
||||
|
||||
@@ -185,3 +188,22 @@ class ImportTestCase(unittest.TestCase):
|
||||
chapter_xml = etree.fromstring(f.read())
|
||||
self.assertEqual(chapter_xml.tag, 'chapter')
|
||||
self.assertFalse('graceperiod' in chapter_xml.attrib)
|
||||
|
||||
def test_metadata_inherit(self):
|
||||
"""Make sure that metadata is inherited properly"""
|
||||
|
||||
print "Starting import"
|
||||
initial_import = XMLModuleStore(DATA_DIR, eager=True, course_dirs=['toy'])
|
||||
|
||||
courses = initial_import.get_courses()
|
||||
self.assertEquals(len(courses), 1)
|
||||
course = courses[0]
|
||||
|
||||
def check_for_key(key, node):
|
||||
"recursive check for presence of key"
|
||||
print "Checking {}".format(node.location.url())
|
||||
self.assertTrue(key in node.metadata)
|
||||
for c in node.get_children():
|
||||
check_for_key(key, c)
|
||||
|
||||
check_for_key('graceperiod', course)
|
||||
|
||||
@@ -10,8 +10,8 @@ class_priority = ['video', 'problem']
|
||||
class VerticalModule(XModule):
|
||||
''' Layout module for laying out submodules vertically.'''
|
||||
|
||||
def __init__(self, system, location, definition, instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, instance_state, shared_state, **kwargs)
|
||||
def __init__(self, system, location, definition, descriptor, instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs)
|
||||
self.contents = None
|
||||
|
||||
def get_html(self):
|
||||
|
||||
@@ -23,9 +23,9 @@ class VideoModule(XModule):
|
||||
css = {'scss': [resource_string(__name__, 'css/video/display.scss')]}
|
||||
js_module_name = "Video"
|
||||
|
||||
def __init__(self, system, location, definition,
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
XModule.__init__(self, system, location, definition,
|
||||
XModule.__init__(self, system, location, definition, descriptor,
|
||||
instance_state, shared_state, **kwargs)
|
||||
xmltree = etree.fromstring(self.definition['data'])
|
||||
self.youtube = xmltree.get('youtube')
|
||||
@@ -80,3 +80,5 @@ class VideoModule(XModule):
|
||||
|
||||
class VideoDescriptor(RawDescriptor):
|
||||
module_class = VideoModule
|
||||
|
||||
stores_state = True
|
||||
|
||||
@@ -143,7 +143,7 @@ class XModule(HTMLSnippet):
|
||||
# in the module
|
||||
icon_class = 'other'
|
||||
|
||||
def __init__(self, system, location, definition,
|
||||
def __init__(self, system, location, definition, descriptor,
|
||||
instance_state=None, shared_state=None, **kwargs):
|
||||
'''
|
||||
Construct a new xmodule
|
||||
@@ -166,6 +166,10 @@ class XModule(HTMLSnippet):
|
||||
'children': is a list of Location-like values for child modules that
|
||||
this module depends on
|
||||
|
||||
descriptor: the XModuleDescriptor that this module is an instance of.
|
||||
TODO (vshnayder): remove the definition parameter and location--they
|
||||
can come from the descriptor.
|
||||
|
||||
instance_state: A string of serialized json that contains the state of
|
||||
this module for current student accessing the system, or None if
|
||||
no state has been saved
|
||||
@@ -189,6 +193,7 @@ class XModule(HTMLSnippet):
|
||||
self.system = system
|
||||
self.location = Location(location)
|
||||
self.definition = definition
|
||||
self.descriptor = descriptor
|
||||
self.instance_state = instance_state
|
||||
self.shared_state = shared_state
|
||||
self.id = self.location.url()
|
||||
@@ -222,7 +227,7 @@ class XModule(HTMLSnippet):
|
||||
def get_display_items(self):
|
||||
'''
|
||||
Returns a list of descendent module instances that will display
|
||||
immediately inside this module
|
||||
immediately inside this module.
|
||||
'''
|
||||
items = []
|
||||
for child in self.get_children():
|
||||
@@ -233,7 +238,7 @@ class XModule(HTMLSnippet):
|
||||
def displayable_items(self):
|
||||
'''
|
||||
Returns list of displayable modules contained by this module. If this
|
||||
module is visible, should return [self]
|
||||
module is visible, should return [self].
|
||||
'''
|
||||
return [self]
|
||||
|
||||
@@ -304,10 +309,18 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
|
||||
entry_point = "xmodule.v1"
|
||||
module_class = XModule
|
||||
|
||||
# Attributes for inpsection of the descriptor
|
||||
stores_state = False # Indicates whether the xmodule state should be
|
||||
# stored in a database (independent of shared state)
|
||||
has_score = False # This indicates whether the xmodule is a problem-type.
|
||||
# It should respond to max_score() and grade(). It can be graded or ungraded
|
||||
# (like a practice problem).
|
||||
|
||||
# A list of metadata that this module can inherit from its parent module
|
||||
inheritable_metadata = (
|
||||
'graded', 'start', 'due', 'graceperiod', 'showanswer', 'rerandomize',
|
||||
|
||||
# TODO (ichuang): used for Fall 2012 xqa server access
|
||||
'xqa_key',
|
||||
# TODO: This is used by the XMLModuleStore to provide for locations for
|
||||
# static files, and will need to be removed when that code is removed
|
||||
'data_dir'
|
||||
@@ -391,6 +404,18 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
|
||||
return dict((k,v) for k,v in self.metadata.items()
|
||||
if k not in self._inherited_metadata)
|
||||
|
||||
@staticmethod
|
||||
def compute_inherited_metadata(node):
|
||||
"""Given a descriptor, traverse all of its descendants and do metadata
|
||||
inheritance. Should be called on a CourseDescriptor after importing a
|
||||
course.
|
||||
|
||||
NOTE: This means that there is no such thing as lazy loading at the
|
||||
moment--this accesses all the children."""
|
||||
for c in node.get_children():
|
||||
c.inherit_metadata(node.metadata)
|
||||
XModuleDescriptor.compute_inherited_metadata(c)
|
||||
|
||||
def inherit_metadata(self, metadata):
|
||||
"""
|
||||
Updates this module with metadata inherited from a containing module.
|
||||
@@ -411,6 +436,9 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
|
||||
self._child_instances = []
|
||||
for child_loc in self.definition.get('children', []):
|
||||
child = self.system.load_item(child_loc)
|
||||
# TODO (vshnayder): this should go away once we have
|
||||
# proper inheritance support in mongo. The xml
|
||||
# datastore does all inheritance on course load.
|
||||
child.inherit_metadata(self.metadata)
|
||||
self._child_instances.append(child)
|
||||
|
||||
@@ -427,6 +455,7 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
|
||||
system,
|
||||
self.location,
|
||||
self.definition,
|
||||
self,
|
||||
metadata=self.metadata
|
||||
)
|
||||
|
||||
@@ -494,7 +523,7 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
|
||||
# Put import here to avoid circular import errors
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
msg = "Error loading from xml."
|
||||
log.exception(msg)
|
||||
log.warning(msg + " " + str(err))
|
||||
system.error_tracker(msg)
|
||||
err_msg = msg + "\n" + exc_info_to_str(sys.exc_info())
|
||||
descriptor = ErrorDescriptor.from_xml(xml_data, system, org, course,
|
||||
@@ -587,9 +616,10 @@ class DescriptorSystem(object):
|
||||
try:
|
||||
x = access_some_resource()
|
||||
check_some_format(x)
|
||||
except SomeProblem:
|
||||
msg = 'Grommet {0} is broken'.format(x)
|
||||
log.exception(msg) # don't rely on handler to log
|
||||
except SomeProblem as err:
|
||||
msg = 'Grommet {0} is broken: {1}'.format(x, str(err))
|
||||
log.warning(msg) # don't rely on tracker to log
|
||||
# NOTE: we generally don't want content errors logged as errors
|
||||
self.system.error_tracker(msg)
|
||||
# work around
|
||||
return 'Oops, couldn't load grommet'
|
||||
@@ -645,7 +675,8 @@ class ModuleSystem(object):
|
||||
filestore=None,
|
||||
debug=False,
|
||||
xqueue=None,
|
||||
is_staff=False):
|
||||
is_staff=False,
|
||||
node_path=""):
|
||||
'''
|
||||
Create a closure around the system environment.
|
||||
|
||||
@@ -668,7 +699,7 @@ class ModuleSystem(object):
|
||||
filestore - A filestore ojbect. Defaults to an instance of OSFS based
|
||||
at settings.DATA_DIR.
|
||||
|
||||
xqueue - Dict containing XqueueInterface object, as well as parameters
|
||||
xqueue - Dict containing XqueueInterface object, as well as parameters
|
||||
for the specific StudentModule
|
||||
|
||||
replace_urls - TEMPORARY - A function like static_replace.replace_urls
|
||||
@@ -688,6 +719,7 @@ class ModuleSystem(object):
|
||||
self.seed = user.id if user is not None else 0
|
||||
self.replace_urls = replace_urls
|
||||
self.is_staff = is_staff
|
||||
self.node_path = node_path
|
||||
|
||||
def get(self, attr):
|
||||
''' provide uniform access to attributes (like etree).'''
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from xmodule.x_module import XModuleDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from lxml import etree
|
||||
import json
|
||||
import copy
|
||||
import logging
|
||||
import traceback
|
||||
@@ -32,7 +33,15 @@ def is_pointer_tag(xml_obj):
|
||||
actual_attr = set(xml_obj.attrib.keys())
|
||||
return len(xml_obj) == 0 and actual_attr == expected_attr
|
||||
|
||||
|
||||
def get_metadata_from_xml(xml_object, remove=True):
|
||||
meta = xml_object.find('meta')
|
||||
if meta is None:
|
||||
return ''
|
||||
dmdata = meta.text
|
||||
log.debug('meta for %s loaded: %s' % (xml_object,dmdata))
|
||||
if remove:
|
||||
xml_object.remove(meta)
|
||||
return dmdata
|
||||
|
||||
_AttrMapBase = namedtuple('_AttrMap', 'from_xml to_xml')
|
||||
|
||||
@@ -71,6 +80,7 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
metadata_attributes = ('format', 'graceperiod', 'showanswer', 'rerandomize',
|
||||
'start', 'due', 'graded', 'display_name', 'url_name', 'hide_from_toc',
|
||||
'ispublic', # if True, then course is listed for all users; see
|
||||
'xqa_key', # for xqaa server access
|
||||
# VS[compat] Remove once unused.
|
||||
'name', 'slug')
|
||||
|
||||
@@ -180,8 +190,11 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
|
||||
definition_xml = cls.load_file(filepath, system.resources_fs, location)
|
||||
|
||||
definition_metadata = get_metadata_from_xml(definition_xml)
|
||||
cls.clean_metadata_from_xml(definition_xml)
|
||||
definition = cls.definition_from_xml(definition_xml, system)
|
||||
if definition_metadata:
|
||||
definition['definition_metadata'] = definition_metadata
|
||||
|
||||
# TODO (ichuang): remove this after migration
|
||||
# for Fall 2012 LMS migration: keep filename (and unmangled filename)
|
||||
@@ -236,9 +249,9 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
filepath = cls._format_filepath(xml_object.tag, url_name)
|
||||
definition_xml = cls.load_file(filepath, system.resources_fs, location)
|
||||
else:
|
||||
definition_xml = xml_object
|
||||
definition_xml = xml_object # this is just a pointer, not the real definition content
|
||||
|
||||
definition = cls.load_definition(definition_xml, system, location)
|
||||
definition = cls.load_definition(definition_xml, system, location) # note this removes metadata
|
||||
# VS[compat] -- make Ike's github preview links work in both old and
|
||||
# new file layouts
|
||||
if is_pointer_tag(xml_object):
|
||||
@@ -246,6 +259,17 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
definition['filename'] = [filepath, filepath]
|
||||
|
||||
metadata = cls.load_metadata(definition_xml)
|
||||
|
||||
# move definition metadata into dict
|
||||
dmdata = definition.get('definition_metadata','')
|
||||
if dmdata:
|
||||
metadata['definition_metadata_raw'] = dmdata
|
||||
try:
|
||||
metadata.update(json.loads(dmdata))
|
||||
except Exception as err:
|
||||
log.debug('Error %s in loading metadata %s' % (err,dmdata))
|
||||
metadata['definition_metadata_err'] = str(err)
|
||||
|
||||
return cls(
|
||||
system,
|
||||
definition,
|
||||
@@ -259,6 +283,15 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
name=name,
|
||||
ext=cls.filename_extension)
|
||||
|
||||
def export_to_file(self):
|
||||
"""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...
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
def export_to_xml(self, resource_fs):
|
||||
"""
|
||||
Returns an xml string representing this module, and all modules
|
||||
@@ -295,14 +328,18 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
if attr not in self.metadata_to_strip:
|
||||
xml_object.set(attr, val_for_xml(attr))
|
||||
|
||||
# Write the definition to a file
|
||||
filepath = self.__class__._format_filepath(self.category, self.url_name)
|
||||
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))
|
||||
if self.export_to_file():
|
||||
# Write the definition to a file
|
||||
filepath = self.__class__._format_filepath(self.category, self.url_name)
|
||||
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))
|
||||
|
||||
# And return just a pointer with the category and filename.
|
||||
record_object = etree.Element(self.category)
|
||||
else:
|
||||
record_object = xml_object
|
||||
|
||||
# And return just a pointer with the category and filename.
|
||||
record_object = etree.Element(self.category)
|
||||
record_object.set('url_name', self.url_name)
|
||||
|
||||
# Special case for course pointers:
|
||||
|
||||
47
common/static/coffee/src/xproblem.coffee
Normal file
@@ -0,0 +1,47 @@
|
||||
class XProblemGenerator
|
||||
|
||||
constructor: (seed, @parameters={}) ->
|
||||
|
||||
@random = new MersenneTwister(seed)
|
||||
|
||||
@problemState = {}
|
||||
|
||||
generate: () ->
|
||||
|
||||
console.error("Abstract method called: XProblemGenerator.generate")
|
||||
|
||||
class XProblemDisplay
|
||||
|
||||
constructor: (@state, @submission, @evaluation, @container, @submissionField, @parameters={}) ->
|
||||
|
||||
render: () ->
|
||||
|
||||
console.error("Abstract method called: XProblemDisplay.render")
|
||||
|
||||
updateSubmission: () ->
|
||||
|
||||
@submissionField.val(JSON.stringify(@getCurrentSubmission()))
|
||||
|
||||
getCurrentSubmission: () ->
|
||||
console.error("Abstract method called: XProblemDisplay.getCurrentSubmission")
|
||||
|
||||
class XProblemGrader
|
||||
|
||||
constructor: (@submission, @problemState, @parameters={}) ->
|
||||
|
||||
@solution = null
|
||||
@evaluation = {}
|
||||
|
||||
solve: () ->
|
||||
|
||||
console.error("Abstract method called: XProblemGrader.solve")
|
||||
|
||||
grade: () ->
|
||||
|
||||
console.error("Abstract method called: XProblemGrader.grade")
|
||||
|
||||
root = exports ? this
|
||||
|
||||
root.XProblemGenerator = XProblemGenerator
|
||||
root.XProblemDisplay = XProblemDisplay
|
||||
root.XProblemGrader = XProblemGrader
|
||||
|
Before Width: | Height: | Size: 203 B After Width: | Height: | Size: 204 B |
|
Before Width: | Height: | Size: 207 B After Width: | Height: | Size: 205 B |
|
Before Width: | Height: | Size: 205 B After Width: | Height: | Size: 206 B |
|
Before Width: | Height: | Size: 168 B After Width: | Height: | Size: 171 B |
|
Before Width: | Height: | Size: 276 B After Width: | Height: | Size: 292 B |
|
Before Width: | Height: | Size: 413 B After Width: | Height: | Size: 344 B |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 284 B |
|
Before Width: | Height: | Size: 300 B After Width: | Height: | Size: 306 B |
|
Before Width: | Height: | Size: 264 B After Width: | Height: | Size: 250 B |
|
Before Width: | Height: | Size: 467 B After Width: | Height: | Size: 457 B |
|
Before Width: | Height: | Size: 241 B After Width: | Height: | Size: 243 B |
|
Before Width: | Height: | Size: 300 B After Width: | Height: | Size: 300 B |
|
Before Width: | Height: | Size: 830 B After Width: | Height: | Size: 845 B |
|
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 403 B |
|
Before Width: | Height: | Size: 398 B After Width: | Height: | Size: 394 B |
|
Before Width: | Height: | Size: 416 B After Width: | Height: | Size: 425 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 234 B |
BIN
common/static/images/spinner.gif
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
202
common/static/js/vendor/mersenne-twister-min.js
vendored
Normal file
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
I've wrapped Makoto Matsumoto and Takuji Nishimura's code in a namespace
|
||||
so it's better encapsulated. Now you can have multiple random number generators
|
||||
and they won't stomp all over eachother's state.
|
||||
|
||||
If you want to use this as a substitute for Math.random(), use the random()
|
||||
method like so:
|
||||
|
||||
var m = new MersenneTwister();
|
||||
var randomNumber = m.random();
|
||||
|
||||
You can also call the other genrand_{foo}() methods on the instance.
|
||||
|
||||
If you want to use a specific seed in order to get a repeatable random
|
||||
sequence, pass an integer into the constructor:
|
||||
|
||||
var m = new MersenneTwister(123);
|
||||
|
||||
and that will always produce the same random sequence.
|
||||
|
||||
Sean McCullough (banksean@gmail.com)
|
||||
*/
|
||||
|
||||
/*
|
||||
A C-program for MT19937, with initialization improved 2002/1/26.
|
||||
Coded by Takuji Nishimura and Makoto Matsumoto.
|
||||
|
||||
Before using, initialize the state by using init_genrand(seed)
|
||||
or init_by_array(init_key, key_length).
|
||||
|
||||
Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura,
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
3. The names of its contributors may not be used to endorse or promote
|
||||
products derived from this software without specific prior written
|
||||
permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
Any feedback is very welcome.
|
||||
http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/emt.html
|
||||
email: m-mat @ math.sci.hiroshima-u.ac.jp (remove space)
|
||||
*/
|
||||
|
||||
var MersenneTwister = function(seed) {
|
||||
if (seed == undefined) {
|
||||
seed = new Date().getTime();
|
||||
}
|
||||
/* Period parameters */
|
||||
this.N = 624;
|
||||
this.M = 397;
|
||||
this.MATRIX_A = 0x9908b0df; /* constant vector a */
|
||||
this.UPPER_MASK = 0x80000000; /* most significant w-r bits */
|
||||
this.LOWER_MASK = 0x7fffffff; /* least significant r bits */
|
||||
|
||||
this.mt = new Array(this.N); /* the array for the state vector */
|
||||
this.mti=this.N+1; /* mti==N+1 means mt[N] is not initialized */
|
||||
|
||||
this.init_genrand(seed);
|
||||
}
|
||||
|
||||
/* initializes mt[N] with a seed */
|
||||
MersenneTwister.prototype.init_genrand = function(s) {
|
||||
this.mt[0] = s >>> 0;
|
||||
for (this.mti=1; this.mti<this.N; this.mti++) {
|
||||
var s = this.mt[this.mti-1] ^ (this.mt[this.mti-1] >>> 30);
|
||||
this.mt[this.mti] = (((((s & 0xffff0000) >>> 16) * 1812433253) << 16) + (s & 0x0000ffff) * 1812433253)
|
||||
+ this.mti;
|
||||
/* See Knuth TAOCP Vol2. 3rd Ed. P.106 for multiplier. */
|
||||
/* In the previous versions, MSBs of the seed affect */
|
||||
/* only MSBs of the array mt[]. */
|
||||
/* 2002/01/09 modified by Makoto Matsumoto */
|
||||
this.mt[this.mti] >>>= 0;
|
||||
/* for >32 bit machines */
|
||||
}
|
||||
}
|
||||
|
||||
/* initialize by an array with array-length */
|
||||
/* init_key is the array for initializing keys */
|
||||
/* key_length is its length */
|
||||
/* slight change for C++, 2004/2/26 */
|
||||
MersenneTwister.prototype.init_by_array = function(init_key, key_length) {
|
||||
var i, j, k;
|
||||
this.init_genrand(19650218);
|
||||
i=1; j=0;
|
||||
k = (this.N>key_length ? this.N : key_length);
|
||||
for (; k; k--) {
|
||||
var s = this.mt[i-1] ^ (this.mt[i-1] >>> 30)
|
||||
this.mt[i] = (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1664525) << 16) + ((s & 0x0000ffff) * 1664525)))
|
||||
+ init_key[j] + j; /* non linear */
|
||||
this.mt[i] >>>= 0; /* for WORDSIZE > 32 machines */
|
||||
i++; j++;
|
||||
if (i>=this.N) { this.mt[0] = this.mt[this.N-1]; i=1; }
|
||||
if (j>=key_length) j=0;
|
||||
}
|
||||
for (k=this.N-1; k; k--) {
|
||||
var s = this.mt[i-1] ^ (this.mt[i-1] >>> 30);
|
||||
this.mt[i] = (this.mt[i] ^ (((((s & 0xffff0000) >>> 16) * 1566083941) << 16) + (s & 0x0000ffff) * 1566083941))
|
||||
- i; /* non linear */
|
||||
this.mt[i] >>>= 0; /* for WORDSIZE > 32 machines */
|
||||
i++;
|
||||
if (i>=this.N) { this.mt[0] = this.mt[this.N-1]; i=1; }
|
||||
}
|
||||
|
||||
this.mt[0] = 0x80000000; /* MSB is 1; assuring non-zero initial array */
|
||||
}
|
||||
|
||||
/* generates a random number on [0,0xffffffff]-interval */
|
||||
MersenneTwister.prototype.genrand_int32 = function() {
|
||||
var y;
|
||||
var mag01 = new Array(0x0, this.MATRIX_A);
|
||||
/* mag01[x] = x * MATRIX_A for x=0,1 */
|
||||
|
||||
if (this.mti >= this.N) { /* generate N words at one time */
|
||||
var kk;
|
||||
|
||||
if (this.mti == this.N+1) /* if init_genrand() has not been called, */
|
||||
this.init_genrand(5489); /* a default initial seed is used */
|
||||
|
||||
for (kk=0;kk<this.N-this.M;kk++) {
|
||||
y = (this.mt[kk]&this.UPPER_MASK)|(this.mt[kk+1]&this.LOWER_MASK);
|
||||
this.mt[kk] = this.mt[kk+this.M] ^ (y >>> 1) ^ mag01[y & 0x1];
|
||||
}
|
||||
for (;kk<this.N-1;kk++) {
|
||||
y = (this.mt[kk]&this.UPPER_MASK)|(this.mt[kk+1]&this.LOWER_MASK);
|
||||
this.mt[kk] = this.mt[kk+(this.M-this.N)] ^ (y >>> 1) ^ mag01[y & 0x1];
|
||||
}
|
||||
y = (this.mt[this.N-1]&this.UPPER_MASK)|(this.mt[0]&this.LOWER_MASK);
|
||||
this.mt[this.N-1] = this.mt[this.M-1] ^ (y >>> 1) ^ mag01[y & 0x1];
|
||||
|
||||
this.mti = 0;
|
||||
}
|
||||
|
||||
y = this.mt[this.mti++];
|
||||
|
||||
/* Tempering */
|
||||
y ^= (y >>> 11);
|
||||
y ^= (y << 7) & 0x9d2c5680;
|
||||
y ^= (y << 15) & 0xefc60000;
|
||||
y ^= (y >>> 18);
|
||||
|
||||
return y >>> 0;
|
||||
}
|
||||
|
||||
/* generates a random number on [0,0x7fffffff]-interval */
|
||||
MersenneTwister.prototype.genrand_int31 = function() {
|
||||
return (this.genrand_int32()>>>1);
|
||||
}
|
||||
|
||||
/* generates a random number on [0,1]-real-interval */
|
||||
MersenneTwister.prototype.genrand_real1 = function() {
|
||||
return this.genrand_int32()*(1.0/4294967295.0);
|
||||
/* divided by 2^32-1 */
|
||||
}
|
||||
|
||||
/* generates a random number on [0,1)-real-interval */
|
||||
MersenneTwister.prototype.random = function() {
|
||||
return this.genrand_int32()*(1.0/4294967296.0);
|
||||
/* divided by 2^32 */
|
||||
}
|
||||
|
||||
/* generates a random number on (0,1)-real-interval */
|
||||
MersenneTwister.prototype.genrand_real3 = function() {
|
||||
return (this.genrand_int32() + 0.5)*(1.0/4294967296.0);
|
||||
/* divided by 2^32 */
|
||||
}
|
||||
|
||||
/* generates a random number on [0,1) with 53-bit resolution*/
|
||||
MersenneTwister.prototype.genrand_res53 = function() {
|
||||
var a=this.genrand_int32()>>>5, b=this.genrand_int32()>>>6;
|
||||
return(a*67108864.0+b)*(1.0/9007199254740992.0);
|
||||
}
|
||||
|
||||
/* These real versions are due to Isaku Wada, 2002/01/09 added */
|
||||
if(typeof exports == 'undefined'){
|
||||
var root = this;
|
||||
} else {
|
||||
var root = exports;
|
||||
}
|
||||
root.MersenneTwister = MersenneTwister;
|
||||
@@ -190,7 +190,7 @@ case `uname -s` in
|
||||
}
|
||||
distro=`lsb_release -cs`
|
||||
case $distro in
|
||||
lisa|natty|oneiric|precise)
|
||||
maya|lisa|natty|oneiric|precise)
|
||||
output "Installing ubuntu requirements"
|
||||
sudo apt-get -y update
|
||||
sudo apt-get -y install $APT_PKGS
|
||||
|
||||
@@ -34,12 +34,34 @@ This will import all courses in your data directory into mongodb
|
||||
This runs all the tests (long, uses collectstatic):
|
||||
|
||||
rake test
|
||||
|
||||
|
||||
If if you aren't changing static files, can run `rake test` once, then run
|
||||
|
||||
rake fasttest_{lms,cms}
|
||||
|
||||
xmodule can be tested independently, with this:
|
||||
|
||||
rake test_common/lib/xmodule
|
||||
|
||||
|
||||
To see all available rake commands, do this:
|
||||
|
||||
rake -T
|
||||
|
||||
|
||||
To run a single django test class:
|
||||
|
||||
django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth
|
||||
|
||||
To run a single django test:
|
||||
|
||||
django-admin.py test --settings=lms.envs.test --pythonpath=. lms/djangoapps/courseware/tests/tests.py:TestViewAuth.test_dark_launch
|
||||
|
||||
|
||||
To run a single nose test file:
|
||||
|
||||
nosetests common/lib/xmodule/xmodule/tests/test_stringify.py
|
||||
|
||||
To run a single nose test:
|
||||
|
||||
nosetests common/lib/xmodule/xmodule/tests/test_stringify.py:test_stringify
|
||||
|
||||
|
||||
|
||||
@@ -8,19 +8,19 @@ from django.conf import settings
|
||||
from django.http import Http404
|
||||
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from static_replace import replace_urls
|
||||
from staticfiles.storage import staticfiles_storage
|
||||
from static_replace import replace_urls, try_staticfiles_lookup
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_course(course_id, course_must_be_open=True, course_required=True):
|
||||
def check_course(user, course_id, course_must_be_open=True, course_required=True):
|
||||
"""
|
||||
Given a course_id, this returns the course object. By default,
|
||||
if the course is not found or the course is not open yet, this
|
||||
method will raise a 404.
|
||||
Given a django user and a course_id, this returns the course
|
||||
object. By default, if the course is not found or the course is
|
||||
not open yet, this method will raise a 404.
|
||||
|
||||
If course_must_be_open is False, the course will be returned
|
||||
without a 404 even if it is not open.
|
||||
@@ -28,6 +28,10 @@ def check_course(course_id, course_must_be_open=True, course_required=True):
|
||||
If course_required is False, a course_id of None is acceptable. The
|
||||
course returned will be None. Even if the course is not required,
|
||||
if a course_id is given that does not exist a 404 will be raised.
|
||||
|
||||
This behavior is modified by MITX_FEATURES['DARK_LAUNCH']:
|
||||
if dark launch is enabled, course_must_be_open is ignored for
|
||||
users that have staff access.
|
||||
"""
|
||||
course = None
|
||||
if course_required or course_id:
|
||||
@@ -39,16 +43,23 @@ def check_course(course_id, course_must_be_open=True, course_required=True):
|
||||
raise Http404("Course not found.")
|
||||
|
||||
started = course.has_started() or settings.MITX_FEATURES['DISABLE_START_DATES']
|
||||
if course_must_be_open and not started:
|
||||
|
||||
must_be_open = course_must_be_open
|
||||
if (settings.MITX_FEATURES['DARK_LAUNCH'] and
|
||||
has_staff_access_to_course(user, course)):
|
||||
must_be_open = False
|
||||
|
||||
if must_be_open and not started:
|
||||
raise Http404("This course has not yet started.")
|
||||
|
||||
return course
|
||||
|
||||
|
||||
def course_image_url(course):
|
||||
return staticfiles_storage.url(course.metadata['data_dir'] +
|
||||
"/images/course_image.jpg")
|
||||
|
||||
"""Try to look up the image url for the course. If it's not found,
|
||||
log an error and return the dead link"""
|
||||
path = course.metadata['data_dir'] + "/images/course_image.jpg"
|
||||
return try_staticfiles_lookup(path)
|
||||
|
||||
def get_course_about_section(course, section_key):
|
||||
"""
|
||||
@@ -145,6 +156,8 @@ def has_staff_access_to_course(user, course):
|
||||
'''
|
||||
Returns True if the given user has staff access to the course.
|
||||
This means that user is in the staff_* group, or is an overall admin.
|
||||
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 the course field of the location being accessed.
|
||||
'''
|
||||
@@ -156,13 +169,26 @@ def has_staff_access_to_course(user, course):
|
||||
# note this is the Auth group, not UserTestGroup
|
||||
user_groups = [x[1] for x in user.groups.values_list()]
|
||||
staff_group = course_staff_group_name(course)
|
||||
log.debug('course %s, staff_group %s, user %s, groups %s' % (
|
||||
course, staff_group, user, user_groups))
|
||||
if staff_group in user_groups:
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_access_to_course(user,course):
|
||||
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_course(user, loc.course)
|
||||
|
||||
|
||||
def has_staff_access_to_location(user, location):
|
||||
"""Helper method that checks whether the user has staff access to
|
||||
the course of the location.
|
||||
|
||||
location: something that can be passed to Location
|
||||
"""
|
||||
return has_staff_access_to_course(user, Location(location).course)
|
||||
|
||||
def has_access_to_course(user, course):
|
||||
'''course is the .course element of a location'''
|
||||
if course.metadata.get('ispublic'):
|
||||
return True
|
||||
return has_staff_access_to_course(user,course)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Compute grades using real division, with no integer truncation
|
||||
from __future__ import division
|
||||
|
||||
import random
|
||||
import logging
|
||||
|
||||
@@ -12,32 +15,34 @@ from models import StudentModule
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
def yield_module_descendents(module):
|
||||
for child in module.get_display_items():
|
||||
yield child
|
||||
for module in yield_module_descendents(child):
|
||||
yield module
|
||||
|
||||
stack = module.get_display_items()
|
||||
|
||||
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):
|
||||
"""
|
||||
This grades a student as quickly as possible. It retuns the
|
||||
This grades a student as quickly as possible. It retuns the
|
||||
output from the course grader, augmented with the final letter
|
||||
grade. The keys in the output are:
|
||||
|
||||
|
||||
- grade : A final letter grade.
|
||||
- percent : The final percent for the class (rounded up).
|
||||
- section_breakdown : A breakdown of each section that makes
|
||||
up the grade. (For display)
|
||||
- grade_breakdown : A breakdown of the major components that
|
||||
make up the final grade. (For display)
|
||||
|
||||
|
||||
More information on the format is in the docstring for CourseGrader.
|
||||
"""
|
||||
|
||||
|
||||
grading_context = course.grading_context
|
||||
|
||||
|
||||
if student_module_cache == None:
|
||||
student_module_cache = StudentModuleCache(student, grading_context['all_descriptors'])
|
||||
|
||||
|
||||
totaled_scores = {}
|
||||
# This next complicated loop is just to collect the totaled_scores, which is
|
||||
# passed to the grader
|
||||
@@ -46,91 +51,91 @@ def grade(student, request, course, student_module_cache=None):
|
||||
for section in sections:
|
||||
section_descriptor = section['section_descriptor']
|
||||
section_name = section_descriptor.metadata.get('display_name')
|
||||
|
||||
|
||||
should_grade_section = False
|
||||
# If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0%
|
||||
for moduledescriptor in section['xmoduledescriptors']:
|
||||
if student_module_cache.lookup(moduledescriptor.category, moduledescriptor.location.url() ):
|
||||
should_grade_section = True
|
||||
break
|
||||
|
||||
|
||||
if should_grade_section:
|
||||
scores = []
|
||||
# TODO: We need the request to pass into here. If we could forgo that, our arguments
|
||||
# would be simpler
|
||||
section_module = get_module(student, request, section_descriptor.location, student_module_cache)
|
||||
|
||||
|
||||
# TODO: We may be able to speed this up by only getting a list of children IDs from section_module
|
||||
# Then, we may not need to instatiate any problems if they are already in the database
|
||||
for module in yield_module_descendents(section_module):
|
||||
for module in yield_module_descendents(section_module):
|
||||
(correct, total) = get_score(student, module, student_module_cache)
|
||||
if correct is None and total is None:
|
||||
continue
|
||||
|
||||
|
||||
if settings.GENERATE_PROFILE_SCORES:
|
||||
if total > 1:
|
||||
correct = random.randrange(max(total - 2, 1), total + 1)
|
||||
else:
|
||||
correct = total
|
||||
|
||||
|
||||
graded = module.metadata.get("graded", False)
|
||||
if not total > 0:
|
||||
#We simply cannot grade a problem that is 12/0, because we might need it as a percentage
|
||||
graded = False
|
||||
|
||||
|
||||
scores.append(Score(correct, total, graded, module.metadata.get('display_name')))
|
||||
|
||||
|
||||
section_total, graded_total = graders.aggregate_scores(scores, section_name)
|
||||
else:
|
||||
section_total = Score(0.0, 1.0, False, section_name)
|
||||
graded_total = Score(0.0, 1.0, True, section_name)
|
||||
|
||||
|
||||
#Add the graded total to totaled_scores
|
||||
if graded_total.possible > 0:
|
||||
format_scores.append(graded_total)
|
||||
else:
|
||||
log.exception("Unable to grade a section with a total possible score of zero. " + str(section_descriptor.id))
|
||||
|
||||
log.exception("Unable to grade a section with a total possible score of zero. " + str(section_descriptor.location))
|
||||
|
||||
totaled_scores[section_format] = format_scores
|
||||
|
||||
|
||||
grade_summary = course.grader.grade(totaled_scores)
|
||||
|
||||
|
||||
# We round the grade here, to make sure that the grade is an whole percentage and
|
||||
# doesn't get displayed differently than it gets grades
|
||||
grade_summary['percent'] = round(grade_summary['percent'] * 100 + 0.05) / 100
|
||||
|
||||
|
||||
letter_grade = grade_for_percentage(course.grade_cutoffs, grade_summary['percent'])
|
||||
grade_summary['grade'] = letter_grade
|
||||
|
||||
|
||||
return grade_summary
|
||||
|
||||
def grade_for_percentage(grade_cutoffs, percentage):
|
||||
"""
|
||||
Returns a letter grade 'A' 'B' 'C' or None.
|
||||
|
||||
|
||||
Arguments
|
||||
- grade_cutoffs is a dictionary mapping a grade to the lowest
|
||||
possible percentage to earn that grade.
|
||||
- percentage is the final percent across all problems in a course
|
||||
"""
|
||||
|
||||
|
||||
letter_grade = None
|
||||
for possible_grade in ['A', 'B', 'C']:
|
||||
if percentage >= grade_cutoffs[possible_grade]:
|
||||
letter_grade = possible_grade
|
||||
break
|
||||
|
||||
return letter_grade
|
||||
|
||||
return letter_grade
|
||||
|
||||
def progress_summary(student, course, grader, student_module_cache):
|
||||
"""
|
||||
This pulls a summary of all problems in the course.
|
||||
|
||||
Returns
|
||||
- courseware_summary is a summary of all sections with problems in the course.
|
||||
It is organized as an array of chapters, each containing an array of sections,
|
||||
each containing an array of scores. This contains information for graded and
|
||||
ungraded problems, and is good for displaying a course summary with due dates,
|
||||
- courseware_summary is a summary of all sections with problems in the course.
|
||||
It is organized as an array of chapters, each containing an array of sections,
|
||||
each containing an array of scores. This contains information for graded and
|
||||
ungraded problems, and is good for displaying a course summary with due dates,
|
||||
etc.
|
||||
|
||||
Arguments:
|
||||
@@ -140,9 +145,11 @@ def progress_summary(student, course, grader, student_module_cache):
|
||||
instance_modules for the student
|
||||
"""
|
||||
chapters = []
|
||||
for c in course.get_children():
|
||||
# Don't include chapters that aren't displayable (e.g. due to error)
|
||||
for c in course.get_display_items():
|
||||
sections = []
|
||||
for s in c.get_children():
|
||||
for s in c.get_display_items():
|
||||
# Same for sections
|
||||
graded = s.metadata.get('graded', False)
|
||||
scores = []
|
||||
for module in yield_module_descendents(s):
|
||||
@@ -150,7 +157,7 @@ def progress_summary(student, course, grader, student_module_cache):
|
||||
if correct is None and total is None:
|
||||
continue
|
||||
|
||||
scores.append(Score(correct, total, graded,
|
||||
scores.append(Score(correct, total, graded,
|
||||
module.metadata.get('display_name')))
|
||||
|
||||
section_total, graded_total = graders.aggregate_scores(
|
||||
@@ -177,12 +184,16 @@ def progress_summary(student, course, grader, student_module_cache):
|
||||
|
||||
def get_score(user, problem, student_module_cache):
|
||||
"""
|
||||
Return the score for a user on a problem
|
||||
Return the score for a user on a problem, as a tuple (correct, total).
|
||||
|
||||
user: a Student object
|
||||
problem: an XModule
|
||||
cache: A StudentModuleCache
|
||||
"""
|
||||
if not (problem.descriptor.stores_state and problem.descriptor.has_score):
|
||||
# These are not problems, and do not have a score
|
||||
return (None, None)
|
||||
|
||||
correct = 0.0
|
||||
|
||||
# If the ID is not in the cache, add the item
|
||||
|
||||
@@ -69,10 +69,10 @@ class StudentModuleCache(object):
|
||||
"""
|
||||
def __init__(self, user, descriptors):
|
||||
'''
|
||||
Find any StudentModule objects that are needed by any child modules of the
|
||||
supplied descriptor, or caches only the StudentModule objects specifically
|
||||
for every descriptor in descriptors. Avoids making multiple queries to the
|
||||
database.
|
||||
Find any StudentModule objects that are needed by any descriptor
|
||||
in descriptors. Avoids making multiple queries to the database.
|
||||
Note: Only modules that have store_state = True or have shared
|
||||
state will have a StudentModule.
|
||||
|
||||
Arguments
|
||||
user: The user for which to fetch maching StudentModules
|
||||
@@ -134,7 +134,8 @@ class StudentModuleCache(object):
|
||||
'''
|
||||
keys = []
|
||||
for descriptor in descriptors:
|
||||
keys.append(descriptor.location.url())
|
||||
if descriptor.stores_state:
|
||||
keys.append(descriptor.location.url())
|
||||
|
||||
shared_state_key = getattr(descriptor, 'shared_state_key', None)
|
||||
if shared_state_key is not None:
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import Http404
|
||||
from django.http import HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
@@ -16,7 +17,8 @@ from xmodule.exceptions import NotFoundError
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xmodule_modifiers import replace_static_urls, add_histogram, wrap_xmodule
|
||||
|
||||
from courseware.courses import has_staff_access_to_course
|
||||
from courseware.courses import (has_staff_access_to_course,
|
||||
has_staff_access_to_location)
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
@@ -46,11 +48,12 @@ def toc_for_course(user, request, course, active_chapter, active_section):
|
||||
'format': format, 'due': due, 'active' : bool}, ...]
|
||||
|
||||
active is set for the section and chapter corresponding to the passed
|
||||
parameters. Everything else comes from the xml, or defaults to "".
|
||||
parameters, which are expected to be url_names of the chapter+section.
|
||||
Everything else comes from the xml, or defaults to "".
|
||||
|
||||
chapters with name 'hidden' are skipped.
|
||||
'''
|
||||
|
||||
|
||||
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(user, course, depth=2)
|
||||
course = get_module(user, request, course.location, student_module_cache)
|
||||
|
||||
@@ -59,8 +62,8 @@ def toc_for_course(user, request, course, active_chapter, active_section):
|
||||
sections = list()
|
||||
for section in chapter.get_display_items():
|
||||
|
||||
active = (chapter.display_name == active_chapter and
|
||||
section.display_name == active_section)
|
||||
active = (chapter.url_name == active_chapter and
|
||||
section.url_name == active_section)
|
||||
hide_from_toc = section.metadata.get('hide_from_toc', 'false').lower() == 'true'
|
||||
|
||||
if not hide_from_toc:
|
||||
@@ -73,7 +76,7 @@ def toc_for_course(user, request, course, active_chapter, active_section):
|
||||
chapters.append({'display_name': chapter.display_name,
|
||||
'url_name': chapter.url_name,
|
||||
'sections': sections,
|
||||
'active': chapter.display_name == active_chapter})
|
||||
'active': chapter.url_name == active_chapter})
|
||||
return chapters
|
||||
|
||||
|
||||
@@ -122,37 +125,47 @@ def get_module(user, request, location, student_module_cache, position=None):
|
||||
position within module
|
||||
|
||||
Returns: xmodule instance
|
||||
|
||||
|
||||
'''
|
||||
descriptor = modulestore().get_item(location)
|
||||
|
||||
|
||||
#TODO Only check the cache if this module can possibly have state
|
||||
instance_module = None
|
||||
shared_module = None
|
||||
if user.is_authenticated():
|
||||
instance_module = student_module_cache.lookup(descriptor.category,
|
||||
descriptor.location.url())
|
||||
|
||||
if descriptor.stores_state:
|
||||
instance_module = student_module_cache.lookup(descriptor.category,
|
||||
descriptor.location.url())
|
||||
|
||||
shared_state_key = getattr(descriptor, 'shared_state_key', None)
|
||||
if shared_state_key is not None:
|
||||
shared_module = student_module_cache.lookup(descriptor.category,
|
||||
shared_state_key)
|
||||
else:
|
||||
shared_module = None
|
||||
else:
|
||||
instance_module = None
|
||||
shared_module = None
|
||||
|
||||
|
||||
|
||||
|
||||
instance_state = instance_module.state if instance_module is not None else None
|
||||
shared_state = shared_module.state if shared_module is not None else None
|
||||
|
||||
# TODO (vshnayder): fix hardcoded urls (use reverse)
|
||||
# Setup system context for module instance
|
||||
ajax_url = settings.MITX_ROOT_URL + '/modx/' + descriptor.location.url() + '/'
|
||||
|
||||
ajax_url = reverse('modx_dispatch',
|
||||
kwargs=dict(course_id=descriptor.location.course_id,
|
||||
id=descriptor.location.url(),
|
||||
dispatch=''),
|
||||
)
|
||||
|
||||
# ajax_url = settings.MITX_ROOT_URL + '/modx/' + descriptor.location.url() + '/'
|
||||
|
||||
# Fully qualified callback URL for external queueing system
|
||||
xqueue_callback_url = (request.build_absolute_uri('/') + settings.MITX_ROOT_URL +
|
||||
'xqueue/' + str(user.id) + '/' + descriptor.location.url() + '/' +
|
||||
'score_update')
|
||||
xqueue_callback_url = request.build_absolute_uri('/')[:-1] # Trailing slash provided by reverse
|
||||
xqueue_callback_url += reverse('xqueue_callback',
|
||||
kwargs=dict(course_id=descriptor.location.course_id,
|
||||
userid=str(user.id),
|
||||
id=descriptor.location.url(),
|
||||
dispatch='score_update'),
|
||||
)
|
||||
|
||||
# Default queuename is course-specific and is derived from the course that
|
||||
# contains the current module.
|
||||
@@ -182,21 +195,23 @@ def get_module(user, request, location, student_module_cache, position=None):
|
||||
# a module is coming through get_html and is therefore covered
|
||||
# by the replace_static_urls code below
|
||||
replace_urls=replace_urls,
|
||||
is_staff=user.is_staff,
|
||||
is_staff=has_staff_access_to_location(user, location),
|
||||
node_path=settings.NODE_PATH
|
||||
)
|
||||
# pass position specified in URL to module through ModuleSystem
|
||||
system.set('position', position)
|
||||
system.set('DEBUG',settings.DEBUG)
|
||||
|
||||
module = descriptor.xmodule_constructor(system)(instance_state, shared_state)
|
||||
|
||||
module.get_html = replace_static_urls(
|
||||
wrap_xmodule(module.get_html, module, 'xmodule_display.html'),
|
||||
module.metadata['data_dir']
|
||||
module.metadata['data_dir'], module
|
||||
)
|
||||
|
||||
if settings.MITX_FEATURES.get('DISPLAY_HISTOGRAMS_TO_STAFF'):
|
||||
if has_staff_access_to_course(user, module.location.course):
|
||||
module.get_html = add_histogram(module.get_html, module)
|
||||
module.get_html = add_histogram(module.get_html, module, user)
|
||||
|
||||
return module
|
||||
|
||||
@@ -206,9 +221,14 @@ def get_instance_module(user, module, student_module_cache):
|
||||
or None if this is an anonymous user
|
||||
"""
|
||||
if user.is_authenticated():
|
||||
if not module.descriptor.stores_state:
|
||||
log.exception("Attempted to get the instance_module for a module "
|
||||
+ str(module.id) + " which does not store state.")
|
||||
return None
|
||||
|
||||
instance_module = student_module_cache.lookup(module.category,
|
||||
module.location.url())
|
||||
|
||||
|
||||
if not instance_module:
|
||||
instance_module = StudentModule(
|
||||
student=user,
|
||||
@@ -218,11 +238,11 @@ def get_instance_module(user, module, student_module_cache):
|
||||
max_grade=module.max_score())
|
||||
instance_module.save()
|
||||
student_module_cache.append(instance_module)
|
||||
|
||||
|
||||
return instance_module
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def get_shared_instance_module(user, module, student_module_cache):
|
||||
"""
|
||||
Return shared_module is a StudentModule specific to all modules with the same
|
||||
@@ -232,7 +252,7 @@ def get_shared_instance_module(user, module, student_module_cache):
|
||||
if user.is_authenticated():
|
||||
# To get the shared_state_key, we need to descriptor
|
||||
descriptor = modulestore().get_item(module.location)
|
||||
|
||||
|
||||
shared_state_key = getattr(module, 'shared_state_key', None)
|
||||
if shared_state_key is not None:
|
||||
shared_module = student_module_cache.lookup(module.category,
|
||||
@@ -247,23 +267,26 @@ def get_shared_instance_module(user, module, student_module_cache):
|
||||
student_module_cache.append(shared_module)
|
||||
else:
|
||||
shared_module = None
|
||||
|
||||
|
||||
return shared_module
|
||||
else:
|
||||
return None
|
||||
|
||||
@csrf_exempt
|
||||
def xqueue_callback(request, userid, id, dispatch):
|
||||
def xqueue_callback(request, course_id, userid, id, dispatch):
|
||||
'''
|
||||
Entry point for graded results from the queueing system.
|
||||
Entry point for graded results from the queueing system.
|
||||
'''
|
||||
# Parse xqueue response
|
||||
# Test xqueue package, which we expect to be:
|
||||
# xpackage = {'xqueue_header': json.dumps({'lms_key':'secretkey',...}),
|
||||
# 'xqueue_body' : 'Message from grader}
|
||||
get = request.POST.copy()
|
||||
try:
|
||||
header = json.loads(get['xqueue_header'])
|
||||
except Exception as err:
|
||||
msg = "Error in xqueue_callback %s: Invalid return format" % err
|
||||
raise Exception(msg)
|
||||
for key in ['xqueue_header', 'xqueue_body']:
|
||||
if not get.has_key(key):
|
||||
return Http404
|
||||
header = json.loads(get['xqueue_header'])
|
||||
if not isinstance(header, dict) or not header.has_key('lms_key'):
|
||||
return Http404
|
||||
|
||||
# Retrieve target StudentModule
|
||||
user = User.objects.get(id=userid)
|
||||
@@ -273,8 +296,7 @@ def xqueue_callback(request, userid, id, dispatch):
|
||||
instance_module = get_instance_module(user, instance, student_module_cache)
|
||||
|
||||
if instance_module is None:
|
||||
log.debug("Couldn't find module '%s' for user '%s'",
|
||||
id, user)
|
||||
log.debug("Couldn't find module '%s' for user '%s'", id, user)
|
||||
raise Http404
|
||||
|
||||
oldgrade = instance_module.grade
|
||||
@@ -302,7 +324,7 @@ def xqueue_callback(request, userid, id, dispatch):
|
||||
return HttpResponse("")
|
||||
|
||||
|
||||
def modx_dispatch(request, dispatch=None, id=None):
|
||||
def modx_dispatch(request, dispatch=None, id=None, course_id=None):
|
||||
''' Generic view for extensions. This is where AJAX calls go.
|
||||
|
||||
Arguments:
|
||||
@@ -325,7 +347,7 @@ def modx_dispatch(request, dispatch=None, id=None):
|
||||
|
||||
instance_module = get_instance_module(request.user, instance, student_module_cache)
|
||||
shared_module = get_shared_instance_module(request.user, instance, student_module_cache)
|
||||
|
||||
|
||||
# Don't track state for anonymous users (who don't have student modules)
|
||||
if instance_module is not None:
|
||||
oldgrade = instance_module.grade
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import copy
|
||||
import json
|
||||
from path import path
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from pprint import pprint
|
||||
from nose import SkipTest
|
||||
from path import path
|
||||
from pprint import pprint
|
||||
|
||||
from django.contrib.auth.models import User, Group
|
||||
from django.test import TestCase
|
||||
from django.test.client import Client
|
||||
from django.conf import settings
|
||||
@@ -13,11 +16,11 @@ from django.core.urlresolvers import reverse
|
||||
from mock import patch, Mock
|
||||
from override_settings import override_settings
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from student.models import Registration
|
||||
|
||||
from xmodule.modulestore.django import modulestore
|
||||
import xmodule.modulestore.django
|
||||
|
||||
from student.models import Registration
|
||||
from courseware.courses import course_staff_group_name
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.xml_importer import import_from_xml
|
||||
|
||||
@@ -55,8 +58,22 @@ def mongo_store_config(data_dir):
|
||||
}
|
||||
}
|
||||
|
||||
def xml_store_config(data_dir):
|
||||
return {
|
||||
'default': {
|
||||
'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore',
|
||||
'OPTIONS': {
|
||||
'data_dir': data_dir,
|
||||
'default_class': 'xmodule.hidden_module.HiddenDescriptor',
|
||||
'eager': True,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
|
||||
TEST_DATA_MODULESTORE = mongo_store_config(TEST_DATA_DIR)
|
||||
TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR)
|
||||
TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR)
|
||||
|
||||
REAL_DATA_DIR = settings.GITHUB_REPO_ROOT
|
||||
REAL_DATA_MODULESTORE = mongo_store_config(REAL_DATA_DIR)
|
||||
@@ -88,6 +105,13 @@ class ActivateLoginTestCase(TestCase):
|
||||
self.assertTrue(data['success'])
|
||||
return resp
|
||||
|
||||
def logout(self):
|
||||
'''Logout, check that it worked.'''
|
||||
resp = self.client.get(reverse('logout'), {})
|
||||
# should redirect
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
return resp
|
||||
|
||||
def _create_account(self, username, email, pw):
|
||||
'''Try to create an account. No error checking'''
|
||||
resp = self.client.post('/create_account', {
|
||||
@@ -131,12 +155,35 @@ class ActivateLoginTestCase(TestCase):
|
||||
'''The setup function does all the work'''
|
||||
pass
|
||||
|
||||
def test_logout(self):
|
||||
'''Setup function does login'''
|
||||
self.logout()
|
||||
|
||||
|
||||
class PageLoader(ActivateLoginTestCase):
|
||||
''' Base class that adds a function to load all pages in a modulestore '''
|
||||
|
||||
def _enroll(self, course):
|
||||
"""Post to the enrollment view, and return the parsed json response"""
|
||||
resp = self.client.post('/change_enrollment', {
|
||||
'enrollment_action': 'enroll',
|
||||
'course_id': course.id,
|
||||
})
|
||||
return parse_json(resp)
|
||||
|
||||
def try_enroll(self, course):
|
||||
"""Try to enroll. Return bool success instead of asserting it."""
|
||||
data = self._enroll(course)
|
||||
print 'Enrollment in {} result: {}'.format(course.location.url(), data)
|
||||
return data['success']
|
||||
|
||||
def enroll(self, course):
|
||||
"""Enroll the currently logged-in user, and check that it worked."""
|
||||
data = self._enroll(course)
|
||||
self.assertTrue(data['success'])
|
||||
|
||||
def unenroll(self, course):
|
||||
"""Unenroll the currently logged-in user, and check that it worked."""
|
||||
resp = self.client.post('/change_enrollment', {
|
||||
'enrollment_action': 'enroll',
|
||||
'course_id': course.id,
|
||||
@@ -145,6 +192,7 @@ class PageLoader(ActivateLoginTestCase):
|
||||
self.assertTrue(data['success'])
|
||||
|
||||
def check_pages_load(self, course_name, data_dir, modstore):
|
||||
"""Make all locations in course load"""
|
||||
print "Checking course {0} in {1}".format(course_name, data_dir)
|
||||
import_from_xml(modstore, data_dir, [course_name])
|
||||
|
||||
@@ -177,7 +225,7 @@ class PageLoader(ActivateLoginTestCase):
|
||||
self.assertTrue(all_ok)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MODULESTORE)
|
||||
@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE)
|
||||
class TestCoursesLoadTestCase(PageLoader):
|
||||
'''Check that all pages in test courses load properly'''
|
||||
|
||||
@@ -193,7 +241,279 @@ class TestCoursesLoadTestCase(PageLoader):
|
||||
self.check_pages_load('full', TEST_DATA_DIR, modulestore())
|
||||
|
||||
|
||||
# ========= TODO: check ajax interaction here too?
|
||||
@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
|
||||
class TestViewAuth(PageLoader):
|
||||
"""Check that view authentication works properly"""
|
||||
|
||||
# NOTE: setUpClass() runs before override_settings takes effect, so
|
||||
# can't do imports there without manually hacking settings.
|
||||
|
||||
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)
|
||||
|
||||
def check_for_get_code(self, code, url):
|
||||
resp = self.client.get(url)
|
||||
# HACK: workaround the bug that returns 200 instead of 404.
|
||||
# TODO (vshnayder): once we're returning 404s, get rid of this if.
|
||||
if code != 404:
|
||||
self.assertEqual(resp.status_code, code)
|
||||
# And 'page not found' shouldn't be in the returned page
|
||||
self.assertTrue(resp.content.lower().find('page not found') == -1)
|
||||
else:
|
||||
# look for "page not found" instead of the status code
|
||||
#print resp.content
|
||||
self.assertTrue(resp.content.lower().find('page not found') != -1)
|
||||
|
||||
def test_instructor_pages(self):
|
||||
"""Make sure only instructors for the course or staff can load the instructor
|
||||
dashboard, the grade views, and student profile pages"""
|
||||
|
||||
# First, try with an enrolled student
|
||||
self.login(self.student, self.password)
|
||||
# shouldn't work before enroll
|
||||
self.check_for_get_code(302, reverse('courseware', kwargs={'course_id': self.toy.id}))
|
||||
self.enroll(self.toy)
|
||||
self.enroll(self.full)
|
||||
# should work now
|
||||
self.check_for_get_code(200, reverse('courseware', kwargs={'course_id': self.toy.id}))
|
||||
|
||||
def instructor_urls(course):
|
||||
"list of urls that only instructors/staff should be able to see"
|
||||
urls = [reverse(name, kwargs={'course_id': course.id}) for name in (
|
||||
'instructor_dashboard',
|
||||
'gradebook',
|
||||
'grade_summary',)]
|
||||
urls.append(reverse('student_profile', kwargs={'course_id': course.id,
|
||||
'student_id': user(self.student).id}))
|
||||
return urls
|
||||
|
||||
# shouldn't be able to get to the instructor pages
|
||||
for url in instructor_urls(self.toy) + instructor_urls(self.full):
|
||||
print 'checking for 404 on {}'.format(url)
|
||||
self.check_for_get_code(404, url)
|
||||
|
||||
# Make the instructor staff in the toy course
|
||||
group_name = course_staff_group_name(self.toy)
|
||||
g = Group.objects.create(name=group_name)
|
||||
g.user_set.add(user(self.instructor))
|
||||
|
||||
self.logout()
|
||||
self.login(self.instructor, self.password)
|
||||
|
||||
# Now should be able to get to the toy course, but not the full course
|
||||
for url in instructor_urls(self.toy):
|
||||
print 'checking for 200 on {}'.format(url)
|
||||
self.check_for_get_code(200, url)
|
||||
|
||||
for url in instructor_urls(self.full):
|
||||
print 'checking for 404 on {}'.format(url)
|
||||
self.check_for_get_code(404, url)
|
||||
|
||||
|
||||
# now also make the instructor staff
|
||||
u = user(self.instructor)
|
||||
u.is_staff = True
|
||||
u.save()
|
||||
|
||||
# and now should be able to load both
|
||||
for url in instructor_urls(self.toy) + instructor_urls(self.full):
|
||||
print 'checking for 200 on {}'.format(url)
|
||||
self.check_for_get_code(200, url)
|
||||
|
||||
|
||||
def run_wrapped(self, test):
|
||||
"""
|
||||
test.py turns off start dates. Enable them and DARK_LAUNCH.
|
||||
Because settings is global, be careful not to mess it up for other tests
|
||||
(Can't use override_settings because we're only changing part of the
|
||||
MITX_FEATURES dict)
|
||||
"""
|
||||
oldDSD = settings.MITX_FEATURES['DISABLE_START_DATES']
|
||||
oldDL = settings.MITX_FEATURES['DARK_LAUNCH']
|
||||
|
||||
try:
|
||||
settings.MITX_FEATURES['DISABLE_START_DATES'] = False
|
||||
settings.MITX_FEATURES['DARK_LAUNCH'] = True
|
||||
test()
|
||||
finally:
|
||||
settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD
|
||||
settings.MITX_FEATURES['DARK_LAUNCH'] = oldDL
|
||||
|
||||
|
||||
def test_dark_launch(self):
|
||||
"""Make sure that when dark launch is on, students can't access course
|
||||
pages, but instructors can"""
|
||||
self.run_wrapped(self._do_test_dark_launch)
|
||||
|
||||
def test_enrollment_period(self):
|
||||
"""Check that enrollment periods work"""
|
||||
self.run_wrapped(self._do_test_enrollment_period)
|
||||
|
||||
|
||||
def _do_test_dark_launch(self):
|
||||
"""Actually do the test, relying on settings to be right."""
|
||||
|
||||
# Make courses start in the future
|
||||
tomorrow = time.time() + 24*3600
|
||||
self.toy.start = self.toy.metadata['start'] = time.gmtime(tomorrow)
|
||||
self.full.start = self.full.metadata['start'] = time.gmtime(tomorrow)
|
||||
|
||||
self.assertFalse(self.toy.has_started())
|
||||
self.assertFalse(self.full.has_started())
|
||||
self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES'])
|
||||
self.assertTrue(settings.MITX_FEATURES['DARK_LAUNCH'])
|
||||
|
||||
def reverse_urls(names, course):
|
||||
"""Reverse a list of course urls"""
|
||||
return [reverse(name, kwargs={'course_id': course.id}) for name in names]
|
||||
|
||||
def dark_student_urls(course):
|
||||
"""
|
||||
list of urls that students should be able to see only
|
||||
after launch, but staff should see before
|
||||
"""
|
||||
urls = reverse_urls(['info', 'book', 'courseware', 'profile'], course)
|
||||
return urls
|
||||
|
||||
def light_student_urls(course):
|
||||
"""
|
||||
list of urls that students should be able to see before
|
||||
launch.
|
||||
"""
|
||||
urls = reverse_urls(['about_course'], course)
|
||||
urls.append(reverse('courses'))
|
||||
# Need separate test for change_enrollment, since it's a POST view
|
||||
#urls.append(reverse('change_enrollment'))
|
||||
|
||||
return urls
|
||||
|
||||
def instructor_urls(course):
|
||||
"""list of urls that only instructors/staff should be able to see"""
|
||||
urls = reverse_urls(['instructor_dashboard','gradebook','grade_summary'],
|
||||
course)
|
||||
urls.append(reverse('student_profile', kwargs={'course_id': course.id,
|
||||
'student_id': user(self.student).id}))
|
||||
return urls
|
||||
|
||||
def check_non_staff(course):
|
||||
"""Check that access is right for non-staff in course"""
|
||||
print '=== Checking non-staff access for {}'.format(course.id)
|
||||
for url in instructor_urls(course) + dark_student_urls(course):
|
||||
print 'checking for 404 on {}'.format(url)
|
||||
self.check_for_get_code(404, url)
|
||||
|
||||
for url in light_student_urls(course):
|
||||
print 'checking for 200 on {}'.format(url)
|
||||
self.check_for_get_code(200, url)
|
||||
|
||||
def check_staff(course):
|
||||
"""Check that access is right for staff in course"""
|
||||
print '=== Checking staff access for {}'.format(course.id)
|
||||
for url in (instructor_urls(course) +
|
||||
dark_student_urls(course) +
|
||||
light_student_urls(course)):
|
||||
print 'checking for 200 on {}'.format(url)
|
||||
self.check_for_get_code(200, url)
|
||||
|
||||
# First, try with an enrolled student
|
||||
print '=== Testing student access....'
|
||||
self.login(self.student, self.password)
|
||||
self.enroll(self.toy)
|
||||
self.enroll(self.full)
|
||||
|
||||
# shouldn't be able to get to anything except the light pages
|
||||
check_non_staff(self.toy)
|
||||
check_non_staff(self.full)
|
||||
|
||||
print '=== Testing course instructor access....'
|
||||
# Make the instructor staff in the toy course
|
||||
group_name = course_staff_group_name(self.toy)
|
||||
g = Group.objects.create(name=group_name)
|
||||
g.user_set.add(user(self.instructor))
|
||||
|
||||
self.logout()
|
||||
self.login(self.instructor, self.password)
|
||||
# Enroll in the classes---can't see courseware otherwise.
|
||||
self.enroll(self.toy)
|
||||
self.enroll(self.full)
|
||||
|
||||
# should now be able to get to everything for toy course
|
||||
check_non_staff(self.full)
|
||||
check_staff(self.toy)
|
||||
|
||||
print '=== Testing staff access....'
|
||||
# now also make the instructor staff
|
||||
u = user(self.instructor)
|
||||
u.is_staff = True
|
||||
u.save()
|
||||
|
||||
# and now should be able to load both
|
||||
check_staff(self.toy)
|
||||
check_staff(self.full)
|
||||
|
||||
def _do_test_enrollment_period(self):
|
||||
"""Actually do the test, relying on settings to be right."""
|
||||
|
||||
# Make courses start in the future
|
||||
tomorrow = time.time() + 24 * 3600
|
||||
nextday = tomorrow + 24 * 3600
|
||||
yesterday = time.time() - 24 * 3600
|
||||
|
||||
print "changing"
|
||||
# toy course's enrollment period hasn't started
|
||||
self.toy.enrollment_start = time.gmtime(tomorrow)
|
||||
self.toy.enrollment_end = time.gmtime(nextday)
|
||||
|
||||
# full course's has
|
||||
self.full.enrollment_start = time.gmtime(yesterday)
|
||||
self.full.enrollment_end = time.gmtime(tomorrow)
|
||||
|
||||
print "login"
|
||||
# First, try with an enrolled student
|
||||
print '=== Testing student access....'
|
||||
self.login(self.student, self.password)
|
||||
self.assertFalse(self.try_enroll(self.toy))
|
||||
self.assertTrue(self.try_enroll(self.full))
|
||||
|
||||
print '=== Testing course instructor access....'
|
||||
# Make the instructor staff in the toy course
|
||||
group_name = course_staff_group_name(self.toy)
|
||||
g = Group.objects.create(name=group_name)
|
||||
g.user_set.add(user(self.instructor))
|
||||
|
||||
print "logout/login"
|
||||
self.logout()
|
||||
self.login(self.instructor, self.password)
|
||||
print "Instructor should be able to enroll in toy course"
|
||||
self.assertTrue(self.try_enroll(self.toy))
|
||||
|
||||
print '=== Testing staff access....'
|
||||
# now make the instructor global staff, but not in the instructor group
|
||||
g.user_set.remove(user(self.instructor))
|
||||
u = user(self.instructor)
|
||||
u.is_staff = True
|
||||
u.save()
|
||||
|
||||
# unenroll and try again
|
||||
self.unenroll(self.toy)
|
||||
self.assertTrue(self.try_enroll(self.toy))
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=REAL_DATA_MODULESTORE)
|
||||
|
||||
@@ -27,7 +27,8 @@ from xmodule.course_module import CourseDescriptor
|
||||
from util.cache import cache, cache_if_anonymous
|
||||
from student.models import UserTestGroup, CourseEnrollment
|
||||
from courseware import grades
|
||||
from courseware.courses import check_course, get_courses_by_university
|
||||
from courseware.courses import (check_course, get_courses_by_university,
|
||||
has_staff_access_to_course_id)
|
||||
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
@@ -35,6 +36,9 @@ log = logging.getLogger("mitx.courseware")
|
||||
template_imports = {'urllib': urllib}
|
||||
|
||||
def user_groups(user):
|
||||
"""
|
||||
TODO (vshnayder): This is not used. When we have a new plan for groups, adjust appropriately.
|
||||
"""
|
||||
if not user.is_authenticated():
|
||||
return []
|
||||
|
||||
@@ -64,64 +68,6 @@ def courses(request):
|
||||
universities = get_courses_by_university(request.user)
|
||||
return render_to_response("courses.html", {'universities': universities})
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def gradebook(request, course_id):
|
||||
if 'course_admin' not in user_groups(request.user):
|
||||
raise Http404
|
||||
course = check_course(course_id)
|
||||
|
||||
student_objects = User.objects.all()[:100]
|
||||
student_info = []
|
||||
|
||||
#TODO: Only select students who are in the course
|
||||
for student in student_objects:
|
||||
student_info.append({
|
||||
'username': student.username,
|
||||
'id': student.id,
|
||||
'email': student.email,
|
||||
'grade_summary': grades.grade(student, request, course),
|
||||
'realname': UserProfile.objects.get(user=student).name
|
||||
})
|
||||
|
||||
return render_to_response('gradebook.html', {'students': student_info, 'course': course})
|
||||
|
||||
|
||||
@login_required
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def profile(request, course_id, student_id=None):
|
||||
''' User profile. Show username, location, etc, as well as grades .
|
||||
We need to allow the user to change some of these settings .'''
|
||||
course = check_course(course_id)
|
||||
|
||||
if student_id is None:
|
||||
student = request.user
|
||||
else:
|
||||
if 'course_admin' not in user_groups(request.user):
|
||||
raise Http404
|
||||
student = User.objects.get(id=int(student_id))
|
||||
|
||||
user_info = UserProfile.objects.get(user=student)
|
||||
|
||||
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, course)
|
||||
course_module = get_module(request.user, request, course.location, student_module_cache)
|
||||
|
||||
courseware_summary = grades.progress_summary(student, course_module, course.grader, student_module_cache)
|
||||
grade_summary = grades.grade(request.user, request, course, student_module_cache)
|
||||
|
||||
context = {'name': user_info.name,
|
||||
'username': student.username,
|
||||
'location': user_info.location,
|
||||
'language': user_info.language,
|
||||
'email': student.email,
|
||||
'course': course,
|
||||
'csrf': csrf(request)['csrf_token'],
|
||||
'courseware_summary' : courseware_summary,
|
||||
'grade_summary' : grade_summary
|
||||
}
|
||||
context.update()
|
||||
|
||||
return render_to_response('profile.html', context)
|
||||
|
||||
|
||||
def render_accordion(request, course, chapter, section):
|
||||
''' Draws navigation bar. Takes current position in accordion as
|
||||
@@ -129,19 +75,14 @@ def render_accordion(request, course, chapter, section):
|
||||
|
||||
If chapter and section are '' or None, renders a default accordion.
|
||||
|
||||
course, chapter, and section are the url_names.
|
||||
|
||||
Returns the html string'''
|
||||
|
||||
# grab the table of contents
|
||||
toc = toc_for_course(request.user, request, course, chapter, section)
|
||||
|
||||
active_chapter = 1
|
||||
for i in range(len(toc)):
|
||||
if toc[i]['active']:
|
||||
active_chapter = i
|
||||
|
||||
context = dict([('active_chapter', active_chapter),
|
||||
('toc', toc),
|
||||
('course_name', course.title),
|
||||
context = dict([('toc', toc),
|
||||
('course_id', course.id),
|
||||
('csrf', csrf(request)['csrf_token'])] + template_imports.items())
|
||||
return render_to_string('accordion.html', context)
|
||||
@@ -169,9 +110,10 @@ def index(request, course_id, chapter=None, section=None,
|
||||
|
||||
- HTTPresponse
|
||||
'''
|
||||
course = check_course(course_id)
|
||||
course = check_course(request.user, course_id)
|
||||
registered = registered_for_course(course, request.user)
|
||||
if not registered:
|
||||
# TODO (vshnayder): do course instructors need to be registered to see course?
|
||||
log.debug('User %s tried to view course %s but is not enrolled' % (request.user,course.location.url()))
|
||||
return redirect(reverse('about_course', args=[course.id]))
|
||||
|
||||
@@ -233,12 +175,10 @@ def jump_to(request, location):
|
||||
'''
|
||||
Show the page that contains a specific location.
|
||||
|
||||
If the location is invalid, return a 404.
|
||||
If the location is invalid or not in any class, return a 404.
|
||||
|
||||
If the location is valid, but not present in a course, ?
|
||||
|
||||
If the location is valid, but in a course the current user isn't registered for, ?
|
||||
TODO -- let the index view deal with it?
|
||||
Otherwise, delegates to the index view to figure out whether this user
|
||||
has access, and what they should see.
|
||||
'''
|
||||
# Complain if the location isn't valid
|
||||
try:
|
||||
@@ -254,17 +194,17 @@ def jump_to(request, location):
|
||||
except NoPathToItem:
|
||||
raise Http404("This location is not in any class: {0}".format(location))
|
||||
|
||||
# Rely on index to do all error handling
|
||||
# Rely on index to do all error handling and access control.
|
||||
return index(request, course_id, chapter, section, position)
|
||||
|
||||
@ensure_csrf_cookie
|
||||
def course_info(request, course_id):
|
||||
'''
|
||||
"""
|
||||
Display the course's info.html, or 404 if there is no such course.
|
||||
|
||||
Assumes the course_id is in a valid format.
|
||||
'''
|
||||
course = check_course(course_id)
|
||||
"""
|
||||
course = check_course(request.user, course_id)
|
||||
|
||||
return render_to_response('info.html', {'course': course})
|
||||
|
||||
@@ -281,7 +221,7 @@ def registered_for_course(course, user):
|
||||
@ensure_csrf_cookie
|
||||
@cache_if_anonymous
|
||||
def course_about(request, course_id):
|
||||
course = check_course(course_id, course_must_be_open=False)
|
||||
course = check_course(request.user, course_id, course_must_be_open=False)
|
||||
registered = registered_for_course(course, request.user)
|
||||
return render_to_response('portal/course_about.html', {'course': course, 'registered': registered})
|
||||
|
||||
@@ -289,7 +229,10 @@ def course_about(request, course_id):
|
||||
@ensure_csrf_cookie
|
||||
@cache_if_anonymous
|
||||
def university_profile(request, org_id):
|
||||
all_courses = sorted(modulestore().get_courses(), key=lambda course: course.number)
|
||||
"""
|
||||
Return the profile for the particular org_id. 404 if it's not valid.
|
||||
"""
|
||||
all_courses = modulestore().get_courses()
|
||||
valid_org_ids = set(c.org for c in all_courses)
|
||||
if org_id not in valid_org_ids:
|
||||
raise Http404("University Profile not found for {0}".format(org_id))
|
||||
@@ -300,3 +243,104 @@ def university_profile(request, org_id):
|
||||
template_file = "university_profile/{0}.html".format(org_id).lower()
|
||||
|
||||
return render_to_response(template_file, context)
|
||||
|
||||
|
||||
@login_required
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def profile(request, course_id, student_id=None):
|
||||
""" User profile. Show username, location, etc, as well as grades .
|
||||
We need to allow the user to change some of these settings.
|
||||
|
||||
Course staff are allowed to see the profiles of students in their class.
|
||||
"""
|
||||
course = check_course(request.user, course_id)
|
||||
|
||||
if student_id is None or student_id == request.user.id:
|
||||
# always allowed to see your own profile
|
||||
student = request.user
|
||||
else:
|
||||
# Requesting access to a different student's profile
|
||||
if not has_staff_access_to_course_id(request.user, course_id):
|
||||
raise Http404
|
||||
student = User.objects.get(id=int(student_id))
|
||||
|
||||
user_info = UserProfile.objects.get(user=student)
|
||||
|
||||
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, course)
|
||||
course_module = get_module(request.user, request, course.location, student_module_cache)
|
||||
|
||||
courseware_summary = grades.progress_summary(student, course_module, course.grader, student_module_cache)
|
||||
grade_summary = grades.grade(request.user, request, course, student_module_cache)
|
||||
|
||||
context = {'name': user_info.name,
|
||||
'username': student.username,
|
||||
'location': user_info.location,
|
||||
'language': user_info.language,
|
||||
'email': student.email,
|
||||
'course': course,
|
||||
'csrf': csrf(request)['csrf_token'],
|
||||
'courseware_summary' : courseware_summary,
|
||||
'grade_summary' : grade_summary
|
||||
}
|
||||
context.update()
|
||||
|
||||
return render_to_response('profile.html', context)
|
||||
|
||||
|
||||
|
||||
# ======== Instructor views =============================================================================
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def gradebook(request, course_id):
|
||||
"""
|
||||
Show the gradebook for this course:
|
||||
- only displayed to course staff
|
||||
- shows students who are enrolled.
|
||||
"""
|
||||
if not has_staff_access_to_course_id(request.user, course_id):
|
||||
raise Http404
|
||||
|
||||
course = check_course(request.user, course_id)
|
||||
|
||||
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
|
||||
|
||||
# TODO (vshnayder): implement pagination.
|
||||
enrolled_students = enrolled_students[:1000] # HACK!
|
||||
|
||||
student_info = [{'username': student.username,
|
||||
'id': student.id,
|
||||
'email': student.email,
|
||||
'grade_summary': grades.grade(student, request, course),
|
||||
'realname': UserProfile.objects.get(user=student).name
|
||||
}
|
||||
for student in enrolled_students]
|
||||
|
||||
return render_to_response('gradebook.html', {'students': student_info,
|
||||
'course': course, 'course_id': course_id})
|
||||
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def grade_summary(request, course_id):
|
||||
"""Display the grade summary for a course."""
|
||||
if not has_staff_access_to_course_id(request.user, course_id):
|
||||
raise Http404
|
||||
|
||||
course = check_course(request.user, course_id)
|
||||
|
||||
# For now, just a static page
|
||||
context = {'course': course }
|
||||
return render_to_response('grade_summary.html', context)
|
||||
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
def instructor_dashboard(request, course_id):
|
||||
"""Display the instructor dashboard for a course."""
|
||||
if not has_staff_access_to_course_id(request.user, course_id):
|
||||
raise Http404
|
||||
|
||||
course = check_course(request.user, course_id)
|
||||
|
||||
# For now, just a static page
|
||||
context = {'course': course }
|
||||
return render_to_response('instructor_dashboard.html', context)
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ def update_template_dictionary(dictionary, request=None, course=None, article=No
|
||||
|
||||
|
||||
def view(request, article_path, course_id=None):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
(article, err) = get_article(request, article_path, course)
|
||||
if err:
|
||||
@@ -67,7 +67,7 @@ def view(request, article_path, course_id=None):
|
||||
|
||||
|
||||
def view_revision(request, revision_number, article_path, course_id=None):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
(article, err) = get_article(request, article_path, course)
|
||||
if err:
|
||||
@@ -91,7 +91,7 @@ def view_revision(request, revision_number, article_path, course_id=None):
|
||||
|
||||
|
||||
def root_redirect(request, course_id=None):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
#TODO: Add a default namespace to settings.
|
||||
namespace = course.wiki_namespace if course else "edX"
|
||||
@@ -109,7 +109,7 @@ def root_redirect(request, course_id=None):
|
||||
|
||||
|
||||
def create(request, article_path, course_id=None):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
article_path_components = article_path.split('/')
|
||||
|
||||
@@ -170,7 +170,7 @@ def create(request, article_path, course_id=None):
|
||||
|
||||
|
||||
def edit(request, article_path, course_id=None):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
(article, err) = get_article(request, article_path, course)
|
||||
if err:
|
||||
@@ -218,7 +218,7 @@ def edit(request, article_path, course_id=None):
|
||||
|
||||
|
||||
def history(request, article_path, page=1, course_id=None):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
(article, err) = get_article(request, article_path, course)
|
||||
if err:
|
||||
@@ -300,7 +300,7 @@ def history(request, article_path, page=1, course_id=None):
|
||||
|
||||
|
||||
def revision_feed(request, page=1, namespace=None, course_id=None):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
page_size = 10
|
||||
|
||||
@@ -333,7 +333,7 @@ def revision_feed(request, page=1, namespace=None, course_id=None):
|
||||
|
||||
|
||||
def search_articles(request, namespace=None, course_id=None):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
# blampe: We should check for the presence of other popular django search
|
||||
# apps and use those if possible. Only fall back on this as a last resort.
|
||||
@@ -382,7 +382,7 @@ def search_articles(request, namespace=None, course_id=None):
|
||||
|
||||
|
||||
def search_add_related(request, course_id, slug, namespace):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
(article, err) = get_article(request, slug, namespace if namespace else course_id)
|
||||
if err:
|
||||
@@ -415,7 +415,7 @@ def search_add_related(request, course_id, slug, namespace):
|
||||
|
||||
|
||||
def add_related(request, course_id, slug, namespace):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
(article, err) = get_article(request, slug, namespace if namespace else course_id)
|
||||
if err:
|
||||
@@ -439,7 +439,7 @@ def add_related(request, course_id, slug, namespace):
|
||||
|
||||
|
||||
def remove_related(request, course_id, namespace, slug, related_id):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
(article, err) = get_article(request, slug, namespace if namespace else course_id)
|
||||
|
||||
@@ -462,7 +462,7 @@ def remove_related(request, course_id, namespace, slug, related_id):
|
||||
|
||||
|
||||
def random_article(request, course_id=None):
|
||||
course = check_course(course_id, course_required=False)
|
||||
course = check_course(request.user, course_id, course_required=False)
|
||||
|
||||
from random import randint
|
||||
num_arts = Article.objects.count()
|
||||
|
||||
@@ -6,7 +6,7 @@ from lxml import etree
|
||||
|
||||
@login_required
|
||||
def index(request, course_id, page=0):
|
||||
course = check_course(course_id)
|
||||
course = check_course(request.user, course_id)
|
||||
raw_table_of_contents = open('lms/templates/book_toc.xml', 'r') # TODO: This will need to come from S3
|
||||
table_of_contents = etree.parse(raw_table_of_contents).getroot()
|
||||
return render_to_response('staticbook.html', {'page': int(page), 'course': course, 'table_of_contents': table_of_contents})
|
||||
|
||||
@@ -48,6 +48,7 @@ MITX_FEATURES = {
|
||||
## DO NOT SET TO True IN THIS FILE
|
||||
## Doing so will cause all courses to be released on production
|
||||
'DISABLE_START_DATES': False, # When True, all courses will be active, regardless of start date
|
||||
'DARK_LAUNCH': False, # When True, courses will be active for staff only
|
||||
|
||||
'ENABLE_TEXTBOOK' : True,
|
||||
'ENABLE_DISCUSSION' : True,
|
||||
@@ -55,6 +56,8 @@ MITX_FEATURES = {
|
||||
'ENABLE_SQL_TRACKING_LOGS': False,
|
||||
'ENABLE_LMS_MIGRATION': False,
|
||||
|
||||
'DISABLE_LOGIN_BUTTON': False, # used in systems where login is automatic, eg MIT SSL
|
||||
|
||||
# extrernal access methods
|
||||
'ACCESS_REQUIRE_STAFF_FOR_COURSE': False,
|
||||
'AUTH_USE_OPENID': False,
|
||||
@@ -86,6 +89,18 @@ sys.path.append(PROJECT_ROOT / 'lib')
|
||||
sys.path.append(COMMON_ROOT / 'djangoapps')
|
||||
sys.path.append(COMMON_ROOT / 'lib')
|
||||
|
||||
# For Node.js
|
||||
|
||||
system_node_path = os.environ.get("NODE_PATH", None)
|
||||
if system_node_path is None:
|
||||
system_node_path = "/usr/local/lib/node_modules"
|
||||
|
||||
node_paths = [COMMON_ROOT / "static/js/vendor",
|
||||
COMMON_ROOT / "static/coffee/src",
|
||||
system_node_path
|
||||
]
|
||||
NODE_PATH = ':'.join(node_paths)
|
||||
|
||||
################################## MITXWEB #####################################
|
||||
# This is where we stick our compiled template files. Most of the app uses Mako
|
||||
# templates
|
||||
@@ -347,7 +362,7 @@ PIPELINE_ALWAYS_RECOMPILE = ['sass/application.scss', 'sass/ie.scss', 'sass/cour
|
||||
courseware_only_js = [
|
||||
PROJECT_ROOT / 'static/coffee/src/' + pth + '.coffee'
|
||||
for pth
|
||||
in ['courseware', 'histogram', 'navigation', 'time', ]
|
||||
in ['courseware', 'histogram', 'navigation', 'time']
|
||||
]
|
||||
courseware_only_js += [
|
||||
pth for pth
|
||||
@@ -475,6 +490,7 @@ if os.path.isdir(DATA_DIR):
|
||||
js_timestamp = os.stat(js_dir / new_filename).st_mtime
|
||||
if coffee_timestamp <= js_timestamp:
|
||||
continue
|
||||
os.system("rm %s" % (js_dir / new_filename))
|
||||
os.system("coffee -c %s" % (js_dir / filename))
|
||||
|
||||
PIPELINE_COMPILERS = [
|
||||
|
||||
@@ -62,6 +62,7 @@ SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
|
||||
################################ LMS Migration #################################
|
||||
MITX_FEATURES['ENABLE_LMS_MIGRATION'] = True
|
||||
MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = False # require that user be in the staff_* group to be able to enroll
|
||||
MITX_FEATURES['USE_XQA_SERVER'] = 'http://xqa:server@content-qa.mitx.mit.edu/xqa'
|
||||
|
||||
LMS_MIGRATION_ALLOWED_IPS = ['127.0.0.1']
|
||||
|
||||
|
||||
@@ -10,14 +10,27 @@ sessions. Assumes structure:
|
||||
from .common import *
|
||||
from .logsettings import get_logger_config
|
||||
from .dev import *
|
||||
import socket
|
||||
|
||||
WIKI_ENABLED = False
|
||||
MITX_FEATURES['ENABLE_TEXTBOOK'] = False
|
||||
MITX_FEATURES['ENABLE_DISCUSSION'] = False
|
||||
MITX_FEATURES['ACCESS_REQUIRE_STAFF_FOR_COURSE'] = True # require that user be in the staff_* group to be able to enroll
|
||||
|
||||
myhost = socket.gethostname()
|
||||
if ('edxvm' in myhost) or ('ocw' in myhost):
|
||||
MITX_FEATURES['DISABLE_LOGIN_BUTTON'] = True # auto-login with MIT certificate
|
||||
MITX_FEATURES['USE_XQA_SERVER'] = 'https://qisx.mit.edu/xqa' # needs to be ssl or browser blocks it
|
||||
|
||||
if ('domU' in myhost):
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
MITX_FEATURES['REROUTE_ACTIVATION_EMAIL'] = 'ichuang@mitx.mit.edu' # nonempty string = address for all activation emails
|
||||
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') # django 1.4 for nginx ssl proxy
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
# disable django debug toolbars
|
||||
|
||||
INSTALLED_APPS = tuple([ app for app in INSTALLED_APPS if not app.startswith('debug_toolbar') ])
|
||||
MIDDLEWARE_CLASSES = tuple([ mcl for mcl in MIDDLEWARE_CLASSES if not mcl.startswith('debug_toolbar') ])
|
||||
TEMPLATE_LOADERS = tuple([ app for app in TEMPLATE_LOADERS if not app.startswith('askbot') ])
|
||||
|
||||
0
lms/envs/devgroups/__init__.py
Normal file
43
lms/envs/devgroups/courses.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from ..dev import *
|
||||
|
||||
CLASSES_TO_DBS = {
|
||||
'BerkeleyX/CS169.1x/2012_Fall' : "cs169.db",
|
||||
'BerkeleyX/CS188.1x/2012_Fall' : "cs188_1.db",
|
||||
'HarvardX/CS50x/2012' : "cs50.db",
|
||||
'HarvardX/PH207x/2012_Fall' : "ph207.db",
|
||||
'MITx/3.091x/2012_Fall' : "3091.db",
|
||||
'MITx/6.002x/2012_Fall' : "6002.db",
|
||||
'MITx/6.00x/2012_Fall' : "600.db",
|
||||
}
|
||||
|
||||
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
|
||||
'LOCATION': '127.0.0.1:11211',
|
||||
'KEY_FUNCTION': 'util.memcache.safe_key',
|
||||
},
|
||||
'general': {
|
||||
'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
|
||||
'LOCATION': '127.0.0.1:11211',
|
||||
'KEY_PREFIX' : 'general',
|
||||
'VERSION' : 5,
|
||||
'KEY_FUNCTION': 'util.memcache.safe_key',
|
||||
}
|
||||
}
|
||||
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
|
||||
|
||||
|
||||
def path_for_db(db_name):
|
||||
return ENV_ROOT / "db" / db_name
|
||||
|
||||
def course_db_for(course_id):
|
||||
db_name = CLASSES_TO_DBS[course_id]
|
||||
return {
|
||||
'default' : {
|
||||
'ENGINE' : 'django.db.backends.sqlite3',
|
||||
'NAME' : path_for_db(db_name)
|
||||
}
|
||||
}
|
||||
|
||||
3
lms/envs/devgroups/h_cs50.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .courses import *
|
||||
|
||||
DATABASES = course_db_for('HarvardX/CS50x/2012')
|
||||
3
lms/envs/devgroups/m_6002.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .courses import *
|
||||
|
||||
DATABASES = course_db_for('MITx/6.002x/2012_Fall')
|
||||
13
lms/envs/devgroups/portal.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Note that for this to work at all, you must have memcached running (or you won't
|
||||
get shared sessions)
|
||||
"""
|
||||
from courses import *
|
||||
|
||||
# Move this to a shared file later:
|
||||
for class_id, db_name in CLASSES_TO_DBS.items():
|
||||
DATABASES[class_id] = {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': path_for_db(db_name)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ SOUTH_TESTS_MIGRATE = False # To disable migrations and use syncdb instead
|
||||
# Nose Test Runner
|
||||
INSTALLED_APPS += ('django_nose',)
|
||||
NOSE_ARGS = ['--cover-erase', '--with-xunit', '--with-xcoverage', '--cover-html',
|
||||
# '-v', '--pdb', # When really stuck, uncomment to start debugger on error
|
||||
'--cover-inclusive', '--cover-html-dir',
|
||||
os.environ.get('NOSE_COVER_HTML_DIR', 'cover_html')]
|
||||
for app in os.listdir(PROJECT_ROOT / 'djangoapps'):
|
||||
@@ -66,6 +67,17 @@ DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': PROJECT_ROOT / "db" / "mitx.db",
|
||||
},
|
||||
|
||||
# The following are for testing purposes...
|
||||
'edX/toy/2012_Fall': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ENV_ROOT / "db" / "course1.db",
|
||||
},
|
||||
|
||||
'edx/full/6.002_Spring_2012': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ENV_ROOT / "db" / "course2.db",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -189,6 +189,10 @@ p.mini {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
img.help-tooltip {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
p img, h1 img, h2 img, h3 img, h4 img, td img {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -259,7 +263,7 @@ tfoot td {
|
||||
color: #666;
|
||||
padding: 2px 5px;
|
||||
font-size: 11px;
|
||||
background: #e1e1e1 url(../img/admin/nav-bg.gif) top left repeat-x;
|
||||
background: #e1e1e1 url(../img/nav-bg.gif) top left repeat-x;
|
||||
border-left: 1px solid #ddd;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
@@ -305,25 +309,84 @@ tr.alt {
|
||||
|
||||
/* SORTABLE TABLES */
|
||||
|
||||
thead th {
|
||||
padding: 2px 5px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
thead th a:link, thead th a:visited {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
thead th.sorted {
|
||||
background: #c5c5c5 url(../img/nav-bg-selected.gif) top left repeat-x;
|
||||
}
|
||||
|
||||
table thead th .text span {
|
||||
padding: 2px 5px;
|
||||
display:block;
|
||||
}
|
||||
|
||||
table thead th .text a {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
table thead th.sorted {
|
||||
background-position: bottom left !important;
|
||||
table thead th.sortable:hover {
|
||||
background: white url(../img/nav-bg-reverse.gif) 0 -5px repeat-x;
|
||||
}
|
||||
|
||||
table thead th.sorted a {
|
||||
padding-right: 13px;
|
||||
thead th.sorted a.sortremove {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
table thead th.ascending a {
|
||||
background: url(../img/admin/arrow-up.gif) right .4em no-repeat;
|
||||
table thead th.sorted:hover a.sortremove {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
table thead th.descending a {
|
||||
background: url(../img/admin/arrow-down.gif) right .4em no-repeat;
|
||||
table thead th.sorted .sortoptions {
|
||||
display: block;
|
||||
padding: 4px 5px 0 5px;
|
||||
float: right;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
table thead th.sorted .sortpriority {
|
||||
font-size: .8em;
|
||||
min-width: 12px;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table thead th.sorted .sortoptions a {
|
||||
width: 14px;
|
||||
height: 12px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
table thead th.sorted .sortoptions a.sortremove {
|
||||
background: url(../img/sorting-icons.gif) -4px -5px no-repeat;
|
||||
}
|
||||
|
||||
table thead th.sorted .sortoptions a.sortremove:hover {
|
||||
background: url(../img/sorting-icons.gif) -4px -27px no-repeat;
|
||||
}
|
||||
|
||||
table thead th.sorted .sortoptions a.ascending {
|
||||
background: url(../img/sorting-icons.gif) -5px -50px no-repeat;
|
||||
}
|
||||
|
||||
table thead th.sorted .sortoptions a.ascending:hover {
|
||||
background: url(../img/sorting-icons.gif) -5px -72px no-repeat;
|
||||
}
|
||||
|
||||
table thead th.sorted .sortoptions a.descending {
|
||||
background: url(../img/sorting-icons.gif) -5px -94px no-repeat;
|
||||
}
|
||||
|
||||
table thead th.sorted .sortoptions a.descending:hover {
|
||||
background: url(../img/sorting-icons.gif) -5px -115px no-repeat;
|
||||
}
|
||||
|
||||
/* ORDERABLE TABLES */
|
||||
@@ -334,7 +397,7 @@ table.orderable tbody tr td:hover {
|
||||
|
||||
table.orderable tbody tr td:first-child {
|
||||
padding-left: 14px;
|
||||
background-image: url(../img/admin/nav-bg-grabber.gif);
|
||||
background-image: url(../img/nav-bg-grabber.gif);
|
||||
background-repeat: repeat-y;
|
||||
}
|
||||
|
||||
@@ -364,7 +427,7 @@ input[type=text], input[type=password], textarea, select, .vTextField {
|
||||
/* FORM BUTTONS */
|
||||
|
||||
.button, input[type=submit], input[type=button], .submit-row input {
|
||||
background: white url(../img/admin/nav-bg.gif) bottom repeat-x;
|
||||
background: white url(../img/nav-bg.gif) bottom repeat-x;
|
||||
padding: 3px 5px;
|
||||
color: black;
|
||||
border: 1px solid #bbb;
|
||||
@@ -372,31 +435,31 @@ input[type=text], input[type=password], textarea, select, .vTextField {
|
||||
}
|
||||
|
||||
.button:active, input[type=submit]:active, input[type=button]:active {
|
||||
background-image: url(../img/admin/nav-bg-reverse.gif);
|
||||
background-image: url(../img/nav-bg-reverse.gif);
|
||||
background-position: top;
|
||||
}
|
||||
|
||||
.button[disabled], input[type=submit][disabled], input[type=button][disabled] {
|
||||
background-image: url(../img/admin/nav-bg.gif);
|
||||
background-image: url(../img/nav-bg.gif);
|
||||
background-position: bottom;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.button.default, input[type=submit].default, .submit-row input.default {
|
||||
border: 2px solid #5b80b2;
|
||||
background: #7CA0C7 url(../img/admin/default-bg.gif) bottom repeat-x;
|
||||
background: #7CA0C7 url(../img/default-bg.gif) bottom repeat-x;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.button.default:active, input[type=submit].default:active {
|
||||
background-image: url(../img/admin/default-bg-reverse.gif);
|
||||
background-image: url(../img/default-bg-reverse.gif);
|
||||
background-position: top;
|
||||
}
|
||||
|
||||
.button[disabled].default, input[type=submit][disabled].default, input[type=button][disabled].default {
|
||||
background-image: url(../img/admin/default-bg.gif);
|
||||
background-image: url(../img/default-bg.gif);
|
||||
background-position: bottom;
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -433,7 +496,7 @@ input[type=text], input[type=password], textarea, select, .vTextField {
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
background: #7CA0C7 url(../img/admin/default-bg.gif) top left repeat-x;
|
||||
background: #7CA0C7 url(../img/default-bg.gif) top left repeat-x;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -455,15 +518,15 @@ ul.messagelist li {
|
||||
margin: 0 0 3px 0;
|
||||
border-bottom: 1px solid #ddd;
|
||||
color: #666;
|
||||
background: #ffc url(../img/admin/icon_success.gif) 5px .3em no-repeat;
|
||||
background: #ffc url(../img/icon_success.gif) 5px .3em no-repeat;
|
||||
}
|
||||
|
||||
ul.messagelist li.warning{
|
||||
background-image: url(../img/admin/icon_alert.gif);
|
||||
background-image: url(../img/icon_alert.gif);
|
||||
}
|
||||
|
||||
ul.messagelist li.error{
|
||||
background-image: url(../img/admin/icon_error.gif);
|
||||
background-image: url(../img/icon_error.gif);
|
||||
}
|
||||
|
||||
.errornote {
|
||||
@@ -473,7 +536,7 @@ ul.messagelist li.error{
|
||||
margin: 0 0 3px 0;
|
||||
border: 1px solid red;
|
||||
color: red;
|
||||
background: #ffc url(../img/admin/icon_error.gif) 5px .3em no-repeat;
|
||||
background: #ffc url(../img/icon_error.gif) 5px .3em no-repeat;
|
||||
}
|
||||
|
||||
ul.errorlist {
|
||||
@@ -488,7 +551,7 @@ ul.errorlist {
|
||||
margin: 0 0 3px 0;
|
||||
border: 1px solid red;
|
||||
color: white;
|
||||
background: red url(../img/admin/icon_alert.gif) 5px .3em no-repeat;
|
||||
background: red url(../img/icon_alert.gif) 5px .3em no-repeat;
|
||||
}
|
||||
|
||||
.errorlist li a {
|
||||
@@ -524,7 +587,7 @@ div.system-message p.system-message-title {
|
||||
padding: 4px 5px 4px 25px;
|
||||
margin: 0;
|
||||
color: red;
|
||||
background: #ffc url(../img/admin/icon_error.gif) 5px .3em no-repeat;
|
||||
background: #ffc url(../img/icon_error.gif) 5px .3em no-repeat;
|
||||
}
|
||||
|
||||
.description {
|
||||
@@ -535,7 +598,7 @@ div.system-message p.system-message-title {
|
||||
/* BREADCRUMBS */
|
||||
|
||||
div.breadcrumbs {
|
||||
background: white url(../img/admin/nav-bg-reverse.gif) 0 -10px repeat-x;
|
||||
background: white url(../img/nav-bg-reverse.gif) 0 -10px repeat-x;
|
||||
padding: 2px 8px 3px 8px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
@@ -548,17 +611,17 @@ div.breadcrumbs {
|
||||
|
||||
.addlink {
|
||||
padding-left: 12px;
|
||||
background: url(../img/admin/icon_addlink.gif) 0 .2em no-repeat;
|
||||
background: url(../img/icon_addlink.gif) 0 .2em no-repeat;
|
||||
}
|
||||
|
||||
.changelink {
|
||||
padding-left: 12px;
|
||||
background: url(../img/admin/icon_changelink.gif) 0 .2em no-repeat;
|
||||
background: url(../img/icon_changelink.gif) 0 .2em no-repeat;
|
||||
}
|
||||
|
||||
.deletelink {
|
||||
padding-left: 12px;
|
||||
background: url(../img/admin/icon_deletelink.gif) 0 .25em no-repeat;
|
||||
background: url(../img/icon_deletelink.gif) 0 .25em no-repeat;
|
||||
}
|
||||
|
||||
a.deletelink:link, a.deletelink:visited {
|
||||
@@ -593,14 +656,14 @@ a.deletelink:hover {
|
||||
.object-tools li {
|
||||
display: block;
|
||||
float: left;
|
||||
background: url(../img/admin/tool-left.gif) 0 0 no-repeat;
|
||||
background: url(../img/tool-left.gif) 0 0 no-repeat;
|
||||
padding: 0 0 0 8px;
|
||||
margin-left: 2px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.object-tools li:hover {
|
||||
background: url(../img/admin/tool-left_over.gif) 0 0 no-repeat;
|
||||
background: url(../img/tool-left_over.gif) 0 0 no-repeat;
|
||||
}
|
||||
|
||||
.object-tools a:link, .object-tools a:visited {
|
||||
@@ -609,29 +672,29 @@ a.deletelink:hover {
|
||||
color: white;
|
||||
padding: .1em 14px .1em 8px;
|
||||
height: 14px;
|
||||
background: #999 url(../img/admin/tool-right.gif) 100% 0 no-repeat;
|
||||
background: #999 url(../img/tool-right.gif) 100% 0 no-repeat;
|
||||
}
|
||||
|
||||
.object-tools a:hover, .object-tools li:hover a {
|
||||
background: #5b80b2 url(../img/admin/tool-right_over.gif) 100% 0 no-repeat;
|
||||
background: #5b80b2 url(../img/tool-right_over.gif) 100% 0 no-repeat;
|
||||
}
|
||||
|
||||
.object-tools a.viewsitelink, .object-tools a.golink {
|
||||
background: #999 url(../img/admin/tooltag-arrowright.gif) top right no-repeat;
|
||||
background: #999 url(../img/tooltag-arrowright.gif) top right no-repeat;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.object-tools a.viewsitelink:hover, .object-tools a.golink:hover {
|
||||
background: #5b80b2 url(../img/admin/tooltag-arrowright_over.gif) top right no-repeat;
|
||||
background: #5b80b2 url(../img/tooltag-arrowright_over.gif) top right no-repeat;
|
||||
}
|
||||
|
||||
.object-tools a.addlink {
|
||||
background: #999 url(../img/admin/tooltag-add.gif) top right no-repeat;
|
||||
background: #999 url(../img/tooltag-add.gif) top right no-repeat;
|
||||
padding-right: 28px;
|
||||
}
|
||||
|
||||
.object-tools a.addlink:hover {
|
||||
background: #5b80b2 url(../img/admin/tooltag-add_over.gif) top right no-repeat;
|
||||
background: #5b80b2 url(../img/tooltag-add_over.gif) top right no-repeat;
|
||||
}
|
||||
|
||||
/* OBJECT HISTORY */
|
||||
@@ -766,7 +829,7 @@ table#change-history tbody th {
|
||||
}
|
||||
|
||||
#content-related .module h2 {
|
||||
background: #eee url(../img/admin/nav-bg.gif) bottom left repeat-x;
|
||||
background: #eee url(../img/nav-bg.gif) bottom left repeat-x;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||