diff --git a/common/djangoapps/pipeline_mako/templates/static_content.html b/common/djangoapps/pipeline_mako/templates/static_content.html
index 1737153260..c153da22fe 100644
--- a/common/djangoapps/pipeline_mako/templates/static_content.html
+++ b/common/djangoapps/pipeline_mako/templates/static_content.html
@@ -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']:
diff --git a/common/djangoapps/static_replace.py b/common/djangoapps/static_replace.py
index f9660e7f5e..73e473c412 100644
--- a/common/djangoapps/static_replace.py
+++ b/common/djangoapps/static_replace.py
@@ -1,4 +1,6 @@
from staticfiles.storage import staticfiles_storage
+from staticfiles import finders
+from django.conf import settings
import re
@@ -9,7 +11,15 @@ 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'))
diff --git a/common/djangoapps/student/management/commands/create_random_users.py b/common/djangoapps/student/management/commands/create_random_users.py
new file mode 100644
index 0000000000..c6cf452a43
--- /dev/null
+++ b/common/djangoapps/student/management/commands/create_random_users.py
@@ -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)
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 87490786c1..8093a5a51a 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -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)
@@ -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.'}))
@@ -184,7 +189,7 @@ def change_enrollment(request):
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,58 @@ 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:
+ # 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 +401,11 @@ 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.
+ (user, profile, registration) = _do_create_account(post_vars)
d = {'name': post_vars['name'],
- 'key': r.activation_key,
+ 'key': registration.activation_key,
}
# composes activation email
@@ -398,10 +417,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 +451,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 +540,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}))
diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index 5092e5c378..0c47892598 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -307,7 +307,17 @@ 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)
@@ -329,10 +339,17 @@ def textbox(element, value, status, render_template, msg=''):
hidden = element.get('hidden', '') # if specified, then textline is hidden and id is stored in div of name given by hidden
if not value: value = element.text # if no student input yet, then use the default input given by the problem
+
+ # Check if problem has been queued
+ queue_len = 0
+ if status == 'incomplete': # Flag indicating that the problem has been queued, 'msg' is length of queue
+ status = 'queued'
+ queue_len = msg
+ msg = 'Submitted to grader. (Queue length: %s)' % queue_len
# For CodeMirror
- mode = element.get('mode') or 'python' # mode, eg "python" or "xml"
- linenumbers = element.get('linenumbers','true') # for CodeMirror
+ mode = element.get('mode','python')
+ linenumbers = element.get('linenumbers','true')
tabsize = element.get('tabsize','4')
tabsize = int(tabsize)
@@ -340,6 +357,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:
diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py
index 66212f1e87..25b99fc00a 100644
--- a/common/lib/capa/capa/responsetypes.py
+++ b/common/lib/capa/capa/responsetypes.py
@@ -898,7 +898,7 @@ class CodeResponse(LoncapaResponse):
'processor': self.code,
}
- # Submit request
+ # 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,
@@ -914,8 +914,11 @@ 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
diff --git a/common/lib/capa/capa/templates/filesubmission.html b/common/lib/capa/capa/templates/filesubmission.html
index ff9fc992fd..d3d57ee318 100644
--- a/common/lib/capa/capa/templates/filesubmission.html
+++ b/common/lib/capa/capa/templates/filesubmission.html
@@ -6,8 +6,9 @@
% elif state == 'incorrect':
- % elif state == 'incomplete':
-
+ % elif state == 'queued':
+
+ ${queue_len}
% endif
(${state})
diff --git a/common/lib/capa/capa/templates/textbox.html b/common/lib/capa/capa/templates/textbox.html
index f201bd6947..647f4fe4e8 100644
--- a/common/lib/capa/capa/templates/textbox.html
+++ b/common/lib/capa/capa/templates/textbox.html
@@ -13,11 +13,12 @@
% elif state == 'incorrect':
- % elif state == 'incomplete':
-
+ % elif state == 'queued':
+
+ ${queue_len}
% endif
% if hidden:
-
+
% endif
(${state})
diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py
index bb55fa7ee8..035413a402 100644
--- a/common/lib/xmodule/xmodule/abtest_module.py
+++ b/common/lib/xmodule/xmodule/abtest_module.py
@@ -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
diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss
index 2088e8baa3..6b1c32ae65 100644
--- a/common/lib/xmodule/xmodule/css/capa/display.scss
+++ b/common/lib/xmodule/xmodule/css/capa/display.scss
@@ -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;
diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
index 18bec8a7d1..ae589b8b04 100644
--- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
@@ -12,7 +12,10 @@ class @Problem
bind: =>
MathJax.Hub.Queue ["Typeset", MathJax.Hub]
window.update_schematics()
- @inputs = @$("[id^=input_#{@element_id.replace(/problem_/, '')}_]")
+
+ problem_prefix = @element_id.replace(/problem_/,'')
+ @inputs = @$("[id^=input_#{problem_prefix}_]")
+
@$('section.action input:button').click @refreshAnswers
@$('section.action input.check').click @check_fd
#@$('section.action input.check').click @check
@@ -26,15 +29,37 @@ 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)
@bind()
+ @queueing()
else
$.postWithPrefix "#{@url}/problem_get", (response) =>
@el.html(response.html)
@executeProblemScripts()
@bind()
+ @queueing()
executeProblemScripts: ->
@el.find(".script_placeholder").each (index, placeholder) ->
diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee
index 0b17111d81..832a5ec7eb 100644
--- a/common/lib/xmodule/xmodule/js/src/sequence/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/sequence/display.coffee
@@ -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) =>
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py
index 1cec6c7f87..b6101a6929 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo.py
@@ -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)
diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py
index f9093f355a..2dc3b33323 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml.py
@@ -213,6 +213,12 @@ class XMLModuleStore(ModuleStoreBase):
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
diff --git a/common/lib/xmodule/xmodule/tests/test_import.py b/common/lib/xmodule/xmodule/tests/test_import.py
index 407057a4ab..1da618f6a4 100644
--- a/common/lib/xmodule/xmodule/tests/test_import.py
+++ b/common/lib/xmodule/xmodule/tests/test_import.py
@@ -8,8 +8,11 @@ from xmodule.x_module import XMLParsingSystem, XModuleDescriptor
from xmodule.xml_module import is_pointer_tag
from xmodule.errortracker import make_error_tracker
from xmodule.modulestore import Location
+from xmodule.modulestore.xml import XMLModuleStore
from xmodule.modulestore.exceptions import ItemNotFoundError
+from .test_export import DATA_DIR
+
ORG = 'test_org'
COURSE = 'test_course'
@@ -185,3 +188,22 @@ class ImportTestCase(unittest.TestCase):
chapter_xml = etree.fromstring(f.read())
self.assertEqual(chapter_xml.tag, 'chapter')
self.assertFalse('graceperiod' in chapter_xml.attrib)
+
+ def test_metadata_inherit(self):
+ """Make sure that metadata is inherited properly"""
+
+ print "Starting import"
+ initial_import = XMLModuleStore(DATA_DIR, eager=True, course_dirs=['toy'])
+
+ courses = initial_import.get_courses()
+ self.assertEquals(len(courses), 1)
+ course = courses[0]
+
+ def check_for_key(key, node):
+ "recursive check for presence of key"
+ print "Checking {}".format(node.location.url())
+ self.assertTrue(key in node.metadata)
+ for c in node.get_children():
+ check_for_key(key, c)
+
+ check_for_key('graceperiod', course)
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 72a8d32d1d..2cd51b9e6e 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -403,6 +403,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.
@@ -423,6 +435,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)
diff --git a/common/static/images/spinner.gif b/common/static/images/spinner.gif
new file mode 100644
index 0000000000..b2f94cd12c
Binary files /dev/null and b/common/static/images/spinner.gif differ
diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py
index 5312a38584..3e603a108d 100644
--- a/lms/djangoapps/courseware/courses.py
+++ b/lms/djangoapps/courseware/courses.py
@@ -145,6 +145,8 @@ def has_staff_access_to_course(user, course):
'''
Returns True if the given user has staff access to the course.
This means that user is in the staff_* group, or is an overall admin.
+ TODO (vshnayder): this needs to be changed to allow per-course_id permissions, not per-course
+ (e.g. staff in 2012 is different from 2013, but maybe some people always have access)
course is the course field of the location being accessed.
'''
@@ -156,13 +158,18 @@ def has_staff_access_to_course(user, course):
# note this is the Auth group, not UserTestGroup
user_groups = [x[1] for x in user.groups.values_list()]
staff_group = course_staff_group_name(course)
- log.debug('course %s, staff_group %s, user %s, groups %s' % (
- course, staff_group, user, user_groups))
if staff_group in user_groups:
return True
return False
-def has_access_to_course(user,course):
+def has_staff_access_to_course_id(user, course_id):
+ """Helper method that takes a course_id instead of a course name"""
+ loc = CourseDescriptor.id_to_location(course_id)
+ return has_staff_access_to_course(user, loc.course)
+
+
+def has_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)
diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py
index b0220670eb..192794b6b3 100644
--- a/lms/djangoapps/courseware/grades.py
+++ b/lms/djangoapps/courseware/grades.py
@@ -1,3 +1,6 @@
+# Compute grades using real division, with no integer truncation
+from __future__ import division
+
import random
import logging
@@ -13,33 +16,33 @@ log = logging.getLogger("mitx.courseware")
def yield_module_descendents(module):
stack = module.get_display_items()
-
+
while len(stack) > 0:
next_module = stack.pop()
stack.extend( next_module.get_display_items() )
yield next_module
-
+
def grade(student, request, course, student_module_cache=None):
"""
- This grades a student as quickly as possible. It retuns the
+ This grades a student as quickly as possible. It retuns the
output from the course grader, augmented with the final letter
grade. The keys in the output are:
-
+
- grade : A final letter grade.
- percent : The final percent for the class (rounded up).
- section_breakdown : A breakdown of each section that makes
up the grade. (For display)
- grade_breakdown : A breakdown of the major components that
make up the final grade. (For display)
-
+
More information on the format is in the docstring for CourseGrader.
"""
-
+
grading_context = course.grading_context
-
+
if student_module_cache == None:
student_module_cache = StudentModuleCache(student, grading_context['all_descriptors'])
-
+
totaled_scores = {}
# This next complicated loop is just to collect the totaled_scores, which is
# passed to the grader
@@ -48,91 +51,91 @@ def grade(student, request, course, student_module_cache=None):
for section in sections:
section_descriptor = section['section_descriptor']
section_name = section_descriptor.metadata.get('display_name')
-
+
should_grade_section = False
# If we haven't seen a single problem in the section, we don't have to grade it at all! We can assume 0%
for moduledescriptor in section['xmoduledescriptors']:
if student_module_cache.lookup(moduledescriptor.category, moduledescriptor.location.url() ):
should_grade_section = True
break
-
+
if should_grade_section:
scores = []
# TODO: We need the request to pass into here. If we could forgo that, our arguments
# would be simpler
section_module = get_module(student, request, section_descriptor.location, student_module_cache)
-
+
# TODO: We may be able to speed this up by only getting a list of children IDs from section_module
# Then, we may not need to instatiate any problems if they are already in the database
- for module in yield_module_descendents(section_module):
+ for module in yield_module_descendents(section_module):
(correct, total) = get_score(student, module, student_module_cache)
if correct is None and total is None:
continue
-
+
if settings.GENERATE_PROFILE_SCORES:
if total > 1:
correct = random.randrange(max(total - 2, 1), total + 1)
else:
correct = total
-
+
graded = module.metadata.get("graded", False)
if not total > 0:
#We simply cannot grade a problem that is 12/0, because we might need it as a percentage
graded = False
-
+
scores.append(Score(correct, total, graded, module.metadata.get('display_name')))
-
+
section_total, graded_total = graders.aggregate_scores(scores, section_name)
else:
section_total = Score(0.0, 1.0, False, section_name)
graded_total = Score(0.0, 1.0, True, section_name)
-
+
#Add the graded total to totaled_scores
if graded_total.possible > 0:
format_scores.append(graded_total)
else:
log.exception("Unable to grade a section with a total possible score of zero. " + str(section_descriptor.location))
-
+
totaled_scores[section_format] = format_scores
-
+
grade_summary = course.grader.grade(totaled_scores)
-
+
# We round the grade here, to make sure that the grade is an whole percentage and
# doesn't get displayed differently than it gets grades
grade_summary['percent'] = round(grade_summary['percent'] * 100 + 0.05) / 100
-
+
letter_grade = grade_for_percentage(course.grade_cutoffs, grade_summary['percent'])
grade_summary['grade'] = letter_grade
-
+
return grade_summary
def grade_for_percentage(grade_cutoffs, percentage):
"""
Returns a letter grade 'A' 'B' 'C' or None.
-
+
Arguments
- grade_cutoffs is a dictionary mapping a grade to the lowest
possible percentage to earn that grade.
- percentage is the final percent across all problems in a course
"""
-
+
letter_grade = None
for possible_grade in ['A', 'B', 'C']:
if percentage >= grade_cutoffs[possible_grade]:
letter_grade = possible_grade
break
-
- return letter_grade
+
+ return letter_grade
def progress_summary(student, course, grader, student_module_cache):
"""
This pulls a summary of all problems in the course.
Returns
- - courseware_summary is a summary of all sections with problems in the course.
- It is organized as an array of chapters, each containing an array of sections,
- each containing an array of scores. This contains information for graded and
- ungraded problems, and is good for displaying a course summary with due dates,
+ - courseware_summary is a summary of all sections with problems in the course.
+ It is organized as an array of chapters, each containing an array of sections,
+ each containing an array of scores. This contains information for graded and
+ ungraded problems, and is good for displaying a course summary with due dates,
etc.
Arguments:
@@ -152,7 +155,7 @@ def progress_summary(student, course, grader, student_module_cache):
if correct is None and total is None:
continue
- scores.append(Score(correct, total, graded,
+ scores.append(Score(correct, total, graded,
module.metadata.get('display_name')))
section_total, graded_total = graders.aggregate_scores(
@@ -179,7 +182,7 @@ def progress_summary(student, course, grader, student_module_cache):
def get_score(user, problem, student_module_cache):
"""
- Return the score for a user on a problem
+ Return the score for a user on a problem, as a tuple (correct, total).
user: a Student object
problem: an XModule
@@ -188,7 +191,7 @@ def get_score(user, problem, student_module_cache):
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
diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index 6d212d6d2c..01407e141f 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -47,11 +47,12 @@ def toc_for_course(user, request, course, active_chapter, active_section):
'format': format, 'due': due, 'active' : bool}, ...]
active is set for the section and chapter corresponding to the passed
- parameters. Everything else comes from the xml, or defaults to "".
+ parameters, which are expected to be url_names of the chapter+section.
+ Everything else comes from the xml, or defaults to "".
chapters with name 'hidden' are skipped.
'''
-
+
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(user, course, depth=2)
course = get_module(user, request, course.location, student_module_cache)
@@ -60,8 +61,8 @@ def toc_for_course(user, request, course, active_chapter, active_section):
sections = list()
for section in chapter.get_display_items():
- active = (chapter.display_name == active_chapter and
- section.display_name == active_section)
+ active = (chapter.url_name == active_chapter and
+ section.url_name == active_section)
hide_from_toc = section.metadata.get('hide_from_toc', 'false').lower() == 'true'
if not hide_from_toc:
@@ -74,7 +75,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
@@ -123,7 +124,7 @@ def get_module(user, request, location, student_module_cache, position=None):
position within module
Returns: xmodule instance
-
+
'''
descriptor = modulestore().get_item(location)
@@ -134,13 +135,13 @@ def get_module(user, request, location, student_module_cache, position=None):
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)
-
-
+
+
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
@@ -218,13 +219,13 @@ def get_instance_module(user, module, student_module_cache):
"""
if user.is_authenticated():
if not module.descriptor.stores_state:
- log.exception("Attempted to get the instance_module for a module "
+ 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,
@@ -234,11 +235,11 @@ def get_instance_module(user, module, student_module_cache):
max_grade=module.max_score())
instance_module.save()
student_module_cache.append(instance_module)
-
+
return instance_module
else:
return None
-
+
def get_shared_instance_module(user, module, student_module_cache):
"""
Return shared_module is a StudentModule specific to all modules with the same
@@ -248,7 +249,7 @@ def get_shared_instance_module(user, module, student_module_cache):
if user.is_authenticated():
# To get the shared_state_key, we need to descriptor
descriptor = modulestore().get_item(module.location)
-
+
shared_state_key = getattr(module, 'shared_state_key', None)
if shared_state_key is not None:
shared_module = student_module_cache.lookup(module.category,
@@ -263,7 +264,7 @@ def get_shared_instance_module(user, module, student_module_cache):
student_module_cache.append(shared_module)
else:
shared_module = None
-
+
return shared_module
else:
return None
@@ -271,7 +272,7 @@ def get_shared_instance_module(user, module, student_module_cache):
@csrf_exempt
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.
'''
# Test xqueue package, which we expect to be:
# xpackage = {'xqueue_header': json.dumps({'lms_key':'secretkey',...}),
@@ -343,7 +344,7 @@ def modx_dispatch(request, dispatch=None, id=None, course_id=None):
instance_module = get_instance_module(request.user, instance, student_module_cache)
shared_module = get_shared_instance_module(request.user, instance, student_module_cache)
-
+
# Don't track state for anonymous users (who don't have student modules)
if instance_module is not None:
oldgrade = instance_module.grade
diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py
index e0369baf7b..da0688be3d 100644
--- a/lms/djangoapps/courseware/tests/tests.py
+++ b/lms/djangoapps/courseware/tests/tests.py
@@ -13,8 +13,9 @@ 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 django.contrib.auth.models import User, Group
from student.models import Registration
+from courseware.courses import course_staff_group_name
from xmodule.modulestore.django import modulestore
import xmodule.modulestore.django
@@ -88,6 +89,13 @@ class ActivateLoginTestCase(TestCase):
self.assertTrue(data['success'])
return resp
+ def logout(self):
+ '''Logout, check that it worked.'''
+ resp = self.client.get(reverse('logout'), {})
+ # should redirect
+ self.assertEqual(resp.status_code, 302)
+ return resp
+
def _create_account(self, username, email, pw):
'''Try to create an account. No error checking'''
resp = self.client.post('/create_account', {
@@ -131,12 +139,16 @@ 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,
@@ -193,7 +205,99 @@ 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 TestInstructorAuth(PageLoader):
+ """Check that authentication works properly"""
+
+ # NOTE: setUpClass() runs before override_settings takes effect, so
+ # can't do imports there without manually hacking settings.
+
+ def setUp(self):
+ xmodule.modulestore.django._MODULESTORES = {}
+ 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)
+ else:
+ # look for "page not found" instead of the status code
+ self.assertTrue(resp.content.lower().find('page not found') != -1)
+
+ def test_instructor_page(self):
+ "Make sure only instructors can load it"
+
+ # 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)
+
+
+
@override_settings(MODULESTORE=REAL_DATA_MODULESTORE)
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index ac00626063..02e6d00a58 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -27,7 +27,8 @@ from xmodule.course_module import CourseDescriptor
from util.cache import cache, cache_if_anonymous
from student.models import UserTestGroup, CourseEnrollment
from courseware import grades
-from courseware.courses import check_course, get_courses_by_university
+from courseware.courses import (check_course, get_courses_by_university,
+ has_staff_access_to_course_id)
log = logging.getLogger("mitx.courseware")
@@ -35,6 +36,9 @@ log = logging.getLogger("mitx.courseware")
template_imports = {'urllib': urllib}
def user_groups(user):
+ """
+ TODO (vshnayder): This is not used. When we have a new plan for groups, adjust appropriately.
+ """
if not user.is_authenticated():
return []
@@ -64,64 +68,6 @@ def courses(request):
universities = get_courses_by_university(request.user)
return render_to_response("courses.html", {'universities': universities})
-@cache_control(no_cache=True, no_store=True, must_revalidate=True)
-def gradebook(request, course_id):
- if 'course_admin' not in user_groups(request.user):
- raise Http404
- course = check_course(course_id)
-
- student_objects = User.objects.all()[:100]
- student_info = []
-
- #TODO: Only select students who are in the course
- for student in student_objects:
- student_info.append({
- 'username': student.username,
- 'id': student.id,
- 'email': student.email,
- 'grade_summary': grades.grade(student, request, course),
- 'realname': UserProfile.objects.get(user=student).name
- })
-
- return render_to_response('gradebook.html', {'students': student_info, 'course': course})
-
-
-@login_required
-@cache_control(no_cache=True, no_store=True, must_revalidate=True)
-def profile(request, course_id, student_id=None):
- ''' User profile. Show username, location, etc, as well as grades .
- We need to allow the user to change some of these settings .'''
- course = check_course(course_id)
-
- if student_id is None:
- student = request.user
- else:
- if 'course_admin' not in user_groups(request.user):
- raise Http404
- student = User.objects.get(id=int(student_id))
-
- user_info = UserProfile.objects.get(user=student)
-
- student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, course)
- course_module = get_module(request.user, request, course.location, student_module_cache)
-
- courseware_summary = grades.progress_summary(student, course_module, course.grader, student_module_cache)
- grade_summary = grades.grade(request.user, request, course, student_module_cache)
-
- context = {'name': user_info.name,
- 'username': student.username,
- 'location': user_info.location,
- 'language': user_info.language,
- 'email': student.email,
- 'course': course,
- 'csrf': csrf(request)['csrf_token'],
- 'courseware_summary' : courseware_summary,
- 'grade_summary' : grade_summary
- }
- context.update()
-
- return render_to_response('profile.html', context)
-
def render_accordion(request, course, chapter, section):
''' Draws navigation bar. Takes current position in accordion as
@@ -129,19 +75,14 @@ def render_accordion(request, course, chapter, section):
If chapter and section are '' or None, renders a default accordion.
+ course, chapter, and section are the url_names.
+
Returns the html string'''
# grab the table of contents
toc = toc_for_course(request.user, request, course, chapter, section)
- active_chapter = 1
- for i in range(len(toc)):
- if toc[i]['active']:
- active_chapter = i
-
- context = dict([('active_chapter', active_chapter),
- ('toc', toc),
- ('course_name', course.title),
+ context = dict([('toc', toc),
('course_id', course.id),
('csrf', csrf(request)['csrf_token'])] + template_imports.items())
return render_to_string('accordion.html', context)
@@ -233,12 +174,10 @@ def jump_to(request, location):
'''
Show the page that contains a specific location.
- If the location is invalid, return a 404.
+ If the location is invalid or not in any class, return a 404.
- If the location is valid, but not present in a course, ?
-
- If the location is valid, but in a course the current user isn't registered for, ?
- TODO -- let the index view deal with it?
+ Otherwise, delegates to the index view to figure out whether this user
+ has access, and what they should see.
'''
# Complain if the location isn't valid
try:
@@ -254,16 +193,16 @@ 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)
return render_to_response('info.html', {'course': course})
@@ -289,7 +228,10 @@ def course_about(request, course_id):
@ensure_csrf_cookie
@cache_if_anonymous
def university_profile(request, org_id):
- all_courses = sorted(modulestore().get_courses(), key=lambda course: course.number)
+ """
+ Return the profile for the particular org_id. 404 if it's not valid.
+ """
+ all_courses = modulestore().get_courses()
valid_org_ids = set(c.org for c in all_courses)
if org_id not in valid_org_ids:
raise Http404("University Profile not found for {0}".format(org_id))
@@ -300,3 +242,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(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(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(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(course_id)
+
+ # For now, just a static page
+ context = {'course': course }
+ return render_to_response('instructor_dashboard.html', context)
+
diff --git a/lms/envs/test.py b/lms/envs/test.py
index e0caa79ac1..cd0e984940 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -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'):
diff --git a/lms/static/coffee/src/navigation.coffee b/lms/static/coffee/src/navigation.coffee
index 7ba0a94fad..1d6b8a8117 100644
--- a/lms/static/coffee/src/navigation.coffee
+++ b/lms/static/coffee/src/navigation.coffee
@@ -1,9 +1,16 @@
class @Navigation
constructor: ->
if $('#accordion').length
+ # First look for an active section
active = $('#accordion ul:has(li.active)').index('#accordion ul')
+ # if we didn't find one, look for an active chapter
+ if active < 0
+ active = $('#accordion h3.active').index('#accordion h3')
+ # if that didn't work either, default to 0
+ if active < 0
+ active = 0
$('#accordion').bind('accordionchange', @log).accordion
- active: if active >= 0 then active else 1
+ active: active
header: 'h3'
autoHeight: false
$('#open_close_accordion a').click @toggle
diff --git a/lms/static/sass/base/_extends.scss b/lms/static/sass/base/_extends.scss
index 9dc10aed5f..2998e25dca 100644
--- a/lms/static/sass/base/_extends.scss
+++ b/lms/static/sass/base/_extends.scss
@@ -89,3 +89,9 @@
border: 1px solid rgb(6, 65, 18);
color: rgb(255, 255, 255);
}
+
+.global {
+ h2 {
+ display: none;
+ }
+}
\ No newline at end of file
diff --git a/lms/static/sass/course/_textbook.scss b/lms/static/sass/course/_textbook.scss
index 50986f5dd5..8e88f8befd 100644
--- a/lms/static/sass/course/_textbook.scss
+++ b/lms/static/sass/course/_textbook.scss
@@ -9,6 +9,27 @@ div.book-wrapper {
ul#booknav {
font-size: em(14);
+ .chapter-number {
+
+ }
+
+ .chapter {
+ float: left;
+ width: 87%;
+ line-height: 1.4em;
+ }
+
+ .page-number {
+ float: right;
+ width: 12%;
+ font-size: .8em;
+ line-height: 2.1em;
+ text-align: right;
+ color: #9a9a9a;
+ opacity: 0;
+ @include transition(opacity .15s);
+ }
+
li {
background: none;
border-bottom: 0;
@@ -16,9 +37,14 @@ div.book-wrapper {
a {
padding: 0;
+ @include clearfix;
&:hover {
background-color: transparent;
+
+ .page-number {
+ opacity: 1;
+ }
}
}
diff --git a/lms/static/sass/course/base/_base.scss b/lms/static/sass/course/base/_base.scss
index 8d952b5040..cd68b4bbaf 100644
--- a/lms/static/sass/course/base/_base.scss
+++ b/lms/static/sass/course/base/_base.scss
@@ -1,3 +1,7 @@
+body {
+ min-width: 980px;
+}
+
body, h1, h2, h3, h4, h5, h6, p, p a:link, p a:visited, a {
font-family: $sans-serif;
}
diff --git a/lms/static/sass/course/base/_extends.scss b/lms/static/sass/course/base/_extends.scss
index 72e2f56bbb..7b3e1cba84 100644
--- a/lms/static/sass/course/base/_extends.scss
+++ b/lms/static/sass/course/base/_extends.scss
@@ -185,3 +185,30 @@ h1.top-header {
.tran {
@include transition( all, .2s, $ease-in-out-quad);
}
+
+.global {
+ .find-courses-button {
+ display: none;
+ }
+
+ h2 {
+ display: block;
+ width: 700px;
+ float: left;
+ font-size: 0.9em;
+ font-weight: 600;
+ line-height: 40px;
+ letter-spacing: 0;
+ text-transform: none;
+ text-shadow: 0 1px 0 #fff;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+
+ .provider {
+ font: inherit;
+ font-weight: bold;
+ color: #6d6d6d;
+ }
+ }
+}
\ No newline at end of file
diff --git a/lms/templates/accordion.html b/lms/templates/accordion.html
index 353b83db70..1f514fe4a4 100644
--- a/lms/templates/accordion.html
+++ b/lms/templates/accordion.html
@@ -1,7 +1,8 @@
<%! from django.core.urlresolvers import reverse %>
<%def name="make_chapter(chapter)">
-