-
-
-
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index cbb12e44cc..a70349bec3 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -82,6 +82,8 @@ def index(request, extra_context={}, user=None):
domain=domain)
context = {'universities': universities, 'entries': entries}
context.update(extra_context)
+ if request.REQUEST.get('next', False):
+ context['show_login_immediately'] = True
return render_to_response('index.html', context)
def course_from_id(course_id):
diff --git a/common/djangoapps/xmodule_modifiers.py b/common/djangoapps/xmodule_modifiers.py
index 80514cf8d4..7ea6778af6 100644
--- a/common/djangoapps/xmodule_modifiers.py
+++ b/common/djangoapps/xmodule_modifiers.py
@@ -35,7 +35,7 @@ def wrap_xmodule(get_html, module, template):
return _get_html
-def replace_course_urls(get_html, course_id, module):
+def replace_course_urls(get_html, course_id):
"""
Updates the supplied module with a new get_html function that wraps
the old get_html function and substitutes urls of the form /course/...
@@ -46,7 +46,7 @@ def replace_course_urls(get_html, course_id, module):
return replace_urls(get_html(), staticfiles_prefix='/courses/'+course_id, replace_prefix='/course/')
return _get_html
-def replace_static_urls(get_html, prefix, module):
+def replace_static_urls(get_html, prefix):
"""
Updates the supplied module with a new get_html function that wraps
the old get_html function and substitutes urls of the form /static/...
diff --git a/common/lib/capa/capa/inputtypes.py b/common/lib/capa/capa/inputtypes.py
index 187d2fd422..9ae63fb43a 100644
--- a/common/lib/capa/capa/inputtypes.py
+++ b/common/lib/capa/capa/inputtypes.py
@@ -333,6 +333,10 @@ def textline_dynamath(element, value, status, render_template, msg=''):
if '' in preprocessor.values():
preprocessor = None
+ # Escape characters in student input for safe XML parsing
+ escapedict = {'"': '"'}
+ value = saxutils.escape(value, escapedict)
+
context = {'id': eid, 'value': value, 'state': status, 'count': count, 'size': size,
'msg': msg, 'hidden': hidden,
'preprocessor': preprocessor,
diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py
index 5b89b78867..0909deea3a 100644
--- a/common/lib/capa/capa/responsetypes.py
+++ b/common/lib/capa/capa/responsetypes.py
@@ -1627,6 +1627,10 @@ class ImageResponse(LoncapaResponse):
for aid in self.answer_ids: # loop through IDs of fields in our stanza
given = student_answers[aid] # this should be a string of the form '[x,y]'
+ if not given: # No answer to parse. Mark as incorrect and move on
+ correct_map.set(aid, 'incorrect')
+ continue
+
# parse expected answer
# TODO: Compile regexp on file load
m = re.match('[\(\[]([0-9]+),([0-9]+)[\)\]]-[\(\[]([0-9]+),([0-9]+)[\)\]]', expectedset[aid].strip().replace(' ', ''))
diff --git a/common/lib/capa/capa/templates/choicegroup.html b/common/lib/capa/capa/templates/choicegroup.html
index cb11350a63..106694ea4a 100644
--- a/common/lib/capa/capa/templates/choicegroup.html
+++ b/common/lib/capa/capa/templates/choicegroup.html
@@ -1,14 +1,4 @@
diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py
index 51ee79b380..052f4eefe7 100644
--- a/common/lib/xmodule/xmodule/capa_module.py
+++ b/common/lib/xmodule/xmodule/capa_module.py
@@ -1,3 +1,4 @@
+import cgi
import datetime
import dateutil
import dateutil.parser
@@ -125,17 +126,17 @@ class CapaModule(XModule):
self.name = only_one(dom2.xpath('/problem/@name'))
if self.rerandomize == 'never':
- seed = 1
+ self.seed = 1
elif self.rerandomize == "per_student" and hasattr(self.system, 'id'):
- seed = system.id
+ self.seed = system.id
else:
- seed = None
+ self.seed = None
try:
# TODO (vshnayder): move as much as possible of this work and error
# checking to descriptor load time
self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
- instance_state, seed=seed, system=self.system)
+ instance_state, seed=self.seed, system=self.system)
except Exception as err:
msg = 'cannot create LoncapaProblem {loc}: {err}'.format(
loc=self.location.url(), err=err)
@@ -154,7 +155,7 @@ class CapaModule(XModule):
(self.location.url(), msg))
self.lcp = LoncapaProblem(
problem_text, self.location.html_id(),
- instance_state, seed=seed, system=self.system)
+ instance_state, seed=self.seed, system=self.system)
else:
# add extra info and raise
raise Exception(msg), None, sys.exc_info()[2]
@@ -172,6 +173,8 @@ class CapaModule(XModule):
return "per_student"
elif rerandomize == "never":
return "never"
+ elif rerandomize == "onreset":
+ return "onreset"
else:
raise Exception("Invalid rerandomize attribute " + rerandomize)
@@ -214,9 +217,10 @@ class CapaModule(XModule):
try:
html = self.lcp.get_html()
except Exception, err:
+ log.exception(err)
+
# TODO (vshnayder): another switch on DEBUG.
if self.system.DEBUG:
- log.exception(err)
msg = (
'[courseware.capa.capa_module] '
'Failed to generate HTML for problem %s' %
@@ -225,7 +229,47 @@ class CapaModule(XModule):
msg += '
%s
' % traceback.format_exc().replace('<', '<')
html = msg
else:
- raise
+ # We're in non-debug mode, and possibly even in production. We want
+ # to avoid bricking of problem as much as possible
+
+ # Presumably, student submission has corrupted LoncapaProblem HTML.
+ # First, pull down all student answers
+ student_answers = self.lcp.student_answers
+ answer_ids = student_answers.keys()
+
+ # Some inputtypes, such as dynamath, have additional "hidden" state that
+ # is not exposed to the student. Keep those hidden
+ # TODO: Use regex, e.g. 'dynamath' is suffix at end of answer_id
+ hidden_state_keywords = ['dynamath']
+ for answer_id in answer_ids:
+ for hidden_state_keyword in hidden_state_keywords:
+ if answer_id.find(hidden_state_keyword) >= 0:
+ student_answers.pop(answer_id)
+
+ # Next, generate a fresh LoncapaProblem
+ self.lcp = LoncapaProblem(self.definition['data'], self.location.html_id(),
+ state=None, # Tabula rasa
+ seed=self.seed, system=self.system)
+
+ # Prepend a scary warning to the student
+ warning = '
'\
+ '
Warning: The problem has been reset to its initial state!
'\
+ 'The problem\'s state was corrupted by an invalid submission. ' \
+ 'The submission consisted of:'\
+ '
'
+ for student_answer in student_answers.values():
+ if student_answer != '':
+ warning += '
' + cgi.escape(student_answer) + '
'
+ warning += '
'\
+ 'If this error persists, please contact the course staff.'\
+ '
'
+
+ html = warning
+ try:
+ html += self.lcp.get_html()
+ except Exception, err: # Couldn't do it. Give up
+ log.exception(err)
+ raise
content = {'name': self.display_name,
'html': html,
@@ -259,7 +303,7 @@ class CapaModule(XModule):
save_button = False
# Only show the reset button if pressing it will show different values
- if self.rerandomize != 'always':
+ if self.rerandomize not in ["always", "onreset"]:
reset_button = False
# User hasn't submitted an answer yet -- we don't want resets
@@ -569,7 +613,7 @@ class CapaModule(XModule):
return "Refresh the page and make an attempt before resetting."
self.lcp.do_reset()
- if self.rerandomize == "always":
+ if self.rerandomize in ["always", "onreset"]:
# reset random number generator seed (note the self.lcp.get_state()
# in next line)
self.lcp.seed = None
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index d6387ee55d..e7e3e4e519 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -1,9 +1,9 @@
from fs.errors import ResourceNotFoundError
-import time
import logging
-import requests
from lxml import etree
from path import path # NOTE (THK): Only used for detecting presence of syllabus
+import requests
+import time
from xmodule.util.decorators import lazyproperty
from xmodule.graders import load_grading_policy
@@ -21,10 +21,15 @@ class CourseDescriptor(SequenceDescriptor):
self.title = title
self.book_url = book_url
self.table_of_contents = self._get_toc_from_s3()
-
- @classmethod
- def from_xml_object(cls, xml_object):
- return cls(xml_object.get('title'), xml_object.get('book_url'))
+ self.start_page = int(self.table_of_contents[0].attrib['page'])
+
+ # The last page should be the last element in the table of contents,
+ # but it may be nested. So recurse all the way down the last element
+ last_el = self.table_of_contents[-1]
+ while last_el.getchildren():
+ last_el = last_el[-1]
+
+ self.end_page = int(last_el.attrib['page'])
@property
def table_of_contents(self):
@@ -57,10 +62,18 @@ class CourseDescriptor(SequenceDescriptor):
return table_of_contents
-
def __init__(self, system, definition=None, **kwargs):
super(CourseDescriptor, self).__init__(system, definition, **kwargs)
- self.textbooks = self.definition['data']['textbooks']
+
+ self.textbooks = []
+ for title, book_url in self.definition['data']['textbooks']:
+ try:
+ self.textbooks.append(self.Textbook(title, book_url))
+ except:
+ # If we can't get to S3 (e.g. on a train with no internet), don't break
+ # the rest of the courseware.
+ log.exception("Couldn't load textbook ({0}, {1})".format(title, book_url))
+ continue
self.wiki_slug = self.definition['data']['wiki_slug'] or self.location.course
@@ -82,7 +95,6 @@ class CourseDescriptor(SequenceDescriptor):
# disable the syllabus content for courses that do not provide a syllabus
self.syllabus_present = self.system.resources_fs.exists(path('syllabus'))
-
def set_grading_policy(self, policy_str):
"""Parse the policy specified in policy_str, and save it"""
try:
@@ -94,19 +106,11 @@ class CourseDescriptor(SequenceDescriptor):
# the error log.
self._grading_policy = {}
-
@classmethod
def definition_from_xml(cls, xml_object, system):
textbooks = []
for textbook in xml_object.findall("textbook"):
- try:
- txt = cls.Textbook.from_xml_object(textbook)
- except:
- # If we can't get to S3 (e.g. on a train with no internet), don't break
- # the rest of the courseware.
- log.exception("Couldn't load textbook")
- continue
- textbooks.append(txt)
+ textbooks.append((textbook.get('title'), textbook.get('book_url')))
xml_object.remove(textbook)
#Load the wiki tag if it exists
@@ -116,7 +120,7 @@ class CourseDescriptor(SequenceDescriptor):
wiki_slug = wiki_tag.attrib.get("slug", default=None)
xml_object.remove(wiki_tag)
- definition = super(CourseDescriptor, cls).definition_from_xml(xml_object, system)
+ definition = super(CourseDescriptor, cls).definition_from_xml(xml_object, system)
definition.setdefault('data', {})['textbooks'] = textbooks
definition['data']['wiki_slug'] = wiki_slug
@@ -134,6 +138,13 @@ class CourseDescriptor(SequenceDescriptor):
def grade_cutoffs(self):
return self._grading_policy['GRADE_CUTOFFS']
+ @property
+ def tabs(self):
+ """
+ Return the tabs config, as a python object, or None if not specified.
+ """
+ return self.metadata.get('tabs')
+
@property
def show_calculator(self):
return self.metadata.get("show_calculator", None) == "Yes"
diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss
index 0591a01843..ce0d2d9bf7 100644
--- a/common/lib/xmodule/xmodule/css/capa/display.scss
+++ b/common/lib/xmodule/xmodule/css/capa/display.scss
@@ -36,9 +36,36 @@ section.problem {
}
.choicegroup {
+ @include clearfix;
+
label.choicegroup_correct:after {
content: url('../images/correct-icon.png');
}
+
+ > span {
+ padding-right: 20px;
+ float: left;
+ background-position: 0 0 !important;
+ }
+
+ fieldset {
+ @include box-sizing(border-box);
+ float: left;
+ border-left: 1px solid #ddd;
+ padding-left: 20px;
+ margin: 20px 0;
+ }
+
+ input[type="radio"],
+ input[type="checkbox"] {
+ float: left;
+ margin: 4px 8px 0 0;
+ }
+
+ text {
+ display: block;
+ margin-left: 25px;
+ }
}
ol.enumerate {
@@ -52,6 +79,23 @@ section.problem {
}
}
+ .solution-span {
+ > span {
+ margin: 20px 0;
+ display: block;
+ border: 1px solid #ddd;
+ padding: 9px 15px 20px;
+ background: #FFF;
+ position: relative;
+ @include box-shadow(inset 0 0 0 1px #eee);
+ @include border-radius(3px);
+
+ &:empty {
+ display: none;
+ }
+ }
+ }
+
div {
p {
&.answer {
@@ -432,18 +476,23 @@ section.problem {
input.save {
@extend .blue-button;
}
+
+ .submission_feedback {
+ // background: #F3F3F3;
+ // border: 1px solid #ddd;
+ // @include border-radius(3px);
+ // padding: 8px 12px;
+ // margin-top: 10px;
+ @include inline-block;
+ font-style: italic;
+ margin: 8px 0 0 10px;
+ color: #777;
+ -webkit-font-smoothing: antialiased;
+ }
}
.detailed-solution {
- border: 1px solid #ddd;
- padding: 9px 15px 20px;
- margin-bottom: 10px;
- background: #FFF;
- position: relative;
- @include box-shadow(inset 0 0 0 1px #eee);
- @include border-radius(3px);
-
- p:first-child {
+ > p:first-child {
font-size: 0.9em;
font-weight: bold;
font-style: normal;
@@ -465,6 +514,22 @@ section.problem {
margin-top: 10px;
}
+ div.capa_reset {
+ padding: 25px;
+ border: 1px solid $error-red;
+ background-color: lighten($error-red, 25%);
+ border-radius: 3px;
+ font-size: 1em;
+ margin-top: 10px;
+ margin-bottom: 10px;
+ }
+ .capa_reset>h2 {
+ color: #AA0000;
+ }
+ .capa_reset li {
+ font-size: 0.9em;
+ }
+
.hints {
border: 1px solid #ccc;
diff --git a/lms/static/js/jquery.sequence.js b/common/lib/xmodule/xmodule/js/src/sequence/display/jquery.sequence.js
similarity index 100%
rename from lms/static/js/jquery.sequence.js
rename to common/lib/xmodule/xmodule/js/src/sequence/display/jquery.sequence.js
diff --git a/common/lib/xmodule/xmodule/js/src/video/display.coffee b/common/lib/xmodule/xmodule/js/src/video/display.coffee
index 3880091661..6587f05899 100644
--- a/common/lib/xmodule/xmodule/js/src/video/display.coffee
+++ b/common/lib/xmodule/xmodule/js/src/video/display.coffee
@@ -11,6 +11,8 @@ class @Video
@parseSpeed()
$("#video_#{@id}").data('video', this).addClass('video-load-complete')
+ @hide_captions = $.cookie('hide_captions') == 'true'
+
if YT.Player
@embed()
else
diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee
index f65debb1a2..b1e41afc3c 100644
--- a/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee
+++ b/common/lib/xmodule/xmodule/js/src/video/display/video_caption.coffee
@@ -1,6 +1,6 @@
class @VideoCaption extends Subview
initialize: ->
- @loaded = false
+ @loaded = false
bind: ->
$(window).bind('resize', @resize)
@@ -49,7 +49,7 @@ class @VideoCaption extends Subview
@$('.subtitles').html(container.html())
@$('.subtitles li[data-index]').click @seekPlayer
- # prepend and append an empty
for cosmatic reason
+ # prepend and append an empty
for cosmetic reason
@$('.subtitles').prepend($('
').height(@topSpacingHeight()))
.append($('
').height(@bottomSpacingHeight()))
@@ -130,14 +130,21 @@ class @VideoCaption extends Subview
toggle: (event) =>
event.preventDefault()
- if @el.hasClass('closed')
+ if @el.hasClass('closed') # Captions are "closed" e.g. turned off
+ @hideCaptions(false)
+ else # Captions are on
+ @hideCaptions(true)
+
+ hideCaptions: (hide_captions) =>
+ if hide_captions
+ @$('.hide-subtitles').attr('title', 'Turn on captions')
+ @el.addClass('closed')
+ else
@$('.hide-subtitles').attr('title', 'Turn off captions')
@el.removeClass('closed')
@scrollCaption()
- else
- @$('.hide-subtitles').attr('title', 'Turn on captions')
- @el.addClass('closed')
-
+ $.cookie('hide_captions', hide_captions, expires: 3650, path: '/')
+
captionHeight: ->
if @el.hasClass('fullscreen')
$(window).height() - @$('.video-controls').height()
diff --git a/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee b/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee
index 4b265d20c8..bb89def63d 100644
--- a/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee
+++ b/common/lib/xmodule/xmodule/js/src/video/display/video_player.coffee
@@ -45,6 +45,7 @@ class @VideoPlayer extends Subview
events:
onReady: @onReady
onStateChange: @onStateChange
+ @caption.hideCaptions(@['video'].hide_captions)
addToolTip: ->
@$('.add-fullscreen, .hide-subtitles').qtip
diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py
index 7aa05e474f..33901947a6 100644
--- a/common/lib/xmodule/xmodule/modulestore/mongo.py
+++ b/common/lib/xmodule/xmodule/modulestore/mongo.py
@@ -316,3 +316,9 @@ class MongoModuleStore(ModuleStoreBase):
{'_id': True})
return [i['_id'] for i in items]
+ def get_errored_courses(self):
+ """
+ This function doesn't make sense for the mongo modulestore, as courses
+ are loaded on demand, rather than up front
+ """
+ return {}
diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py
index 82b0abd8ab..874c7d3d7f 100644
--- a/common/lib/xmodule/xmodule/modulestore/xml.py
+++ b/common/lib/xmodule/xmodule/modulestore/xml.py
@@ -414,7 +414,6 @@ class XMLModuleStore(ModuleStoreBase):
policy_str = self.read_grading_policy(paths, tracker)
course_descriptor.set_grading_policy(policy_str)
-
log.debug('========> Done with course import from {0}'.format(course_dir))
return course_descriptor
diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py
index b05ea36e50..841936cf17 100644
--- a/common/lib/xmodule/xmodule/seq_module.py
+++ b/common/lib/xmodule/xmodule/seq_module.py
@@ -21,7 +21,8 @@ class SequenceModule(XModule):
''' Layout module which lays out content in a temporal sequence
'''
js = {'coffee': [resource_string(__name__,
- 'js/src/sequence/display.coffee')]}
+ 'js/src/sequence/display.coffee')],
+ 'js': [resource_string(__name__, 'js/src/sequence/display/jquery.sequence.js')]}
css = {'scss': [resource_string(__name__, 'css/sequence/display.scss')]}
js_module_name = "Sequence"
diff --git a/create-dev-env.sh b/create-dev-env.sh
index 3664129775..e7ffadf5f5 100755
--- a/create-dev-env.sh
+++ b/create-dev-env.sh
@@ -105,7 +105,7 @@ NUMPY_VER="1.6.2"
SCIPY_VER="0.10.1"
BREW_FILE="$BASE/mitx/brew-formulas.txt"
LOG="/var/tmp/install-$(date +%Y%m%d-%H%M%S).log"
-APT_PKGS="curl git python-virtualenv build-essential python-dev gfortran liblapack-dev libfreetype6-dev libpng12-dev libxml2-dev libxslt-dev yui-compressor coffeescript graphviz libgraphviz-dev"
+APT_PKGS="pkg-config curl git python-virtualenv build-essential python-dev gfortran liblapack-dev libfreetype6-dev libpng12-dev libxml2-dev libxslt-dev yui-compressor coffeescript graphviz graphviz-dev"
if [[ $EUID -eq 0 ]]; then
error "This script should not be run using sudo or as the root user"
@@ -223,7 +223,7 @@ EO
command -v brew &>/dev/null || {
output "Installing brew"
- /usr/bin/ruby -e "$(curl -fsSL https://raw.github.com/mxcl/homebrew/master/Library/Contributions/install_homebrew.rb)"
+ /usr/bin/ruby <(curl -fsSkL raw.github.com/mxcl/homebrew/go)
}
command -v git &>/dev/null || {
output "Installing git"
diff --git a/doc/xml-format.md b/doc/xml-format.md
index 29c60fea99..9d07f432c9 100644
--- a/doc/xml-format.md
+++ b/doc/xml-format.md
@@ -219,6 +219,13 @@ Values are dictionaries of the form {"metadata-key" : "metadata-value"}.
* The order in which things appear does not matter, though it may be helpful to organize the file in the same order as things appear in the content.
* NOTE: json is picky about commas. If you have trailing commas before closing braces, it will complain and refuse to parse the file. This can be irritating at first.
+Supported fields at the course level:
+
+* "start" -- specify the start date for the course. Format-by-example: "2012-09-05T12:00".
+* "enrollment_start", "enrollment_end" -- when can students enroll? (if not specified, can enroll anytime). Same format as "start".
+* "tabs" -- have custom tabs in the courseware. See below for details on config.
+* TODO: there are others
+
### Grading policy file contents
TODO: This needs to be improved, but for now here's a sketch of how grading works:
@@ -274,6 +281,7 @@ __Inherited:__
* `showanswer` - When to show answer. For 'attempted', will show answer after first attempt. Values: never, attempted, answered, closed. Default: closed. Optional.
* `graded` - Whether this section will count towards the students grade. "true" or "false". Defaults to "false".
* `rerandomise` - Randomize question on each attempt. Values: 'always' (students see a different version of the problem after each attempt to solve it)
+ 'onreset' (randomize question when reset button is pressed by the student)
'never' (all students see the same version of the problem)
'per_student' (individual students see the same version of the problem each time the look at it, but that version is different from what other students see)
Default: 'always'. Optional.
@@ -340,7 +348,43 @@ If you look at some older xml, you may see some tags or metadata attributes that
# Static links
-if your content links (e.g. in an html file) to `"static/blah/ponies.jpg"`, we will look for this in `YOUR_COURSE_DIR/blah/ponies.jpg`. Note that this is not looking in a `static/` subfolder in your course dir. This may (should?) change at some point. Links that include `/course` will be rewritten to the root of your course in the courseware (e.g. `courses/{org}/{course}/{url_name}/` in the current url structure). This is useful for linking to the course wiki, for example.
+If your content links (e.g. in an html file) to `"static/blah/ponies.jpg"`, we will look for this...
+
+* If your course dir has a `static/` subdirectory, we will look in `YOUR_COURSE_DIR/static/blah/ponies.jpg`. This is the prefered organization, as it does not expose anything except what's in `static/` to the world.
+* If your course dir does not have a `static/` subdirectory, we will look in `YOUR_COURSE_DIR/blah/ponies.jpg`. This is the old organization, and requires that the web server allow access to everything in the couse dir. To switch to the new organization, move all your static content into a new `static/` dir (e.g. if you currently have things in `images/`, `css/`, and `special/`, create a dir called `static/`, and move `images/, css/, and special/` there).
+
+Links that include `/course` will be rewritten to the root of your course in the courseware (e.g. `courses/{org}/{course}/{url_name}/` in the current url structure). This is useful for linking to the course wiki, for example.
+
+# Tabs
+
+If you want to customize the courseware tabs displayed for your course, specify a "tabs" list in the course-level policy. e.g.:
+
+ "tabs" : [
+ {"type": "courseware"}, # no name--always "Courseware" for consistency between courses
+ {"type": "course_info", "name": "Course Info"},
+ {"type": "external_link", "name": "My Discussion", "link": "http://www.mydiscussion.org/blah"},
+ {"type": "progress", "name": "Progress"},
+ {"type": "wiki", "name": "Wonderwiki"},
+ {"type": "static_tab", "url_slug": "news", "name": "Exciting news"},
+ {"type": "textbooks"} # generates one tab per textbook, taking names from the textbook titles
+ ]
+
+
+* If you specify any tabs, you must specify all tabs. They will appear in the order given.
+* The first two tabs must have types `"courseware"` and `"course_info"`, in that order. Otherwise, we'll refuse to load the course.
+* for static tabs, the url_slug will be the url that points to the tab. It can not be one of the existing courseware url types (even if those aren't used in your course). The static content will come from `tabs/{course_url_name}/{url_slug}.html`, or `tabs/{url_slug}.html` if that doesn't exist.
+
+* An Instructor tab will be automatically added at the end for course staff users.
+
+## Supported tab types:
+
+* "courseware". No other parameters.
+* "course_info". Parameter "name".
+* "wiki". Parameter "name".
+* "discussion". Parameter "name".
+* "external_link". Parameters "name", "link".
+* "textbooks". No parameters--generates tab names from book titles.
+* "progress". Parameter "name".
# Tips for content developers
diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index b033660c17..a65d73e4bc 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -254,12 +254,11 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
module.get_html = replace_static_urls(
wrap_xmodule(module.get_html, module, 'xmodule_display.html'),
- module.metadata['data_dir'], module
- )
+ module.metadata['data_dir'])
# Allow URLs of the form '/course/' refer to the root of multicourse directory
# hierarchy of this course
- module.get_html = replace_course_urls(module.get_html, course_id, module)
+ module.get_html = replace_course_urls(module.get_html, course_id)
if settings.MITX_FEATURES.get('DISPLAY_HISTOGRAMS_TO_STAFF'):
if has_access(user, module, 'staff'):
diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py
new file mode 100644
index 0000000000..8ec856e5b3
--- /dev/null
+++ b/lms/djangoapps/courseware/tabs.py
@@ -0,0 +1,274 @@
+"""
+Tabs configuration. By the time the tab is being rendered, it's just a name,
+link, and css class (CourseTab tuple). Tabs are specified in course policy.
+Each tab has a type, and possibly some type-specific parameters.
+
+To add a new tab type, add a TabImpl to the VALID_TAB_TYPES dict below--it will
+contain a validation function that checks whether config for the tab type is
+valid, and a generator function that takes the config, user, and course, and
+actually generates the CourseTab.
+"""
+
+from collections import namedtuple
+import logging
+
+from django.conf import settings
+from django.core.urlresolvers import reverse
+
+from courseware.access import has_access
+from static_replace import replace_urls
+
+log = logging.getLogger(__name__)
+
+class InvalidTabsException(Exception):
+ """
+ A complaint about invalid tabs.
+ """
+ pass
+
+CourseTab = namedtuple('CourseTab', 'name link is_active')
+
+# encapsulate implementation for a tab:
+# - a validation function: takes the config dict and raises
+# InvalidTabsException if required fields are missing or otherwise
+# wrong. (e.g. "is there a 'name' field?). Validators can assume
+# that the type field is valid.
+#
+# - a function that takes a config, a user, and a course, and active_page and
+# return a list of CourseTabs. (e.g. "return a CourseTab with specified
+# name, link to courseware, and is_active=True/False"). The function can
+# assume that it is only called with configs of the appropriate type that
+# have passed the corresponding validator.
+TabImpl = namedtuple('TabImpl', 'validator generator')
+
+
+##### Generators for various tabs.
+
+def _courseware(tab, user, course, active_page):
+ link = reverse('courseware', args=[course.id])
+ return [CourseTab('Courseware', link, active_page == "courseware")]
+
+def _course_info(tab, user, course, active_page):
+ link = reverse('info', args=[course.id])
+ return [CourseTab(tab['name'], link, active_page == "info")]
+
+def _progress(tab, user, course, active_page):
+ if user.is_authenticated():
+ link = reverse('progress', args=[course.id])
+ return [CourseTab(tab['name'], link, active_page == "progress")]
+ return []
+
+def _wiki(tab, user, course, active_page):
+ if settings.WIKI_ENABLED:
+ link = reverse('course_wiki', args=[course.id])
+ return [CourseTab(tab['name'], link, active_page == 'wiki')]
+ return []
+
+def _discussion(tab, user, course, active_page):
+ """
+ This tab format only supports the new Berkeley discussion forums.
+ """
+ if settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
+ link = reverse('django_comment_client.forum.views.forum_form_discussion',
+ args=[course.id])
+ return [CourseTab(tab['name'], link, active_page=='discussion')]
+ return []
+
+def _external_link(tab, user, course, active_page):
+ # external links are never active
+ return [CourseTab(tab['name'], tab['link'], False)]
+
+def _static_tab(tab, user, course, active_page):
+ link = reverse('static_tab', args=[course.id, tab['url_slug']])
+ active_str = 'static_tab_{0}'.format(tab['url_slug'])
+ return [CourseTab(tab['name'], link, active_page==active_str)]
+
+
+def _textbooks(tab, user, course, active_page):
+ """
+ Generates one tab per textbook. Only displays if user is authenticated.
+ """
+ if user.is_authenticated() and settings.MITX_FEATURES.get('ENABLE_TEXTBOOK'):
+ # since there can be more than one textbook, active_page is e.g. "book/0".
+ return [CourseTab(textbook.title, reverse('book', args=[course.id, index]),
+ active_page=="textbook/{0}".format(index))
+ for index, textbook in enumerate(course.textbooks)]
+ return []
+
+#### Validators
+
+
+def key_checker(expected_keys):
+ """
+ Returns a function that checks that specified keys are present in a dict
+ """
+ def check(d):
+ for k in expected_keys:
+ if k not in d:
+ raise InvalidTabsException("Key {0} not present in {1}"
+ .format(k, d))
+ return check
+
+
+need_name = key_checker(['name'])
+
+def null_validator(d):
+ """
+ Don't check anything--use for tabs that don't need any params. (e.g. textbook)
+ """
+ pass
+
+##### The main tab config dict.
+
+# type -> TabImpl
+VALID_TAB_TYPES = {
+ 'courseware': TabImpl(null_validator, _courseware),
+ 'course_info': TabImpl(need_name, _course_info),
+ 'wiki': TabImpl(need_name, _wiki),
+ 'discussion': TabImpl(need_name, _discussion),
+ 'external_link': TabImpl(key_checker(['name', 'link']), _external_link),
+ 'textbooks': TabImpl(null_validator, _textbooks),
+ 'progress': TabImpl(need_name, _progress),
+ 'static_tab': TabImpl(key_checker(['name', 'url_slug']), _static_tab),
+ }
+
+
+### External interface below this.
+
+def validate_tabs(course):
+ """
+ Check that the tabs set for the specified course is valid. If it
+ isn't, raise InvalidTabsException with the complaint.
+
+ Specific rules checked:
+ - if no tabs specified, that's fine
+ - if tabs specified, first two must have type 'courseware' and 'course_info', in that order.
+ - All the tabs must have a type in VALID_TAB_TYPES.
+
+ """
+ tabs = course.tabs
+ if tabs is None:
+ return
+
+ if len(tabs) < 2:
+ raise InvalidTabsException("Expected at least two tabs. tabs: '{0}'".format(tabs))
+ if tabs[0]['type'] != 'courseware':
+ raise InvalidTabsException(
+ "Expected first tab to have type 'courseware'. tabs: '{0}'".format(tabs))
+ if tabs[1]['type'] != 'course_info':
+ raise InvalidTabsException(
+ "Expected second tab to have type 'course_info'. tabs: '{0}'".format(tabs))
+ for t in tabs:
+ if t['type'] not in VALID_TAB_TYPES:
+ raise InvalidTabsException("Unknown tab type {0}. Known types: {1}"
+ .format(t['type'], VALID_TAB_TYPES))
+ # the type-specific validator checks the rest of the tab config
+ VALID_TAB_TYPES[t['type']].validator(t)
+
+ # Possible other checks: make sure tabs that should only appear once (e.g. courseware)
+ # are actually unique (otherwise, will break active tag code)
+
+
+def get_course_tabs(user, course, active_page):
+ """
+ Return the tabs to show a particular user, as a list of CourseTab items.
+ """
+ if not course.tabs:
+ return get_default_tabs(user, course, active_page)
+
+ # TODO (vshnayder): There needs to be a place to call this right after course
+ # load, but not from inside xmodule, since that doesn't (and probably
+ # shouldn't) know about the details of what tabs are supported, etc.
+ validate_tabs(course)
+
+ tabs = []
+ for tab in course.tabs:
+ # expect handlers to return lists--handles things that are turned off
+ # via feature flags, and things like 'textbook' which might generate
+ # multiple tabs.
+ gen = VALID_TAB_TYPES[tab['type']].generator
+ tabs.extend(gen(tab, user, course, active_page))
+
+ # Instructor tab is special--automatically added if user is staff for the course
+ if has_access(user, course, 'staff'):
+ tabs.append(CourseTab('Instructor',
+ reverse('instructor_dashboard', args=[course.id]),
+ active_page == 'instructor'))
+ return tabs
+
+
+def get_default_tabs(user, course, active_page):
+
+ # When calling the various _tab methods, can omit the 'type':'blah' from the
+ # first arg, since that's only used for dispatch
+ tabs = []
+ tabs.extend(_courseware({''}, user, course, active_page))
+ tabs.extend(_course_info({'name': 'Course Info'}, user, course, active_page))
+
+ if hasattr(course, 'syllabus_present') and course.syllabus_present:
+ link = reverse('syllabus', args=[course.id])
+ tabs.append(CourseTab('Syllabus', link, active_page=='syllabus'))
+
+ tabs.extend(_textbooks({}, user, course, active_page))
+
+ ## If they have a discussion link specified, use that even if we feature
+ ## flag discussions off. Disabling that is mostly a server safety feature
+ ## at this point, and we don't need to worry about external sites.
+ if course.discussion_link:
+ tabs.append(CourseTab('Discussion', course.discussion_link, active_page == 'discussion'))
+ elif settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'):
+ link = reverse('django_comment_client.forum.views.forum_form_discussion',
+ args=[course.id])
+ tabs.append(CourseTab('Discussion', link, active_page == 'discussion'))
+ elif settings.MITX_FEATURES.get('ENABLE_DISCUSSION'):
+ ## This is Askbot, which we should be retiring soon...
+ tabs.append(CourseTab('Discussion', reverse('questions'), active_page == 'discussion'))
+
+ tabs.extend(_wiki({'name': 'Wiki', 'type': 'wiki'}, user, course, active_page))
+
+ if user.is_authenticated() and not course.hide_progress_tab:
+ tabs.extend(_progress({'name': 'Progress'}, user, course, active_page))
+
+ if has_access(user, course, 'staff'):
+ link = reverse('instructor_dashboard', args=[course.id])
+ tabs.append(CourseTab('Instructor', link, active_page=='instructor'))
+
+ return tabs
+
+def get_static_tab_by_slug(course, tab_slug):
+ """
+ Look for a tab with type 'static_tab' and the specified 'tab_slug'. Returns
+ the tab (a config dict), or None if not found.
+ """
+ if course.tabs is None:
+ return None
+ for tab in course.tabs:
+ # The validation code checks that these exist.
+ if tab['type'] == 'static_tab' and tab['url_slug'] == tab_slug:
+ return tab
+
+ return None
+
+
+def get_static_tab_contents(course, tab):
+ """
+ Given a course and a static tab config dict, load the tab contents,
+ returning None if not found.
+
+ Looks in tabs/{course_url_name}/{tab_slug}.html first, then tabs/{tab_slug}.html.
+ """
+ slug = tab['url_slug']
+ paths = ['tabs/{0}/{1}.html'.format(course.url_name, slug),
+ 'tabs/{0}.html'.format(slug)]
+ fs = course.system.resources_fs
+ for p in paths:
+ if fs.exists(p):
+ try:
+ with fs.open(p) as tabfile:
+ # TODO: redundant with module_render.py. Want to be helper methods in static_replace or something.
+ contents = replace_urls(tabfile.read(), course.metadata['data_dir'])
+ return replace_urls(contents, staticfiles_prefix='/courses/'+course.id, replace_prefix='/course/')
+ except (ResourceNotFoundError) as err:
+ log.exception("Couldn't load tab contents from '{0}': {1}".format(p, err))
+ return None
+ return None
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index f3e8277484..eba45e373a 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -22,6 +22,7 @@ from django.views.decorators.cache import cache_control
from courseware import grades
from courseware.access import has_access
from courseware.courses import (get_course_with_access, get_courses_by_university)
+import courseware.tabs as tabs
from courseware.models import StudentModuleCache
from module_render import toc_for_course, get_module, get_instance_module
from student.models import UserProfile
@@ -343,6 +344,30 @@ def course_info(request, course_id):
return render_to_response('courseware/info.html', {'course': course,
'staff_access': staff_access,})
+@ensure_csrf_cookie
+def static_tab(request, course_id, tab_slug):
+ """
+ Display the courses tab with the given name.
+
+ Assumes the course_id is in a valid format.
+ """
+ course = get_course_with_access(request.user, course_id, 'load')
+
+ tab = tabs.get_static_tab_by_slug(course, tab_slug)
+ if tab is None:
+ raise Http404
+
+ contents = tabs.get_static_tab_contents(course, tab)
+ if contents is None:
+ raise Http404
+
+ staff_access = has_access(request.user, course, 'staff')
+ return render_to_response('courseware/static_tab.html',
+ {'course': course,
+ 'tab': tab,
+ 'tab_contents': contents,
+ 'staff_access': staff_access,})
+
# TODO arjun: remove when custom tabs in place, see courseware/syllabus.py
@ensure_csrf_cookie
def syllabus(request, course_id):
@@ -357,6 +382,7 @@ def syllabus(request, course_id):
return render_to_response('courseware/syllabus.html', {'course': course,
'staff_access': staff_access,})
+
def registered_for_course(course, user):
'''Return CourseEnrollment if user is registered for course, else False'''
if user is None:
@@ -404,6 +430,9 @@ def university_profile(request, org_id):
context = dict(courses=courses, org_id=org_id)
template_file = "university_profile/{0}.html".format(org_id).lower()
+ if request.REQUEST.get('next', False):
+ context['show_login_immediately'] = True
+
return render_to_response(template_file, context)
def render_notifications(request, course, notifications):
diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py
index 573badbb92..925c59a03b 100644
--- a/lms/djangoapps/django_comment_client/forum/views.py
+++ b/lms/djangoapps/django_comment_client/forum/views.py
@@ -28,26 +28,6 @@ PAGES_NEARBY_DELTA = 2
escapedict = {'"': '"'}
log = logging.getLogger("edx.discussions")
-def _general_discussion_id(course_id):
- return course_id.replace('/', '_').replace('.', '_')
-
-def _should_perform_search(request):
- return bool(request.GET.get('text', False) or \
- request.GET.get('tags', False))
-
-def render_accordion(request, course, discussion_id):
- # TODO: Delete if obsolete
- discussion_info = utils.get_categorized_discussion_info(request, course)
-
- context = {
- 'course': course,
- 'discussion_info': discussion_info,
- 'active': discussion_id,
- 'csrf': csrf(request)['csrf_token'],
- }
-
- return render_to_string('discussion/_accordion.html', context)
-
def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAGE):
"""
This may raise cc.utils.CommentClientError or
@@ -63,6 +43,7 @@ def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAG
'tags': '',
'commentable_id': discussion_id,
'course_id': course_id,
+ 'user_id': request.user.id,
}
if not request.GET.get('sort_key'):
@@ -101,6 +82,7 @@ def inline_discussion(request, course_id, discussion_id):
# TODO (vshnayder): since none of this code seems to be aware of the fact that
# sometimes things go wrong, I suspect that the js client is also not
# checking for errors on request. Check and fix as needed.
+ log.error("Error loading inline discussion threads.")
raise Http404
def infogetter(thread):
@@ -134,6 +116,7 @@ def forum_form_discussion(request, course_id):
unsafethreads, query_params = get_threads(request, course_id) # This might process a search query
threads = [utils.safe_content(thread) for thread in unsafethreads]
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
+ log.error("Error loading forum discussion threads: %s" % str(err))
raise Http404
user_info = cc.User.from_django_user(request.user).to_dict()
@@ -184,14 +167,18 @@ def forum_form_discussion(request, course_id):
@login_required
def single_thread(request, course_id, discussion_id, thread_id):
- if request.is_ajax():
- course = get_course_with_access(request.user, course_id, 'load')
- user_info = cc.User.from_django_user(request.user).to_dict()
+ course = get_course_with_access(request.user, course_id, 'load')
+ cc_user = cc.User.from_django_user(request.user)
+ user_info = cc_user.to_dict()
+
+ try:
+ thread = cc.Thread.find(thread_id).retrieve(recursive=True, user_id=request.user.id)
+ except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
+ log.error("Error loading single thread.")
+ raise Http404
+
+ if request.is_ajax():
- try:
- thread = cc.Thread.find(thread_id).retrieve(recursive=True)
- except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
- raise Http404
courseware_context = get_courseware_context(thread, course)
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info)
@@ -208,13 +195,13 @@ def single_thread(request, course_id, discussion_id, thread_id):
})
else:
- course = get_course_with_access(request.user, course_id, 'load')
category_map = utils.get_discussion_category_map(course)
+
try:
threads, query_params = get_threads(request, course_id)
- thread = cc.Thread.find(thread_id).retrieve(recursive=True)
threads.append(thread.to_dict())
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
+ log.error("Error loading single thread.")
raise Http404
course = get_course_with_access(request.user, course_id, 'load')
@@ -236,8 +223,7 @@ def single_thread(request, course_id, discussion_id, thread_id):
# course_id,
#)
- user_info = cc.User.from_django_user(request.user).to_dict()
-
+
def infogetter(thread):
return utils.get_annotated_content_infos(course_id, thread, request.user, user_info)
diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py
index 305f3d0929..cd323e56c1 100644
--- a/lms/djangoapps/django_comment_client/utils.py
+++ b/lms/djangoapps/django_comment_client/utils.py
@@ -189,7 +189,7 @@ def initialize_discussion_info(course):
"sort_key": entry["sort_key"],
"start_date": entry["start_date"]}
- default_topics = {'General': course.location.html_id()}
+ default_topics = {'General': {'id' :course.location.html_id()}}
discussion_topics = course.metadata.get('discussion_topics', default_topics)
for topic, entry in discussion_topics.items():
category_map['entries'][topic] = {"id": entry["id"],
@@ -336,7 +336,8 @@ def safe_content(content):
'endorsed', 'parent_id', 'thread_id', 'votes', 'closed', 'created_at',
'updated_at', 'depth', 'type', 'commentable_id', 'comments_count',
'at_position_list', 'children', 'highlighted_title', 'highlighted_body',
- 'courseware_title', 'courseware_url', 'tags'
+ 'courseware_title', 'courseware_url', 'tags', 'unread_comments_count',
+ 'read',
]
if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False):
diff --git a/lms/djangoapps/instructor/views.py b/lms/djangoapps/instructor/views.py
index 3ce975410c..0b6392a7fc 100644
--- a/lms/djangoapps/instructor/views.py
+++ b/lms/djangoapps/instructor/views.py
@@ -85,7 +85,8 @@ def instructor_dashboard(request, course_id):
writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL)
writer.writerow(datatable['header'])
for datarow in datatable['data']:
- writer.writerow(datarow)
+ encoded_row = [unicode(s).encode('utf-8') for s in datarow]
+ writer.writerow(encoded_row)
return response
def get_staff_group(course):
@@ -250,7 +251,7 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True,
If get_raw_scores=True, then instead of grade summaries, the raw grades for all graded modules are returned.
'''
- enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username')
+ enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).prefetch_related("groups").order_by('username')
header = ['ID', 'Username', 'Full Name', 'edX email', 'External email']
if get_grades:
diff --git a/lms/djangoapps/staticbook/views.py b/lms/djangoapps/staticbook/views.py
index d68117dd8a..fabb8b861c 100644
--- a/lms/djangoapps/staticbook/views.py
+++ b/lms/djangoapps/staticbook/views.py
@@ -7,16 +7,23 @@ from courseware.courses import get_course_with_access
from lxml import etree
@login_required
-def index(request, course_id, book_index, page=0):
+def index(request, course_id, book_index, page=None):
course = get_course_with_access(request.user, course_id, 'load')
staff_access = has_access(request.user, course, 'staff')
- textbook = course.textbooks[int(book_index)]
+ book_index = int(book_index)
+ textbook = course.textbooks[book_index]
table_of_contents = textbook.table_of_contents
+ if page is None:
+ page = textbook.start_page
+
return render_to_response('staticbook.html',
- {'page': int(page), 'course': course, 'book_url': textbook.book_url,
+ {'book_index': book_index, 'page': int(page),
+ 'course': course, 'book_url': textbook.book_url,
'table_of_contents': table_of_contents,
+ 'start_page' : textbook.start_page,
+ 'end_page' : textbook.end_page,
'staff_access': staff_access})
def index_shifted(request, course_id, page):
diff --git a/lms/templates/discussion/_accordion.html b/lms/envs/cms/__init__.py
similarity index 100%
rename from lms/templates/discussion/_accordion.html
rename to lms/envs/cms/__init__.py
diff --git a/lms/envs/with_cms.py b/lms/envs/cms/aws.py
similarity index 100%
rename from lms/envs/with_cms.py
rename to lms/envs/cms/aws.py
diff --git a/lms/envs/cms/dev.py b/lms/envs/cms/dev.py
new file mode 100644
index 0000000000..6e4697cccb
--- /dev/null
+++ b/lms/envs/cms/dev.py
@@ -0,0 +1,19 @@
+"""
+Settings for the LMS that runs alongside the CMS on AWS
+"""
+
+from ..dev import *
+
+MODULESTORE = {
+ 'default': {
+ 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore',
+ 'OPTIONS': {
+ 'default_class': 'xmodule.raw_module.RawDescriptor',
+ 'host': 'localhost',
+ 'db': 'xmodule',
+ 'collection': 'modulestore',
+ 'fs_root': DATA_DIR,
+ 'render_template': 'mitxmako.shortcuts.render_to_string',
+ }
+ }
+}
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 6c127b8049..4681f5fd9a 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -406,7 +406,6 @@ MIDDLEWARE_CLASSES = (
# 'debug_toolbar.middleware.DebugToolbarMiddleware',
'django_comment_client.utils.ViewNameMiddleware',
- 'django_comment_client.utils.QueryCountDebugMiddleware',
)
############################### Pipeline #######################################
@@ -551,6 +550,8 @@ PIPELINE_JS = {
}
}
+PIPELINE_DISABLE_WRAPPER = True
+
# Compile all coffee files in course data directories if they are out of date.
# TODO: Remove this once we move data into Mongo. This is only temporary while
# course data directories are still in use.
diff --git a/lms/envs/dev.py b/lms/envs/dev.py
index a9f1454193..d833a5a2fc 100644
--- a/lms/envs/dev.py
+++ b/lms/envs/dev.py
@@ -135,7 +135,8 @@ MITX_FEATURES['AUTH_USE_MIT_CERTIFICATES'] = True
################################ DEBUG TOOLBAR #################################
INSTALLED_APPS += ('debug_toolbar',)
-MIDDLEWARE_CLASSES += ('debug_toolbar.middleware.DebugToolbarMiddleware',)
+MIDDLEWARE_CLASSES += ('django_comment_client.utils.QueryCountDebugMiddleware',
+ 'debug_toolbar.middleware.DebugToolbarMiddleware',)
INTERNAL_IPS = ('127.0.0.1',)
DEBUG_TOOLBAR_PANELS = (
diff --git a/lms/lib/comment_client/thread.py b/lms/lib/comment_client/thread.py
index bda032bbdf..424250033e 100644
--- a/lms/lib/comment_client/thread.py
+++ b/lms/lib/comment_client/thread.py
@@ -8,8 +8,9 @@ class Thread(models.Model):
accessible_fields = [
'id', 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'closed', 'tags', 'votes', 'commentable_id', 'username', 'user_id',
- 'created_at', 'updated_at', 'comments_count', 'at_position_list',
- 'children', 'type', 'highlighted_title', 'highlighted_body', 'endorsed'
+ 'created_at', 'updated_at', 'comments_count', 'unread_comments_count',
+ 'at_position_list', 'children', 'type', 'highlighted_title',
+ 'highlighted_body', 'endorsed', 'read'
]
updatable_fields = [
@@ -59,7 +60,21 @@ class Thread(models.Model):
else:
return super(Thread, cls).url(action, params)
+ # TODO: This is currently overriding Model._retrieve only to add parameters
+ # for the request. Model._retrieve should be modified to handle this such
+ # that subclasses don't need to override for this.
def _retrieve(self, *args, **kwargs):
url = self.url(action='get', params=self.attributes)
- response = perform_request('get', url, {'recursive': kwargs.get('recursive')})
+
+ request_params = {
+ 'recursive': kwargs.get('recursive'),
+ 'user_id': kwargs.get('user_id'),
+ 'mark_as_read': kwargs.get('mark_as_read', True),
+ }
+
+ # user_id may be none, in which case it shouldn't be part of the
+ # request.
+ request_params = strip_none(request_params)
+
+ response = perform_request('get', url, request_params)
self.update_attributes(**response)
diff --git a/lms/static/coffee/src/discussion/discussion_filter.coffee b/lms/static/coffee/src/discussion/discussion_filter.coffee
new file mode 100644
index 0000000000..6b3ab03689
--- /dev/null
+++ b/lms/static/coffee/src/discussion/discussion_filter.coffee
@@ -0,0 +1,28 @@
+class @DiscussionFilter
+ @filterDrop: (e) ->
+ $drop = $(e.target).parents('.topic_menu_wrapper, .browse-topic-drop-menu-wrapper')
+ query = $(e.target).val()
+ $items = $drop.find('a')
+
+ if(query.length == 0)
+ $items.removeClass('hidden')
+ return;
+
+ $items.addClass('hidden')
+ $items.each (i) ->
+ thisText = $(this).not('.unread').text()
+ $(this).parents('ul').siblings('a').not('.unread').each (i) ->
+ thisText = thisText + ' ' + $(this).text();
+
+ test = true
+ terms = thisText.split(' ')
+
+ if(thisText.toLowerCase().search(query.toLowerCase()) == -1)
+ test = false
+
+ if(test)
+ $(this).removeClass('hidden')
+ # show children
+ $(this).parent().find('a').removeClass('hidden');
+ # show parents
+ $(this).parents('ul').siblings('a').removeClass('hidden');
diff --git a/lms/static/coffee/src/discussion/discussion_router.coffee b/lms/static/coffee/src/discussion/discussion_router.coffee
index b2e41cd5af..50c14b20de 100644
--- a/lms/static/coffee/src/discussion/discussion_router.coffee
+++ b/lms/static/coffee/src/discussion/discussion_router.coffee
@@ -14,6 +14,9 @@ if Backbone?
@newPostView = new NewPostView(el: $(".new-post-article"), collection: @discussion)
@nav.on "thread:created", @navigateToThread
+ @newPost = $('.new-post-article')
+ $('.new-post-btn').bind "click", @showNewPost
+ $('.new-post-cancel').bind "click", @hideNewPost
allThreads: ->
@nav.updateSidebar()
@@ -24,6 +27,8 @@ if Backbone?
showThread: (forum_name, thread_id) ->
@thread = @discussion.get(thread_id)
+ @thread.set("unread_comments_count", 0)
+ @thread.set("read", true)
@setActiveThread()
if(@main)
@main.cleanup()
@@ -43,3 +48,10 @@ if Backbone?
navigateToAllThreads: =>
@navigate("", trigger: true)
+
+ showNewPost: (event) =>
+ @newPost.slideDown(300)
+ $('.new-post-title').focus()
+
+ hideNewPost: (event) =>
+ @newPost.slideUp(300)
diff --git a/lms/static/coffee/src/discussion/tooltip_manager.coffee b/lms/static/coffee/src/discussion/tooltip_manager.coffee
new file mode 100644
index 0000000000..695ad52e3d
--- /dev/null
+++ b/lms/static/coffee/src/discussion/tooltip_manager.coffee
@@ -0,0 +1,47 @@
+$ ->
+ new TooltipManager
+
+class @TooltipManager
+ constructor: () ->
+ @$body = $('body')
+ @$tooltip = $('')
+ @$body.delegate '[data-tooltip]',
+ 'mouseover': @showTooltip,
+ 'mousemove': @moveTooltip,
+ 'mouseout': @hideTooltip,
+ 'click': @hideTooltip
+
+ showTooltip: (e) =>
+ tooltipText = $(e.target).attr('data-tooltip')
+ @$tooltip.html(tooltipText)
+ @$body.append(@$tooltip)
+ $(e.target).children().css('pointer-events', 'none')
+
+ tooltipCoords =
+ x: e.pageX - (@$tooltip.outerWidth() / 2)
+ y: e.pageY - (@$tooltip.outerHeight() + 15)
+
+ @$tooltip.css
+ 'left': tooltipCoords.x,
+ 'top': tooltipCoords.y
+
+ @tooltipTimer = setTimeout ()=>
+ @$tooltip.show().css('opacity', 1)
+
+ @tooltipTimer = setTimeout ()=>
+ @hideTooltip()
+ , 3000
+ , 500
+
+ moveTooltip: (e) =>
+ tooltipCoords =
+ x: e.pageX - (@$tooltip.outerWidth() / 2)
+ y: e.pageY - (@$tooltip.outerHeight() + 15)
+
+ @$tooltip.css
+ 'left': tooltipCoords.x
+ 'top': tooltipCoords.y
+
+ hideTooltip: (e) =>
+ @$tooltip.hide().css('opacity', 0)
+ clearTimeout(@tooltipTimer)
diff --git a/lms/static/coffee/src/discussion/views/discussion_thread_list_view.coffee b/lms/static/coffee/src/discussion/views/discussion_thread_list_view.coffee
index d4c144f808..cca6502158 100644
--- a/lms/static/coffee/src/discussion/views/discussion_thread_list_view.coffee
+++ b/lms/static/coffee/src/discussion/views/discussion_thread_list_view.coffee
@@ -9,6 +9,7 @@ if Backbone?
"click .browse-topic-drop-search-input": "ignoreClick"
"click .post-list .list-item a": "threadSelected"
"click .post-list .more-pages a": "loadMorePages"
+ 'keyup .browse-topic-drop-search-input': DiscussionFilter.filterDrop
initialize: ->
@displayedCollection = new Discussion(@collection.models, pages: @collection.pages)
@@ -129,6 +130,8 @@ if Backbone?
content.addClass("followed")
if thread.get('endorsed')
content.addClass("resolved")
+ if thread.get('read')
+ content.addClass("read")
@highlight(content)
@@ -244,7 +247,8 @@ if Backbone?
item = $(event.target).closest('li')
if item.find("span.board-name").data("discussion_id") == "#all"
@discussionIds = ""
- @clearSearch()
+ @$(".post-search-field").val("")
+ @retrieveAllThreads()
else
discussionIds = _.map item.find(".board-name[data-discussion_id]"), (board) -> $(board).data("discussion_id").id
@retrieveDiscussions(discussionIds)
@@ -277,6 +281,18 @@ if Backbone?
Content.loadContentInfos(response.content_info)
@displayedCollection.reset(@collection.models)
+ retrieveAllThreads: () ->
+ url = DiscussionUtil.urlFor("threads")
+ DiscussionUtil.safeAjax
+ url: url
+ type: "GET"
+ success: (response, textStatus) =>
+ @collection.current_page = response.page
+ @collection.pages = response.num_pages
+ @collection.reset(response.discussion_data)
+ Content.loadContentInfos(response.content_info)
+ @displayedCollection.reset(@collection.models)
+
sortThreads: (event) ->
@$(".sort-bar a").removeClass("active")
$(event.target).addClass("active")
diff --git a/lms/static/coffee/src/discussion/views/new_post_view.coffee b/lms/static/coffee/src/discussion/views/new_post_view.coffee
index 2400a3b18e..1c49fdbc8e 100644
--- a/lms/static/coffee/src/discussion/views/new_post_view.coffee
+++ b/lms/static/coffee/src/discussion/views/new_post_view.coffee
@@ -21,6 +21,7 @@ if Backbone?
"click .topic_dropdown_button": "toggleTopicDropdown"
"click .topic_menu_wrapper": "setTopic"
"click .topic_menu_search": "ignoreClick"
+ "keyup .form-topic-drop-search-input": DiscussionFilter.filterDrop
# Because we want the behavior that when the body is clicked the menu is
# closed, we need to ignore clicks in the search field and stop propagation.
@@ -120,9 +121,6 @@ if Backbone?
anonymous_to_peers = false || @$("input.discussion-anonymous-to-peers").is(":checked")
follow = false || @$("input.discussion-follow").is(":checked")
- $formTopicDropBtn.bind('click', showFormTopicDrop)
- $formTopicDropMenu.bind('click', setFormTopic)
-
url = DiscussionUtil.urlFor('create_thread', @topicId)
DiscussionUtil.safeAjax
diff --git a/lms/static/js/discussions-temp.js b/lms/static/js/discussions-temp.js
deleted file mode 100644
index dd6af6ef14..0000000000
--- a/lms/static/js/discussions-temp.js
+++ /dev/null
@@ -1,350 +0,0 @@
-var $body;
-var $browse;
-var $search;
-var $searchField;
-var $topicDrop;
-var $currentBoard;
-var $tooltip;
-var $newPost;
-var $thread;
-var $sidebar;
-var $sidebarWidthStyles;
-var $formTopicDropBtn;
-var $formTopicDropMenu;
-var $postListWrapper;
-var $dropFilter;
-var $topicFilter;
-var $discussionBody;
-var sidebarWidth;
-var sidebarHeight;
-var sidebarHeaderHeight;
-var sidebarXOffset;
-var scrollTop;
-var discussionsBodyTop;
-var discussionsBodyBottom;
-var tooltipTimer;
-var tooltipCoords;
-var SIDEBAR_PADDING = 10;
-var SIDEBAR_HEADER_HEIGHT = 87;
-
-
-$(document).ready(function() {
- $body = $('body');
- //$browse = $('.browse-search .browse');
- //$search = $('.browse-search .search');
- $searchField = $('.post-search-field');
- //$topicDrop = $('.browse-topic-drop-menu-wrapper');
- $currentBoard = $('.current-board');
- $tooltip = $('');
- $newPost = $('.new-post-article');
- $sidebar = $('.sidebar');
- $discussionBody = $('.discussion-body');
- $postListWrapper = $('.post-list-wrapper');
- $formTopicDropBtn = $('.new-post-article .form-topic-drop-btn');
- $formTopicDropMenu = $('.new-post-article .form-topic-drop-menu-wrapper');
- // $dropFilter = $('.browse-topic-drop-search-input');
- // $topicFilter = $('.topic-drop-search-input');
- $sidebarWidthStyles = $('');
- $body.append($sidebarWidthStyles);
-
- sidebarWidth = $('.sidebar').width();
- sidebarXOffset = $sidebar.offset().top;
-
- //$browse.bind('click', showTopicDrop);
- //$search.bind('click', showSearch);
- // $topicDrop.bind('click', setTopic);
- $formTopicDropBtn.bind('click', showFormTopicDrop);
- $formTopicDropMenu.bind('click', setFormTopic);
- $('.new-post-btn').bind('click', newPost);
- $('.new-post-cancel').bind('click', closeNewPost);
-
- $body.delegate('[data-tooltip]', {
- 'mouseover': showTooltip,
- 'mousemove': moveTooltip,
- 'mouseout': hideTooltip,
- 'click': hideTooltip
- });
-
- $body.delegate('.browse-topic-drop-search-input, .form-topic-drop-search-input', 'keyup', filterDrop);
-});
-
-function filterDrop(e) {
- /*
- * multiple queries
- */
-
- // var $drop = $(e.target).parents('.form-topic-drop-menu-wrapper, .browse-topic-drop-menu-wrapper');
- // var queries = $(this).val().split(' ');
- // var $items = $drop.find('a');
-
- // if(queries.length == 0) {
- // $items.show();
- // return;
- // }
-
- // $items.hide();
- // $items.each(function(i) {
- // var thisText = $(this).children().not('.unread').text();
- // $(this).parents('ul').siblings('a').not('.unread').each(function(i) {
- // thisText = thisText + ' ' + $(this).text();
- // });
-
- // var test = true;
- // var terms = thisText.split(' ');
-
- // for(var i = 0; i < queries.length; i++) {
- // if(thisText.toLowerCase().search(queries[i].toLowerCase()) == -1) {
- // test = false;
- // }
- // }
-
- // if(test) {
- // $(this).show();
-
- // // show children
- // $(this).parent().find('a').show();
-
- // // show parents
- // $(this).parents('ul').siblings('a').show();
- // }
- // });
-
-
-
- /*
- * single query
- */
-
- var $drop = $(e.target).parents('.topic_menu_wrapper, .browse-topic-drop-menu-wrapper');
- var query = $(this).val();
- var $items = $drop.find('a');
-
- if(query.length == 0) {
- $items.removeClass('hidden');
- return;
- }
-
- $items.addClass('hidden');
- $items.each(function(i) {
- var thisText = $(this).not('.unread').text();
- $(this).parents('ul').siblings('a').not('.unread').each(function(i) {
- thisText = thisText + ' ' + $(this).text();
- });
-
- var test = true;
- var terms = thisText.split(' ');
-
- if(thisText.toLowerCase().search(query.toLowerCase()) == -1) {
- test = false;
- }
-
- if(test) {
- $(this).removeClass('hidden');
-
- // show children
- $(this).parent().find('a').removeClass('hidden');
-
- // show parents
- $(this).parents('ul').siblings('a').removeClass('hidden');
- }
- });
-}
-
-function showTooltip(e) {
- var tooltipText = $(this).attr('data-tooltip');
- $tooltip.html(tooltipText);
- $body.append($tooltip);
- $(this).children().css('pointer-events', 'none');
-
- tooltipCoords = {
- x: e.pageX - ($tooltip.outerWidth() / 2),
- y: e.pageY - ($tooltip.outerHeight() + 15)
- };
-
- $tooltip.css({
- 'left': tooltipCoords.x,
- 'top': tooltipCoords.y
- });
-
- tooltipTimer = setTimeout(function() {
- $tooltip.show().css('opacity', 1);
-
- tooltipTimer = setTimeout(function() {
- hideTooltip();
- }, 3000);
- }, 500);
-}
-
-function moveTooltip(e) {
- tooltipCoords = {
- x: e.pageX - ($tooltip.outerWidth() / 2),
- y: e.pageY - ($tooltip.outerHeight() + 15)
- };
-
- $tooltip.css({
- 'left': tooltipCoords.x,
- 'top': tooltipCoords.y
- });
-}
-
-function hideTooltip(e) {
- $tooltip.hide().css('opacity', 0);
- clearTimeout(tooltipTimer);
-}
-
-function showBrowse(e) {
- $browse.addClass('is-open');
- $search.removeClass('is-open');
- $searchField.val('');
-}
-
-function showSearch(e) {
- $search.addClass('is-open');
- $browse.removeClass('is-open');
- setTimeout(function() {
- $searchField.focus();
- }, 200);
-}
-
-function showTopicDrop(e) {
- e.preventDefault();
-
- $browse.addClass('is-dropped');
-
- if(!$topicDrop[0]) {
- $topicDrop = $('.browse-topic-drop-menu-wrapper');
- }
-
- $topicDrop.show();
- $browse.unbind('click', showTopicDrop);
- $body.bind('keyup', setActiveDropItem);
- $browse.bind('click', hideTopicDrop);
- setTimeout(function() {
- $body.bind('click', hideTopicDrop);
- }, 0);
-}
-
-function hideTopicDrop(e) {
- if(e.target == $('.browse-topic-drop-search-input')[0]) {
- return;
- }
-
- $browse.removeClass('is-dropped');
- $topicDrop.hide();
- $body.unbind('click', hideTopicDrop);
- $browse.bind('click', showTopicDrop);
-}
-
-function setTopic(e) {
- if(e.target == $('.browse-topic-drop-search-input')[0]) {
- return;
- }
-
- var $item = $(e.target).closest('a');
- var boardName = $item.find('.board-name').html();
-
- $item.parents('ul').not('.browse-topic-drop-menu').each(function(i) {
- boardName = $(this).siblings('a').find('.board-name').html() + ' / ' + boardName;
- });
-
- if(!$currentBoard[0]) {
- $currentBoard = $('.current-board');
- }
- $currentBoard.html(boardName);
-
- var fontSize = 16;
- $currentBoard.css('font-size', '16px');
-
- while($currentBoard.width() > (sidebarWidth * .8) - 40) {
- fontSize--;
- if(fontSize < 11) {
- break;
- }
- $currentBoard.css('font-size', fontSize + 'px');
- }
-
- showBrowse();
-}
-
-function newPost(e) {
- $newPost.slideDown(300);
- $('.new-post-title').focus();
-}
-
-function closeNewPost(e) {
- $newPost.slideUp(300);
-}
-
-function showFormTopicDrop(e) {
- $formTopicDropBtn.addClass('is-dropped');
- $formTopicDropMenu.show();
- $formTopicDropBtn.unbind('click', showFormTopicDrop);
- $formTopicDropBtn.bind('click', hideFormTopicDrop);
-
- setTimeout(function() {
- $body.bind('click', hideFormTopicDrop);
- }, 0);
-
-}
-
-function hideFormTopicDrop(e) {
- if(e.target == $('.topic-drop-search-input')[0]) {
- return;
- }
-
- $formTopicDropBtn.removeClass('is-dropped');
- $formTopicDropMenu.hide();
- $body.unbind('click', hideFormTopicDrop);
- $formTopicDropBtn.unbind('click', hideFormTopicDrop);
- $formTopicDropBtn.bind('click', showFormTopicDrop);
-}
-
-function setFormTopic(e) {
- if(e.target == $('.topic-drop-search-input')[0]) {
- return;
- }
- $formTopicDropBtn.removeClass('is-dropped');
- hideFormTopicDrop(e);
-
- var $item = $(e.target);
- var boardName = $item.html();
- $item.parents('ul').not('.form-topic-drop-menu').each(function(i) {
- boardName = $(this).siblings('a').html() + ' / ' + boardName;
- });
- $formTopicDropBtn.html(boardName + ' ▾');
-}
-
-function updateSidebar(e) {
- // determine page scroll attributes
- scrollTop = $(window).scrollTop();
- discussionsBodyTop = $discussionBody.offset().top;
- discussionsBodyBottom = discussionsBodyTop + $discussionBody.height();
- var windowHeight = $(window).height();
-
- // toggle fixed positioning
- if(scrollTop > discussionsBodyTop - SIDEBAR_PADDING) {
- $sidebar.addClass('fixed');
- $sidebar.css('top', SIDEBAR_PADDING + 'px');
- } else {
- $sidebar.removeClass('fixed');
- $sidebar.css('top', '0');
- }
-
- // set sidebar width
- var sidebarWidth = .32 * $discussionBody.width() - 10;
- $sidebar.css('width', sidebarWidth + 'px');
-
- // show the entire sidebar at all times
- var sidebarHeight = windowHeight - (scrollTop < discussionsBodyTop - SIDEBAR_PADDING ? discussionsBodyTop - scrollTop : SIDEBAR_PADDING) - SIDEBAR_PADDING - (scrollTop + windowHeight > discussionsBodyBottom + SIDEBAR_PADDING ? scrollTop + windowHeight - discussionsBodyBottom - SIDEBAR_PADDING : 0);
- $sidebar.css('height', sidebarHeight > 400 ? sidebarHeight : 400 + 'px');
-
- // update the list height
- if(!$postListWrapper[0]) {
- $postListWrapper = $('.post-list-wrapper');
- }
- $postListWrapper.css('height', (sidebarHeight - SIDEBAR_HEADER_HEIGHT - 4) + 'px');
-
- // update title wrappers
- var titleWidth = sidebarWidth - 115;
- $sidebarWidthStyles.html('.discussion-body .post-list a .title { width: ' + titleWidth + 'px !important; }');
-}
diff --git a/lms/static/js/jquery.ajaxfileupload.js b/lms/static/js/jquery.ajaxfileupload.js
index d761c5ae3c..e588d3555a 100644
--- a/lms/static/js/jquery.ajaxfileupload.js
+++ b/lms/static/js/jquery.ajaxfileupload.js
@@ -79,7 +79,7 @@ jQuery.extend({
try {
if(io.contentWindow){
xml.responseText = io.contentWindow.document.body ?
- io.contentWindow.document.body.innerText : null;
+ io.contentWindow.document.body.textContent || io.contentWindow.document.body.innerText : null;
xml.responseXML = io.contentWindow.document.XMLDocument ?
io.contentWindow.document.XMLDocument : io.contentWindow.document;
diff --git a/lms/static/sass/_discussion.scss b/lms/static/sass/_discussion.scss
index 78bda06442..8d1257d440 100644
--- a/lms/static/sass/_discussion.scss
+++ b/lms/static/sass/_discussion.scss
@@ -940,7 +940,7 @@ body.discussion {
display: block;
width: 100%;
height: 30px;
- padding: 0;
+ padding: 0 0 0 30px;
margin: 14px auto;
@include box-sizing(border-box);
border: 1px solid #acacac;
@@ -951,7 +951,6 @@ body.discussion {
font-weight: 400;
font-size: 13px;
line-height: 20px;
- text-indent: 30px;
color: #333;
outline: 0;
cursor: pointer;
@@ -1019,6 +1018,7 @@ body.discussion {
text-shadow: 0 -1px 0 rgba(0, 0, 0, .3);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 1px rgba(0, 0, 0, .2) inset;
}
+
}
}
@@ -1053,14 +1053,17 @@ body.discussion {
}
a {
- position: relative;
display: block;
- height: 36px;
+ position: relative;
+ float: left;
+ clear: both;
+ width: 100%;
padding: 0 10px 0 18px;
margin-bottom: 1px;
margin-right: -1px;
@include linear-gradient(top, rgba(255, 255, 255, .7), rgba(255, 255, 255, 0));
background-color: #fff;
+ @include clearfix;
&:hover {
@include linear-gradient(top, rgba(255, 255, 255, .7), rgba(255, 255, 255, 0));
@@ -1097,15 +1100,23 @@ body.discussion {
}
.title {
+ display: block;
+ float: left;
+ width: 70%;
+ margin: 8px 0 10px;
font-size: 13px;
font-weight: 700;
- line-height: 34px;
+ line-height: 1.4;
color: #333;
}
- &.read .title {
- font-weight: 400;
- color: #737373;
+ &.read {
+ background: #f2f2f2;
+
+ .title {
+ font-weight: 400;
+ color: #737373;
+ }
}
&.resolved:before {
@@ -1122,7 +1133,7 @@ body.discussion {
content: '';
position: absolute;
top: 0;
- right: 1px;
+ right: 0;
width: 10px;
height: 12px;
background: url(../images/following-flag.png) no-repeat;
@@ -1165,22 +1176,13 @@ body.discussion {
}
}
- .title {
- display: block;
- float: left;
- width: 70%;
- white-space: nowrap;
- text-overflow: ellipsis;
- overflow: hidden;
- }
-
.votes-count,
.comments-count {
display: block;
float: right;
width: 32px;
height: 16px;
- margin-top: 9px;
+ margin-top: 8px;
border-radius: 2px;
@include linear-gradient(top, #d4d4d4, #dfdfdf);
font-size: 11px;
@@ -1207,12 +1209,13 @@ body.discussion {
background-position: 0 -5px;
}
- &.new {
- @include linear-gradient(top, #84d7fe, #99e0fe);
+ &.unread {
+ @include linear-gradient(top, #84d7fe, #60a8d6);
color: #333;
&:after {
color: #99e0fe;
+ background-position: 0 0px;
}
}
}
@@ -1260,6 +1263,10 @@ body.discussion {
padding: 40px;
min-height: 468px;
+ a {
+ word-wrap: break-word;
+ }
+
h1 {
margin-bottom: 10px;
font-size: 28px;
@@ -1317,6 +1324,7 @@ body.discussion {
position: relative;
z-index: 100;
margin-top: 5px;
+ margin-left: 40px;
}
.post-tools {
@@ -1472,17 +1480,31 @@ body.discussion {
}
}
+ blockquote {
+ background: #f6f6f6;
+ border-radius: 3px;
+ padding: 5px 10px;
+ font-size: 14px;
+ }
+
.comments {
list-style: none;
margin-top: 20px;
padding: 0;
border-top: 1px solid #ddd;
- li {
+ > li {
background: #f6f6f6;
border-bottom: 1px solid #ddd;
}
+ blockquote {
+ background: #e6e6e6;
+ border-radius: 3px;
+ padding: 5px 10px;
+ font-size: 14px;
+ }
+
.comment-form {
background: #eee;
@include clearfix;
@@ -1506,7 +1528,6 @@ body.discussion {
.discussion-errors {
margin: 0px;
}
-
}
.response-body {
diff --git a/lms/templates/courseware/course_navigation.html b/lms/templates/courseware/course_navigation.html
index ffa8b0cadd..5ae69908fb 100644
--- a/lms/templates/courseware/course_navigation.html
+++ b/lms/templates/courseware/course_navigation.html
@@ -6,54 +6,21 @@ if active_page == None and active_page_context is not UNDEFINED:
# If active_page is not passed in as an argument, it may be in the context as active_page_context
active_page = active_page_context
-def url_class(url):
- if url == active_page:
+def url_class(is_active):
+ if is_active:
return "active"
return ""
%>
-<%! from django.core.urlresolvers import reverse %>
-<%! from courseware.access import has_access %>
+<%! from courseware.tabs import get_course_tabs %>