diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py
index 31be96ad7b..6d653db6cc 100644
--- a/cms/djangoapps/contentstore/views.py
+++ b/cms/djangoapps/contentstore/views.py
@@ -215,9 +215,6 @@ def preview_module_system(request, preview_id, descriptor):
render_template=render_from_lms,
debug=True,
replace_urls=replace_urls,
- # TODO (vshnayder): All CMS users get staff view by default
- # is that what we want?
- is_staff=True,
)
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 6faecafec1..1767202141 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -90,6 +90,16 @@ TEMPLATE_CONTEXT_PROCESSORS = (
################################# Jasmine ###################################
JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee'
+
+#################### CAPA External Code Evaluation #############################
+XQUEUE_INTERFACE = {
+ 'url': 'http://localhost:8888',
+ 'django_auth': {'username': 'local',
+ 'password': 'local'},
+ 'basic_auth': None,
+}
+
+
################################# Middleware ###################################
# List of finder classes that know how to find static files in
# various locations.
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index ea1770109b..b6aa62e03d 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -38,8 +38,8 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from datetime import date
from collections import namedtuple
-from courseware.courses import (course_staff_group_name, has_staff_access_to_course,
- get_courses_by_university)
+from courseware.courses import get_courses_by_university
+from courseware.access import has_access
log = logging.getLogger("mitx.student")
Article = namedtuple('Article', 'title url author image deck publication publish_date')
@@ -166,22 +166,6 @@ def change_enrollment_view(request):
"""Delegate to change_enrollment to actually do the work."""
return HttpResponse(json.dumps(change_enrollment(request)))
-def enrollment_allowed(user, course):
- """If the course has an enrollment period, check whether we are in it.
- Also respects the DARK_LAUNCH setting"""
- now = time.gmtime()
- start = course.enrollment_start
- end = course.enrollment_end
-
- if (start is None or now > start) and (end is None or now < end):
- # in enrollment period.
- return True
-
- if settings.MITX_FEATURES['DARK_LAUNCH']:
- if has_staff_access_to_course(user, course):
- # if dark launch, staff can enroll outside enrollment window
- return True
- return False
def change_enrollment(request):
@@ -209,18 +193,7 @@ def change_enrollment(request):
.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):
- 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}
-
- if not enrollment_allowed(user, course):
+ if not has_access(user, course, 'enroll'):
return {'success': False,
'error': 'enrollment in {} not allowed at this time'
.format(course.display_name)}
diff --git a/common/lib/capa/capa/xqueue_interface.py b/common/lib/capa/capa/xqueue_interface.py
index 70f086120e..2847968a89 100644
--- a/common/lib/capa/capa/xqueue_interface.py
+++ b/common/lib/capa/capa/xqueue_interface.py
@@ -7,13 +7,10 @@ import logging
import requests
import time
-# TODO: Collection of parameters to be hooked into rest of edX system
-XQUEUE_LMS_AUTH = { 'username': 'LMS',
- 'password': 'PaloAltoCA' }
-XQUEUE_URL = 'http://xqueue.edx.org'
log = logging.getLogger('mitx.' + __name__)
+
def make_hashkey(seed=None):
'''
Generate a string key by hashing
@@ -58,15 +55,15 @@ def parse_xreply(xreply):
return (return_code, content)
-class XqueueInterface:
+class XQueueInterface(object):
'''
Interface to the external grading system
'''
- def __init__(self, url=XQUEUE_URL, auth=XQUEUE_LMS_AUTH):
+ def __init__(self, url, django_auth, requests_auth=None):
self.url = url
- self.auth = auth
- self.session = requests.session()
+ self.auth = django_auth
+ self.session = requests.session(auth=requests_auth)
def send_to_queue(self, header, body, file_to_upload=None):
'''
@@ -117,5 +114,3 @@ class XqueueInterface:
return (1, 'unexpected HTTP status code [%d]' % r.status_code)
return parse_xreply(r.text)
-
-qinterface = XqueueInterface()
diff --git a/common/lib/xmodule/xmodule/abtest_module.py b/common/lib/xmodule/xmodule/abtest_module.py
index 035413a402..ca00db4c9a 100644
--- a/common/lib/xmodule/xmodule/abtest_module.py
+++ b/common/lib/xmodule/xmodule/abtest_module.py
@@ -49,9 +49,9 @@ class ABTestModule(XModule):
return json.dumps({'group': self.group})
def displayable_items(self):
- return [self.system.get_module(child)
- for child
- in self.definition['data']['group_content'][self.group]]
+ return filter(None, [self.system.get_module(child)
+ for child
+ in self.definition['data']['group_content'][self.group]])
# TODO (cpennington): Use Groups should be a first class object, rather than being
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index c613283683..40eec1f70f 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -1,12 +1,12 @@
from fs.errors import ResourceNotFoundError
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
+from xmodule.timeparse import parse_time, stringify_time
log = logging.getLogger(__name__)
@@ -18,38 +18,15 @@ class CourseDescriptor(SequenceDescriptor):
super(CourseDescriptor, self).__init__(system, definition, **kwargs)
msg = None
- try:
- self.start = time.strptime(self.metadata["start"], "%Y-%m-%dT%H:%M")
- except KeyError:
- msg = "Course loaded without a start date. id = %s" % self.id
- except ValueError as e:
- msg = "Course loaded with a bad start date. %s '%s'" % (self.id, e)
-
- # Don't call the tracker from the exception handler.
- if msg is not None:
- self.start = time.gmtime(0) # The epoch
+ if self.start is None:
+ msg = "Course loaded without a valid start date. id = %s" % self.id
+ # hack it -- start in 1970
+ self.metadata['start'] = stringify_time(time.gmtime(0))
log.critical(msg)
system.error_tracker(msg)
- def try_parse_time(key):
- """
- Parse an optional metadata key: if present, must be valid.
- Return None if not present.
- """
- if key in self.metadata:
- try:
- return time.strptime(self.metadata[key], "%Y-%m-%dT%H:%M")
- except ValueError as e:
- msg = "Course %s loaded with a bad metadata key %s '%s'" % (
- self.id, self.metadata[key], e)
- log.warning(msg)
- return None
-
- self.enrollment_start = try_parse_time("enrollment_start")
- self.enrollment_end = try_parse_time("enrollment_end")
-
-
-
+ self.enrollment_start = self._try_parse_time("enrollment_start")
+ self.enrollment_end = self._try_parse_time("enrollment_end")
def has_started(self):
return time.gmtime() > self.start
@@ -154,6 +131,7 @@ class CourseDescriptor(SequenceDescriptor):
@property
def id(self):
+ """Return the course_id for this course"""
return self.location_to_id(self.location)
@property
diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py
index 20301ee460..bdd7179a0a 100644
--- a/common/lib/xmodule/xmodule/error_module.py
+++ b/common/lib/xmodule/xmodule/error_module.py
@@ -24,16 +24,8 @@ class ErrorModule(XModule):
return self.system.render_template('module-error.html', {
'data' : self.definition['data']['contents'],
'error' : self.definition['data']['error_msg'],
- '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):
"""
diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
index 6b39805d1a..c00b680eba 100644
--- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee
@@ -32,22 +32,37 @@ class @Problem
queueing: =>
@queued_items = @$(".xqueue")
- if @queued_items.length > 0
+ @num_queued_items = @queued_items.length
+ if @num_queued_items > 0
if window.queuePollerID # Only one poller 'thread' per Problem
window.clearTimeout(window.queuePollerID)
- window.queuePollerID = window.setTimeout(@poll, 100)
+ queuelen = @get_queuelen()
+ window.queuePollerID = window.setTimeout(@poll, queuelen*10)
+ # Retrieves the minimum queue length of all queued items
+ get_queuelen: =>
+ minlen = Infinity
+ @queued_items.each (index, qitem) ->
+ len = parseInt($.text(qitem))
+ if len < minlen
+ minlen = len
+ return minlen
+
poll: =>
$.postWithPrefix "#{@url}/problem_get", (response) =>
- @queued_items = $(response.html).find(".xqueue")
- if @queued_items.length == 0
+ # If queueing status changed, then render
+ @new_queued_items = $(response.html).find(".xqueue")
+ if @new_queued_items.length isnt @num_queued_items
@el.html(response.html)
@executeProblemScripts () =>
@setupInputTypes()
@bind()
+
+ @num_queued_items = @new_queued_items.length
+ if @num_queued_items == 0
delete window.queuePollerID
else
- # TODO: Dynamically adjust timeout interval based on @queued_items.value
+ # TODO: Some logic to dynamically adjust polling rate based on queuelen
window.queuePollerID = window.setTimeout(@poll, 1000)
render: (content) ->
@@ -141,9 +156,16 @@ class @Problem
fd = new FormData()
+ # Sanity check of file size
+ file_too_large = false
+ max_filesize = 4*1000*1000 # 4 MB
+
@inputs.each (index, element) ->
if element.type is 'file'
if element.files[0] instanceof File
+ if element.files[0].size > max_filesize
+ file_too_large = true
+ alert 'Submission aborted! Your file "' + element.files[0].name + '" is too large (max size: ' + max_filesize/(1000*1000) + ' MB)'
fd.append(element.id, element.files[0])
else
fd.append(element.id, '')
@@ -163,7 +185,8 @@ class @Problem
else
alert(response.success)
- $.ajaxWithPrefix("#{@url}/problem_check", settings)
+ if not file_too_large
+ $.ajaxWithPrefix("#{@url}/problem_check", settings)
check: =>
Logger.log 'problem_check', @answers
diff --git a/common/lib/xmodule/xmodule/tests/__init__.py b/common/lib/xmodule/xmodule/tests/__init__.py
index 7b2bd6bc2b..f08b0f8d6e 100644
--- a/common/lib/xmodule/xmodule/tests/__init__.py
+++ b/common/lib/xmodule/xmodule/tests/__init__.py
@@ -35,7 +35,6 @@ i4xs = ModuleSystem(
filestore=fs.osfs.OSFS(os.path.dirname(os.path.realpath(__file__))+"/test_files"),
debug=True,
xqueue={'interface':None, 'callback_url':'/', 'default_queuename': 'testqueue'},
- is_staff=False,
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules")
)
@@ -336,7 +335,7 @@ 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)
@@ -347,7 +346,7 @@ class CodeResponseTest(unittest.TestCase):
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):
diff --git a/common/lib/xmodule/xmodule/timeparse.py b/common/lib/xmodule/xmodule/timeparse.py
new file mode 100644
index 0000000000..117105d085
--- /dev/null
+++ b/common/lib/xmodule/xmodule/timeparse.py
@@ -0,0 +1,19 @@
+"""
+Helper functions for handling time in the format we like.
+"""
+import time
+
+TIME_FORMAT = "%Y-%m-%dT%H:%M"
+
+def parse_time(time_str):
+ """
+ Takes a time string in TIME_FORMAT, returns
+ it as a time_struct. Raises ValueError if the string is not in the right format.
+ """
+ return time.strptime(time_str, TIME_FORMAT)
+
+def stringify_time(time_struct):
+ """
+ Convert a time struct to a string
+ """
+ return time.strftime(TIME_FORMAT, time_struct)
diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py
index 071e453901..06449dc37f 100644
--- a/common/lib/xmodule/xmodule/x_module.py
+++ b/common/lib/xmodule/xmodule/x_module.py
@@ -8,8 +8,9 @@ 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
+from xmodule.modulestore import Location
+from xmodule.timeparse import parse_time
log = logging.getLogger('mitx.' + __name__)
@@ -218,9 +219,11 @@ class XModule(HTMLSnippet):
Return module instances for all the children of this module.
'''
if self._loaded_children is None:
- self._loaded_children = [
- self.system.get_module(child)
- for child in self.definition.get('children', [])]
+ # get_module returns None if the current user doesn't have access
+ # to the location.
+ self._loaded_children = filter(None,
+ [self.system.get_module(child)
+ for child in self.definition.get('children', [])])
return self._loaded_children
@@ -396,6 +399,15 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
return self.metadata.get('display_name',
self.url_name.replace('_', ' '))
+ @property
+ def start(self):
+ """
+ If self.metadata contains start, return it. Else return None.
+ """
+ if 'start' not in self.metadata:
+ return None
+ return self._try_parse_time('start')
+
@property
def own_metadata(self):
"""
@@ -596,6 +608,24 @@ class XModuleDescriptor(Plugin, HTMLSnippet):
metadata=self.metadata
))
+ # ================================ Internal helpers =======================
+
+ def _try_parse_time(self, key):
+ """
+ Parse an optional metadata key containing a time: if present, complain
+ if it doesn't parse.
+ Return None if not present or invalid.
+ """
+ if key in self.metadata:
+ try:
+ return parse_time(self.metadata[key])
+ except ValueError as e:
+ msg = "Descriptor {} loaded with a bad metadata key '{}': '{}'".format(
+ self.location.url(), self.metadata[key], e)
+ log.warning(msg)
+ return None
+
+
class DescriptorSystem(object):
def __init__(self, load_item, resources_fs, error_tracker, **kwargs):
@@ -675,7 +705,6 @@ class ModuleSystem(object):
filestore=None,
debug=False,
xqueue=None,
- is_staff=False,
node_path=""):
'''
Create a closure around the system environment.
@@ -688,7 +717,8 @@ class ModuleSystem(object):
files. Update or remove.
get_module - function that takes (location) and returns a corresponding
- module instance object.
+ module instance object. If the current user does not have
+ access to that location, returns None.
render_template - a function that takes (template_file, context), and
returns rendered html.
@@ -705,9 +735,6 @@ class ModuleSystem(object):
replace_urls - TEMPORARY - A function like static_replace.replace_urls
that capa_module can use to fix up the static urls in
ajax results.
-
- is_staff - Is the user making the request a staff user?
- TODO (vshnayder): this will need to change once we have real user roles.
'''
self.ajax_url = ajax_url
self.xqueue = xqueue
@@ -718,7 +745,6 @@ class ModuleSystem(object):
self.DEBUG = self.debug = debug
self.seed = user.id if user is not None else 0
self.replace_urls = replace_urls
- self.is_staff = is_staff
self.node_path = node_path
def get(self, attr):
diff --git a/doc/development.md b/doc/development.md
index 44965cb0de..590a935405 100644
--- a/doc/development.md
+++ b/doc/development.md
@@ -65,3 +65,4 @@ To run a single nose test:
nosetests common/lib/xmodule/xmodule/tests/test_stringify.py:test_stringify
+Very handy: if you uncomment the `--pdb` argument in `NOSE_ARGS` in `lms/envs/test.py`, it will drop you into pdb on error. This lets you go up and down the stack and see what the values of the variables are. Check out http://docs.python.org/library/pdb.html
diff --git a/lms/askbot/skins/mitx/media/images/email-sharing.png b/lms/askbot/skins/mitx/media/images/email-sharing.png
new file mode 100644
index 0000000000..57fcee00e9
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/email-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/facebook-sharing.png b/lms/askbot/skins/mitx/media/images/facebook-sharing.png
new file mode 100644
index 0000000000..a82612c342
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/facebook-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/google-plus-sharing.png b/lms/askbot/skins/mitx/media/images/google-plus-sharing.png
new file mode 100644
index 0000000000..04f21d7860
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/google-plus-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/lrg/email-sharing.png b/lms/askbot/skins/mitx/media/images/lrg/email-sharing.png
new file mode 100644
index 0000000000..2e5e972c2b
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/lrg/email-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/lrg/facebook-sharing.png b/lms/askbot/skins/mitx/media/images/lrg/facebook-sharing.png
new file mode 100644
index 0000000000..782e830680
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/lrg/facebook-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/lrg/facebook.png b/lms/askbot/skins/mitx/media/images/lrg/facebook.png
new file mode 100644
index 0000000000..f024b7c8ae
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/lrg/facebook.png differ
diff --git a/lms/askbot/skins/mitx/media/images/lrg/google-plus-sharing.png b/lms/askbot/skins/mitx/media/images/lrg/google-plus-sharing.png
new file mode 100644
index 0000000000..ddf2abef83
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/lrg/google-plus-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/lrg/linkedin.png b/lms/askbot/skins/mitx/media/images/lrg/linkedin.png
new file mode 100644
index 0000000000..34261b05e5
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/lrg/linkedin.png differ
diff --git a/lms/askbot/skins/mitx/media/images/lrg/twitter-sharing.png b/lms/askbot/skins/mitx/media/images/lrg/twitter-sharing.png
new file mode 100644
index 0000000000..55b29fafc0
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/lrg/twitter-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/lrg/twitter.png b/lms/askbot/skins/mitx/media/images/lrg/twitter.png
new file mode 100644
index 0000000000..3d1856f834
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/lrg/twitter.png differ
diff --git a/lms/askbot/skins/mitx/media/images/lrg/youtube-sharing.png b/lms/askbot/skins/mitx/media/images/lrg/youtube-sharing.png
new file mode 100644
index 0000000000..111aa685a8
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/lrg/youtube-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/social/email-sharing.png b/lms/askbot/skins/mitx/media/images/social/email-sharing.png
new file mode 100644
index 0000000000..57fcee00e9
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/social/email-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/social/facebook-sharing.png b/lms/askbot/skins/mitx/media/images/social/facebook-sharing.png
new file mode 100644
index 0000000000..a82612c342
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/social/facebook-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/social/google-plus-sharing.png b/lms/askbot/skins/mitx/media/images/social/google-plus-sharing.png
new file mode 100644
index 0000000000..04f21d7860
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/social/google-plus-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/social/twitter-sharing.png b/lms/askbot/skins/mitx/media/images/social/twitter-sharing.png
new file mode 100644
index 0000000000..7d171d0fef
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/social/twitter-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/social/youtube-sharing.png b/lms/askbot/skins/mitx/media/images/social/youtube-sharing.png
new file mode 100644
index 0000000000..a26b18121b
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/social/youtube-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/twitter-sharing.png b/lms/askbot/skins/mitx/media/images/twitter-sharing.png
new file mode 100644
index 0000000000..7d171d0fef
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/twitter-sharing.png differ
diff --git a/lms/askbot/skins/mitx/media/images/youtube-sharing.png b/lms/askbot/skins/mitx/media/images/youtube-sharing.png
new file mode 100644
index 0000000000..a26b18121b
Binary files /dev/null and b/lms/askbot/skins/mitx/media/images/youtube-sharing.png differ
diff --git a/lms/askbot/skins/mitx/templates/base.html b/lms/askbot/skins/mitx/templates/base.html
index 18ca213cb7..a344009c60 100644
--- a/lms/askbot/skins/mitx/templates/base.html
+++ b/lms/askbot/skins/mitx/templates/base.html
@@ -3,7 +3,7 @@
{% spaceless %}
- {% block title %}{% endblock %} - MITX 6.002
+ {% block title %}{% endblock %}
{% include "meta/html_head_meta.html" %}
{% include "meta/html_head_stylesheets.html" %}
diff --git a/lms/askbot/skins/mitx/templates/meta/bottom_scripts.html b/lms/askbot/skins/mitx/templates/meta/bottom_scripts.html
index d9d5eb97dd..4e189f598c 100644
--- a/lms/askbot/skins/mitx/templates/meta/bottom_scripts.html
+++ b/lms/askbot/skins/mitx/templates/meta/bottom_scripts.html
@@ -33,8 +33,14 @@
+{#
+
+#}
diff --git a/lms/askbot/skins/mitx/templates/navigation.jinja.html b/lms/askbot/skins/mitx/templates/navigation.jinja.html
index 59c7148184..686ae3a724 100644
--- a/lms/askbot/skins/mitx/templates/navigation.jinja.html
+++ b/lms/askbot/skins/mitx/templates/navigation.jinja.html
@@ -1,25 +1,25 @@
-
+
- Find Courses
+ Find Courses
-
+
- ${user.username}
+ {{user.username}}
▾
diff --git a/lms/askbot/skins/mitx/templates/widgets/footer.html b/lms/askbot/skins/mitx/templates/widgets/footer.html
index d152e2a38e..8d7b6d7737 100644
--- a/lms/askbot/skins/mitx/templates/widgets/footer.html
+++ b/lms/askbot/skins/mitx/templates/widgets/footer.html
@@ -3,19 +3,19 @@
@@ -25,10 +25,10 @@
diff --git a/lms/djangoapps/course_wiki/course_nav.py b/lms/djangoapps/course_wiki/course_nav.py
index 51158cf7bd..e95bb4dee4 100644
--- a/lms/djangoapps/course_wiki/course_nav.py
+++ b/lms/djangoapps/course_wiki/course_nav.py
@@ -4,7 +4,7 @@ from urlparse import urlparse
from django.http import Http404
from django.shortcuts import redirect
-from courseware.courses import check_course
+from courseware.courses import get_course_with_access
class Middleware(object):
@@ -20,9 +20,7 @@ class Middleware(object):
same page on the regular wiki.
"""
- def process_request(self, request):
- #TODO: We should also redirect people who can't see the class to the regular wiki, so urls don't break
-
+ def process_request(self, request):
referer = request.META.get('HTTP_REFERER')
try:
@@ -30,7 +28,7 @@ class Middleware(object):
referer_path = parsed_referer.path
except:
referer_path =""
-
+
path_match = re.match(r'^/wiki/(?P.*|)$', request.path)
if path_match:
# We are going to the wiki. Check if we came from a course
@@ -40,7 +38,7 @@ class Middleware(object):
# See if we are able to view the course. If we are, redirect to it
try:
- course = check_course(request.user, course_id)
+ course = get_course_with_access(request.user, course_id, 'load')
return redirect("/courses/" + course.id + "/wiki/" + path_match.group('wiki_path') )
except Http404:
@@ -55,14 +53,13 @@ class Middleware(object):
course_id = course_match.group('course_id')
# See if we are able to view the course. If we aren't, redirect to regular wiki
try:
- course = check_course(request.user, course_id)
+ course = get_course_with_access(request.user, course_id, 'load')
# Good, we can see the course. Carry on
return None
except Http404:
# We can't see the course, so redirect to the regular wiki
return redirect("/wiki/" + course_match.group('wiki_path'))
-
return None
def context_processor(request):
@@ -79,7 +76,7 @@ def context_processor(request):
course_id = match.group('course_id')
try:
- course = check_course(request.user, course_id)
+ course = get_course_with_access(request.user, course_id, 'load')
return {'course' : course}
except Http404:
# We couldn't access the course for whatever reason. It is too late to change
diff --git a/lms/djangoapps/course_wiki/tests/__init__.py b/lms/djangoapps/course_wiki/tests/__init__.py
new file mode 100644
index 0000000000..8b13789179
--- /dev/null
+++ b/lms/djangoapps/course_wiki/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/lms/djangoapps/course_wiki/tests/tests.py b/lms/djangoapps/course_wiki/tests/tests.py
new file mode 100644
index 0000000000..e004265379
--- /dev/null
+++ b/lms/djangoapps/course_wiki/tests/tests.py
@@ -0,0 +1,119 @@
+from django.core.urlresolvers import reverse
+from override_settings import override_settings
+
+import xmodule.modulestore.django
+
+from courseware.tests.tests import PageLoader, TEST_DATA_XML_MODULESTORE
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.xml_importer import import_from_xml
+
+
+@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE)
+class WikiRedirectTestCase(PageLoader):
+ def setUp(self):
+ xmodule.modulestore.django._MODULESTORES = {}
+ courses = modulestore().get_courses()
+
+ def find_course(name):
+ """Assumes the course is present"""
+ return [c for c in courses if c.location.course==name][0]
+
+ self.full = find_course("full")
+ self.toy = find_course("toy")
+
+ # Create two accounts
+ self.student = 'view@test.com'
+ self.instructor = 'view2@test.com'
+ self.password = 'foo'
+ self.create_account('u1', self.student, self.password)
+ self.create_account('u2', self.instructor, self.password)
+ self.activate_user(self.student)
+ self.activate_user(self.instructor)
+
+
+
+ def test_wiki_redirect(self):
+ """
+ Test that requesting wiki URLs redirect properly to or out of classes.
+
+ An enrolled in student going from /courses/edX/toy/2012_Fall/profile
+ to /wiki/some/fake/wiki/page/ will redirect to
+ /courses/edX/toy/2012_Fall/wiki/some/fake/wiki/page/
+
+ An unenrolled student going to /courses/edX/toy/2012_Fall/wiki/some/fake/wiki/page/
+ will be redirected to /wiki/some/fake/wiki/page/
+
+ """
+ self.login(self.student, self.password)
+
+ self.enroll(self.toy)
+
+ referer = reverse("profile", kwargs={ 'course_id' : self.toy.id })
+ destination = reverse("wiki:get", kwargs={'path': 'some/fake/wiki/page/'})
+
+ redirected_to = referer.replace("profile", "wiki/some/fake/wiki/page/")
+
+ resp = self.client.get( destination, HTTP_REFERER=referer)
+ self.assertEqual(resp.status_code, 302 )
+
+ self.assertEqual(resp['Location'], 'http://testserver' + redirected_to )
+
+
+ # Now we test that the student will be redirected away from that page if the course doesn't exist
+ # We do this in the same test because we want to make sure the redirected_to is constructed correctly
+
+ # This is a location like /courses/*/wiki/* , but with an invalid course ID
+ bad_course_wiki_page = redirected_to.replace( self.toy.location.course, "bad_course" )
+
+ resp = self.client.get( bad_course_wiki_page, HTTP_REFERER=referer)
+ self.assertEqual(resp.status_code, 302)
+ self.assertEqual(resp['Location'], 'http://testserver' + destination )
+
+
+ def create_course_page(self, course):
+ """
+ Test that loading the course wiki page creates the wiki page.
+ The user must be enrolled in the course to see the page.
+ """
+
+ course_wiki_home = reverse('course_wiki', kwargs={'course_id' : course.id})
+ referer = reverse("profile", kwargs={ 'course_id' : self.toy.id })
+
+ resp = self.client.get(course_wiki_home, follow=True, HTTP_REFERER=referer)
+
+ course_wiki_page = referer.replace('profile', 'wiki/' + self.toy.wiki_slug + "/")
+
+ ending_location = resp.redirect_chain[-1][0]
+ ending_status = resp.redirect_chain[-1][1]
+
+ self.assertEquals(ending_location, 'http://testserver' + course_wiki_page )
+ self.assertEquals(resp.status_code, 200)
+
+ self.has_course_navigator(resp)
+
+ def has_course_navigator(self, resp):
+ """
+ Ensure that the response has the course navigator.
+ """
+ self.assertTrue( "course info" in resp.content.lower() )
+ self.assertTrue( "courseware" in resp.content.lower() )
+
+
+ def test_course_navigator(self):
+ """"
+ Test that going from a course page to a wiki page contains the course navigator.
+ """
+
+ self.login(self.student, self.password)
+ self.enroll(self.toy)
+ self.create_course_page(self.toy)
+
+
+ course_wiki_page = reverse('wiki:get', kwargs={'path' : self.toy.wiki_slug + '/'})
+ referer = reverse("courseware", kwargs={ 'course_id' : self.toy.id })
+
+ resp = self.client.get(course_wiki_page, follow=True, HTTP_REFERER=referer)
+
+ self.has_course_navigator(resp)
+
+
diff --git a/lms/djangoapps/course_wiki/views.py b/lms/djangoapps/course_wiki/views.py
index 0ec69c5c3b..7a27625297 100644
--- a/lms/djangoapps/course_wiki/views.py
+++ b/lms/djangoapps/course_wiki/views.py
@@ -5,7 +5,7 @@ from django.shortcuts import redirect
from wiki.core.exceptions import NoRootURL
from wiki.models import URLPath, Article
-from courseware.courses import check_course
+from courseware.courses import get_course_by_id
log = logging.getLogger(__name__)
@@ -25,7 +25,7 @@ def course_wiki_redirect(request, course_id):
as it's home page. A course's wiki must be an article on the root (for
example, "/6.002x") to keep things simple.
"""
- course = check_course(request.user, course_id)
+ course = get_course_by_id(course_id)
course_slug = course.wiki_slug
diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py
new file mode 100644
index 0000000000..9605c827de
--- /dev/null
+++ b/lms/djangoapps/courseware/access.py
@@ -0,0 +1,301 @@
+"""This file contains (or should), all access control logic for the courseware.
+Ideally, it will be the only place that needs to know about any special settings
+like DISABLE_START_DATES"""
+
+import logging
+import time
+
+from django.conf import settings
+
+from xmodule.course_module import CourseDescriptor
+from xmodule.error_module import ErrorDescriptor
+from xmodule.modulestore import Location
+from xmodule.timeparse import parse_time
+from xmodule.x_module import XModule, XModuleDescriptor
+
+
+DEBUG_ACCESS = False
+
+log = logging.getLogger(__name__)
+
+def debug(*args, **kwargs):
+ # to avoid overly verbose output, this is off by default
+ if DEBUG_ACCESS:
+ log.debug(*args, **kwargs)
+
+def has_access(user, obj, action):
+ """
+ Check whether a user has the access to do action on obj. Handles any magic
+ switching based on various settings.
+
+ Things this module understands:
+ - start dates for modules
+ - DISABLE_START_DATES
+ - different access for staff, course staff, and students.
+
+ user: a Django user object. May be anonymous.
+
+ obj: The object to check access for. For now, a module or descriptor.
+
+ action: A string specifying the action that the client is trying to perform.
+
+ actions depend on the obj type, but include e.g. 'enroll' for courses. See the
+ type-specific functions below for the known actions for that type.
+
+ Returns a bool. It is up to the caller to actually deny access in a way
+ that makes sense in context.
+ """
+ # delegate the work to type-specific functions.
+ # (start with more specific types, then get more general)
+ if isinstance(obj, CourseDescriptor):
+ return _has_access_course_desc(user, obj, action)
+
+ if isinstance(obj, ErrorDescriptor):
+ return _has_access_error_desc(user, obj, action)
+
+ # NOTE: any descriptor access checkers need to go above this
+ if isinstance(obj, XModuleDescriptor):
+ return _has_access_descriptor(user, obj, action)
+
+ if isinstance(obj, XModule):
+ return _has_access_xmodule(user, obj, action)
+
+ if isinstance(obj, Location):
+ return _has_access_location(user, obj, action)
+
+ # Passing an unknown object here is a coding error, so rather than
+ # returning a default, complain.
+ raise TypeError("Unknown object type in has_access(). Object type: '{}'"
+ .format(type(obj)))
+
+# ================ Implementation helpers ================================
+
+def _has_access_course_desc(user, course, action):
+ """
+ Check if user has access to a course descriptor.
+
+ Valid actions:
+
+ 'load' -- load the courseware, see inside the course
+ 'enroll' -- enroll. Checks for enrollment window,
+ ACCESS_REQUIRE_STAFF_FOR_COURSE,
+ 'see_exists' -- can see that the course exists.
+ 'staff' -- staff access to course.
+ """
+ def can_load():
+ "Can this user load this course?"
+ # delegate to generic descriptor check
+ return _has_access_descriptor(user, course, action)
+
+ def can_enroll():
+ """
+ If the course has an enrollment period, check whether we are in it.
+ (staff can always enroll)
+ """
+
+ now = time.gmtime()
+ start = course.enrollment_start
+ end = course.enrollment_end
+
+ if (start is None or now > start) and (end is None or now < end):
+ # in enrollment period, so any user is allowed to enroll.
+ debug("Allow: in enrollment period")
+ return True
+
+ # otherwise, need staff access
+ return _has_staff_access_to_descriptor(user, course)
+
+ def see_exists():
+ """
+ Can see if can enroll, but also if can load it: if user enrolled in a course and now
+ it's past the enrollment period, they should still see it.
+
+ TODO (vshnayder): This means that courses with limited enrollment periods will not appear
+ to non-staff visitors after the enrollment period is over. If this is not what we want, will
+ need to change this logic.
+ """
+ # VS[compat] -- this setting should go away once all courses have
+ # properly configured enrollment_start times (if course should be
+ # staff-only, set enrollment_start far in the future.)
+ if settings.MITX_FEATURES.get('ACCESS_REQUIRE_STAFF_FOR_COURSE'):
+ # if this feature is on, only allow courses that have ispublic set to be
+ # seen by non-staff
+ if course.metadata.get('ispublic'):
+ debug("Allow: ACCESS_REQUIRE_STAFF_FOR_COURSE and ispublic")
+ return True
+ return _has_staff_access_to_descriptor(user, course)
+
+ return can_enroll() or can_load()
+
+ checkers = {
+ 'load': can_load,
+ 'enroll': can_enroll,
+ 'see_exists': see_exists,
+ 'staff': lambda: _has_staff_access_to_descriptor(user, course)
+ }
+
+ return _dispatch(checkers, action, user, course)
+
+
+def _has_access_error_desc(user, descriptor, action):
+ """
+ Only staff should see error descriptors.
+
+ Valid actions:
+ 'load' -- load this descriptor, showing it to the user.
+ 'staff' -- staff access to descriptor.
+ """
+ def check_for_staff():
+ return _has_staff_access_to_descriptor(user, descriptor)
+
+ checkers = {
+ 'load': check_for_staff,
+ 'staff': check_for_staff
+ }
+
+ return _dispatch(checkers, action, user, descriptor)
+
+
+def _has_access_descriptor(user, descriptor, action):
+ """
+ Check if user has access to this descriptor.
+
+ Valid actions:
+ 'load' -- load this descriptor, showing it to the user.
+ 'staff' -- staff access to descriptor.
+
+ NOTE: This is the fallback logic for descriptors that don't have custom policy
+ (e.g. courses). If you call this method directly instead of going through
+ has_access(), it will not do the right thing.
+ """
+ def can_load():
+ # If start dates are off, can always load
+ if settings.MITX_FEATURES['DISABLE_START_DATES']:
+ debug("Allow: DISABLE_START_DATES")
+ return True
+
+ # Check start date
+ if descriptor.start is not None:
+ now = time.gmtime()
+ if now > descriptor.start:
+ # after start date, everyone can see it
+ debug("Allow: now > start date")
+ return True
+ # otherwise, need staff access
+ return _has_staff_access_to_descriptor(user, descriptor)
+
+ # No start date, so can always load.
+ debug("Allow: no start date")
+ return True
+
+ checkers = {
+ 'load': can_load,
+ 'staff': lambda: _has_staff_access_to_descriptor(user, descriptor)
+ }
+
+ return _dispatch(checkers, action, user, descriptor)
+
+
+
+
+def _has_access_xmodule(user, xmodule, action):
+ """
+ Check if user has access to this xmodule.
+
+ Valid actions:
+ - same as the valid actions for xmodule.descriptor
+ """
+ # Delegate to the descriptor
+ return has_access(user, xmodule.descriptor, action)
+
+
+def _has_access_location(user, location, action):
+ """
+ Check if user has access to this location.
+
+ Valid actions:
+ 'staff' : True if the user has staff access to this location
+
+ NOTE: if you add other actions, make sure that
+
+ has_access(user, location, action) == has_access(user, get_item(location), action)
+
+ And in general, prefer checking access on loaded items, rather than locations.
+ """
+ checkers = {
+ 'staff': lambda: _has_staff_access_to_location(user, location)
+ }
+
+ return _dispatch(checkers, action, user, location)
+
+
+##### Internal helper methods below
+
+def _dispatch(table, action, user, obj):
+ """
+ Helper: call table[action], raising a nice pretty error if there is no such key.
+
+ user and object passed in only for error messages and debugging
+ """
+ if action in table:
+ result = table[action]()
+ debug("%s user %s, object %s, action %s",
+ 'ALLOWED' if result else 'DENIED',
+ user,
+ obj.location.url() if isinstance(obj, XModuleDescriptor) else str(obj)[:60],
+ action)
+ return result
+
+ raise ValueError("Unknown action for object type '{}': '{}'".format(
+ type(obj), action))
+
+def _course_staff_group_name(location):
+ """
+ Get the name of the staff group for a location. Right now, that's staff_COURSE.
+
+ location: something that can passed to Location.
+ """
+ return 'staff_%s' % Location(location).course
+
+def _has_staff_access_to_location(user, location):
+ '''
+ Returns True if the given user has staff access to a location. For now this
+ is equivalent to having staff access to the course location.course.
+
+ 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 a string: the course field of the location being accessed.
+ '''
+ if user is None or (not user.is_authenticated()):
+ debug("Deny: no user or anon user")
+ return False
+ if user.is_staff:
+ debug("Allow: user.is_staff")
+ return True
+
+ # If not global staff, is the user in the Auth group for this class?
+ user_groups = [x[1] for x in user.groups.values_list()]
+ staff_group = _course_staff_group_name(location)
+ if staff_group in user_groups:
+ debug("Allow: user in group %s", staff_group)
+ return True
+ debug("Deny: user not in group %s", staff_group)
+ return False
+
+def _has_staff_access_to_course_id(user, course_id):
+ """Helper method that takes a course_id instead of a course name"""
+ loc = CourseDescriptor.id_to_location(course_id)
+ return _has_staff_access_to_location(user, loc)
+
+
+def _has_staff_access_to_descriptor(user, descriptor):
+ """Helper method that checks whether the user has staff access to
+ the course of the location.
+
+ location: something that can be passed to Location
+ """
+ return _has_staff_access_to_location(user, descriptor.location)
+
diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py
index ffdf0650a6..2e74853760 100644
--- a/lms/djangoapps/courseware/courses.py
+++ b/lms/djangoapps/courseware/courses.py
@@ -12,49 +12,51 @@ from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from static_replace import replace_urls, try_staticfiles_lookup
+from courseware.access import has_access
log = logging.getLogger(__name__)
-def check_course(user, course_id, course_must_be_open=True, course_required=True):
+def get_course_by_id(course_id):
"""
- 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.
+ Given a course id, return the corresponding course descriptor.
- If course_must_be_open is False, the course will be returned
- without a 404 even if it is not open.
-
- 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.
+ If course_id is not valid, raises a 404.
"""
- course = None
- if course_required or course_id:
- try:
- course_loc = CourseDescriptor.id_to_location(course_id)
- course = modulestore().get_item(course_loc)
+ try:
+ course_loc = CourseDescriptor.id_to_location(course_id)
+ return modulestore().get_item(course_loc)
+ except (KeyError, ItemNotFoundError):
+ raise Http404("Course not found.")
- except (KeyError, ItemNotFoundError):
- raise Http404("Course not found.")
- started = course.has_started() or settings.MITX_FEATURES['DISABLE_START_DATES']
- 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.")
+def get_course_with_access(user, course_id, action):
+ """
+ Given a course_id, look up the corresponding course descriptor,
+ check that the user has the access to perform the specified action
+ on the course, and return the descriptor.
+ Raises a 404 if the course_id is invalid, or the user doesn't have access.
+ """
+ course = get_course_by_id(course_id)
+ if not has_access(user, course, action):
+ # Deliberately return a non-specific error message to avoid
+ # leaking info about access control settings
+ raise Http404("Course not found.")
return course
+def get_opt_course_with_access(user, course_id, action):
+ """
+ Same as get_course_with_access, except that if course_id is None,
+ return None without performing any access checks.
+ """
+ if course_id is None:
+ return None
+ return get_course_with_access(user, course_id, action)
+
+
def course_image_url(course):
"""Try to look up the image url for the course. If it's not found,
log an error and return the dead link"""
@@ -140,66 +142,10 @@ def get_course_info_section(course, section_key):
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)
- '''
- if isinstance(course, str) or isinstance(course, unicode):
- coursename = course
- else:
- # 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):
- '''
- 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
-
- # 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)
- if staff_group in user_groups:
- return True
- return False
-
-def has_staff_access_to_course_id(user, course_id):
- """Helper method that takes a course_id instead of a course name"""
- loc = CourseDescriptor.id_to_location(course_id)
- return has_staff_access_to_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)
-
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.
'''
# TODO: Clean up how 'error' is done.
# filter out any courses that errored.
@@ -208,9 +154,7 @@ 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('ACCESS_REQUIRE_STAFF_FOR_COURSE'):
- if not has_access_to_course(user,course):
- continue
- universities[course.org].append(course)
+ if has_access(user, course, 'see_exists'):
+ universities[course.org].append(course)
return universities
diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py
index aa160ee22a..8417bf9a48 100644
--- a/lms/djangoapps/courseware/grades.py
+++ b/lms/djangoapps/courseware/grades.py
@@ -63,7 +63,12 @@ def grade(student, request, course, student_module_cache=None):
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)
+ section_module = get_module(student, request,
+ section_descriptor.location, student_module_cache)
+ if section_module is None:
+ # student doesn't have access to this module, or something else
+ # went wrong.
+ continue
# 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
diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py
index aa9f946733..261140dec7 100644
--- a/lms/djangoapps/courseware/models.py
+++ b/lms/djangoapps/courseware/models.py
@@ -67,7 +67,7 @@ class StudentModuleCache(object):
"""
A cache of StudentModules for a specific student
"""
- def __init__(self, user, descriptors):
+ def __init__(self, user, descriptors, select_for_update=False):
'''
Find any StudentModule objects that are needed by any descriptor
in descriptors. Avoids making multiple queries to the database.
@@ -77,6 +77,7 @@ class StudentModuleCache(object):
Arguments
user: The user for which to fetch maching StudentModules
descriptors: An array of XModuleDescriptors.
+ select_for_update: Flag indicating whether the row should be locked until end of transaction
'''
if user.is_authenticated():
module_ids = self._get_module_state_keys(descriptors)
@@ -86,23 +87,30 @@ class StudentModuleCache(object):
self.cache = []
chunk_size = 500
for id_chunk in [module_ids[i:i + chunk_size] for i in xrange(0, len(module_ids), chunk_size)]:
- self.cache.extend(StudentModule.objects.filter(
- student=user,
- module_state_key__in=id_chunk)
- )
+ if select_for_update:
+ self.cache.extend(StudentModule.objects.select_for_update().filter(
+ student=user,
+ module_state_key__in=id_chunk)
+ )
+ else:
+ self.cache.extend(StudentModule.objects.filter(
+ student=user,
+ module_state_key__in=id_chunk)
+ )
else:
self.cache = []
@classmethod
- def cache_for_descriptor_descendents(cls, user, descriptor, depth=None, descriptor_filter=lambda descriptor: True):
+ def cache_for_descriptor_descendents(cls, user, descriptor, depth=None, descriptor_filter=lambda descriptor: True, select_for_update=False):
"""
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
+ select_for_update: Flag indicating whether the row should be locked until end of transaction
"""
def get_child_descriptors(descriptor, depth, descriptor_filter):
@@ -122,7 +130,7 @@ class StudentModuleCache(object):
descriptors = get_child_descriptors(descriptor, depth, descriptor_filter)
- return StudentModuleCache(user, descriptors)
+ return StudentModuleCache(user, descriptors, select_for_update)
def _get_module_state_keys(self, descriptors):
'''
diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index 3bb1c477cb..dae9d7a952 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -2,27 +2,40 @@ import json
import logging
from django.conf import settings
+from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.http import Http404
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
-from django.contrib.auth.models import User
-from xmodule.modulestore.django import modulestore
-from capa.xqueue_interface import qinterface
+from requests.auth import HTTPBasicAuth
+
+from capa.xqueue_interface import XQueueInterface
+from courseware.access import has_access
from mitxmako.shortcuts import render_to_string
from models import StudentModule, StudentModuleCache
from static_replace import replace_urls
from xmodule.exceptions import NotFoundError
+from xmodule.modulestore import Location
+from xmodule.modulestore.django import modulestore
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,
- has_staff_access_to_location)
-
log = logging.getLogger("mitx.courseware")
+if settings.XQUEUE_INTERFACE['basic_auth'] is not None:
+ requests_auth = HTTPBasicAuth(*settings.XQUEUE_INTERFACE['basic_auth'])
+else:
+ requests_auth = None
+
+xqueue_interface = XQueueInterface(
+ settings.XQUEUE_INTERFACE['url'],
+ settings.XQUEUE_INTERFACE['django_auth'],
+ requests_auth,
+)
+
+
def make_track_function(request):
'''
Make a tracking function that logs what happened.
@@ -52,6 +65,9 @@ def toc_for_course(user, request, course, active_chapter, active_section):
Everything else comes from the xml, or defaults to "".
chapters with name 'hidden' are skipped.
+
+ NOTE: assumes that if we got this far, user has access to course. Returns
+ None if this is not the case.
'''
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(user, course, depth=2)
@@ -129,6 +145,10 @@ def get_module(user, request, location, student_module_cache, position=None):
'''
descriptor = modulestore().get_item(location)
+ # Short circuit--if the user shouldn't have access, bail without doing any work
+ if not has_access(user, descriptor, 'load'):
+ return None
+
#TODO Only check the cache if this module can possibly have state
instance_module = None
shared_module = None
@@ -142,22 +162,16 @@ def get_module(user, request, location, student_module_cache, position=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 = reverse('modx_dispatch',
+ 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('/')[:-1] # Trailing slash provided by reverse
xqueue_callback_url += reverse('xqueue_callback',
@@ -172,11 +186,14 @@ def get_module(user, request, location, student_module_cache, position=None):
# TODO: Queuename should be derived from 'course_settings.json' of each course
xqueue_default_queuename = descriptor.location.org + '-' + descriptor.location.course
- xqueue = { 'interface': qinterface,
- 'callback_url': xqueue_callback_url,
- 'default_queuename': xqueue_default_queuename.replace(' ','_') }
+ xqueue = {'interface': xqueue_interface,
+ 'callback_url': xqueue_callback_url,
+ 'default_queuename': xqueue_default_queuename.replace(' ', '_')}
def _get_module(location):
+ """
+ Delegate to get_module. It does an access check, so may return None
+ """
return get_module(user, request, location,
student_module_cache, position)
@@ -195,12 +212,11 @@ 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=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)
+ system.set('DEBUG', settings.DEBUG)
module = descriptor.xmodule_constructor(system)(instance_state, shared_state)
@@ -210,7 +226,7 @@ def get_module(user, request, location, student_module_cache, position=None):
)
if settings.MITX_FEATURES.get('DISPLAY_HISTOGRAMS_TO_STAFF'):
- if has_staff_access_to_course(user, module.location.course):
+ if has_access(user, module, 'staff'):
module.get_html = add_histogram(module.get_html, module, user)
return module
@@ -291,12 +307,17 @@ def xqueue_callback(request, course_id, userid, id, dispatch):
# Retrieve target StudentModule
user = User.objects.get(id=userid)
- student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(user, modulestore().get_item(id))
+ student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(
+ user, modulestore().get_item(id), depth=0, select_for_update=True)
instance = get_module(user, request, id, student_module_cache)
+ if instance is None:
+ log.debug("No module {} for user {}--access denied?".format(id, user))
+ raise Http404
+
instance_module = get_instance_module(user, instance, student_module_cache)
if instance_module is None:
- log.debug("Couldn't find module '%s' for user '%s'", id, user)
+ log.debug("Couldn't find instance of module '%s' for user '%s'", id, user)
raise Http404
oldgrade = instance_module.grade
@@ -336,14 +357,25 @@ def modx_dispatch(request, dispatch=None, id=None, course_id=None):
- id -- the module id. Used to look up the XModule instance
'''
# ''' (fix emacs broken parsing)
- # Check for submitted files
+
+ # Check for submitted files and basic file size checks
p = request.POST.copy()
if request.FILES:
for inputfile_id in request.FILES.keys():
- p[inputfile_id] = request.FILES[inputfile_id]
+ inputfile = request.FILES[inputfile_id]
+ if inputfile.size > settings.STUDENT_FILEUPLOAD_MAX_SIZE: # Bytes
+ file_too_big_msg = 'Submission aborted! Your file "%s" is too large (max size: %d MB)' %\
+ (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE/(1000**2))
+ return HttpResponse(json.dumps({'success': file_too_big_msg}))
+ p[inputfile_id] = inputfile
student_module_cache = StudentModuleCache.cache_for_descriptor_descendents(request.user, modulestore().get_item(id))
instance = get_module(request.user, request, id, student_module_cache)
+ if instance is None:
+ # Either permissions just changed, or someone is trying to be clever
+ # and load something they shouldn't have access to.
+ log.debug("No module {} for user {}--access denied?".format(id, user))
+ raise Http404
instance_module = get_instance_module(request.user, instance, student_module_cache)
shared_module = get_shared_instance_module(request.user, instance, student_module_cache)
diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py
index daffa44d2a..f3b978adac 100644
--- a/lms/djangoapps/courseware/tests/tests.py
+++ b/lms/djangoapps/courseware/tests/tests.py
@@ -18,12 +18,14 @@ from override_settings import override_settings
import xmodule.modulestore.django
+# Need access to internal func to put users in the right group
+from courseware.access import _course_staff_group_name
+
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
-
+from xmodule.timeparse import stringify_time
def parse_json(response):
"""Parse response, which is assumed to be json"""
@@ -185,7 +187,7 @@ class PageLoader(ActivateLoginTestCase):
def unenroll(self, course):
"""Unenroll the currently logged-in user, and check that it worked."""
resp = self.client.post('/change_enrollment', {
- 'enrollment_action': 'enroll',
+ 'enrollment_action': 'unenroll',
'course_id': course.id,
})
data = parse_json(resp)
@@ -310,7 +312,7 @@ class TestViewAuth(PageLoader):
self.check_for_get_code(404, url)
# Make the instructor staff in the toy course
- group_name = course_staff_group_name(self.toy)
+ group_name = _course_staff_group_name(self.toy.location)
g = Group.objects.create(name=group_name)
g.user_set.add(user(self.instructor))
@@ -340,25 +342,22 @@ class TestViewAuth(PageLoader):
def run_wrapped(self, test):
"""
- test.py turns off start dates. Enable them and DARK_LAUNCH.
+ test.py turns off start dates. Enable them.
Because settings is global, be careful not to mess it up for other tests
(Can't use override_settings because we're only changing part of the
MITX_FEATURES dict)
"""
oldDSD = settings.MITX_FEATURES['DISABLE_START_DATES']
- oldDL = settings.MITX_FEATURES['DARK_LAUNCH']
try:
settings.MITX_FEATURES['DISABLE_START_DATES'] = False
- settings.MITX_FEATURES['DARK_LAUNCH'] = True
test()
finally:
settings.MITX_FEATURES['DISABLE_START_DATES'] = oldDSD
- settings.MITX_FEATURES['DARK_LAUNCH'] = oldDL
def test_dark_launch(self):
- """Make sure that when dark launch is on, students can't access course
+ """Make sure that before course start, students can't access course
pages, but instructors can"""
self.run_wrapped(self._do_test_dark_launch)
@@ -372,13 +371,12 @@ class TestViewAuth(PageLoader):
# 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.toy.metadata['start'] = stringify_time(time.gmtime(tomorrow))
+ self.full.metadata['start'] = stringify_time(time.gmtime(tomorrow))
self.assertFalse(self.toy.has_started())
self.assertFalse(self.full.has_started())
self.assertFalse(settings.MITX_FEATURES['DISABLE_START_DATES'])
- self.assertTrue(settings.MITX_FEATURES['DARK_LAUNCH'])
def reverse_urls(names, course):
"""Reverse a list of course urls"""
@@ -444,7 +442,7 @@ class TestViewAuth(PageLoader):
print '=== Testing course instructor access....'
# Make the instructor staff in the toy course
- group_name = course_staff_group_name(self.toy)
+ group_name = _course_staff_group_name(self.toy.location)
g = Group.objects.create(name=group_name)
g.user_set.add(user(self.instructor))
@@ -494,7 +492,7 @@ class TestViewAuth(PageLoader):
print '=== Testing course instructor access....'
# Make the instructor staff in the toy course
- group_name = course_staff_group_name(self.toy)
+ group_name = _course_staff_group_name(self.toy.location)
g = Group.objects.create(name=group_name)
g.user_set.add(user(self.instructor))
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index 00ab45e605..ab63872170 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -15,21 +15,19 @@ from mitxmako.shortcuts import render_to_response, render_to_string
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
-from module_render import toc_for_course, get_module, get_section
-from models import StudentModuleCache
-from student.models import UserProfile
-from xmodule.modulestore import Location
-from xmodule.modulestore.search import path_to_location
-from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
-from xmodule.modulestore.django import modulestore
-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,
- has_staff_access_to_course_id)
-
+from courseware.access import has_access
+from courseware.courses import (get_course_with_access, get_courses_by_university)
+from models import StudentModuleCache
+from module_render import toc_for_course, get_module, get_section
+from student.models import UserProfile
+from student.models import UserTestGroup, CourseEnrollment
+from util.cache import cache, cache_if_anonymous
+from xmodule.course_module import CourseDescriptor
+from xmodule.modulestore import Location
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem
+from xmodule.modulestore.search import path_to_location
log = logging.getLogger("mitx.courseware")
@@ -93,7 +91,8 @@ def render_accordion(request, course, chapter, section):
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
def index(request, course_id, chapter=None, section=None,
position=None):
- ''' Displays courseware accordion, and any associated content.
+ """
+ Displays courseware accordion, and any associated content.
If course, chapter, and section aren't all specified, just returns
the accordion. If they are specified, returns an error if they don't
point to a valid module.
@@ -109,8 +108,8 @@ def index(request, course_id, chapter=None, section=None,
Returns:
- HTTPresponse
- '''
- course = check_course(request.user, course_id)
+ """
+ course = get_course_with_access(request.user, course_id, 'load')
registered = registered_for_course(course, request.user)
if not registered:
# TODO (vshnayder): do course instructors need to be registered to see course?
@@ -137,6 +136,10 @@ def index(request, course_id, chapter=None, section=None,
module = get_module(request.user, request,
section_descriptor.location,
student_module_cache)
+ if module is None:
+ # User is probably being clever and trying to access something
+ # they don't have access to.
+ raise Http404
context['content'] = module.get_html()
else:
log.warning("Couldn't find a section descriptor for course_id '{0}',"
@@ -204,7 +207,7 @@ def course_info(request, course_id):
Assumes the course_id is in a valid format.
"""
- course = check_course(request.user, course_id)
+ course = get_course_with_access(request.user, course_id, 'load')
return render_to_response('info.html', {'course': course})
@@ -221,7 +224,7 @@ def registered_for_course(course, user):
@ensure_csrf_cookie
@cache_if_anonymous
def course_about(request, course_id):
- course = check_course(request.user, course_id, course_must_be_open=False)
+ course = get_course_with_access(request.user, course_id, 'see_exists')
registered = registered_for_course(course, request.user)
return render_to_response('portal/course_about.html', {'course': course, 'registered': registered})
@@ -253,14 +256,14 @@ def profile(request, course_id, student_id=None):
Course staff are allowed to see the profiles of students in their class.
"""
- course = check_course(request.user, course_id)
+ course = get_course_with_access(request.user, course_id, 'load')
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):
+ if not has_access(request.user, course, 'staff'):
raise Http404
student = User.objects.get(id=int(student_id))
@@ -297,10 +300,7 @@ def gradebook(request, course_id):
- 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)
+ course = get_course_with_access(request.user, course_id, 'staff')
enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
@@ -322,10 +322,7 @@ def gradebook(request, 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)
+ course = get_course_with_access(request.user, course_id, 'staff')
# For now, just a static page
context = {'course': course }
@@ -335,10 +332,7 @@ def grade_summary(request, course_id):
@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)
+ course = get_course_with_access(request.user, course_id, 'staff')
# For now, just a static page
context = {'course': course }
diff --git a/lms/djangoapps/simplewiki/views.py b/lms/djangoapps/simplewiki/views.py
index a6bb192fd7..2ee76a1868 100644
--- a/lms/djangoapps/simplewiki/views.py
+++ b/lms/djangoapps/simplewiki/views.py
@@ -9,7 +9,7 @@ from django.utils import simplejson
from django.utils.translation import ugettext_lazy as _
from mitxmako.shortcuts import render_to_response
-from courseware.courses import check_course
+from courseware.courses import get_opt_course_with_access
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.django import modulestore
@@ -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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
(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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
(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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
#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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
(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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
(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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
# 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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
(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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
(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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
(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(request.user, course_id, course_required=False)
+ course = get_opt_course_with_access(request.user, course_id, 'load')
from random import randint
num_arts = Article.objects.count()
diff --git a/lms/djangoapps/staticbook/views.py b/lms/djangoapps/staticbook/views.py
index e63619756c..aec3fb1448 100644
--- a/lms/djangoapps/staticbook/views.py
+++ b/lms/djangoapps/staticbook/views.py
@@ -1,15 +1,17 @@
from django.contrib.auth.decorators import login_required
from mitxmako.shortcuts import render_to_response
-from courseware.courses import check_course
+from courseware.courses import get_course_with_access
from lxml import etree
@login_required
def index(request, course_id, page=0):
- course = check_course(request.user, course_id)
+ course = get_course_with_access(request.user, course_id, 'load')
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})
+ return render_to_response('staticbook.html',
+ {'page': int(page), 'course': course,
+ 'table_of_contents': table_of_contents})
def index_shifted(request, course_id, page):
diff --git a/lms/envs/aws.py b/lms/envs/aws.py
index 460ec18d27..c704fd164e 100644
--- a/lms/envs/aws.py
+++ b/lms/envs/aws.py
@@ -54,3 +54,5 @@ AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
DATABASES = AUTH_TOKENS['DATABASES']
+
+XQUEUE_INTERFACE = AUTH_TOKENS['XQUEUE_INTERFACE']
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 678d592f43..48fe931869 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -48,7 +48,6 @@ 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,
@@ -95,12 +94,12 @@ system_node_path = os.environ.get("NODE_PATH", None)
if system_node_path is None:
system_node_path = "/usr/local/lib/node_modules"
-node_paths = [COMMON_ROOT / "static/js/vendor",
+node_paths = [COMMON_ROOT / "static/js/vendor",
COMMON_ROOT / "static/coffee/src",
system_node_path
]
NODE_PATH = ':'.join(node_paths)
-
+
################################## MITXWEB #####################################
# This is where we stick our compiled template files. Most of the app uses Mako
# templates
@@ -135,6 +134,7 @@ TEMPLATE_CONTEXT_PROCESSORS = (
'course_wiki.course_nav.context_processor',
)
+STUDENT_FILEUPLOAD_MAX_SIZE = 4*1000*1000 # 4 MB
# FIXME:
# We should have separate S3 staged URLs in case we need to make changes to
@@ -228,8 +228,7 @@ STATIC_ROOT = ENV_ROOT / "staticfiles"
STATICFILES_DIRS = [
COMMON_ROOT / "static",
PROJECT_ROOT / "static",
- ASKBOT_ROOT / "askbot" / "skins",
-
+ PROJECT_ROOT / "askbot" / "skins",
]
if os.path.isdir(DATA_DIR):
STATICFILES_DIRS += [
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index 85850e81e3..882a82b8f0 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -53,6 +53,15 @@ CACHES = {
}
}
+XQUEUE_INTERFACE = {
+ "url": "http://xqueue.sandbox.edx.org",
+ "django_auth": {
+ "username": "lms",
+ "password": "***REMOVED***"
+ },
+ "basic_auth": ('anant', 'agarwal'),
+}
+
# Make the keyedcache startup warnings go away
CACHE_TIMEOUT = 0
diff --git a/lms/envs/devgroups/courses.py b/lms/envs/devgroups/courses.py
index bcaae70a99..92b28f3575 100644
--- a/lms/envs/devgroups/courses.py
+++ b/lms/envs/devgroups/courses.py
@@ -41,3 +41,6 @@ def course_db_for(course_id):
}
}
+def askbot_url_for(course_id):
+ return "courses/{0}/discussions/".format(course_id)
+
diff --git a/lms/envs/devgroups/h_cs50.py b/lms/envs/devgroups/h_cs50.py
index b838b1fdc3..3cd6d6f2c0 100644
--- a/lms/envs/devgroups/h_cs50.py
+++ b/lms/envs/devgroups/h_cs50.py
@@ -1,3 +1,4 @@
from .courses import *
-DATABASES = course_db_for('HarvardX/CS50x/2012')
\ No newline at end of file
+DATABASES = course_db_for('HarvardX/CS50x/2012')
+ASKBOT_URL = askbot_url_for("HarvardX/CS50x/2012")
\ No newline at end of file
diff --git a/lms/envs/devgroups/m_6002.py b/lms/envs/devgroups/m_6002.py
index 3d8feef764..e791857d3a 100644
--- a/lms/envs/devgroups/m_6002.py
+++ b/lms/envs/devgroups/m_6002.py
@@ -1,3 +1,4 @@
from .courses import *
-DATABASES = course_db_for('MITx/6.002x/2012_Fall')
\ No newline at end of file
+DATABASES = course_db_for('MITx/6.002x/2012_Fall')
+ASKBOT_URL = askbot_url_for("MITx/6.002x/2012_Fall")
diff --git a/lms/envs/test.py b/lms/envs/test.py
index cd0e984940..187cb5c68e 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -50,6 +50,16 @@ COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data"
GITHUB_REPO_ROOT = ENV_ROOT / "data"
+XQUEUE_INTERFACE = {
+ "url": "http://xqueue.sandbox.edx.org",
+ "django_auth": {
+ "username": "lms",
+ "password": "***REMOVED***"
+ },
+ "basic_auth": ('anant', 'agarwal'),
+}
+
+
# TODO (cpennington): We need to figure out how envs/test.py can inject things
# into common.py so that we don't have to repeat this sort of thing
STATICFILES_DIRS = [
diff --git a/lms/static/images/search-icon.png b/lms/static/images/search-icon.png
new file mode 100644
index 0000000000..22b0d2d3c2
Binary files /dev/null and b/lms/static/images/search-icon.png differ
diff --git a/lms/static/js/jquery.gradebook.js b/lms/static/js/jquery.gradebook.js
new file mode 100644
index 0000000000..187e58189f
--- /dev/null
+++ b/lms/static/js/jquery.gradebook.js
@@ -0,0 +1,76 @@
+
+
+
+var Gradebook = function($element) {
+ var _this = this;
+ var $element = $element;
+ var $grades = $element.find('.grades');
+ var $gradeTable = $element.find('.grade-table');
+ var $leftShadow = $('
');
+ var $rightShadow = $('
');
+ var tableHeight = $gradeTable.height();
+ var maxScroll = $gradeTable.width() - $grades.width();
+ var $body = $('body');
+ var mouseOrigin;
+ var tableOrigin;
+
+ var startDrag = function(e) {
+ mouseOrigin = e.pageX;
+ tableOrigin = $gradeTable.position().left;
+ $body.css('-webkit-user-select', 'none');
+ $body.bind('mousemove', moveDrag);
+ $body.bind('mouseup', stopDrag);
+ };
+
+ var moveDrag = function(e) {
+ var offset = e.pageX - mouseOrigin;
+ var targetLeft = clamp(tableOrigin + offset, -maxScroll, 0);
+
+ updateHorizontalPosition(targetLeft);
+
+ setShadows(targetLeft);
+ };
+
+ var stopDrag = function(e) {
+ $body.css('-webkit-user-select', 'auto');
+ $body.unbind('mousemove', moveDrag);
+ $body.unbind('mouseup', stopDrag);
+ };
+
+ var setShadows = function(left) {
+ var padding = 30;
+
+ var leftPercent = clamp(-left / padding, 0, 1);
+ $leftShadow.css('opacity', leftPercent);
+
+ var rightPercent = clamp((maxScroll + left) / padding, 0, 1);
+ $rightShadow.css('opacity', rightPercent);
+ };
+
+ var clamp = function(val, min, max) {
+ if(val > max) return max;
+ if(val < min) return min;
+ return val;
+ };
+
+ var updateWidths = function(e) {
+ maxScroll = $gradeTable.width() - $grades.width();
+ var targetLeft = clamp($gradeTable.position().left, -maxScroll, 0);
+ updateHorizontalPosition(targetLeft);
+ setShadows(targetLeft);
+ }
+
+ var updateHorizontalPosition = function(left) {
+ $gradeTable.css({
+ 'left': left + 'px'
+ });
+ }
+
+ $leftShadow.css('height', tableHeight + 'px');
+ $rightShadow.css('height', tableHeight + 'px');
+ $grades.append($leftShadow).append($rightShadow);
+ setShadows(0);
+ $grades.css('height', tableHeight);
+ $gradeTable.bind('mousedown', startDrag);
+ $(window).bind('resize', updateWidths);
+}
\ No newline at end of file
diff --git a/lms/static/sass/course.scss b/lms/static/sass/course.scss
index c874076a31..d3a74cb91b 100644
--- a/lms/static/sass/course.scss
+++ b/lms/static/sass/course.scss
@@ -10,7 +10,7 @@
@import 'shared/tooltips';
// Course base / layout styles
-@import 'course/layout/courseware_subnav';
+@import 'course/layout/courseware_header';
@import 'course/base/base';
@import 'course/base/extends';
@import 'module/module-styles.scss';
diff --git a/lms/static/sass/course/_gradebook.scss b/lms/static/sass/course/_gradebook.scss
index b94f5de178..cd3205149c 100644
--- a/lms/static/sass/course/_gradebook.scss
+++ b/lms/static/sass/course/_gradebook.scss
@@ -1,11 +1,203 @@
+$cell-border-color: #e1e1e1;
+$table-border-color: #c8c8c8;
+
div.gradebook-wrapper {
@extend .table-wrapper;
section.gradebook-content {
@extend .content;
+ .student-search {
+ padding: 0 20px 0 15px;
+ }
+
+ .student-search-field {
+ width: 100%;
+ height: 27px;
+ padding: 0 15px 0 35px;
+ box-sizing: border-box;
+ border-radius: 13px;
+ border: 1px solid $table-border-color;
+ background: url(../images/search-icon.png) no-repeat 9px center #f6f6f6;
+ font-family: $sans-serif;
+ font-size: 11px;
+ @include box-shadow(0 1px 4px rgba(0, 0, 0, .12) inset);
+ outline: none;
+ @include transition(border-color .15s);
+
+ &::-webkit-input-placeholder,
+ &::-moz-input-placeholder {
+ font-style: italic;
+ }
+
+ &:focus {
+ border-color: #1d9dd9;
+ }
+ }
+
+ .student-table {
+ float: left;
+ // width: 264px;
+ width: 24%;
+ border-radius: 3px 0 0 3px;
+ color: #3c3c3c;
+
+ th {
+ height: 50px;
+ }
+
+ tr:first-child td {
+ border-top: 1px solid $table-border-color;
+ border-radius: 5px 0 0 0;
+ }
+
+ tr:last-child td {
+ border-bottom: 1px solid $table-border-color;
+ border-radius: 0 0 0 5px;
+ }
+
+ td {
+ height: 50px;
+ padding-left: 20px;
+ border-bottom: 1px solid $cell-border-color;
+ border-left: 1px solid $table-border-color;
+ background: #f3f3f3;
+ font-size: 13px;
+ line-height: 50px;
+ }
+
+ tr:nth-child(odd) td {
+ background-color: #fbfbfb;
+ }
+ }
+
+ .grades {
+ position: relative;
+ float: left;
+ width: 76%;
+ overflow: hidden;
+
+ .left-shadow,
+ .right-shadow {
+ position: absolute;
+ top: 0;
+ z-index: 9999;
+ width: 20px;
+ pointer-events: none;
+ }
+
+ .left-shadow {
+ left: 0;
+ background: -webkit-linear-gradient(left, rgba(0, 0, 0, .1), rgba(0, 0, 0, 0) 20%), -webkit-linear-gradient(left, rgba(0, 0, 0, .1), rgba(0, 0, 0, 0));
+ }
+
+ .right-shadow {
+ right: 0;
+ background: -webkit-linear-gradient(right, rgba(0, 0, 0, .1), rgba(0, 0, 0, 0) 20%), -webkit-linear-gradient(right, rgba(0, 0, 0, .1), rgba(0, 0, 0, 0));
+ }
+ }
+
+ .grade-table {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 1000px;
+ cursor: move;
+ -webkit-transition: none;
+ -webkit-user-select: none;
+ user-select: none;
+
+ td,
+ th {
+ width: 50px;
+ text-align: center;
+ }
+
+ thead th {
+ position: relative;
+ height: 50px;
+ background: -webkit-linear-gradient(top, $cell-border-color, #ddd);
+ font-size: 10px;
+ line-height: 10px;
+ font-weight: bold;
+ text-align: center;
+ box-shadow: 0 1px 0 $table-border-color inset, 0 2px 0 rgba(255, 255, 255, .7) inset;
+
+ &:before {
+ content: '';
+ display: block;
+ position: absolute;
+ left: 0;
+ top: 0;
+ z-index: 9999;
+ width: 1px;
+ height: 100%;
+ background: -webkit-linear-gradient(top, rgba(0, 0, 0, 0) 30%, rgba(0, 0, 0, .15));
+ }
+
+ &:first-child {
+ border-radius: 5px 0 0 0;
+ box-shadow: 1px 1px 0 $table-border-color inset, 1px 2px 0 rgba(255, 255, 255, .7) inset;
+
+ &:before {
+ display: hidden;
+ }
+ }
+
+ &:last-child {
+ border-radius: 0 3px 0 0;
+ box-shadow: -1px 1px 0 $table-border-color inset, -1px 2px 0 rgba(255, 255, 255, .7) inset;
+ }
+
+ .assignment {
+ margin: 9px 0;
+ }
+
+ .type,
+ .number,
+ .max {
+ display: block;
+ }
+
+ .max {
+ height: 12px;
+ background: -webkit-linear-gradient(top, #c6c6c6, #bababa);
+ font-size: 9px;
+ line-height: 12px;
+ color: #fff;
+ }
+ }
+
+ tr {
+ border-right: 1px solid $table-border-color;
+ }
+
+ tr:first-child td {
+ border-top: 1px solid $table-border-color;
+ }
+
+ tr:last-child td {
+ border-bottom: 1px solid $table-border-color;
+ }
+
+ td {
+ height: 50px;
+ border-bottom: 1px solid $cell-border-color;
+ background: #f3f3f3;
+ font-size: 13px;
+ line-height: 50px;
+ border-left: 1px solid $cell-border-color;
+ }
+
+ tr:nth-child(odd) td {
+ background-color: #fbfbfb;
+ }
+ }
+
h1 {
@extend .top-header;
}
}
-}
\ No newline at end of file
+}
+
+
diff --git a/lms/static/sass/course/base/_extends.scss b/lms/static/sass/course/base/_extends.scss
index c5e61f593e..04eaf73094 100644
--- a/lms/static/sass/course/base/_extends.scss
+++ b/lms/static/sass/course/base/_extends.scss
@@ -173,29 +173,3 @@ h1.top-header {
@include transition( all, .2s, $ease-in-out-quad);
}
-.global {
- .find-courses-button {
- display: none;
- }
-
- h2 {
- display: block;
- float: left;
- font-size: 0.9em;
- font-weight: 600;
- letter-spacing: 0;
- line-height: 40px;
- overflow: hidden;
- text-overflow: ellipsis;
- text-shadow: 0 1px 0 #fff;
- text-transform: none;
- white-space: nowrap;
- width: 700px;
-
- .provider {
- font: inherit;
- font-weight: bold;
- color: #6d6d6d;
- }
- }
-}
diff --git a/lms/static/sass/course/layout/_courseware_header.scss b/lms/static/sass/course/layout/_courseware_header.scss
new file mode 100644
index 0000000000..dfa30b43a0
--- /dev/null
+++ b/lms/static/sass/course/layout/_courseware_header.scss
@@ -0,0 +1,85 @@
+nav.course-material {
+ @include clearfix;
+ @include box-sizing(border-box);
+ background: #f6f6f6;
+ border-bottom: 1px solid rgb(200,200,200);
+ margin: 0px auto 0px;
+ padding: 0px;
+ width: 100%;
+
+ .inner-wrapper {
+ margin: 0 auto;
+ max-width: 1200px;
+ width: flex-grid(12);
+ }
+
+ ol.course-tabs {
+ @include border-top-radius(4px);
+ @include clearfix;
+ padding: 10px 0 0 0;
+
+ li {
+ float: left;
+ list-style: none;
+
+ a {
+ color: darken($lighter-base-font-color, 20%);
+ display: block;
+ text-align: center;
+ padding: 8px 13px 12px;
+ font-size: 14px;
+ font-weight: 400;
+ text-decoration: none;
+ text-shadow: 0 1px rgb(255,255,255);
+
+ &:hover {
+ color: $base-font-color;
+ }
+
+ &.active {
+ background: rgb(255,255,255);
+ border: 1px solid rgb(200,200,200);
+ border-bottom: 0px;
+ @include border-top-radius(4px);
+ @include box-shadow(0 2px 0 0 rgba(255,255,255, 1));
+ color: $blue;
+ }
+ }
+ }
+ }
+}
+
+.course-content {
+ margin-top: 30px;
+
+ .courseware {
+ min-height: 300px;
+ }
+}
+
+.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/course_navigation.html b/lms/templates/course_navigation.html
index a9ee199774..5ca51b9039 100644
--- a/lms/templates/course_navigation.html
+++ b/lms/templates/course_navigation.html
@@ -12,7 +12,7 @@ def url_class(url):
return ""
%>
<%! from django.core.urlresolvers import reverse %>
-<%! from courseware.courses import has_staff_access_to_course_id %>
+<%! from courseware.access import has_access %>
@@ -33,7 +33,7 @@ def url_class(url):
% if user.is_authenticated():
Profile
% endif
-% if has_staff_access_to_course_id(user, course.id):
+% if has_access(user, course, 'staff'):
Instructor
% endif
diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html
index fc8e9abf30..cfe8a0953c 100644
--- a/lms/templates/dashboard.html
+++ b/lms/templates/dashboard.html
@@ -1,6 +1,7 @@
-<%!
- from django.core.urlresolvers import reverse
+<%!
+ from django.core.urlresolvers import reverse
from courseware.courses import course_image_url, get_course_about_section
+ from courseware.access import has_access
%>
<%inherit file="main.html" />
@@ -17,7 +18,7 @@
$("#unenroll_course_number").text( $(event.target).data("course-number") );
});
-
+
$(document).delegate('#unenroll_form', 'ajax:success', function(data, json, xhr) {
if(json.success) {
location.href="${reverse('dashboard')}";
@@ -33,7 +34,7 @@
%block>
-
+
%if message:
${message}
@@ -66,7 +67,7 @@
<%
- if course.has_started() or settings.MITX_FEATURES['DISABLE_START_DATES']:
+ if has_access(user, course, 'load'):
course_target = reverse('info', args=[course.id])
else:
course_target = reverse('about_course', args=[course.id])
diff --git a/lms/templates/gradebook.html b/lms/templates/gradebook.html
index a4a81a6868..787fc23c9a 100644
--- a/lms/templates/gradebook.html
+++ b/lms/templates/gradebook.html
@@ -6,6 +6,7 @@
+
%block>
<%block name="headextra">
@@ -19,6 +20,12 @@
.grade_None {color:LightGray;}
+
+
%block>
<%include file="course_navigation.html" args="active_page=''" />
@@ -28,50 +35,75 @@
Gradebook
- %if len(students) > 0:
-
- <%
- templateSummary = students[0]['grade_summary']
- %>
-
-
-
- Student
- %for section in templateSummary['section_breakdown']:
- ${section['label']}
+
+
+
+
+
+
+
+
+
+ %for student in students:
+
+
+ ${student['username']}
+
+
%endfor
- Total
-
-
- <%def name="percent_data(fraction)">
- <%
- letter_grade = 'None'
- if fraction > 0:
- letter_grade = 'F'
- for grade in ['A', 'B', 'C']:
- if fraction >= course.grade_cutoffs[grade]:
- letter_grade = grade
- break
-
- data_class = "grade_" + letter_grade
- %>
- ${ "{0:.0f}".format( 100 * fraction ) }
- %def>
-
- %for student in students:
-
-
- ${student['username']}
- %for section in student['grade_summary']['section_breakdown']:
- ${percent_data( section['percent'] )}
- %endfor
- ${percent_data( student['grade_summary']['percent'])}
-
- %endfor
+
+
+
+
+ %if len(students) > 0:
+
+
+ <%
+ templateSummary = students[0]['grade_summary']
+ %>
+
+
+ %for section in templateSummary['section_breakdown']:
+ ${section['label']}
+ %endfor
+ Total
+
+
+
+ <%def name="percent_data(fraction)">
+ <%
+ letter_grade = 'None'
+ if fraction > 0:
+ letter_grade = 'F'
+ for grade in ['A', 'B', 'C']:
+ if fraction >= course.grade_cutoffs[grade]:
+ letter_grade = grade
+ break
+
+ data_class = "grade_" + letter_grade
+ %>
+ ${ "{0:.0f}".format( 100 * fraction ) }
+ %def>
+
+
+ %for student in students:
+
+ %for section in student['grade_summary']['section_breakdown']:
+ ${percent_data( section['percent'] )}
+ %endfor
+ ${percent_data( student['grade_summary']['percent'])}
+
+ %endfor
+
+
+
+
%endif
+
+
diff --git a/lms/templates/module-error.html b/lms/templates/module-error.html
index 7c731db17a..2a51f5b11a 100644
--- a/lms/templates/module-error.html
+++ b/lms/templates/module-error.html
@@ -2,12 +2,10 @@
There has been an error on the MITx servers
We're sorry, this module is temporarily unavailable. Our staff is working to fix it as soon as possible. Please email us at technical@mitx.mit.edu to report any problems or downtime.
-% if is_staff:
-Staff-only details below:
+Details below:
Error: ${error | h}
Raw data: ${data | h}
-% endif
diff --git a/lms/templates/portal/course_about.html b/lms/templates/portal/course_about.html
index 36ec33607f..4931f1fed6 100644
--- a/lms/templates/portal/course_about.html
+++ b/lms/templates/portal/course_about.html
@@ -1,6 +1,7 @@
-<%!
- from django.core.urlresolvers import reverse
+<%!
+ from django.core.urlresolvers import reverse
from courseware.courses import course_image_url, get_course_about_section
+ from courseware.access import has_access
%>
<%namespace name='static' file='../static_content.html'/>
@@ -15,7 +16,7 @@
$(".register").click(function() {
$("#class_enroll_form").submit();
});
-
+
$(document).delegate('#class_enroll_form', 'ajax:success', function(data, json, xhr) {
if(json.success) {
location.href="${reverse('dashboard')}";
@@ -64,7 +65,7 @@
%if registered:
<%
## TODO: move this logic into a view
- if course.has_started() or settings.MITX_FEATURES['DISABLE_START_DATES']:
+ if has_access(user, course, 'load'):
course_target = reverse('info', args=[course.id])
else:
course_target = reverse('about_course', args=[course.id])