Merge remote-tracking branch 'origin/master' into arjun/javascript_response
Conflicts: common/lib/xmodule/xmodule/js/src/capa/display.coffee common/lib/xmodule/xmodule/tests/__init__.py common/lib/xmodule/xmodule/x_module.py lms/djangoapps/courseware/module_render.py
2
askbot
@@ -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())
|
||||
|
||||
@@ -83,7 +83,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
|
||||
'django.core.context_processors.request',
|
||||
'django.core.context_processors.static',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.core.context_processors.auth', # this is required for admin
|
||||
'django.contrib.auth.context_processors.auth', # this is required for admin
|
||||
'django.core.context_processors.csrf', # necessary for csrf protection
|
||||
)
|
||||
|
||||
@@ -121,6 +121,7 @@ MIDDLEWARE_CLASSES = (
|
||||
)
|
||||
|
||||
############################ SIGNAL HANDLERS ################################
|
||||
# This is imported to register the exception signal handling that logs exceptions
|
||||
import monitoring.exceptions # noqa
|
||||
|
||||
############################ DJANGO_BUILTINS ################################
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
from django.conf import settings
|
||||
from django.conf.urls.defaults import patterns, include, url
|
||||
from django.conf.urls import patterns, include, url
|
||||
|
||||
import django.contrib.auth.views
|
||||
|
||||
|
||||
@@ -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 a dead link--don't kill everything.
|
||||
url = "file_not_found"
|
||||
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']
|
||||
return replicate_model(User.save, user_obj, user_obj.id)
|
||||
|
||||
@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:
|
||||
# If the user exists in the Course DB, update the appropriate fields and
|
||||
# save it back out to the Course DB.
|
||||
course_user = User.objects.using(course_db_name).get(id=portal_user.id)
|
||||
for field in USER_FIELDS_TO_COPY:
|
||||
setattr(course_user, field, getattr(portal_user, field))
|
||||
|
||||
mark_handled(course_user)
|
||||
log.debug("User {0} found in Course DB, replicating fields to {1}"
|
||||
.format(course_user, course_db_name))
|
||||
course_user.save(using=course_db_name) # Just being explicit.
|
||||
|
||||
except User.DoesNotExist:
|
||||
# Otherwise, just make a straight copy to the Course DB.
|
||||
mark_handled(portal_user)
|
||||
log.debug("User {0} not found in Course DB, creating copy in {1}"
|
||||
.format(portal_user, course_db_name))
|
||||
portal_user.save(using=course_db_name)
|
||||
|
||||
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
|
||||
|
||||
mark_handled(instance)
|
||||
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))
|
||||
|
||||
for db_name in course_db_names:
|
||||
model_method(instance, using=db_name)
|
||||
|
||||
######### 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')
|
||||
|
||||
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 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,178 @@ when you run "manage.py test".
|
||||
|
||||
Replace this with more appropriate tests for your application.
|
||||
"""
|
||||
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'
|
||||
|
||||
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
|
||||
))
|
||||
|
||||
if hasattr(portal_user, 'seen_response_count'):
|
||||
# Since it's the first copy over of User data, we should have all of it
|
||||
self.assertEqual(portal_user.seen_response_count,
|
||||
course_user.seen_response_count)
|
||||
|
||||
# But if we replicate again, the user already exists in the Course DB,
|
||||
# so it shouldn't update the seen_response_count (which is Askbot
|
||||
# controlled).
|
||||
# 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).
|
||||
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, 10)
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -94,8 +94,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 +128,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,15 +159,19 @@ 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 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.'}))
|
||||
@@ -177,14 +182,14 @@ 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):
|
||||
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}
|
||||
@@ -264,6 +269,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 +278,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 +402,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 +421,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 +455,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 +544,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}))
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ def expect_json(view_function):
|
||||
if request.META['CONTENT_TYPE'] == "application/json":
|
||||
cloned_request = copy.copy(request)
|
||||
cloned_request.POST = cloned_request.POST.copy()
|
||||
cloned_request.POST.update(json.loads(request.raw_post_data))
|
||||
cloned_request.POST.update(json.loads(request.body))
|
||||
return view_function(cloned_request, *args, **kwargs)
|
||||
else:
|
||||
return view_function(request, *args, **kwargs)
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponseServerError
|
||||
|
||||
log = logging.getLogger("mitx")
|
||||
|
||||
|
||||
class ExceptionLoggingMiddleware(object):
|
||||
"""Just here to log unchecked exceptions that go all the way up the Django
|
||||
stack"""
|
||||
|
||||
if not settings.TEMPLATE_DEBUG:
|
||||
def process_exception(self, request, exception):
|
||||
log.exception(exception)
|
||||
return HttpResponseServerError("Server Error - Please try again later.")
|
||||
@@ -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():
|
||||
@@ -90,19 +90,27 @@ def add_histogram(get_html, module):
|
||||
|
||||
# TODO (ichuang): Remove after fall 2012 LMS migration done
|
||||
if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'):
|
||||
[filepath, filename] = module.definition.get('filename','')
|
||||
[filepath, filename] = module.definition.get('filename', ['', None])
|
||||
osfs = module.system.filestore
|
||||
if filename is not None and osfs.exists(filename):
|
||||
filepath = filename # if original, unmangled filename exists then use it (github doesn't like symlinks)
|
||||
# if original, unmangled filename exists then use it (github
|
||||
# doesn't like symlinks)
|
||||
filepath = filename
|
||||
data_dir = osfs.root_path.rsplit('/')[-1]
|
||||
edit_link = "https://github.com/MITx/%s/tree/master/%s" % (data_dir,filepath)
|
||||
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()}
|
||||
|
||||
@@ -203,8 +203,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 +229,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 +295,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 +306,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 +393,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
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ def extract_choices(element):
|
||||
raise Exception("[courseware.capa.inputtypes.extract_choices] \
|
||||
Expected a <choice> tag; got %s instead"
|
||||
% choice.tag)
|
||||
choice_text = ''.join([etree.tostring(x) for x in choice])
|
||||
choice_text = ''.join([x.text for x in choice])
|
||||
|
||||
choices.append((choice.get("name"), choice_text))
|
||||
|
||||
@@ -336,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)
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
@@ -359,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)
|
||||
|
||||
@@ -369,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:
|
||||
|
||||
@@ -990,6 +990,12 @@ class CodeResponse(LoncapaResponse):
|
||||
'''
|
||||
Grade student code using an external queueing server, called 'xqueue'
|
||||
|
||||
Expects 'xqueue' dict in ModuleSystem with the following keys:
|
||||
system.xqueue = { 'interface': XqueueInterface object,
|
||||
'callback_url': Per-StudentModule callback URL where results are posted (string),
|
||||
'default_queuename': Default queuename to submit request (string)
|
||||
}
|
||||
|
||||
External requests are only submitted for student submission grading
|
||||
(i.e. and not for getting reference answers)
|
||||
'''
|
||||
@@ -999,10 +1005,27 @@ 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')
|
||||
self._parse_externalresponse_xml()
|
||||
|
||||
def _parse_externalresponse_xml(self):
|
||||
'''
|
||||
VS[compat]: Suppport for old ExternalResponse XML format. When successful, sets:
|
||||
self.code
|
||||
self.tests
|
||||
self.answer
|
||||
self.initial_display
|
||||
'''
|
||||
answer = self.xml.find('answer')
|
||||
|
||||
if answer is not None:
|
||||
answer_src = answer.get('src')
|
||||
if answer_src is not None:
|
||||
@@ -1016,7 +1039,7 @@ class CodeResponse(LoncapaResponse):
|
||||
msg += "\nSee XML source line %s" % getattr(self.xml, 'sourceline', '<unavailable>')
|
||||
raise LoncapaProblemError(msg)
|
||||
|
||||
self.tests = xml.get('tests')
|
||||
self.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
|
||||
@@ -1063,15 +1086,16 @@ class CodeResponse(LoncapaResponse):
|
||||
'edX_cmd': 'get_score',
|
||||
'edX_tests': self.tests,
|
||||
'processor': self.code,
|
||||
'edX_student_response': unicode(submission), # unicode on File object returns its filename
|
||||
}
|
||||
|
||||
# Submit request
|
||||
if hasattr(submission, 'read'): # Test for whether submission is a file
|
||||
# Submit request. When successful, 'msg' is the prior length of the queue
|
||||
if is_file(submission):
|
||||
contents.update({'edX_student_response': submission.name})
|
||||
(error, msg) = qinterface.send_to_queue(header=xheader,
|
||||
body=json.dumps(contents),
|
||||
file_to_upload=submission)
|
||||
else:
|
||||
contents.update({'edX_student_response': submission})
|
||||
(error, msg) = qinterface.send_to_queue(header=xheader,
|
||||
body=json.dumps(contents))
|
||||
|
||||
@@ -1080,33 +1104,31 @@ 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, score, 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 = 'incorrect'
|
||||
if correct:
|
||||
correctness = 'correct'
|
||||
|
||||
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
|
||||
oldcmap.set(self.answer_id, 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))
|
||||
|
||||
@@ -1119,6 +1141,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'])
|
||||
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -39,5 +39,18 @@ def convert_files_to_filenames(answers):
|
||||
'''
|
||||
new_answers = dict()
|
||||
for answer_id in answers.keys():
|
||||
new_answers[answer_id] = unicode(answers[answer_id])
|
||||
if is_file(answers[answer_id]):
|
||||
new_answers[answer_id] = answers[answer_id].name
|
||||
else:
|
||||
new_answers[answer_id] = answers[answer_id]
|
||||
return new_answers
|
||||
|
||||
def is_file(file_to_test):
|
||||
'''
|
||||
Duck typing to check if 'file_to_test' is a File object
|
||||
'''
|
||||
is_file = True
|
||||
for method in ['read', 'name']:
|
||||
if not hasattr(file_to_test, method):
|
||||
is_file = False
|
||||
return is_file
|
||||
|
||||
@@ -67,7 +67,6 @@ class XqueueInterface:
|
||||
self.url = url
|
||||
self.auth = auth
|
||||
self.session = requests.session()
|
||||
self._login()
|
||||
|
||||
def send_to_queue(self, header, body, file_to_upload=None):
|
||||
'''
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -103,7 +104,9 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
|
||||
experiment = xml_object.get('experiment')
|
||||
|
||||
if experiment is None:
|
||||
raise InvalidDefinitionError("ABTests must specify an experiment. Not found in:\n{xml}".format(xml=etree.tostring(xml_object, pretty_print=True)))
|
||||
raise InvalidDefinitionError(
|
||||
"ABTests must specify an experiment. Not found in:\n{xml}"
|
||||
.format(xml=etree.tostring(xml_object, pretty_print=True)))
|
||||
|
||||
definition = {
|
||||
'data': {
|
||||
@@ -127,7 +130,9 @@ class ABTestDescriptor(RawDescriptor, XmlDescriptor):
|
||||
definition['data']['group_content'][name] = child_content_urls
|
||||
definition['children'].extend(child_content_urls)
|
||||
|
||||
default_portion = 1 - sum(portion for (name, portion) in definition['data']['group_portions'].items())
|
||||
default_portion = 1 - sum(
|
||||
portion for (name, portion) in definition['data']['group_portions'].items())
|
||||
|
||||
if default_portion < 0:
|
||||
raise InvalidDefinitionError("ABTest portions must add up to less than or equal to 1")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -119,9 +119,9 @@ class CapaModule(XModule):
|
||||
if self.show_answer == "":
|
||||
self.show_answer = "closed"
|
||||
|
||||
if instance_state != None:
|
||||
if instance_state is not None:
|
||||
instance_state = json.loads(instance_state)
|
||||
if instance_state != None and 'attempts' in instance_state:
|
||||
if instance_state is not None and 'attempts' in instance_state:
|
||||
self.attempts = instance_state['attempts']
|
||||
|
||||
self.name = only_one(dom2.xpath('/problem/@name'))
|
||||
@@ -130,16 +130,18 @@ class CapaModule(XModule):
|
||||
if weight_string:
|
||||
self.weight = float(weight_string)
|
||||
else:
|
||||
self.weight = 1
|
||||
self.weight = None
|
||||
|
||||
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
|
||||
@@ -238,7 +240,7 @@ class CapaModule(XModule):
|
||||
content = {'name': self.metadata['display_name'],
|
||||
'html': html,
|
||||
'weight': self.weight,
|
||||
}
|
||||
}
|
||||
|
||||
# We using strings as truthy values, because the terminology of the
|
||||
# check button is context-specific.
|
||||
@@ -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)
|
||||
|
||||
@@ -563,6 +565,14 @@ 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?
|
||||
metadata_attributes = RawDescriptor.metadata_attributes + ('type', 'points')
|
||||
|
||||
# VS[compat]
|
||||
# TODO (cpennington): Delete this method once all fall 2012 course are being
|
||||
# edited in the cms
|
||||
@@ -572,8 +582,3 @@ class CapaDescriptor(RawDescriptor):
|
||||
'problems/' + path[8:],
|
||||
path[8:],
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def split_to_file(cls, xml_object):
|
||||
'''Problems always written in their own files'''
|
||||
return True
|
||||
|
||||
@@ -3,6 +3,7 @@ import time
|
||||
import dateutil.parser
|
||||
import logging
|
||||
|
||||
from xmodule.util.decorators import lazyproperty
|
||||
from xmodule.graders import load_grading_policy
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.seq_module import SequenceDescriptor, SequenceModule
|
||||
@@ -12,13 +13,9 @@ log = logging.getLogger(__name__)
|
||||
|
||||
class CourseDescriptor(SequenceDescriptor):
|
||||
module_class = SequenceModule
|
||||
metadata_attributes = SequenceDescriptor.metadata_attributes + ('org', 'course')
|
||||
|
||||
def __init__(self, system, definition=None, **kwargs):
|
||||
super(CourseDescriptor, self).__init__(system, definition, **kwargs)
|
||||
|
||||
self._grader = None
|
||||
self._grade_cutoffs = None
|
||||
|
||||
msg = None
|
||||
try:
|
||||
@@ -39,34 +36,84 @@ class CourseDescriptor(SequenceDescriptor):
|
||||
|
||||
def has_started(self):
|
||||
return time.gmtime() > self.start
|
||||
|
||||
|
||||
@property
|
||||
def grader(self):
|
||||
self.__load_grading_policy()
|
||||
return self._grader
|
||||
|
||||
return self.__grading_policy['GRADER']
|
||||
|
||||
@property
|
||||
def grade_cutoffs(self):
|
||||
self.__load_grading_policy()
|
||||
return self._grade_cutoffs
|
||||
|
||||
|
||||
def __load_grading_policy(self):
|
||||
if not self._grader or not self._grade_cutoffs:
|
||||
policy_string = ""
|
||||
|
||||
try:
|
||||
with self.system.resources_fs.open("grading_policy.json") as grading_policy_file:
|
||||
policy_string = grading_policy_file.read()
|
||||
except (IOError, ResourceNotFoundError):
|
||||
log.warning("Unable to load course settings file from grading_policy.json in course " + self.id)
|
||||
|
||||
grading_policy = load_grading_policy(policy_string)
|
||||
|
||||
self._grader = grading_policy['GRADER']
|
||||
self._grade_cutoffs = grading_policy['GRADE_CUTOFFS']
|
||||
|
||||
|
||||
return self.__grading_policy['GRADE_CUTOFFS']
|
||||
|
||||
@lazyproperty
|
||||
def __grading_policy(self):
|
||||
policy_string = ""
|
||||
|
||||
try:
|
||||
with self.system.resources_fs.open("grading_policy.json") as grading_policy_file:
|
||||
policy_string = grading_policy_file.read()
|
||||
except (IOError, ResourceNotFoundError):
|
||||
log.warning("Unable to load course settings file from grading_policy.json in course " + self.id)
|
||||
|
||||
grading_policy = load_grading_policy(policy_string)
|
||||
|
||||
return grading_policy
|
||||
|
||||
|
||||
@lazyproperty
|
||||
def grading_context(self):
|
||||
"""
|
||||
This returns a dictionary with keys necessary for quickly grading
|
||||
a student. They are used by grades.grade()
|
||||
|
||||
The grading context has two keys:
|
||||
graded_sections - This contains the sections that are graded, as
|
||||
well as all possible children modules that can affect the
|
||||
grading. This allows some sections to be skipped if the student
|
||||
hasn't seen any part of it.
|
||||
|
||||
The format is a dictionary keyed by section-type. The values are
|
||||
arrays of dictionaries containing
|
||||
"section_descriptor" : The section descriptor
|
||||
"xmoduledescriptors" : An array of xmoduledescriptors that
|
||||
could possibly be in the section, for any student
|
||||
|
||||
all_descriptors - This contains a list of all xmodules that can
|
||||
effect grading a student. This is used to efficiently fetch
|
||||
all the xmodule state for a StudentModuleCache without walking
|
||||
the descriptor tree again.
|
||||
|
||||
|
||||
"""
|
||||
|
||||
all_descriptors = []
|
||||
graded_sections = {}
|
||||
|
||||
def yield_descriptor_descendents(module_descriptor):
|
||||
for child in module_descriptor.get_children():
|
||||
yield child
|
||||
for module_descriptor in yield_descriptor_descendents(child):
|
||||
yield module_descriptor
|
||||
|
||||
for c in self.get_children():
|
||||
sections = []
|
||||
for s in c.get_children():
|
||||
if s.metadata.get('graded', False):
|
||||
xmoduledescriptors = list(yield_descriptor_descendents(s))
|
||||
|
||||
# The xmoduledescriptors included here are only the ones that have scores.
|
||||
section_description = { 'section_descriptor' : s, 'xmoduledescriptors' : filter(lambda child: child.has_score, xmoduledescriptors) }
|
||||
|
||||
section_format = s.metadata.get('format', "")
|
||||
graded_sections[ section_format ] = graded_sections.get( section_format, [] ) + [section_description]
|
||||
|
||||
all_descriptors.extend(xmoduledescriptors)
|
||||
all_descriptors.append(s)
|
||||
|
||||
return { 'graded_sections' : graded_sections,
|
||||
'all_descriptors' : all_descriptors,}
|
||||
|
||||
|
||||
@staticmethod
|
||||
def id_to_location(course_id):
|
||||
'''Convert the given course_id (org/course/name) to a location object.
|
||||
|
||||
@@ -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();
|
||||
@@ -134,6 +146,15 @@ div {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
&.processing, &.ui-icon-check {
|
||||
@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;
|
||||
|
||||
@@ -16,7 +16,6 @@ div.video {
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
padding-bottom: 56.25%;
|
||||
padding-top: 30px;
|
||||
position: relative;
|
||||
|
||||
object, iframe {
|
||||
@@ -207,7 +206,7 @@ div.video {
|
||||
h3 {
|
||||
color: #999;
|
||||
float: left;
|
||||
font-size: 12px;
|
||||
font-size: em(14);
|
||||
font-weight: normal;
|
||||
letter-spacing: 1px;
|
||||
padding: 0 lh(.25) 0 lh(.5);
|
||||
@@ -221,6 +220,7 @@ div.video {
|
||||
margin-bottom: 0;
|
||||
padding: 0 lh(.5) 0 0;
|
||||
line-height: 46px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover, &:active, &:focus {
|
||||
@@ -462,7 +462,8 @@ div.video {
|
||||
}
|
||||
|
||||
ol.subtitles {
|
||||
width: 0px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import sys
|
||||
import hashlib
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import sys
|
||||
|
||||
from pkg_resources import resource_string
|
||||
from lxml import etree
|
||||
@@ -24,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.
|
||||
@@ -35,7 +46,8 @@ class ErrorDescriptor(EditingDescriptor):
|
||||
error_msg='Error not available'):
|
||||
'''Create an instance of this descriptor from the supplied data.
|
||||
|
||||
Does not try to parse the data--just stores it.
|
||||
Does not require that xml_data be parseable--just stores it and exports
|
||||
as-is if not.
|
||||
|
||||
Takes an extra, optional, parameter--the error that caused an
|
||||
issue. (should be a string, or convert usefully into one).
|
||||
@@ -45,6 +57,13 @@ class ErrorDescriptor(EditingDescriptor):
|
||||
definition = {'data': inner}
|
||||
inner['error_msg'] = str(error_msg)
|
||||
|
||||
# Pick a unique url_name -- the sha1 hash of the xml_data.
|
||||
# NOTE: We could try to pull out the url_name of the errored descriptor,
|
||||
# but url_names aren't guaranteed to be unique between descriptor types,
|
||||
# and ErrorDescriptor can wrap any type. When the wrapped module is fixed,
|
||||
# it will be written out with the original url_name.
|
||||
url_name = hashlib.sha1(xml_data).hexdigest()
|
||||
|
||||
try:
|
||||
# If this is already an error tag, don't want to re-wrap it.
|
||||
xml_obj = etree.fromstring(xml_data)
|
||||
@@ -63,8 +82,9 @@ class ErrorDescriptor(EditingDescriptor):
|
||||
inner['contents'] = xml_data
|
||||
# TODO (vshnayder): Do we need a unique slug here? Just pick a random
|
||||
# 64-bit num?
|
||||
location = ['i4x', org, course, 'error', 'slug']
|
||||
metadata = {} # stays in the xml_data
|
||||
location = ['i4x', org, course, 'error', url_name]
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -13,13 +13,14 @@ from .html_checker import check_html
|
||||
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
|
||||
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']
|
||||
|
||||
@@ -36,18 +37,19 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
# are being edited in the cms
|
||||
@classmethod
|
||||
def backcompat_paths(cls, path):
|
||||
origpath = path
|
||||
if path.endswith('.html.xml'):
|
||||
path = path[:-9] + '.html' #backcompat--look for html instead of xml
|
||||
path = path[:-9] + '.html' # backcompat--look for html instead of xml
|
||||
candidates = []
|
||||
while os.sep in path:
|
||||
candidates.append(path)
|
||||
_, _, path = path.partition(os.sep)
|
||||
|
||||
# also look for .html versions instead of .xml
|
||||
if origpath.endswith('.xml'):
|
||||
candidates.append(origpath[:-4] + '.html')
|
||||
return candidates
|
||||
nc = []
|
||||
for candidate in candidates:
|
||||
if candidate.endswith('.xml'):
|
||||
nc.append(candidate[:-4] + '.html')
|
||||
return candidates + nc
|
||||
|
||||
# NOTE: html descriptors are special. We do not want to parse and
|
||||
# export them ourselves, because that can break things (e.g. lxml
|
||||
@@ -69,7 +71,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
if filename is None:
|
||||
definition_xml = copy.deepcopy(xml_object)
|
||||
cls.clean_metadata_from_xml(definition_xml)
|
||||
return {'data' : stringify_children(definition_xml)}
|
||||
return {'data': stringify_children(definition_xml)}
|
||||
else:
|
||||
filepath = cls._format_filepath(xml_object.tag, filename)
|
||||
|
||||
@@ -80,7 +82,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
# online and has imported all current (fall 2012) courses from xml
|
||||
if not system.resources_fs.exists(filepath):
|
||||
candidates = cls.backcompat_paths(filepath)
|
||||
#log.debug("candidates = {0}".format(candidates))
|
||||
log.debug("candidates = {0}".format(candidates))
|
||||
for candidate in candidates:
|
||||
if system.resources_fs.exists(candidate):
|
||||
filepath = candidate
|
||||
@@ -95,7 +97,7 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
log.warning(msg)
|
||||
system.error_tracker("Warning: " + msg)
|
||||
|
||||
definition = {'data' : html}
|
||||
definition = {'data': html}
|
||||
|
||||
# TODO (ichuang): remove this after migration
|
||||
# for Fall 2012 LMS migration: keep filename (and unmangled filename)
|
||||
@@ -109,17 +111,11 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
# add more info and re-raise
|
||||
raise Exception(msg), None, sys.exc_info()[2]
|
||||
|
||||
@classmethod
|
||||
def split_to_file(cls, xml_object):
|
||||
'''Never include inline html'''
|
||||
return True
|
||||
|
||||
|
||||
# TODO (vshnayder): make export put things in the right places.
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
'''If the contents are valid xml, write them to filename.xml. Otherwise,
|
||||
write just the <html filename=""> tag to filename.xml, and the html
|
||||
write just <html filename="" [meta-attrs="..."]> to filename.xml, and the html
|
||||
string to filename.html.
|
||||
'''
|
||||
try:
|
||||
@@ -138,4 +134,3 @@ class HtmlDescriptor(XmlDescriptor, EditingDescriptor):
|
||||
elt = etree.Element('html')
|
||||
elt.set("filename", self.url_name)
|
||||
return elt
|
||||
|
||||
|
||||
@@ -13,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
|
||||
@@ -27,18 +30,40 @@ 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) =>
|
||||
@el.html(response.html)
|
||||
@executeProblemScripts()
|
||||
@bind()
|
||||
|
||||
@queued_items = @$(".xqueue")
|
||||
if @queued_items.length == 0
|
||||
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)
|
||||
@executeProblemScripts () =>
|
||||
@setupInputTypes()
|
||||
@bind()
|
||||
@queueing()
|
||||
else
|
||||
$.postWithPrefix "#{@url}/problem_get", (response) =>
|
||||
@el.html(response.html)
|
||||
@executeProblemScripts () =>
|
||||
@setupInputTypes()
|
||||
@bind()
|
||||
@queueing()
|
||||
|
||||
# TODO add hooks for problem types here by inspecting response.html and doing
|
||||
# stuff if a div w a class is found
|
||||
|
||||
@@ -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
|
||||
@@ -188,26 +189,37 @@ class XMLModuleStore(ModuleStoreBase):
|
||||
course_file = StringIO(clean_out_mako_templating(course_file.read()))
|
||||
|
||||
course_data = etree.parse(course_file).getroot()
|
||||
|
||||
org = course_data.get('org')
|
||||
|
||||
if org is None:
|
||||
log.error("No 'org' attribute set for course in {dir}. "
|
||||
msg = ("No 'org' attribute set for course in {dir}. "
|
||||
"Using default 'edx'".format(dir=course_dir))
|
||||
log.warning(msg)
|
||||
tracker(msg)
|
||||
org = 'edx'
|
||||
|
||||
course = course_data.get('course')
|
||||
|
||||
if course is None:
|
||||
log.error("No 'course' attribute set for course in {dir}."
|
||||
msg = ("No 'course' attribute set for course in {dir}."
|
||||
" Using default '{default}'".format(
|
||||
dir=course_dir,
|
||||
default=course_dir
|
||||
))
|
||||
log.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):
|
||||
@@ -122,16 +124,3 @@ class SequenceDescriptor(MakoModuleDescriptor, XmlDescriptor):
|
||||
etree.fromstring(child.export_to_xml(resource_fs)))
|
||||
return xml_object
|
||||
|
||||
@classmethod
|
||||
def split_to_file(cls, xml_object):
|
||||
# Note: if we end up needing subclasses, can port this logic there.
|
||||
yes = ('chapter',)
|
||||
no = ('course',)
|
||||
|
||||
if xml_object.tag in yes:
|
||||
return True
|
||||
elif xml_object.tag in no:
|
||||
return False
|
||||
|
||||
# otherwise maybe--delegate to superclass.
|
||||
return XmlDescriptor.split_to_file(xml_object)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ import os
|
||||
import fs
|
||||
import json
|
||||
|
||||
import json
|
||||
import numpy
|
||||
|
||||
import xmodule
|
||||
import capa.calc as calc
|
||||
import capa.capa_problem as lcp
|
||||
from capa.correctmap import CorrectMap
|
||||
from capa.util import convert_files_to_filenames
|
||||
from xmodule import graders, x_module
|
||||
from xmodule.x_module import ModuleSystem
|
||||
from xmodule.graders import Score, aggregate_scores
|
||||
@@ -32,7 +34,7 @@ i4xs = ModuleSystem(
|
||||
user=Mock(),
|
||||
filestore=fs.osfs.OSFS(os.path.dirname(os.path.realpath(__file__))+"/test_files"),
|
||||
debug=True,
|
||||
xqueue=None,
|
||||
xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue'},
|
||||
is_staff=False,
|
||||
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules")
|
||||
)
|
||||
@@ -280,7 +282,6 @@ class StringResponseWithHintTest(unittest.TestCase):
|
||||
class CodeResponseTest(unittest.TestCase):
|
||||
'''
|
||||
Test CodeResponse
|
||||
|
||||
'''
|
||||
def test_update_score(self):
|
||||
problem_file = os.path.dirname(__file__) + "/test_files/coderesponse.xml"
|
||||
@@ -293,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,
|
||||
}
|
||||
@@ -329,7 +335,18 @@ class CodeResponseTest(unittest.TestCase):
|
||||
self.assertFalse(test_lcp.correct_map.is_queued(answer_ids[j])) # Should be dequeued, message delivered
|
||||
else:
|
||||
self.assertTrue(test_lcp.correct_map.is_queued(answer_ids[j])) # Should be queued, message undelivered
|
||||
|
||||
|
||||
def test_convert_files_to_filenames(self):
|
||||
problem_file = os.path.dirname(__file__) + "/test_files/coderesponse.xml"
|
||||
fp = open(problem_file)
|
||||
answers_with_file = {'1_2_1': 'String-based answer',
|
||||
'1_3_1': ['answer1', 'answer2', 'answer3'],
|
||||
'1_4_1': fp}
|
||||
answers_converted = convert_files_to_filenames(answers_with_file)
|
||||
self.assertEquals(answers_converted['1_2_1'], 'String-based answer')
|
||||
self.assertEquals(answers_converted['1_3_1'], ['answer1', 'answer2', 'answer3'])
|
||||
self.assertEquals(answers_converted['1_4_1'], fp.name)
|
||||
|
||||
|
||||
class ChoiceResponseTest(unittest.TestCase):
|
||||
|
||||
@@ -712,6 +729,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)
|
||||
|
||||
@@ -1,36 +1,113 @@
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from nose.tools import assert_equals
|
||||
from nose import SkipTest
|
||||
from tempfile import mkdtemp
|
||||
import unittest
|
||||
|
||||
from fs.osfs import OSFS
|
||||
from nose.tools import assert_equals, assert_true
|
||||
from path import path
|
||||
from tempfile import mkdtemp
|
||||
from shutil import copytree
|
||||
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
|
||||
# from ~/mitx_all/mitx/common/lib/xmodule/xmodule/tests/
|
||||
# to ~/mitx_all/mitx/common/test
|
||||
TEST_DIR = path(__file__).abspath().dirname()
|
||||
for i in range(4):
|
||||
TEST_DIR = TEST_DIR.dirname()
|
||||
TEST_DIR = TEST_DIR / 'test'
|
||||
|
||||
DATA_DIR = TEST_DIR / 'data'
|
||||
|
||||
|
||||
def check_export_roundtrip(data_dir):
|
||||
print "Starting import"
|
||||
initial_import = XMLModuleStore('org', 'course', data_dir, eager=True)
|
||||
initial_course = initial_import.course
|
||||
def strip_metadata(descriptor, key):
|
||||
"""
|
||||
Recursively strips tag from all children.
|
||||
"""
|
||||
print "strip {key} from {desc}".format(key=key, desc=descriptor.location.url())
|
||||
descriptor.metadata.pop(key, None)
|
||||
for d in descriptor.get_children():
|
||||
strip_metadata(d, key)
|
||||
|
||||
print "Starting export"
|
||||
export_dir = mkdtemp()
|
||||
fs = OSFS(export_dir)
|
||||
xml = initial_course.export_to_xml(fs)
|
||||
with fs.open('course.xml', 'w') as course_xml:
|
||||
course_xml.write(xml)
|
||||
|
||||
print "Starting second import"
|
||||
second_import = XMLModuleStore('org', 'course', export_dir, eager=True)
|
||||
|
||||
print "Checking key equality"
|
||||
assert_equals(initial_import.modules.keys(), second_import.modules.keys())
|
||||
|
||||
print "Checking module equality"
|
||||
for location in initial_import.modules.keys():
|
||||
print "Checking", location
|
||||
assert_equals(initial_import.modules[location], second_import.modules[location])
|
||||
def strip_filenames(descriptor):
|
||||
"""
|
||||
Recursively strips 'filename' from all children's definitions.
|
||||
"""
|
||||
print "strip filename from {desc}".format(desc=descriptor.location.url())
|
||||
descriptor.definition.pop('filename', None)
|
||||
for d in descriptor.get_children():
|
||||
strip_filenames(d)
|
||||
|
||||
|
||||
def test_toy_roundtrip():
|
||||
dir = ""
|
||||
# TODO: add paths and make this run.
|
||||
raise SkipTest()
|
||||
check_export_roundtrip(dir)
|
||||
|
||||
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(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"
|
||||
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(root_dir, eager=True, course_dirs=[course_dir])
|
||||
|
||||
courses2 = second_import.get_courses()
|
||||
self.assertEquals(len(courses2), 1)
|
||||
exported_course = courses2[0]
|
||||
|
||||
print "Checking course equality"
|
||||
# HACK: data_dir metadata tags break equality because they
|
||||
# aren't real metadata, and depend on paths. Remove them.
|
||||
strip_metadata(initial_course, 'data_dir')
|
||||
strip_metadata(exported_course, 'data_dir')
|
||||
|
||||
# HACK: filenames change when changing file formats
|
||||
# during imports from old-style courses. Ignore them.
|
||||
strip_filenames(initial_course)
|
||||
strip_filenames(exported_course)
|
||||
|
||||
self.assertEquals(initial_course, exported_course)
|
||||
|
||||
print "Checking key equality"
|
||||
self.assertEquals(sorted(initial_import.modules.keys()),
|
||||
sorted(second_import.modules.keys()))
|
||||
|
||||
print "Checking module equality"
|
||||
for location in initial_import.modules.keys():
|
||||
print "Checking", location
|
||||
if location.category == 'html':
|
||||
print ("Skipping html modules--they can't import in"
|
||||
" final form without writing files...")
|
||||
continue
|
||||
self.assertEquals(initial_import.modules[location],
|
||||
second_import.modules[location])
|
||||
|
||||
|
||||
def setUp(self):
|
||||
self.maxDiff = None
|
||||
|
||||
def test_toy_roundtrip(self):
|
||||
self.check_export_roundtrip(DATA_DIR, "toy")
|
||||
|
||||
def test_simple_roundtrip(self):
|
||||
self.check_export_roundtrip(DATA_DIR, "simple")
|
||||
|
||||
def test_full_roundtrip(self):
|
||||
self.check_export_roundtrip(DATA_DIR, "full")
|
||||
|
||||
@@ -5,10 +5,14 @@ from fs.memoryfs import MemoryFS
|
||||
from lxml import etree
|
||||
|
||||
from xmodule.x_module import XMLParsingSystem, XModuleDescriptor
|
||||
from xmodule.xml_module import is_pointer_tag
|
||||
from xmodule.errortracker import make_error_tracker
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.modulestore.xml import XMLModuleStore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
from .test_export import DATA_DIR
|
||||
|
||||
ORG = 'test_org'
|
||||
COURSE = 'test_course'
|
||||
|
||||
@@ -46,22 +50,17 @@ class DummySystem(XMLParsingSystem):
|
||||
raise Exception("Shouldn't be called")
|
||||
|
||||
|
||||
|
||||
|
||||
class ImportTestCase(unittest.TestCase):
|
||||
'''Make sure module imports work properly, including for malformed inputs'''
|
||||
|
||||
|
||||
@staticmethod
|
||||
def get_system():
|
||||
'''Get a dummy system'''
|
||||
return DummySystem()
|
||||
|
||||
def test_fallback(self):
|
||||
'''Make sure that malformed xml loads as an ErrorDescriptor.'''
|
||||
'''Check that malformed xml loads as an ErrorDescriptor.'''
|
||||
|
||||
bad_xml = '''<sequential display_name="oops"><video url="hi"></sequential>'''
|
||||
|
||||
system = self.get_system()
|
||||
|
||||
descriptor = XModuleDescriptor.load_from_xml(bad_xml, system, 'org', 'course',
|
||||
@@ -70,6 +69,22 @@ class ImportTestCase(unittest.TestCase):
|
||||
self.assertEqual(descriptor.__class__.__name__,
|
||||
'ErrorDescriptor')
|
||||
|
||||
|
||||
def test_unique_url_names(self):
|
||||
'''Check that each error gets its very own url_name'''
|
||||
bad_xml = '''<sequential display_name="oops"><video url="hi"></sequential>'''
|
||||
bad_xml2 = '''<sequential url_name="oops"><video url="hi"></sequential>'''
|
||||
system = self.get_system()
|
||||
|
||||
descriptor1 = XModuleDescriptor.load_from_xml(bad_xml, system, 'org',
|
||||
'course', None)
|
||||
|
||||
descriptor2 = XModuleDescriptor.load_from_xml(bad_xml2, system, 'org',
|
||||
'course', None)
|
||||
|
||||
self.assertNotEqual(descriptor1.location, descriptor2.location)
|
||||
|
||||
|
||||
def test_reimport(self):
|
||||
'''Make sure an already-exported error xml tag loads properly'''
|
||||
|
||||
@@ -111,30 +126,84 @@ class ImportTestCase(unittest.TestCase):
|
||||
xml_out = etree.fromstring(xml_str_out)
|
||||
self.assertEqual(xml_out.tag, 'sequential')
|
||||
|
||||
def test_metadata_inherit(self):
|
||||
"""Make sure metadata inherits properly"""
|
||||
def test_metadata_import_export(self):
|
||||
"""Two checks:
|
||||
- unknown metadata is preserved across import-export
|
||||
- inherited metadata doesn't leak to children.
|
||||
"""
|
||||
system = self.get_system()
|
||||
v = "1 hour"
|
||||
start_xml = '''<course graceperiod="{grace}" url_name="test1" display_name="myseq">
|
||||
<chapter url="hi" url_name="ch" display_name="CH">
|
||||
<html url_name="h" display_name="H">Two houses, ...</html></chapter>
|
||||
</course>'''.format(grace=v)
|
||||
v = '1 hour'
|
||||
org = 'foo'
|
||||
course = 'bbhh'
|
||||
url_name = 'test1'
|
||||
start_xml = '''
|
||||
<course org="{org}" course="{course}"
|
||||
graceperiod="{grace}" url_name="{url_name}" unicorn="purple">
|
||||
<chapter url="hi" url_name="ch" display_name="CH">
|
||||
<html url_name="h" display_name="H">Two houses, ...</html>
|
||||
</chapter>
|
||||
</course>'''.format(grace=v, org=org, course=course, url_name=url_name)
|
||||
descriptor = XModuleDescriptor.load_from_xml(start_xml, system,
|
||||
'org', 'course')
|
||||
org, course)
|
||||
|
||||
print "Errors: {0}".format(system.errorlog.errors)
|
||||
print descriptor, descriptor.metadata
|
||||
self.assertEqual(descriptor.metadata['graceperiod'], v)
|
||||
self.assertEqual(descriptor.metadata['unicorn'], 'purple')
|
||||
|
||||
# Check that the child inherits correctly
|
||||
# Check that the child inherits graceperiod correctly
|
||||
child = descriptor.get_children()[0]
|
||||
self.assertEqual(child.metadata['graceperiod'], v)
|
||||
|
||||
# Now export and see if the chapter tag has a graceperiod attribute
|
||||
# check that the child does _not_ inherit any unicorns
|
||||
self.assertTrue('unicorn' not in child.metadata)
|
||||
|
||||
# Now export and check things
|
||||
resource_fs = MemoryFS()
|
||||
exported_xml = descriptor.export_to_xml(resource_fs)
|
||||
|
||||
# Check that the exported xml is just a pointer
|
||||
print "Exported xml:", exported_xml
|
||||
root = etree.fromstring(exported_xml)
|
||||
chapter_tag = root[0]
|
||||
self.assertEqual(chapter_tag.tag, 'chapter')
|
||||
self.assertFalse('graceperiod' in chapter_tag.attrib)
|
||||
pointer = etree.fromstring(exported_xml)
|
||||
self.assertTrue(is_pointer_tag(pointer))
|
||||
# but it's a special case course pointer
|
||||
self.assertEqual(pointer.attrib['course'], course)
|
||||
self.assertEqual(pointer.attrib['org'], org)
|
||||
|
||||
# Does the course still have unicorns?
|
||||
with resource_fs.open('course/{url_name}.xml'.format(url_name=url_name)) as f:
|
||||
course_xml = etree.fromstring(f.read())
|
||||
|
||||
self.assertEqual(course_xml.attrib['unicorn'], 'purple')
|
||||
|
||||
# the course and org tags should be _only_ in the pointer
|
||||
self.assertTrue('course' not in course_xml.attrib)
|
||||
self.assertTrue('org' not in course_xml.attrib)
|
||||
|
||||
# did we successfully strip the url_name from the definition contents?
|
||||
self.assertTrue('url_name' not in course_xml.attrib)
|
||||
|
||||
# Does the chapter tag now have a graceperiod attribute?
|
||||
# hardcoded path to child
|
||||
with resource_fs.open('chapter/ch.xml') as f:
|
||||
chapter_xml = etree.fromstring(f.read())
|
||||
self.assertEqual(chapter_xml.tag, 'chapter')
|
||||
self.assertFalse('graceperiod' in chapter_xml.attrib)
|
||||
|
||||
def 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)
|
||||
|
||||
0
common/lib/xmodule/xmodule/util/__init__.py
Normal file
36
common/lib/xmodule/xmodule/util/decorators.py
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
|
||||
def lazyproperty(fn):
|
||||
"""
|
||||
Use this decorator for lazy generation of properties that
|
||||
are expensive to compute. From http://stackoverflow.com/a/3013910/86828
|
||||
|
||||
|
||||
Example:
|
||||
class Test(object):
|
||||
|
||||
@lazyproperty
|
||||
def a(self):
|
||||
print 'generating "a"'
|
||||
return range(5)
|
||||
|
||||
Interactive Session:
|
||||
>>> t = Test()
|
||||
>>> t.__dict__
|
||||
{}
|
||||
>>> t.a
|
||||
generating "a"
|
||||
[0, 1, 2, 3, 4]
|
||||
>>> t.__dict__
|
||||
{'_lazy_a': [0, 1, 2, 3, 4]}
|
||||
>>> t.a
|
||||
[0, 1, 2, 3, 4]
|
||||
"""
|
||||
|
||||
attr_name = '_lazy_' + fn.__name__
|
||||
@property
|
||||
def _lazyprop(self):
|
||||
if not hasattr(self, attr_name):
|
||||
setattr(self, attr_name, fn(self))
|
||||
return getattr(self, attr_name)
|
||||
return _lazyprop
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ from fs.errors import ResourceNotFoundError
|
||||
from functools import partial
|
||||
from lxml import etree
|
||||
from lxml.etree import XMLSyntaxError
|
||||
from pprint import pprint
|
||||
|
||||
from xmodule.modulestore import Location
|
||||
from xmodule.errortracker import exc_info_to_str
|
||||
@@ -142,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
|
||||
@@ -165,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
|
||||
@@ -188,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()
|
||||
@@ -303,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'
|
||||
@@ -390,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.
|
||||
@@ -410,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)
|
||||
|
||||
@@ -426,6 +455,7 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
|
||||
system,
|
||||
self.location,
|
||||
self.definition,
|
||||
self,
|
||||
metadata=self.metadata
|
||||
)
|
||||
|
||||
@@ -493,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,
|
||||
@@ -550,9 +580,9 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
|
||||
|
||||
if not eq:
|
||||
for attr in self.equality_attributes:
|
||||
print(getattr(self, attr, None),
|
||||
getattr(other, attr, None),
|
||||
getattr(self, attr, None) == getattr(other, attr, None))
|
||||
pprint((getattr(self, attr, None),
|
||||
getattr(other, attr, None),
|
||||
getattr(self, attr, None) == getattr(other, attr, None)))
|
||||
|
||||
return eq
|
||||
|
||||
@@ -586,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'
|
||||
@@ -643,7 +674,7 @@ class ModuleSystem(object):
|
||||
user=None,
|
||||
filestore=None,
|
||||
debug=False,
|
||||
xqueue = None,
|
||||
xqueue=None,
|
||||
is_staff=False,
|
||||
node_path=""):
|
||||
'''
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -11,22 +12,52 @@ import sys
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_AttrMapBase = namedtuple('_AttrMap', 'metadata_key to_metadata from_metadata')
|
||||
|
||||
def is_pointer_tag(xml_obj):
|
||||
"""
|
||||
Check if xml_obj is a pointer tag: <blah url_name="something" />.
|
||||
No children, one attribute named url_name.
|
||||
|
||||
Special case for course roots: the pointer is
|
||||
<course url_name="something" org="myorg" course="course">
|
||||
|
||||
xml_obj: an etree Element
|
||||
|
||||
Returns a bool.
|
||||
"""
|
||||
if xml_obj.tag != "course":
|
||||
expected_attr = set(['url_name'])
|
||||
else:
|
||||
expected_attr = set(['url_name', 'course', 'org'])
|
||||
|
||||
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')
|
||||
|
||||
class AttrMap(_AttrMapBase):
|
||||
"""
|
||||
A class that specifies a metadata_key, and two functions:
|
||||
A class that specifies two functions:
|
||||
|
||||
to_metadata: convert value from the xml representation into
|
||||
from_xml: convert value from the xml representation into
|
||||
an internal python representation
|
||||
|
||||
from_metadata: convert the internal python representation into
|
||||
to_xml: convert the internal python representation into
|
||||
the value to store in the xml.
|
||||
"""
|
||||
def __new__(_cls, metadata_key,
|
||||
to_metadata=lambda x: x,
|
||||
from_metadata=lambda x: x):
|
||||
return _AttrMapBase.__new__(_cls, metadata_key, to_metadata, from_metadata)
|
||||
def __new__(_cls, from_xml=lambda x: x,
|
||||
to_xml=lambda x: x):
|
||||
return _AttrMapBase.__new__(_cls, from_xml, to_xml)
|
||||
|
||||
|
||||
class XmlDescriptor(XModuleDescriptor):
|
||||
@@ -39,19 +70,29 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
|
||||
# The attributes will be removed from the definition xml passed
|
||||
# to definition_from_xml, and from the xml returned by definition_to_xml
|
||||
|
||||
# Note -- url_name isn't in this list because it's handled specially on
|
||||
# import and export.
|
||||
|
||||
# TODO (vshnayder): Do we need a list of metadata we actually
|
||||
# understand? And if we do, is this the place?
|
||||
# Related: What's the right behavior for clean_metadata?
|
||||
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
|
||||
'ispublic', # if True, then course is listed for all users; see
|
||||
'xqa_key', # for xqaa server access
|
||||
# VS[compat] Remove once unused.
|
||||
'name', 'slug')
|
||||
|
||||
metadata_to_strip = ('data_dir',
|
||||
# VS[compat] -- remove the below attrs once everything is in the CMS
|
||||
'course', 'org', 'url_name', 'filename')
|
||||
|
||||
# A dictionary mapping xml attribute names AttrMaps that describe how
|
||||
# to import and export them
|
||||
xml_attribute_map = {
|
||||
# type conversion: want True/False in python, "true"/"false" in xml
|
||||
'graded': AttrMap('graded',
|
||||
lambda val: val == 'true',
|
||||
'graded': AttrMap(lambda val: val == 'true',
|
||||
lambda val: str(val).lower()),
|
||||
}
|
||||
|
||||
@@ -101,12 +142,32 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
"""
|
||||
return etree.parse(file_object).getroot()
|
||||
|
||||
@classmethod
|
||||
def load_file(cls, filepath, fs, location):
|
||||
'''
|
||||
Open the specified file in fs, and call cls.file_to_xml on it,
|
||||
returning the lxml object.
|
||||
|
||||
Add details and reraise on error.
|
||||
'''
|
||||
try:
|
||||
with fs.open(filepath) as file:
|
||||
return cls.file_to_xml(file)
|
||||
except Exception as err:
|
||||
# Add info about where we are, but keep the traceback
|
||||
msg = 'Unable to load file contents at path %s for item %s: %s ' % (
|
||||
filepath, location.url(), str(err))
|
||||
raise Exception, msg, sys.exc_info()[2]
|
||||
|
||||
|
||||
@classmethod
|
||||
def load_definition(cls, xml_object, system, location):
|
||||
'''Load a descriptor definition from the specified xml_object.
|
||||
Subclasses should not need to override this except in special
|
||||
cases (e.g. html module)'''
|
||||
|
||||
# VS[compat] -- the filename tag should go away once everything is
|
||||
# converted. (note: make sure html files still work once this goes away)
|
||||
filename = xml_object.get('filename')
|
||||
if filename is None:
|
||||
definition_xml = copy.deepcopy(xml_object)
|
||||
@@ -120,25 +181,20 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
# again in the correct format. This should go away once the CMS is
|
||||
# online and has imported all current (fall 2012) courses from xml
|
||||
if not system.resources_fs.exists(filepath) and hasattr(
|
||||
cls,
|
||||
'backcompat_paths'):
|
||||
cls, 'backcompat_paths'):
|
||||
candidates = cls.backcompat_paths(filepath)
|
||||
for candidate in candidates:
|
||||
if system.resources_fs.exists(candidate):
|
||||
filepath = candidate
|
||||
break
|
||||
|
||||
try:
|
||||
with system.resources_fs.open(filepath) as file:
|
||||
definition_xml = cls.file_to_xml(file)
|
||||
except Exception:
|
||||
msg = 'Unable to load file contents at path %s for item %s' % (
|
||||
filepath, location.url())
|
||||
# Add info about where we are, but keep the traceback
|
||||
raise Exception, msg, sys.exc_info()[2]
|
||||
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)
|
||||
@@ -146,6 +202,28 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
|
||||
return definition
|
||||
|
||||
@classmethod
|
||||
def load_metadata(cls, xml_object):
|
||||
"""
|
||||
Read the metadata attributes from this xml_object.
|
||||
|
||||
Returns a dictionary {key: value}.
|
||||
"""
|
||||
metadata = {}
|
||||
for attr in xml_object.attrib:
|
||||
val = xml_object.get(attr)
|
||||
if val is not None:
|
||||
# VS[compat]. Remove after all key translations done
|
||||
attr = cls._translate(attr)
|
||||
|
||||
if attr in cls.metadata_to_strip:
|
||||
# don't load these
|
||||
continue
|
||||
|
||||
attr_map = cls.xml_attribute_map.get(attr, AttrMap())
|
||||
metadata[attr] = attr_map.from_xml(val)
|
||||
return metadata
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_xml(cls, xml_data, system, org=None, course=None):
|
||||
@@ -160,26 +238,38 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
url identifiers
|
||||
"""
|
||||
xml_object = etree.fromstring(xml_data)
|
||||
# VS[compat] -- just have the url_name lookup once translation is done
|
||||
slug = xml_object.get('url_name', xml_object.get('slug'))
|
||||
location = Location('i4x', org, course, xml_object.tag, slug)
|
||||
# VS[compat] -- just have the url_name lookup, once translation is done
|
||||
url_name = xml_object.get('url_name', xml_object.get('slug'))
|
||||
location = Location('i4x', org, course, xml_object.tag, url_name)
|
||||
|
||||
def load_metadata():
|
||||
metadata = {}
|
||||
for attr in cls.metadata_attributes:
|
||||
val = xml_object.get(attr)
|
||||
if val is not None:
|
||||
# VS[compat]. Remove after all key translations done
|
||||
attr = cls._translate(attr)
|
||||
# VS[compat] -- detect new-style each-in-a-file mode
|
||||
if is_pointer_tag(xml_object):
|
||||
# new style:
|
||||
# read the actual definition file--named using url_name
|
||||
filepath = cls._format_filepath(xml_object.tag, url_name)
|
||||
definition_xml = cls.load_file(filepath, system.resources_fs, location)
|
||||
else:
|
||||
definition_xml = xml_object # this is just a pointer, not the real definition content
|
||||
|
||||
attr_map = cls.xml_attribute_map.get(attr, AttrMap(attr))
|
||||
metadata[attr_map.metadata_key] = attr_map.to_metadata(val)
|
||||
return metadata
|
||||
definition = cls.load_definition(definition_xml, system, location) # note this removes metadata
|
||||
# VS[compat] -- make Ike's github preview links work in both old and
|
||||
# new file layouts
|
||||
if is_pointer_tag(xml_object):
|
||||
# new style -- contents actually at filepath
|
||||
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)
|
||||
|
||||
definition = cls.load_definition(xml_object, system, location)
|
||||
metadata = load_metadata()
|
||||
# VS[compat] -- just have the url_name lookup once translation is done
|
||||
slug = xml_object.get('url_name', xml_object.get('slug'))
|
||||
return cls(
|
||||
system,
|
||||
definition,
|
||||
@@ -193,19 +283,14 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
name=name,
|
||||
ext=cls.filename_extension)
|
||||
|
||||
@classmethod
|
||||
def split_to_file(cls, xml_object):
|
||||
'''
|
||||
Decide whether to write this object to a separate file or not.
|
||||
def export_to_file(self):
|
||||
"""If this returns True, write the definition of this descriptor to a separate
|
||||
file.
|
||||
|
||||
xml_object: an xml definition of an instance of cls.
|
||||
NOTE: Do not override this without a good reason. It is here specifically for customtag...
|
||||
"""
|
||||
return True
|
||||
|
||||
This default implementation will split if this has more than 7
|
||||
descendant tags.
|
||||
|
||||
Can be overridden by subclasses.
|
||||
'''
|
||||
return len(list(xml_object.iter())) > 7
|
||||
|
||||
def export_to_xml(self, resource_fs):
|
||||
"""
|
||||
@@ -227,42 +312,43 @@ class XmlDescriptor(XModuleDescriptor):
|
||||
xml_object = self.definition_to_xml(resource_fs)
|
||||
self.__class__.clean_metadata_from_xml(xml_object)
|
||||
|
||||
# Set the tag first, so it's right if writing to a file
|
||||
# Set the tag so we get the file path right
|
||||
xml_object.tag = self.category
|
||||
|
||||
# Write it to a file if necessary
|
||||
if self.split_to_file(xml_object):
|
||||
# Put this object in its own file
|
||||
def val_for_xml(attr):
|
||||
"""Get the value for this attribute that we want to store.
|
||||
(Possible format conversion through an AttrMap).
|
||||
"""
|
||||
attr_map = self.xml_attribute_map.get(attr, AttrMap())
|
||||
return attr_map.to_xml(self.own_metadata[attr])
|
||||
|
||||
# Add the non-inherited metadata
|
||||
for attr in sorted(self.own_metadata):
|
||||
# don't want e.g. data_dir
|
||||
if attr not in self.metadata_to_strip:
|
||||
xml_object.set(attr, val_for_xml(attr))
|
||||
|
||||
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 remove all of its children here
|
||||
for child in xml_object:
|
||||
xml_object.remove(child)
|
||||
# also need to remove the text of this object.
|
||||
xml_object.text = ''
|
||||
# and the tail for good measure...
|
||||
xml_object.tail = ''
|
||||
|
||||
# And return just a pointer with the category and filename.
|
||||
record_object = etree.Element(self.category)
|
||||
else:
|
||||
record_object = xml_object
|
||||
|
||||
xml_object.set('filename', self.url_name)
|
||||
record_object.set('url_name', self.url_name)
|
||||
|
||||
# Add the metadata
|
||||
xml_object.set('url_name', self.url_name)
|
||||
for attr in self.metadata_attributes:
|
||||
attr_map = self.xml_attribute_map.get(attr, AttrMap(attr))
|
||||
metadata_key = attr_map.metadata_key
|
||||
# Special case for course pointers:
|
||||
if self.category == 'course':
|
||||
# add org and course attributes on the pointer tag
|
||||
record_object.set('org', self.location.org)
|
||||
record_object.set('course', self.location.course)
|
||||
|
||||
if (metadata_key not in self.metadata or
|
||||
metadata_key in self._inherited_metadata):
|
||||
continue
|
||||
|
||||
val = attr_map.from_metadata(self.metadata[metadata_key])
|
||||
xml_object.set(attr, val)
|
||||
|
||||
# Now we just have to make it beautiful
|
||||
return etree.tostring(xml_object, pretty_print=True)
|
||||
return etree.tostring(record_object, pretty_print=True)
|
||||
|
||||
def definition_to_xml(self, resource_fs):
|
||||
"""
|
||||
|
||||
|
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 |
@@ -2,7 +2,7 @@
|
||||
<chapter name="Overview">
|
||||
<video name="Welcome" youtube="0.75:izygArpw-Qo,1.0:p2Q6BrNhdh8,1.25:1EeWXzPdhSA,1.50:rABDYkeK0x8"/>
|
||||
<videosequence format="Lecture Sequence" name="A simple sequence">
|
||||
<html id="toylab" filename="toylab"/>
|
||||
<html name="toylab" filename="toylab"/>
|
||||
<video name="S0V1: Video Resources" youtube="0.75:EuzkdzfR0i8,1.0:1bK-WdDi6Qw,1.25:0v1VzoDVUTM,1.50:Bxk_-ZJb240"/>
|
||||
</videosequence>
|
||||
<section name="Lecture 2">
|
||||
@@ -15,7 +15,7 @@
|
||||
<chapter name="Chapter 2">
|
||||
<section name="Problem Set 1">
|
||||
<sequential>
|
||||
<problem type="lecture" showanswer="attempted" rerandomize="true" title="A simple coding problem" name="Simple coding problem" filename="ps01-simple"/>
|
||||
<problem type="lecture" showanswer="attempted" rerandomize="true" display_name="A simple coding problem" name="Simple coding problem" filename="ps01-simple"/>
|
||||
</sequential>
|
||||
</section>
|
||||
<video name="Lost Video" youtube="1.0:TBvX7HzxexQ"/>
|
||||
|
||||
@@ -28,3 +28,40 @@ Check out the course data directories that you want to work with into the
|
||||
Replace `../data` with your `GITHUB_REPO_ROOT` if it's not the default value.
|
||||
|
||||
This will import all courses in your data directory into mongodb
|
||||
|
||||
## Unit tests
|
||||
|
||||
This runs all the tests (long, uses collectstatic):
|
||||
|
||||
rake test
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ def certificate_request(request):
|
||||
return return_error(survey_response['error'])
|
||||
|
||||
grade = None
|
||||
student_gradesheet = grades.grade_sheet(request.user)
|
||||
student_gradesheet = grades.grade(request.user, request, course)
|
||||
grade = student_gradesheet['grade']
|
||||
|
||||
if not grade:
|
||||
@@ -65,7 +65,7 @@ def certificate_request(request):
|
||||
else:
|
||||
#This is not a POST, we should render the page with the form
|
||||
|
||||
grade_sheet = grades.grade_sheet(request.user)
|
||||
student_gradesheet = grades.grade(request.user, request, course)
|
||||
certificate_state = certificate_state_for_student(request.user, grade_sheet['grade'])
|
||||
|
||||
if certificate_state['state'] != "requestable":
|
||||
|
||||
@@ -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,19 +43,29 @@ 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):
|
||||
"""
|
||||
This returns the snippet of html to be rendered on the course about page, given the key for the section.
|
||||
This returns the snippet of html to be rendered on the course about page,
|
||||
given the key for the section.
|
||||
|
||||
Valid keys:
|
||||
- overview
|
||||
- title
|
||||
@@ -70,18 +84,23 @@ def get_course_about_section(course, section_key):
|
||||
- more_info
|
||||
"""
|
||||
|
||||
# Many of these are stored as html files instead of some semantic markup. This can change without effecting
|
||||
# this interface when we find a good format for defining so many snippets of text/html.
|
||||
# Many of these are stored as html files instead of some semantic
|
||||
# markup. This can change without effecting this interface when we find a
|
||||
# good format for defining so many snippets of text/html.
|
||||
|
||||
# TODO: Remove number, instructors from this list
|
||||
if section_key in ['short_description', 'description', 'key_dates', 'video', 'course_staff_short', 'course_staff_extended',
|
||||
'requirements', 'syllabus', 'textbook', 'faq', 'more_info', 'number', 'instructors', 'overview',
|
||||
'effort', 'end_date', 'prerequisites']:
|
||||
if section_key in ['short_description', 'description', 'key_dates', 'video',
|
||||
'course_staff_short', 'course_staff_extended',
|
||||
'requirements', 'syllabus', 'textbook', 'faq', 'more_info',
|
||||
'number', 'instructors', 'overview',
|
||||
'effort', 'end_date', 'prerequisites']:
|
||||
try:
|
||||
with course.system.resources_fs.open(path("about") / section_key + ".html") as htmlFile:
|
||||
return replace_urls(htmlFile.read().decode('utf-8'), course.metadata['data_dir'])
|
||||
return replace_urls(htmlFile.read().decode('utf-8'),
|
||||
course.metadata['data_dir'])
|
||||
except ResourceNotFoundError:
|
||||
log.warning("Missing about section {key} in course {url}".format(key=section_key, url=course.location.url()))
|
||||
log.warning("Missing about section {key} in course {url}".format(
|
||||
key=section_key, url=course.location.url()))
|
||||
return None
|
||||
elif section_key == "title":
|
||||
return course.metadata.get('display_name', course.url_name)
|
||||
@@ -95,7 +114,9 @@ def get_course_about_section(course, section_key):
|
||||
|
||||
def get_course_info_section(course, section_key):
|
||||
"""
|
||||
This returns the snippet of html to be rendered on the course info page, given the key for the section.
|
||||
This returns the snippet of html to be rendered on the course info page,
|
||||
given the key for the section.
|
||||
|
||||
Valid keys:
|
||||
- handouts
|
||||
- guest_handouts
|
||||
@@ -103,48 +124,71 @@ def get_course_info_section(course, section_key):
|
||||
- guest_updates
|
||||
"""
|
||||
|
||||
# Many of these are stored as html files instead of some semantic markup. This can change without effecting
|
||||
# this interface when we find a good format for defining so many snippets of text/html.
|
||||
# Many of these are stored as html files instead of some semantic
|
||||
# markup. This can change without effecting this interface when we find a
|
||||
# good format for defining so many snippets of text/html.
|
||||
|
||||
if section_key in ['handouts', 'guest_handouts', 'updates', 'guest_updates']:
|
||||
try:
|
||||
with course.system.resources_fs.open(path("info") / section_key + ".html") as htmlFile:
|
||||
return replace_urls(htmlFile.read().decode('utf-8'), course.metadata['data_dir'])
|
||||
return replace_urls(htmlFile.read().decode('utf-8'),
|
||||
course.metadata['data_dir'])
|
||||
except ResourceNotFoundError:
|
||||
log.exception("Missing info section {key} in course {url}".format(key=section_key, url=course.location.url()))
|
||||
log.exception("Missing info section {key} in course {url}".format(
|
||||
key=section_key, url=course.location.url()))
|
||||
return "! Info section missing !"
|
||||
|
||||
raise KeyError("Invalid about key " + str(section_key))
|
||||
|
||||
def course_staff_group_name(course):
|
||||
'''
|
||||
course should be either a CourseDescriptor instance, or a string (the .course entry of a Location)
|
||||
course should be either a CourseDescriptor instance, or a string (the
|
||||
.course entry of a Location)
|
||||
'''
|
||||
if isinstance(course,str):
|
||||
if isinstance(course, str) or isinstance(course, unicode):
|
||||
coursename = course
|
||||
else:
|
||||
coursename = course.metadata.get('data_dir','UnknownCourseName')
|
||||
if not coursename: # Fall 2012: not all course.xml have metadata correct yet
|
||||
coursename = course.metadata.get('course','')
|
||||
# should be a CourseDescriptor, so grab its location.course:
|
||||
coursename = course.location.course
|
||||
return 'staff_%s' % coursename
|
||||
|
||||
def has_staff_access_to_course(user,course):
|
||||
def has_staff_access_to_course(user, course):
|
||||
'''
|
||||
Returns True if the given user has staff access to the course.
|
||||
This means that user is in the staff_* group, or is an overall admin.
|
||||
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.
|
||||
'''
|
||||
if user is None or (not user.is_authenticated()) or course is None:
|
||||
return False
|
||||
if user.is_staff:
|
||||
return True
|
||||
user_groups = [x[1] for x in user.groups.values_list()] # note this is the Auth group, not UserTestGroup
|
||||
|
||||
# note this is the Auth group, not UserTestGroup
|
||||
user_groups = [x[1] for x in user.groups.values_list()]
|
||||
staff_group = course_staff_group_name(course)
|
||||
log.debug('course %s user %s groups %s' % (staff_group, user, user_groups))
|
||||
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)
|
||||
@@ -154,7 +198,8 @@ def get_courses_by_university(user):
|
||||
Returns dict of lists of courses available, keyed by course.org (ie university).
|
||||
Courses are sorted by course.number.
|
||||
|
||||
if ACCESS_REQUIRE_STAFF_FOR_COURSE then list only includes those accessible to user.
|
||||
if ACCESS_REQUIRE_STAFF_FOR_COURSE then list only includes those accessible
|
||||
to user.
|
||||
'''
|
||||
# TODO: Clean up how 'error' is done.
|
||||
# filter out any courses that errored.
|
||||
@@ -163,9 +208,9 @@ def get_courses_by_university(user):
|
||||
courses = sorted(courses, key=lambda course: course.number)
|
||||
universities = defaultdict(list)
|
||||
for course in courses:
|
||||
if settings.MITX_FEATURES.get('ENABLE_LMS_MIGRATION'):
|
||||
if settings.MITX_FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'):
|
||||
if not has_access_to_course(user,course):
|
||||
continue
|
||||
universities[course.org].append(course)
|
||||
return universities
|
||||
|
||||
|
||||
|
||||
@@ -1,28 +1,142 @@
|
||||
# Compute grades using real division, with no integer truncation
|
||||
from __future__ import division
|
||||
|
||||
import random
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from models import StudentModuleCache
|
||||
from module_render import get_module, get_instance_module
|
||||
from xmodule import graders
|
||||
from xmodule.graders import Score
|
||||
from models import StudentModule
|
||||
|
||||
_log = logging.getLogger("mitx.courseware")
|
||||
log = logging.getLogger("mitx.courseware")
|
||||
|
||||
def yield_module_descendents(module):
|
||||
stack = module.get_display_items()
|
||||
|
||||
def grade_sheet(student, course, grader, student_module_cache):
|
||||
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 pulls a summary of all problems in the course. It returns a dictionary
|
||||
with two datastructures:
|
||||
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:
|
||||
|
||||
- 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.
|
||||
- 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)
|
||||
|
||||
- grade_summary is the output from the course grader. More information on
|
||||
the format is in the docstring for CourseGrader.
|
||||
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
|
||||
for section_format, sections in grading_context['graded_sections'].iteritems():
|
||||
format_scores = []
|
||||
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):
|
||||
(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.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
|
||||
|
||||
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,
|
||||
etc.
|
||||
|
||||
Arguments:
|
||||
student: A User object for the student to grade
|
||||
@@ -30,49 +144,30 @@ def grade_sheet(student, course, grader, student_module_cache):
|
||||
student_module_cache: A StudentModuleCache initialized with all
|
||||
instance_modules for the student
|
||||
"""
|
||||
totaled_scores = {}
|
||||
chapters = []
|
||||
for c in course.get_children():
|
||||
# Don't include chapters that aren't displayable (e.g. due to error)
|
||||
if c not in c.displayable_items():
|
||||
continue
|
||||
sections = []
|
||||
for s in c.get_children():
|
||||
def yield_descendents(module):
|
||||
yield module
|
||||
for child in module.get_display_items():
|
||||
for module in yield_descendents(child):
|
||||
yield module
|
||||
|
||||
# Same for sections
|
||||
if s not in s.displayable_items():
|
||||
continue
|
||||
graded = s.metadata.get('graded', False)
|
||||
scores = []
|
||||
for module in yield_descendents(s):
|
||||
for module in yield_module_descendents(s):
|
||||
(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
|
||||
|
||||
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')))
|
||||
module.metadata.get('display_name')))
|
||||
|
||||
section_total, graded_total = graders.aggregate_scores(
|
||||
scores, s.metadata.get('display_name'))
|
||||
|
||||
#Add the graded total to totaled_scores
|
||||
format = s.metadata.get('format', "")
|
||||
if format and graded_total.possible > 0:
|
||||
format_scores = totaled_scores.get(format, [])
|
||||
format_scores.append(graded_total)
|
||||
totaled_scores[format] = format_scores
|
||||
|
||||
sections.append({
|
||||
'display_name': s.display_name,
|
||||
'url_name': s.url_name,
|
||||
@@ -88,34 +183,36 @@ def grade_sheet(student, course, grader, student_module_cache):
|
||||
'url_name': c.url_name,
|
||||
'sections': sections})
|
||||
|
||||
grade_summary = grader.grade(totaled_scores)
|
||||
|
||||
return {'courseware_summary': chapters,
|
||||
'grade_summary': grade_summary}
|
||||
return chapters
|
||||
|
||||
|
||||
def get_score(user, problem, 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
|
||||
instance_module = cache.lookup(problem.category, problem.id)
|
||||
if instance_module is None:
|
||||
instance_module = StudentModule(module_type=problem.category,
|
||||
module_state_key=problem.id,
|
||||
student=user,
|
||||
state=None,
|
||||
grade=0,
|
||||
max_grade=problem.max_score(),
|
||||
done='i')
|
||||
cache.append(instance_module)
|
||||
instance_module.save()
|
||||
instance_module = get_instance_module(user, problem, student_module_cache)
|
||||
# instance_module = student_module_cache.lookup(problem.category, problem.id)
|
||||
# if instance_module is None:
|
||||
# instance_module = StudentModule(module_type=problem.category,
|
||||
# module_state_key=problem.id,
|
||||
# student=user,
|
||||
# state=None,
|
||||
# grade=0,
|
||||
# max_grade=problem.max_score(),
|
||||
# done='i')
|
||||
# cache.append(instance_module)
|
||||
# instance_module.save()
|
||||
|
||||
# If this problem is ungraded/ungradable, bail
|
||||
if instance_module.max_grade is None:
|
||||
@@ -126,8 +223,11 @@ def get_score(user, problem, cache):
|
||||
|
||||
if correct is not None and total is not None:
|
||||
#Now we re-weight the problem, if specified
|
||||
weight = getattr(problem, 'weight', 1)
|
||||
if weight != 1:
|
||||
weight = getattr(problem, 'weight', None)
|
||||
if weight is not None:
|
||||
if total == 0:
|
||||
log.exception("Cannot reweight a problem with zero weight. Problem: " + str(instance_module))
|
||||
return (correct, total)
|
||||
correct = correct * weight / total
|
||||
total = weight
|
||||
|
||||
|
||||
@@ -78,8 +78,8 @@ class Command(BaseCommand):
|
||||
|
||||
# TODO (cpennington): Get coursename in a legitimate way
|
||||
course_location = 'i4x://edx/6002xs12/course/6.002_Spring_2012'
|
||||
student_module_cache = StudentModuleCache(sample_user, modulestore().get_item(course_location))
|
||||
(course, _, _, _) = get_module(sample_user, None, course_location, student_module_cache)
|
||||
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(sample_user, modulestore().get_item(course_location))
|
||||
course = get_module(sample_user, None, course_location, student_module_cache)
|
||||
|
||||
to_run = [
|
||||
#TODO (vshnayder) : make check_rendering work (use module_render.py),
|
||||
|
||||
@@ -67,17 +67,19 @@ class StudentModuleCache(object):
|
||||
"""
|
||||
A cache of StudentModules for a specific student
|
||||
"""
|
||||
def __init__(self, user, descriptor, depth=None):
|
||||
def __init__(self, user, descriptors):
|
||||
'''
|
||||
Find any StudentModule objects that are needed by any child modules of the
|
||||
supplied descriptor. Avoids making multiple queries to the database
|
||||
|
||||
descriptor: An XModuleDescriptor
|
||||
depth is the number of levels of descendent modules to load StudentModules for, in addition to
|
||||
the supplied descriptor. If depth is None, load all descendent StudentModules
|
||||
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
|
||||
descriptors: An array of XModuleDescriptors.
|
||||
'''
|
||||
if user.is_authenticated():
|
||||
module_ids = self._get_module_state_keys(descriptor, depth)
|
||||
module_ids = self._get_module_state_keys(descriptors)
|
||||
|
||||
# This works around a limitation in sqlite3 on the number of parameters
|
||||
# that can be put into a single query
|
||||
@@ -91,27 +93,53 @@ class StudentModuleCache(object):
|
||||
|
||||
else:
|
||||
self.cache = []
|
||||
|
||||
def _get_module_state_keys(self, descriptor, depth):
|
||||
'''
|
||||
Get a list of the state_keys needed for StudentModules
|
||||
required for this module descriptor
|
||||
|
||||
|
||||
|
||||
@classmethod
|
||||
def cache_for_descriptor_descendents(cls, user, descriptor, depth=None, descriptor_filter=lambda descriptor: True):
|
||||
"""
|
||||
descriptor: An XModuleDescriptor
|
||||
depth is the number of levels of descendent modules to load StudentModules for, in addition to
|
||||
the supplied descriptor. If depth is None, load all descendent StudentModules
|
||||
descriptor_filter is a function that accepts a descriptor and return wether the StudentModule
|
||||
should be cached
|
||||
"""
|
||||
|
||||
def get_child_descriptors(descriptor, depth, descriptor_filter):
|
||||
if descriptor_filter(descriptor):
|
||||
descriptors = [descriptor]
|
||||
else:
|
||||
descriptors = []
|
||||
|
||||
if depth is None or depth > 0:
|
||||
new_depth = depth - 1 if depth is not None else depth
|
||||
|
||||
for child in descriptor.get_children():
|
||||
descriptors.extend(get_child_descriptors(child, new_depth, descriptor_filter))
|
||||
|
||||
return descriptors
|
||||
|
||||
|
||||
descriptors = get_child_descriptors(descriptor, depth, descriptor_filter)
|
||||
|
||||
return StudentModuleCache(user, descriptors)
|
||||
|
||||
def _get_module_state_keys(self, descriptors):
|
||||
'''
|
||||
keys = [descriptor.location.url()]
|
||||
|
||||
shared_state_key = getattr(descriptor, 'shared_state_key', None)
|
||||
if shared_state_key is not None:
|
||||
keys.append(shared_state_key)
|
||||
|
||||
if depth is None or depth > 0:
|
||||
new_depth = depth - 1 if depth is not None else depth
|
||||
|
||||
for child in descriptor.get_children():
|
||||
keys.extend(self._get_module_state_keys(child, new_depth))
|
||||
Get a list of the state_keys needed for StudentModules
|
||||
required for this module descriptor
|
||||
|
||||
descriptor_filter is a function that accepts a descriptor and return wether the StudentModule
|
||||
should be cached
|
||||
'''
|
||||
keys = []
|
||||
for descriptor in descriptors:
|
||||
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:
|
||||
keys.append(shared_state_key)
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
@@ -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,21 +48,22 @@ 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(user, course, depth=2)
|
||||
(course, _, _, _) = get_module(user, request, course.location, student_module_cache)
|
||||
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(user, course, depth=2)
|
||||
course = get_module(user, request, course.location, student_module_cache)
|
||||
|
||||
chapters = list()
|
||||
for chapter in course.get_display_items():
|
||||
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
|
||||
|
||||
|
||||
@@ -121,37 +124,48 @@ def get_module(user, request, location, student_module_cache, position=None):
|
||||
- position : extra information from URL for user-specified
|
||||
position within module
|
||||
|
||||
Returns:
|
||||
- a tuple (xmodule instance, instance_module, shared_module, module category).
|
||||
instance_module is a StudentModule specific to this module for this student,
|
||||
or None if this is an anonymous user
|
||||
shared_module is a StudentModule specific to all modules with the same
|
||||
'shared_state_key' attribute, or None if the module does not elect to
|
||||
share state
|
||||
Returns: xmodule instance
|
||||
|
||||
'''
|
||||
descriptor = modulestore().get_item(location)
|
||||
|
||||
instance_module = student_module_cache.lookup(descriptor.category,
|
||||
#TODO Only check the cache if this module can possibly have state
|
||||
instance_module = None
|
||||
shared_module = None
|
||||
if user.is_authenticated():
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
|
||||
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.
|
||||
@@ -163,9 +177,8 @@ def get_module(user, request, location, student_module_cache, position=None):
|
||||
'default_queuename': xqueue_default_queuename.replace(' ','_') }
|
||||
|
||||
def _get_module(location):
|
||||
(module, _, _, _) = get_module(user, request, location,
|
||||
return get_module(user, request, location,
|
||||
student_module_cache, position)
|
||||
return module
|
||||
|
||||
# TODO (cpennington): When modules are shared between courses, the static
|
||||
# prefix is going to have to be specific to the module, not the directory
|
||||
@@ -182,71 +195,108 @@ 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)
|
||||
|
||||
# If StudentModule for this instance wasn't already in the database,
|
||||
# and this isn't a guest user, create it.
|
||||
return module
|
||||
|
||||
def get_instance_module(user, module, student_module_cache):
|
||||
"""
|
||||
Returns instance_module is a StudentModule specific to this module for this student,
|
||||
or None if this is an anonymous user
|
||||
"""
|
||||
if user.is_authenticated():
|
||||
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,
|
||||
module_type=descriptor.category,
|
||||
module_type=module.category,
|
||||
module_state_key=module.id,
|
||||
state=module.get_instance_state(),
|
||||
max_grade=module.max_score())
|
||||
instance_module.save()
|
||||
# Add to cache. The caller and the system context have references
|
||||
# to it, so the change persists past the return
|
||||
student_module_cache.append(instance_module)
|
||||
if not shared_module and shared_state_key is not None:
|
||||
shared_module = StudentModule(
|
||||
student=user,
|
||||
module_type=descriptor.category,
|
||||
module_state_key=shared_state_key,
|
||||
state=module.get_shared_state())
|
||||
shared_module.save()
|
||||
student_module_cache.append(shared_module)
|
||||
|
||||
return (module, instance_module, shared_module, descriptor.category)
|
||||
return instance_module
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_shared_instance_module(user, module, student_module_cache):
|
||||
"""
|
||||
Return shared_module is a StudentModule specific to all modules with the same
|
||||
'shared_state_key' attribute, or None if the module does not elect to
|
||||
share state
|
||||
"""
|
||||
if user.is_authenticated():
|
||||
# To get the shared_state_key, we need to descriptor
|
||||
descriptor = modulestore().get_item(module.location)
|
||||
|
||||
shared_state_key = getattr(module, 'shared_state_key', None)
|
||||
if shared_state_key is not None:
|
||||
shared_module = student_module_cache.lookup(module.category,
|
||||
shared_state_key)
|
||||
if not shared_module:
|
||||
shared_module = StudentModule(
|
||||
student=user,
|
||||
module_type=descriptor.category,
|
||||
module_state_key=shared_state_key,
|
||||
state=module.get_shared_state())
|
||||
shared_module.save()
|
||||
student_module_cache.append(shared_module)
|
||||
else:
|
||||
shared_module = None
|
||||
|
||||
return shared_module
|
||||
else:
|
||||
return None
|
||||
|
||||
@csrf_exempt
|
||||
def xqueue_callback(request, userid, id, dispatch):
|
||||
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)
|
||||
|
||||
student_module_cache = StudentModuleCache(user, modulestore().get_item(id))
|
||||
instance, instance_module, shared_module, module_type = get_module(request.user, request, id, student_module_cache)
|
||||
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(user, modulestore().get_item(id))
|
||||
instance = get_module(user, request, id, student_module_cache)
|
||||
instance_module = get_instance_module(user, instance, student_module_cache)
|
||||
|
||||
if instance_module is None:
|
||||
log.debug("Couldn't find module '%s' for user '%s'",
|
||||
id, request.user)
|
||||
log.debug("Couldn't find module '%s' for user '%s'", id, user)
|
||||
raise Http404
|
||||
|
||||
oldgrade = instance_module.grade
|
||||
@@ -274,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:
|
||||
@@ -286,15 +336,17 @@ def modx_dispatch(request, dispatch=None, id=None):
|
||||
- id -- the module id. Used to look up the XModule instance
|
||||
'''
|
||||
# ''' (fix emacs broken parsing)
|
||||
|
||||
# Check for submitted files
|
||||
p = request.POST.copy()
|
||||
if request.FILES:
|
||||
for inputfile_id in request.FILES.keys():
|
||||
p[inputfile_id] = request.FILES[inputfile_id]
|
||||
|
||||
student_module_cache = StudentModuleCache(request.user, modulestore().get_item(id))
|
||||
instance, instance_module, shared_module, module_type = get_module(request.user, request, id, student_module_cache)
|
||||
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, modulestore().get_item(id))
|
||||
instance = get_module(request.user, request, id, student_module_cache)
|
||||
|
||||
instance_module = get_instance_module(request.user, instance, student_module_cache)
|
||||
shared_module = get_shared_instance_module(request.user, instance, student_module_cache)
|
||||
|
||||
# Don't track state for anonymous users (who don't have student modules)
|
||||
if instance_module is not None:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,6 +91,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,14 +141,33 @@ 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):
|
||||
"""Enroll the currently logged-in user, and check that it worked."""
|
||||
resp = self.client.post('/change_enrollment', {
|
||||
'enrollment_action': 'enroll',
|
||||
'course_id': course.id,
|
||||
})
|
||||
data = parse_json(resp)
|
||||
self.assertTrue(data['success'])
|
||||
|
||||
def check_pages_load(self, course_name, data_dir, modstore):
|
||||
print "Checking course {0} in {1}".format(course_name, data_dir)
|
||||
import_from_xml(modstore, data_dir, [course_name])
|
||||
|
||||
# enroll in the course before trying to access pages
|
||||
courses = modstore.get_courses()
|
||||
self.assertEqual(len(courses), 1)
|
||||
course = courses[0]
|
||||
self.enroll(course)
|
||||
|
||||
n = 0
|
||||
num_bad = 0
|
||||
all_ok = True
|
||||
@@ -178,7 +207,222 @@ class TestCoursesLoadTestCase(PageLoader):
|
||||
self.check_pages_load('full', TEST_DATA_DIR, modulestore())
|
||||
|
||||
|
||||
# ========= TODO: check ajax interaction here too?
|
||||
@override_settings(MODULESTORE=TEST_DATA_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):
|
||||
print "sys.path: {}".format(sys.path)
|
||||
xmodule.modulestore.django._MODULESTORES = {}
|
||||
modulestore().collection.drop()
|
||||
import_from_xml(modulestore(), TEST_DATA_DIR, ['toy'])
|
||||
import_from_xml(modulestore(), TEST_DATA_DIR, ['full'])
|
||||
courses = modulestore().get_courses()
|
||||
# get the two courses sorted out
|
||||
courses.sort(key=lambda c: c.location.course)
|
||||
[self.full, self.toy] = courses
|
||||
|
||||
# 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 test_dark_launch(self):
|
||||
"""Make sure that when dark launch is on, students can't access course
|
||||
pages, but instructors can"""
|
||||
|
||||
# test.py turns off start dates, enable them and set them correctly.
|
||||
# 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
|
||||
self._do_test_dark_launch()
|
||||
finally:
|
||||
settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD
|
||||
settings.MITX_FEATURES['DARK_LAUNCH'] = oldDL
|
||||
|
||||
|
||||
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):
|
||||
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)
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=REAL_DATA_MODULESTORE)
|
||||
|
||||
@@ -27,15 +27,18 @@ 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")
|
||||
|
||||
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 []
|
||||
|
||||
@@ -45,6 +48,8 @@ def user_groups(user):
|
||||
|
||||
# Kill caching on dev machines -- we switch groups a lot
|
||||
group_names = cache.get(key)
|
||||
if settings.DEBUG:
|
||||
group_names = None
|
||||
|
||||
if group_names is None:
|
||||
group_names = [u.name for u in UserTestGroup.objects.filter(users=user)]
|
||||
@@ -63,60 +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 = []
|
||||
|
||||
for student in student_objects:
|
||||
student_module_cache = StudentModuleCache(student, course)
|
||||
course, _, _, _ = get_module(request.user, request, course.location, student_module_cache)
|
||||
student_info.append({
|
||||
'username': student.username,
|
||||
'id': student.id,
|
||||
'email': student.email,
|
||||
'grade_info': grades.grade_sheet(student, course, student_module_cache),
|
||||
'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(request.user, course)
|
||||
course_module, _, _, _ = get_module(request.user, request, course.location, 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']
|
||||
}
|
||||
context.update(grades.grade_sheet(student, course_module, course.grader, student_module_cache))
|
||||
|
||||
return render_to_response('profile.html', context)
|
||||
|
||||
|
||||
def render_accordion(request, course, chapter, section):
|
||||
''' Draws navigation bar. Takes current position in accordion as
|
||||
@@ -124,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)
|
||||
@@ -164,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]))
|
||||
|
||||
@@ -184,11 +131,12 @@ def index(request, course_id, chapter=None, section=None,
|
||||
if look_for_module:
|
||||
section_descriptor = get_section(course, chapter, section)
|
||||
if section_descriptor is not None:
|
||||
student_module_cache = StudentModuleCache(request.user,
|
||||
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
|
||||
request.user,
|
||||
section_descriptor)
|
||||
module, _, _, _ = get_module(request.user, request,
|
||||
section_descriptor.location,
|
||||
student_module_cache)
|
||||
module = get_module(request.user, request,
|
||||
section_descriptor.location,
|
||||
student_module_cache)
|
||||
context['content'] = module.get_html()
|
||||
else:
|
||||
log.warning("Couldn't find a section descriptor for course_id '{0}',"
|
||||
@@ -227,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:
|
||||
@@ -248,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})
|
||||
|
||||
@@ -275,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})
|
||||
|
||||
@@ -283,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))
|
||||
@@ -294,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)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.conf.urls.defaults import *
|
||||
from django.conf.urls import *
|
||||
|
||||
urlpatterns = patterns('',
|
||||
url(r'^$', 'heartbeat.views.heartbeat', name='heartbeat'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.conf.urls.defaults import patterns, url
|
||||
from django.conf.urls import patterns, url
|
||||
|
||||
namespace_regex = r"[a-zA-Z\d._-]+"
|
||||
article_slug = r'/(?P<article_path>' + namespace_regex + r'/[a-zA-Z\d_-]*)'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,12 +2,14 @@ from django.contrib.auth.decorators import login_required
|
||||
from mitxmako.shortcuts import render_to_response
|
||||
|
||||
from courseware.courses import check_course
|
||||
|
||||
from lxml import etree
|
||||
|
||||
@login_required
|
||||
def index(request, course_id, page=0):
|
||||
course = check_course(course_id)
|
||||
return render_to_response('staticbook.html', {'page': int(page), 'course': course})
|
||||
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})
|
||||
|
||||
|
||||
def index_shifted(request, course_id, page):
|
||||
|
||||
@@ -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,
|
||||
@@ -121,7 +124,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
#'django.core.context_processors.i18n',
|
||||
'askbot.user_messages.context_processors.user_messages',#must be before auth
|
||||
'django.core.context_processors.auth', #this is required for admin
|
||||
'django.contrib.auth.context_processors.auth', #this is required for admin
|
||||
'django.core.context_processors.csrf', #necessary for csrf protection
|
||||
)
|
||||
|
||||
@@ -169,6 +172,9 @@ COURSE_SETTINGS = {'6.002x_Fall_2012': {'number' : '6.002x',
|
||||
}
|
||||
}
|
||||
|
||||
# IP addresses that are allowed to reload the course, etc.
|
||||
# TODO (vshnayder): Will probably need to change as we get real access control in.
|
||||
LMS_MIGRATION_ALLOWED_IPS = []
|
||||
|
||||
############################### XModule Store ##################################
|
||||
MODULESTORE = {
|
||||
@@ -182,6 +188,9 @@ MODULESTORE = {
|
||||
}
|
||||
}
|
||||
|
||||
############################ SIGNAL HANDLERS ################################
|
||||
# This is imported to register the exception signal handling that logs exceptions
|
||||
import monitoring.exceptions # noqa
|
||||
|
||||
############################### DJANGO BUILT-INS ###############################
|
||||
# Change DEBUG/TEMPLATE_DEBUG in your environment settings files, not here
|
||||
@@ -294,7 +303,6 @@ TEMPLATE_LOADERS = (
|
||||
)
|
||||
|
||||
MIDDLEWARE_CLASSES = (
|
||||
'util.middleware.ExceptionLoggingMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
|
||||
@@ -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,9 +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;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
|
||||
.change-list .filtered {
|
||||
background: white url(../img/admin/changelist-bg.gif) top right repeat-y !important;
|
||||
background: white url(../img/changelist-bg.gif) top right repeat-y !important;
|
||||
}
|
||||
|
||||
.change-list .filtered .results, .change-list .filtered .paginator, .filtered #toolbar, .filtered div.xfull {
|
||||
@@ -40,7 +40,7 @@
|
||||
color: #666;
|
||||
border-top: 1px solid #eee;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: white url(../img/admin/nav-bg.gif) 0 180% repeat-x;
|
||||
background: white url(../img/nav-bg.gif) 0 180% repeat-x;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
/* CHANGELIST TABLES */
|
||||
|
||||
#changelist table thead th {
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -82,7 +83,7 @@
|
||||
#changelist #toolbar {
|
||||
padding: 3px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background: #e1e1e1 url(../img/admin/nav-bg.gif) top left repeat-x;
|
||||
background: #e1e1e1 url(../img/nav-bg.gif) top left repeat-x;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@@ -156,7 +157,7 @@
|
||||
|
||||
.change-list ul.toplinks {
|
||||
display: block;
|
||||
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;
|
||||
border-top: 1px solid white;
|
||||
float: left;
|
||||
padding: 0 !important;
|
||||
@@ -165,11 +166,10 @@
|
||||
}
|
||||
|
||||
.change-list ul.toplinks li {
|
||||
float: left;
|
||||
width: 9em;
|
||||
padding: 3px 6px;
|
||||
font-weight: bold;
|
||||
list-style-type: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.change-list ul.toplinks .date-back a {
|
||||
@@ -246,7 +246,7 @@
|
||||
padding: 3px;
|
||||
border-top: 1px solid #fff;
|
||||
border-bottom: 1px solid #ddd;
|
||||
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;
|
||||
}
|
||||
|
||||
#changelist .actions.selected {
|
||||
|
||||