Merge remote-tracking branch 'origin/master' into feature/bridger/course_grading

This commit is contained in:
Bridger Maxwell
2012-09-16 00:39:43 -04:00
158 changed files with 8520 additions and 2050 deletions

View File

@@ -53,7 +53,7 @@ def index(request):
"""
courses = modulestore().get_items(['i4x', None, None, 'course', None])
return render_to_response('index.html', {
'courses': [(course.metadata['display_name'],
'courses': [(course.metadata.get('display_name'),
reverse('course_index', args=[
course.location.org,
course.location.course,

View File

@@ -14,9 +14,11 @@ $yellow: #fff8af;
$cream: #F6EFD4;
$border-color: #ddd;
// edX colors
$blue: rgb(29,157,217);
$pink: rgb(182,37,104);
$error-red: rgb(253, 87, 87);
@mixin hide-text {
background-color: transparent;

View File

@@ -330,11 +330,6 @@ section.cal {
&:hover {
opacity: 1;
width: flex-grid(5) + flex-gutter();
+ section.main-content {
width: flex-grid(7);
}
}
> header {

View File

@@ -5,4 +5,8 @@ django admin pages for courseware model
from external_auth.models import *
from django.contrib import admin
admin.site.register(ExternalAuthMap)
class ExternalAuthMapAdmin(admin.ModelAdmin):
search_fields = ['external_id','user__username']
date_hierarchy = 'dtcreated'
admin.site.register(ExternalAuthMap, ExternalAuthMapAdmin)

View File

@@ -0,0 +1,8 @@
'''
django admin pages for courseware model
'''
from track.models import *
from django.contrib import admin
admin.site.register(TrackingLog)

View File

@@ -125,7 +125,7 @@ def add_histogram(get_html, module, user):
mstart = getattr(module.descriptor,'start')
if mstart is not None:
is_released = "<font color='red'>Yes!</font>" if (now > mstart) else "<font color='green'>Not yet</font>"
staff_context = {'definition': module.definition.get('data'),
'metadata': json.dumps(module.metadata, indent=4),
'location': module.location,
@@ -133,6 +133,7 @@ def add_histogram(get_html, module, user):
'source_file' : source_file,
'source_url': '%s/%s/tree/master/%s' % (giturl,data_dir,source_file),
'category': str(module.__class__.__name__),
# Template uses element_id in js function names, so can't allow dashes
'element_id': module.location.html_id().replace('-','_'),
'edit_link': edit_link,
'user': user,

View File

@@ -265,7 +265,7 @@ class LoncapaProblem(object):
# include solutions from <solution>...</solution> stanzas
for entry in self.tree.xpath("//" + "|//".join(solution_types)):
answer = etree.tostring(entry)
if answer: answer_map[entry.get('id')] = answer
if answer: answer_map[entry.get('id')] = contextualize_text(answer, self.context)
log.debug('answer_map = %s' % answer_map)
return answer_map
@@ -382,10 +382,10 @@ class LoncapaProblem(object):
original_path = sys.path
for script in scripts:
sys.path = original_path + self._extract_system_path(script)
stype = script.get('type')
if stype:
if 'javascript' in stype:
continue # skip javascript

View File

@@ -326,8 +326,16 @@ def textline_dynamath(element, value, status, render_template, msg=''):
count = int(eid.split('_')[-2]) - 1 # HACK
size = element.get('size')
hidden = element.get('hidden', '') # if specified, then textline is hidden and id is stored in div of name given by hidden
# Preprocessor to insert between raw input and Mathjax
preprocessor = {'class_name': element.get('preprocessorClassName',''),
'script_src': element.get('preprocessorSrc','')}
if '' in preprocessor.values():
preprocessor = None
context = {'id': eid, 'value': value, 'state': status, 'count': count, 'size': size,
'msg': msg, 'hidden': hidden,
'preprocessor': preprocessor,
}
html = render_template("textinput_dynamath.html", context)
return etree.XML(html)

View File

@@ -11,13 +11,11 @@ importAll("xproblem");
generatorModulePath = process.argv[2];
dependencies = JSON.parse(process.argv[3]);
seed = process.argv[4];
seed = JSON.parse(process.argv[4]);
params = JSON.parse(process.argv[5]);
if(seed==null){
seed = 4;
}else{
seed = parseInt(seed);
}
for(var i = 0; i < dependencies.length; i++){

View File

@@ -408,7 +408,7 @@ class JavascriptResponse(LoncapaResponse):
output = self.call_node([generator_file,
self.generator,
json.dumps(self.generator_dependencies),
json.dumps(str(self.system.seed)),
json.dumps(str(self.context['the_lcp'].seed)),
json.dumps(self.params)]).strip()
return json.loads(output)
@@ -971,8 +971,9 @@ def sympy_check2():
# build map giving "correct"ness of the answer(s)
correct_map = CorrectMap()
for k in range(len(idset)):
npoints = self.maxpoints[idset[k]] if correct[k] == 'correct' else 0
correct_map.set(idset[k], correct[k], msg=messages[k],
npoints=self.maxpoints[idset[k]])
npoints=npoints)
return correct_map
def get_answers(self):

View File

@@ -1,7 +1,13 @@
###
### version of textline.html which does dynammic math
### version of textline.html which does dynamic math
###
<section class="text-input-dynamath">
<section class="text-input-dynamath capa_inputtype" id="inputtype_${id}">
% if preprocessor is not None:
<div class="text-input-dynamath_data" data-preprocessor="${preprocessor['class_name']}"/>
<div class="script_placeholder" data-src="${preprocessor['script_src']}"/>
% endif
% if state == 'unsubmitted':
<div class="unanswered" id="status_${id}">
% elif state == 'correct':
@@ -15,27 +21,26 @@
<div style="display:none;" name="${hidden}" inputid="input_${id}" />
% endif
<input type="text" name="input_${id}" id="input_${id}" value="${value}" class="math" size="${size if size else ''}"
% if hidden:
style="display:none;"
% endif
/>
<input type="text" name="input_${id}" id="input_${id}" value="${value}" class="math" size="${size if size else ''}"
% if hidden:
style="display:none;"
% endif
/>
<p class="status">
% if state == 'unsubmitted':
unanswered
% elif state == 'correct':
correct
% elif state == 'incorrect':
incorrect
% elif state == 'incomplete':
incomplete
% endif
</p>
<p class="status">
% if state == 'unsubmitted':
unanswered
% elif state == 'correct':
correct
% elif state == 'incorrect':
incorrect
% elif state == 'incomplete':
incomplete
% endif
</p>
<p id="answer_${id}" class="answer"></p>
<p id="answer_${id}" class="answer"></p>
<div id="display_${id}" class="equation">`{::}`</div>
<div id="display_${id}" class="equation">`{::}`</div>
</div>
<textarea style="display:none" id="input_${id}_dynamath" name="input_${id}_dynamath"> </textarea>

View File

@@ -507,8 +507,12 @@ class CapaModule(XModule):
# 'success' will always be incorrect
event_info['correct_map'] = correct_map.get_dict()
event_info['success'] = success
event_info['attempts'] = self.attempts
self.system.track_function('save_problem_check', event_info)
if hasattr(self.system,'psychometrics_handler'): # update PsychometricsData using callback
self.system.psychometrics_handler(self.get_instance_state())
# render problem into HTML
html = self.get_problem_html(encapsulate=False)

View File

@@ -158,7 +158,7 @@ section.problem {
border: 1px solid #e3e3e3;
@include inline-block;
@include border-radius(4px);
min-width: 300px;
min-width: 30px;
}
}
}
@@ -311,6 +311,10 @@ section.problem {
text-align: left;
}
td {
text-align: left;
}
caption, th, td {
padding: .25em .75em .25em 0;
padding: .25rem .75rem .25rem 0;
@@ -432,7 +436,7 @@ section.problem {
.detailed-solution {
border: 1px solid #ddd;
padding: 9px 9px 20px;
padding: 9px 15px 20px;
margin-bottom: 10px;
background: #FFF;
position: relative;

View File

@@ -32,7 +32,7 @@ nav.sequence-nav {
.sequence-list-wrapper {
position: relative;
z-index: 9999;
z-index: 99;
border: 1px solid #ccc;
height: 44px;
margin: 0 30px;
@@ -297,7 +297,6 @@ nav.sequence-bottom {
ul {
@extend .clearfix;
@include inline-block();
width: 103px;
li {
float: left;

View File

@@ -157,6 +157,7 @@ div.video {
opacity: 1;
padding: 0;
margin: 0;
list-style: none;
}
}
@@ -411,6 +412,7 @@ div.video {
width: flex-grid(3, 9);
margin: 0;
font-size: 14px;
list-style: none;
li {
border: 0;

View File

@@ -11,7 +11,9 @@ class @Problem
$(selector, @el)
bind: =>
MathJax.Hub.Queue ["Typeset", MathJax.Hub]
@el.find('.problem > div').each (index, element) =>
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
window.update_schematics()
problem_prefix = @element_id.replace(/problem_/,'')
@@ -23,7 +25,11 @@ class @Problem
@$('section.action input.reset').click @reset
@$('section.action input.show').click @show
@$('section.action input.save').click @save
@$('input.math').keyup(@refreshMath).each(@refreshMath)
# Dynamath
@$('input.math').keyup(@refreshMath)
@$('input.math').each (index, element) =>
MathJax.Hub.Queue [@refreshMath, null, element]
updateProgress: (response) =>
if response.progress_changed
@@ -262,7 +268,9 @@ class @Problem
showMethod = @inputtypeShowAnswerMethods[cls]
showMethod(inputtype, display, answers) if showMethod?
MathJax.Hub.Queue ["Typeset", MathJax.Hub]
@el.find('.problem > div').each (index, element) =>
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element]
@$('.show').val 'Hide Answer'
@el.addClass 'showed'
@updateProgress response
@@ -296,12 +304,21 @@ class @Problem
refreshMath: (event, element) =>
element = event.target unless element
target = "display_#{element.id.replace(/^input_/, '')}"
elid = element.id.replace(/^input_/,'')
target = "display_" + elid
# MathJax preprocessor is loaded by 'setupInputTypes'
preprocessor_tag = "inputtype_" + elid
mathjax_preprocessor = @inputtypeDisplays[preprocessor_tag]
if jax = MathJax.Hub.getAllJax(target)[0]
MathJax.Hub.Queue ['Text', jax, $(element).val()],
[@updateMathML, jax, element]
eqn = $(element).val()
if mathjax_preprocessor
eqn = mathjax_preprocessor(eqn)
MathJax.Hub.Queue(['Text', jax, eqn], [@updateMathML, jax, element])
return # Explicit return for CoffeeScript
updateMathML: (jax, element) =>
try
$("##{element.id}_dynamath").val(jax.root.toMathML '')
@@ -317,6 +334,22 @@ class @Problem
@answers = @inputs.serialize()
inputtypeSetupMethods:
'text-input-dynamath': (element) =>
###
Return: function (eqn) -> eqn that preprocesses the user formula input before
it is fed into MathJax. Return 'false' if no preprocessor specified
###
data = $(element).find('.text-input-dynamath_data')
preprocessorClassName = data.data('preprocessor')
preprocessorClass = window[preprocessorClassName]
if not preprocessorClass?
return false
else
preprocessor = new preprocessorClass()
return preprocessor.fn
javascriptinput: (element) =>
data = $(element).find(".javascriptinput_data")

View File

@@ -2023,7 +2023,16 @@ function add_schematic_handler(other_onload) {
update_schematics();
}
}
window.onload = add_schematic_handler(window.onload);
/*
* THK: Attaching update_schematic to window.onload is rather presumptuous...
* The function is called for EVERY page load, whether in courseware or in
* course info, in 6.002x or the public health course. It is also redundant
* because courseware includes an explicit call to update_schematic after
* each ajax exchange. In this case, calling update_schematic twice appears
* to contribute to a bug in Firefox that does not render the schematic
* properly depending on timing.
*/
//window.onload = add_schematic_handler(window.onload);
// ask each schematic input widget to update its value field for submission
function prepare_schematics() {

View File

@@ -29,6 +29,9 @@ INVALID_CHARS = re.compile(r"[^\w.-]")
# Names are allowed to have colons.
INVALID_CHARS_NAME = re.compile(r"[^\w.:-]")
# html ids can contain word chars and dashes
INVALID_HTML_CHARS = re.compile(r"[^\w-]")
_LocationBase = namedtuple('LocationBase', 'tag org course category name revision')
@@ -44,12 +47,35 @@ class Location(_LocationBase):
'''
__slots__ = ()
@staticmethod
def _clean(value, invalid):
"""
invalid should be a compiled regexp of chars to replace with '_'
"""
return re.sub('_+', '_', invalid.sub('_', value))
@staticmethod
def clean(value):
"""
Return value, made into a form legal for locations
"""
return re.sub('_+', '_', INVALID_CHARS.sub('_', value))
return Location._clean(value, INVALID_CHARS)
@staticmethod
def clean_for_url_name(value):
"""
Convert value into a format valid for location names (allows colons).
"""
return Location._clean(value, INVALID_CHARS_NAME)
@staticmethod
def clean_for_html(value):
"""
Convert a string into a form that's safe for use in html ids, classes, urls, etc.
Replaces all INVALID_HTML_CHARS with '_', collapses multiple '_' chars
"""
return Location._clean(value, INVALID_HTML_CHARS)
@staticmethod
def is_valid(value):
@@ -183,9 +209,9 @@ class Location(_LocationBase):
Return a string with a version of the location that is safe for use in
html id attributes
"""
# TODO: is ':' ok in html ids?
return "-".join(str(v) for v in self.list()
if v is not None).replace('.', '_')
s = "-".join(str(v) for v in self.list()
if v is not None)
return Location.clean_for_html(s)
def dict(self):
"""

View File

@@ -114,12 +114,44 @@ def test_equality():
Location('tag', 'org', 'course', 'category', 'name')
)
# All the cleaning functions should do the same thing with these
general_pairs = [ ('',''),
(' ', '_'),
('abc,', 'abc_'),
('ab fg!@//\\aj', 'ab_fg_aj'),
(u"ab\xA9", "ab_"), # no unicode allowed for now
]
def test_clean():
pairs = [ ('',''),
(' ', '_'),
('abc,', 'abc_'),
('ab fg!@//\\aj', 'ab_fg_aj'),
(u"ab\xA9", "ab_"), # no unicode allowed for now
]
pairs = general_pairs + [
('a:b', 'a_b'), # no colons in non-name components
('a-b', 'a-b'), # dashes ok
('a.b', 'a.b'), # dot ok
]
for input, output in pairs:
assert_equals(Location.clean(input), output)
def test_clean_for_url_name():
pairs = general_pairs + [
('a:b', 'a:b'), # colons ok in names
('a-b', 'a-b'), # dashes ok in names
('a.b', 'a.b'), # dot ok in names
]
for input, output in pairs:
assert_equals(Location.clean_for_url_name(input), output)
def test_clean_for_html():
pairs = general_pairs + [
("a:b", "a_b"), # no colons for html use
("a-b", "a-b"), # dashes ok (though need to be replaced in various use locations. ugh.)
('a.b', 'a_b'), # no dots.
]
for input, output in pairs:
assert_equals(Location.clean_for_html(input), output)
def test_html_id():
loc = Location("tag://org/course/cat/name:more_name@rev")
assert_equals(loc.html_id(), "tag-org-course-cat-name_more_name-rev")

View File

@@ -75,7 +75,7 @@ class SequenceModule(XModule):
contents = []
for child in self.get_display_items():
progress = child.get_progress()
contents.append({
childinfo = {
'content': child.get_html(),
'title': "\n".join(
grand_child.display_name.strip()
@@ -85,7 +85,10 @@ class SequenceModule(XModule):
'progress_status': Progress.to_js_status_str(progress),
'progress_detail': Progress.to_js_detail_str(progress),
'type': child.get_icon_class(),
})
}
if childinfo['title']=='':
childinfo['title'] = child.metadata.get('display_name','')
contents.append(childinfo)
params = {'items': contents,
'element_id': self.location.html_id(),

View File

@@ -29,7 +29,7 @@ from nose.plugins.skip import SkipTest
from mock import Mock
i4xs = ModuleSystem(
ajax_url='/',
ajax_url='courses/course_id/modx/a_location',
track_function=Mock(),
get_module=Mock(),
render_template=Mock(),

View File

@@ -28,7 +28,7 @@ def is_pointer_tag(xml_obj):
No children, one attribute named url_name.
Special case for course roots: the pointer is
<course url_name="something" org="myorg" course="course">
<course url_name="something" org="myorg" course="course">
xml_obj: an etree Element

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1,412 @@
/*
Axis Labels Plugin for flot.
http://github.com/markrcote/flot-axislabels
Original code is Copyright (c) 2010 Xuan Luo.
Original code was released under the GPLv3 license by Xuan Luo, September 2010.
Original code was rereleased under the MIT license by Xuan Luo, April 2012.
Improvements by Mark Cote.
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
(function ($) {
var options = { };
function canvasSupported() {
return !!document.createElement('canvas').getContext;
}
function canvasTextSupported() {
if (!canvasSupported()) {
return false;
}
var dummy_canvas = document.createElement('canvas');
var context = dummy_canvas.getContext('2d');
return typeof context.fillText == 'function';
}
function css3TransitionSupported() {
var div = document.createElement('div');
return typeof div.style.MozTransition != 'undefined' // Gecko
|| typeof div.style.OTransition != 'undefined' // Opera
|| typeof div.style.webkitTransition != 'undefined' // WebKit
|| typeof div.style.transition != 'undefined';
}
function AxisLabel(axisName, position, padding, plot, opts) {
this.axisName = axisName;
this.position = position;
this.padding = padding;
this.plot = plot;
this.opts = opts;
this.width = 0;
this.height = 0;
}
CanvasAxisLabel.prototype = new AxisLabel();
CanvasAxisLabel.prototype.constructor = CanvasAxisLabel;
function CanvasAxisLabel(axisName, position, padding, plot, opts) {
AxisLabel.prototype.constructor.call(this, axisName, position, padding,
plot, opts);
}
CanvasAxisLabel.prototype.calculateSize = function() {
if (!this.opts.axisLabelFontSizePixels)
this.opts.axisLabelFontSizePixels = 14;
if (!this.opts.axisLabelFontFamily)
this.opts.axisLabelFontFamily = 'sans-serif';
var textWidth = this.opts.axisLabelFontSizePixels + this.padding;
var textHeight = this.opts.axisLabelFontSizePixels + this.padding;
if (this.position == 'left' || this.position == 'right') {
this.width = this.opts.axisLabelFontSizePixels + this.padding;
this.height = 0;
} else {
this.width = 0;
this.height = this.opts.axisLabelFontSizePixels + this.padding;
}
};
CanvasAxisLabel.prototype.draw = function(box) {
var ctx = this.plot.getCanvas().getContext('2d');
ctx.save();
ctx.font = this.opts.axisLabelFontSizePixels + 'px ' +
this.opts.axisLabelFontFamily;
var width = ctx.measureText(this.opts.axisLabel).width;
var height = this.opts.axisLabelFontSizePixels;
var x, y, angle = 0;
if (this.position == 'top') {
x = box.left + box.width/2 - width/2;
y = box.top + height*0.72;
} else if (this.position == 'bottom') {
x = box.left + box.width/2 - width/2;
y = box.top + box.height - height*0.72;
} else if (this.position == 'left') {
x = box.left + height*0.72;
y = box.height/2 + box.top + width/2;
angle = -Math.PI/2;
} else if (this.position == 'right') {
x = box.left + box.width - height*0.72;
y = box.height/2 + box.top - width/2;
angle = Math.PI/2;
}
ctx.translate(x, y);
ctx.rotate(angle);
ctx.fillText(this.opts.axisLabel, 0, 0);
ctx.restore();
};
HtmlAxisLabel.prototype = new AxisLabel();
HtmlAxisLabel.prototype.constructor = HtmlAxisLabel;
function HtmlAxisLabel(axisName, position, padding, plot, opts) {
AxisLabel.prototype.constructor.call(this, axisName, position,
padding, plot, opts);
}
HtmlAxisLabel.prototype.calculateSize = function() {
var elem = $('<div class="axisLabels" style="position:absolute;">' +
this.opts.axisLabel + '</div>');
this.plot.getPlaceholder().append(elem);
// store height and width of label itself, for use in draw()
this.labelWidth = elem.outerWidth(true);
this.labelHeight = elem.outerHeight(true);
elem.remove();
this.width = this.height = 0;
if (this.position == 'left' || this.position == 'right') {
this.width = this.labelWidth + this.padding;
} else {
this.height = this.labelHeight + this.padding;
}
};
HtmlAxisLabel.prototype.draw = function(box) {
this.plot.getPlaceholder().find('#' + this.axisName + 'Label').remove();
var elem = $('<div id="' + this.axisName +
'Label" " class="axisLabels" style="position:absolute;">'
+ this.opts.axisLabel + '</div>');
this.plot.getPlaceholder().append(elem);
if (this.position == 'top') {
elem.css('left', box.left + box.width/2 - this.labelWidth/2 + 'px');
elem.css('top', box.top + 'px');
} else if (this.position == 'bottom') {
elem.css('left', box.left + box.width/2 - this.labelWidth/2 + 'px');
elem.css('top', box.top + box.height - this.labelHeight + 'px');
} else if (this.position == 'left') {
elem.css('top', box.top + box.height/2 - this.labelHeight/2 + 'px');
elem.css('left', box.left + 'px');
} else if (this.position == 'right') {
elem.css('top', box.top + box.height/2 - this.labelHeight/2 + 'px');
elem.css('left', box.left + box.width - this.labelWidth + 'px');
}
};
CssTransformAxisLabel.prototype = new HtmlAxisLabel();
CssTransformAxisLabel.prototype.constructor = CssTransformAxisLabel;
function CssTransformAxisLabel(axisName, position, padding, plot, opts) {
HtmlAxisLabel.prototype.constructor.call(this, axisName, position,
padding, plot, opts);
}
CssTransformAxisLabel.prototype.calculateSize = function() {
HtmlAxisLabel.prototype.calculateSize.call(this);
this.width = this.height = 0;
if (this.position == 'left' || this.position == 'right') {
this.width = this.labelHeight + this.padding;
} else {
this.height = this.labelHeight + this.padding;
}
};
CssTransformAxisLabel.prototype.transforms = function(degrees, x, y) {
var stransforms = {
'-moz-transform': '',
'-webkit-transform': '',
'-o-transform': '',
'-ms-transform': ''
};
if (x != 0 || y != 0) {
var stdTranslate = ' translate(' + x + 'px, ' + y + 'px)';
stransforms['-moz-transform'] += stdTranslate;
stransforms['-webkit-transform'] += stdTranslate;
stransforms['-o-transform'] += stdTranslate;
stransforms['-ms-transform'] += stdTranslate;
}
if (degrees != 0) {
var rotation = degrees / 90;
var stdRotate = ' rotate(' + degrees + 'deg)';
stransforms['-moz-transform'] += stdRotate;
stransforms['-webkit-transform'] += stdRotate;
stransforms['-o-transform'] += stdRotate;
stransforms['-ms-transform'] += stdRotate;
}
var s = 'top: 0; left: 0; ';
for (var prop in stransforms) {
if (stransforms[prop]) {
s += prop + ':' + stransforms[prop] + ';';
}
}
s += ';';
return s;
};
CssTransformAxisLabel.prototype.calculateOffsets = function(box) {
var offsets = { x: 0, y: 0, degrees: 0 };
if (this.position == 'bottom') {
offsets.x = box.left + box.width/2 - this.labelWidth/2;
offsets.y = box.top + box.height - this.labelHeight;
} else if (this.position == 'top') {
offsets.x = box.left + box.width/2 - this.labelWidth/2;
offsets.y = box.top;
} else if (this.position == 'left') {
offsets.degrees = -90;
offsets.x = box.left - this.labelWidth/2 + this.labelHeight/2;
offsets.y = box.height/2 + box.top;
} else if (this.position == 'right') {
offsets.degrees = 90;
offsets.x = box.left + box.width - this.labelWidth/2
- this.labelHeight/2;
offsets.y = box.height/2 + box.top;
}
return offsets;
};
CssTransformAxisLabel.prototype.draw = function(box) {
this.plot.getPlaceholder().find("." + this.axisName + "Label").remove();
var offsets = this.calculateOffsets(box);
var elem = $('<div class="axisLabels ' + this.axisName +
'Label" style="position:absolute; ' +
'color: ' + this.opts.color + '; ' +
this.transforms(offsets.degrees, offsets.x, offsets.y) +
'">' + this.opts.axisLabel + '</div>');
this.plot.getPlaceholder().append(elem);
};
IeTransformAxisLabel.prototype = new CssTransformAxisLabel();
IeTransformAxisLabel.prototype.constructor = IeTransformAxisLabel;
function IeTransformAxisLabel(axisName, position, padding, plot, opts) {
CssTransformAxisLabel.prototype.constructor.call(this, axisName,
position, padding,
plot, opts);
this.requiresResize = false;
}
IeTransformAxisLabel.prototype.transforms = function(degrees, x, y) {
// I didn't feel like learning the crazy Matrix stuff, so this uses
// a combination of the rotation transform and CSS positioning.
var s = '';
if (degrees != 0) {
var rotation = degrees/90;
while (rotation < 0) {
rotation += 4;
}
s += ' filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=' + rotation + '); ';
// see below
this.requiresResize = (this.position == 'right');
}
if (x != 0) {
s += 'left: ' + x + 'px; ';
}
if (y != 0) {
s += 'top: ' + y + 'px; ';
}
return s;
};
IeTransformAxisLabel.prototype.calculateOffsets = function(box) {
var offsets = CssTransformAxisLabel.prototype.calculateOffsets.call(
this, box);
// adjust some values to take into account differences between
// CSS and IE rotations.
if (this.position == 'top') {
// FIXME: not sure why, but placing this exactly at the top causes
// the top axis label to flip to the bottom...
offsets.y = box.top + 1;
} else if (this.position == 'left') {
offsets.x = box.left;
offsets.y = box.height/2 + box.top - this.labelWidth/2;
} else if (this.position == 'right') {
offsets.x = box.left + box.width - this.labelHeight;
offsets.y = box.height/2 + box.top - this.labelWidth/2;
}
return offsets;
};
IeTransformAxisLabel.prototype.draw = function(box) {
CssTransformAxisLabel.prototype.draw.call(this, box);
if (this.requiresResize) {
var elem = this.plot.getPlaceholder().find("." + this.axisName + "Label");
// Since we used CSS positioning instead of transforms for
// translating the element, and since the positioning is done
// before any rotations, we have to reset the width and height
// in case the browser wrapped the text (specifically for the
// y2axis).
elem.css('width', this.labelWidth);
elem.css('height', this.labelHeight);
}
};
function init(plot) {
// This is kind of a hack. There are no hooks in Flot between
// the creation and measuring of the ticks (setTicks, measureTickLabels
// in setupGrid() ) and the drawing of the ticks and plot box
// (insertAxisLabels in setupGrid() ).
//
// Therefore, we use a trick where we run the draw routine twice:
// the first time to get the tick measurements, so that we can change
// them, and then have it draw it again.
var secondPass = false;
var axisLabels = {};
var axisOffsetCounts = { left: 0, right: 0, top: 0, bottom: 0 };
var defaultPadding = 2; // padding between axis and tick labels
plot.hooks.draw.push(function (plot, ctx) {
if (!secondPass) {
// MEASURE AND SET OPTIONS
$.each(plot.getAxes(), function(axisName, axis) {
var opts = axis.options // Flot 0.7
|| plot.getOptions()[axisName]; // Flot 0.6
if (!opts || !opts.axisLabel || !axis.show)
return;
var renderer = null;
if (!opts.axisLabelUseHtml &&
navigator.appName == 'Microsoft Internet Explorer') {
var ua = navigator.userAgent;
var re = new RegExp("MSIE ([0-9]{1,}[\.0-9]{0,})");
if (re.exec(ua) != null) {
rv = parseFloat(RegExp.$1);
}
if (rv >= 9 && !opts.axisLabelUseCanvas && !opts.axisLabelUseHtml) {
renderer = CssTransformAxisLabel;
} else if (!opts.axisLabelUseCanvas && !opts.axisLabelUseHtml) {
renderer = IeTransformAxisLabel;
} else if (opts.axisLabelUseCanvas) {
renderer = CanvasAxisLabel;
} else {
renderer = HtmlAxisLabel;
}
} else {
if (opts.axisLabelUseHtml || (!css3TransitionSupported() && !canvasTextSupported()) && !opts.axisLabelUseCanvas) {
renderer = HtmlAxisLabel;
} else if (opts.axisLabelUseCanvas || !css3TransitionSupported()) {
renderer = CanvasAxisLabel;
} else {
renderer = CssTransformAxisLabel;
}
}
var padding = opts.axisLabelPadding === undefined ?
defaultPadding : opts.axisLabelPadding;
axisLabels[axisName] = new renderer(axisName,
axis.position, padding,
plot, opts);
// flot interprets axis.labelHeight and .labelWidth as
// the height and width of the tick labels. We increase
// these values to make room for the axis label and
// padding.
axisLabels[axisName].calculateSize();
// AxisLabel.height and .width are the size of the
// axis label and padding.
axis.labelHeight += axisLabels[axisName].height;
axis.labelWidth += axisLabels[axisName].width;
opts.labelHeight = axis.labelHeight;
opts.labelWidth = axis.labelWidth;
});
// re-draw with new label widths and heights
secondPass = true;
plot.setupGrid();
plot.draw();
} else {
// DRAW
$.each(plot.getAxes(), function(axisName, axis) {
var opts = axis.options // Flot 0.7
|| plot.getOptions()[axisName]; // Flot 0.6
if (!opts || !opts.axisLabel || !axis.show)
return;
axisLabels[axisName].draw(axis.box);
});
}
});
}
$.plot.plugins.push({
init: init,
options: options,
name: 'axisLabels',
version: '2.0b0'
});
})(jQuery);

View File

@@ -9,7 +9,7 @@ If you haven't done so already:
brew install mongodb
Make sure that you have mongodb running. You can simply open a new terminal tab and type:
mongod
## Installing elasticsearch
@@ -72,9 +72,9 @@ For convenience, add the following environment variables to the terminal (assumi
export DJANGO_SETTINGS_MODULE=lms.envs.dev
export PYTHONPATH=.
Now initialzie roles and permissions:
Now initialzie roles and permissions, providing a course id eg.:
django-admin.py seed_permissions_roles
django-admin.py seed_permissions_roles "MITx/6.002x/2012_Fall"
To assign yourself as a moderator, use the following command (assuming your username is "test", and the course id is "MITx/6.002x/2012_Fall"):

View File

@@ -299,7 +299,7 @@ This is a sketch ("tue" is not a valid start date), that should help illustrate
## Specifying metadata in the xml file
Metadata can also live in the xml files, but anything defined in the policy file overrides anything in the xml. This is primarily for backwards compatibility, and you should probably not use both. If you do leave some metadata tags in the xml, you should be consistent (e.g. if `display_name`s stay in xml, they should all stay in xml).
Metadata can also live in the xml files, but anything defined in the policy file overrides anything in the xml. This is primarily for backwards compatibility, and you should probably not use both. If you do leave some metadata tags in the xml, you should be consistent (e.g. if `display_name`s stay in xml, they should all stay in xml. Note `display_name` should be specified in the problem xml definition itself, ie, <problem display_name="Title"> Problem Text </problem>, in file ProblemFoo.xml).
- note, some xml attributes are not metadata. e.g. in `<video youtube="xyz987293487293847"/>`, the `youtube` attribute specifies what video this is, and is logically part of the content, not the policy, so it should stay in the xml.
Another example policy file:

View File

@@ -101,6 +101,7 @@ def get_course_about_section(course, section_key):
- textbook
- faq
- more_info
- ocw_links
"""
# Many of these are stored as html files instead of some semantic
@@ -112,7 +113,7 @@ def get_course_about_section(course, section_key):
'course_staff_short', 'course_staff_extended',
'requirements', 'syllabus', 'textbook', 'faq', 'more_info',
'number', 'instructors', 'overview',
'effort', 'end_date', 'prerequisites']:
'effort', 'end_date', 'prerequisites', 'ocw_links']:
try:
fs = course.system.resources_fs
@@ -194,7 +195,11 @@ def get_course_syllabus_section(course, section_key):
if section_key in ['syllabus', 'guest_syllabus']:
try:
with course.system.resources_fs.open(path("syllabus") / section_key + ".html") as htmlFile:
fs = course.system.resources_fs
# first look for a run-specific version
dirs = [path("syllabus") / course.url_name, path("syllabus")]
filepath = find_file(fs, dirs, section_key + ".html")
with fs.open(filepath) as htmlFile:
return replace_urls(htmlFile.read().decode('utf-8'),
course.metadata['data_dir'])
except ResourceNotFoundError:

View File

@@ -16,6 +16,7 @@ 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 psychometrics.psychoanalyze import make_psychometrics_data_update_handler
from static_replace import replace_urls
from xmodule.errortracker import exc_info_to_str
from xmodule.exceptions import NotFoundError
@@ -230,6 +231,9 @@ def _get_module(user, request, location, student_module_cache, course_id, positi
# pass position specified in URL to module through ModuleSystem
system.set('position', position)
system.set('DEBUG', settings.DEBUG)
if settings.MITX_FEATURES.get('ENABLE_PSYCHOMETRICS') and instance_module is not None:
system.set('psychometrics_handler', # set callback for updating PsychometricsData
make_psychometrics_data_update_handler(instance_module))
try:
module = descriptor.xmodule_constructor(system)(instance_state, shared_state)

View File

@@ -22,7 +22,7 @@ from django.contrib.auth.models import User
from mitxmako.shortcuts import render_to_response, render_to_string
from courseware.courses import get_course_with_access
from django_comment_client.utils import JsonResponse, JsonError, extract
from django_comment_client.utils import JsonResponse, JsonError, extract, get_courseware_context
from django_comment_client.permissions import check_permissions_by_view
from django_comment_client.models import Role
@@ -38,11 +38,10 @@ def permitted(fn):
else:
content = None
return content
if check_permissions_by_view(request.user, kwargs['course_id'], fetch_content(), request.view_name):
return fn(request, *args, **kwargs)
else:
return JsonError("unauthorized")
return JsonError("unauthorized", status=401)
return wrapper
def ajax_content_response(request, course_id, content, template_name):
@@ -63,22 +62,39 @@ def ajax_content_response(request, course_id, content, template_name):
@login_required
@permitted
def create_thread(request, course_id, commentable_id):
course = get_course_with_access(request.user, course_id, 'load')
post = request.POST
if course.metadata.get("allow_anonymous", True):
anonymous = post.get('anonymous', 'false').lower() == 'true'
else:
anonymous = False
if course.metadata.get("allow_anonymous_to_peers", False):
anonymous_to_peers = post.get('anonymous_to_peers', 'false').lower() == 'true'
else:
anonymous_to_peers = False
thread = cc.Thread(**extract(post, ['body', 'title', 'tags']))
thread.update_attributes(**{
'anonymous' : post.get('anonymous', 'false').lower() == 'true',
'commentable_id' : commentable_id,
'course_id' : course_id,
'user_id' : request.user.id,
'anonymous' : anonymous,
'anonymous_to_peers' : anonymous_to_peers,
'commentable_id' : commentable_id,
'course_id' : course_id,
'user_id' : request.user.id,
})
thread.save()
if post.get('auto_subscribe', 'false').lower() == 'true':
user = cc.User.from_django_user(request.user)
user.follow(thread)
courseware_context = get_courseware_context(thread, course)
data = thread.to_dict()
if courseware_context:
data.update(courseware_context)
if request.is_ajax():
return ajax_content_response(request, course_id, thread.to_dict(), 'discussion/ajax_create_thread.html')
return ajax_content_response(request, course_id, data, 'discussion/ajax_create_thread.html')
else:
return JsonResponse(utils.safe_content(thread.to_dict()))
return JsonResponse(utils.safe_content(data))
@require_POST
@login_required
@@ -95,8 +111,21 @@ def update_thread(request, course_id, thread_id):
def _create_comment(request, course_id, thread_id=None, parent_id=None):
post = request.POST
comment = cc.Comment(**extract(post, ['body']))
course = get_course_with_access(request.user, course_id, 'load')
if course.metadata.get("allow_anonymous", True):
anonymous = post.get('anonymous', 'false').lower() == 'true'
else:
anonymous = False
if course.metadata.get("allow_anonymous_to_peers", False):
anonymous_to_peers = post.get('anonymous_to_peers', 'false').lower() == 'true'
else:
anonymous_to_peers = False
comment.update_attributes(**{
'anonymous' : post.get('anonymous', 'false').lower() == 'true',
'anonymous' : anonymous,
'anonymous_to_peers' : anonymous_to_peers,
'user_id' : request.user.id,
'course_id' : course_id,
'thread_id' : thread_id,
@@ -214,7 +243,7 @@ def undo_vote_for_thread(request, course_id, thread_id):
thread = cc.Thread.find(thread_id)
user.unvote(thread)
return JsonResponse(utils.safe_content(thread.to_dict()))
@require_POST
@login_required
@@ -288,7 +317,7 @@ def update_moderator_status(request, course_id, user_id):
course = get_course_with_access(request.user, course_id, 'load')
discussion_user = cc.User(id=user_id, course_id=course_id)
context = {
'course': course,
'course': course,
'course_id': course_id,
'user': request.user,
'django_user': user,
@@ -327,7 +356,7 @@ def tags_autocomplete(request, course_id):
@require_POST
@login_required
@csrf.csrf_exempt
def upload(request, course_id):#ajax upload file to a question or answer
def upload(request, course_id):#ajax upload file to a question or answer
"""view that handles file upload via Ajax
"""
@@ -337,7 +366,7 @@ def upload(request, course_id):#ajax upload file to a question or answer
new_file_name = ''
try:
# TODO authorization
#may raise exceptions.PermissionDenied
#may raise exceptions.PermissionDenied
#if request.user.is_anonymous():
# msg = _('Sorry, anonymous users cannot upload files')
# raise exceptions.PermissionDenied(msg)
@@ -357,7 +386,7 @@ def upload(request, course_id):#ajax upload file to a question or answer
new_file_name = str(
time.time()
).replace(
'.',
'.',
str(random.randint(0,100000))
) + file_extension
@@ -386,7 +415,7 @@ def upload(request, course_id):#ajax upload file to a question or answer
parsed_url = urlparse.urlparse(file_url)
file_url = urlparse.urlunparse(
urlparse.ParseResult(
parsed_url.scheme,
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
'', '', ''

View File

@@ -1,3 +1,6 @@
import json
import logging
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
from django.http import HttpResponse, Http404
@@ -15,14 +18,15 @@ from operator import methodcaller
from django_comment_client.permissions import check_permissions_by_view
from django_comment_client.utils import merge_dict, extract, strip_none, strip_blank, get_courseware_context
import json
import django_comment_client.utils as utils
import comment_client as cc
import xml.sax.saxutils as saxutils
THREADS_PER_PAGE = 8
THREADS_PER_PAGE = 20
INLINE_THREADS_PER_PAGE = 20
PAGES_NEARBY_DELTA = 2
escapedict = {'"': '&quot;'}
log = logging.getLogger("edx.discussions")
def _general_discussion_id(course_id):
return course_id.replace('/', '_').replace('.', '_')
@@ -31,9 +35,8 @@ 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 = {
@@ -45,69 +48,7 @@ def render_accordion(request, course, discussion_id):
return render_to_string('discussion/_accordion.html', context)
def render_discussion(request, course_id, threads, *args, **kwargs):
discussion_id = kwargs.get('discussion_id')
user_id = kwargs.get('user_id')
discussion_type = kwargs.get('discussion_type', 'inline')
query_params = kwargs.get('query_params', {})
template = {
'inline': 'discussion/_inline.html',
'forum': 'discussion/_forum.html',
'user': 'discussion/_user_active_threads.html',
}[discussion_type]
base_url = {
'inline': (lambda: reverse('django_comment_client.forum.views.inline_discussion', args=[course_id, discussion_id])),
'forum': (lambda: reverse('django_comment_client.forum.views.forum_form_discussion', args=[course_id])),
'user': (lambda: reverse('django_comment_client.forum.views.user_profile', args=[course_id, user_id])),
}[discussion_type]()
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)
annotated_content_info = reduce(merge_dict, map(infogetter, threads), {})
if discussion_type != 'inline':
course = get_course_with_access(request.user, course_id, 'load')
for thread in threads:
courseware_context = get_courseware_context(thread, course)
if courseware_context:
thread['courseware_location'] = courseware_context['courseware_location']
thread['courseware_title'] = courseware_context['courseware_title']
context = {
'threads': threads,
'discussion_id': discussion_id,
'user_id': user_id,
'course_id': course_id,
'request': request,
'performed_search': _should_perform_search(request),
'pages_nearby_delta': PAGES_NEARBY_DELTA,
'discussion_type': discussion_type,
'base_url': base_url,
'query_params': strip_blank(strip_none(extract(query_params, ['page', 'sort_key', 'sort_order', 'tags', 'text']))),
'annotated_content_info': json.dumps(annotated_content_info),
'discussion_data': json.dumps({ (discussion_id or user_id): map(utils.safe_content, threads) })
}
context = dict(context.items() + query_params.items())
return render_to_string(template, context)
def render_inline_discussion(*args, **kwargs):
return render_discussion(discussion_type='inline', *args, **kwargs)
def render_forum_discussion(*args, **kwargs):
return render_discussion(discussion_type='forum', *args, **kwargs)
def render_user_discussion(*args, **kwargs):
return render_discussion(discussion_type='user', *args, **kwargs)
def get_threads(request, course_id, discussion_id=None):
def get_threads(request, course_id, discussion_id=None, per_page=THREADS_PER_PAGE):
"""
This may raise cc.utils.CommentClientError or
cc.utils.CommentClientUnknownError if something goes wrong.
@@ -115,7 +56,7 @@ def get_threads(request, course_id, discussion_id=None):
default_query_params = {
'page': 1,
'per_page': THREADS_PER_PAGE,
'per_page': per_page,
'sort_key': 'date',
'sort_order': 'desc',
'text': '',
@@ -137,7 +78,7 @@ def get_threads(request, course_id, discussion_id=None):
user.save()
query_params = merge_dict(default_query_params,
strip_none(extract(request.GET, ['page', 'sort_key', 'sort_order', 'text', 'tags'])))
strip_none(extract(request.GET, ['page', 'sort_key', 'sort_order', 'text', 'tags', 'commentable_ids'])))
threads, page, num_pages = cc.Thread.search(query_params)
@@ -146,132 +87,182 @@ def get_threads(request, course_id, discussion_id=None):
return threads, query_params
# discussion per page is fixed for now
def inline_discussion(request, course_id, discussion_id):
"""
Renders JSON for DiscussionModules
"""
course = get_course_with_access(request.user, course_id, 'load')
try:
threads, query_params = get_threads(request, course_id, discussion_id)
threads, query_params = get_threads(request, course_id, discussion_id, per_page=INLINE_THREADS_PER_PAGE)
user_info = cc.User.from_django_user(request.user).to_dict()
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
# 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.
raise Http404
html = render_inline_discussion(request, course_id, threads, discussion_id=discussion_id, \
query_params=query_params)
def infogetter(thread):
return utils.get_annotated_content_infos(course_id, thread, request.user, user_info)
annotated_content_info = reduce(merge_dict, map(infogetter, threads), {})
allow_anonymous = course.metadata.get("allow_anonymous", True)
allow_anonymous_to_peers = course.metadata.get("allow_anonymous_to_peers", False)
return utils.JsonResponse({
'html': html,
'discussion_data': map(utils.safe_content, threads),
'user_info': user_info,
'annotated_content_info': annotated_content_info,
'page': query_params['page'],
'num_pages': query_params['num_pages'],
'roles': utils.get_role_ids(course_id),
'allow_anonymous_to_peers': allow_anonymous_to_peers,
'allow_anonymous': allow_anonymous,
})
def render_search_bar(request, course_id, discussion_id=None, text=''):
if not discussion_id:
return ''
context = {
'discussion_id': discussion_id,
'text': text,
'course_id': course_id,
}
return render_to_string('discussion/_search_bar.html', context)
@login_required
def forum_form_discussion(request, course_id):
"""
Renders the main Discussion page, potentially filtered by a search query
"""
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)
content = render_forum_discussion(request, course_id, threads, discussion_id=_general_discussion_id(course_id), query_params=query_params)
if request.is_ajax():
return utils.JsonResponse({
'html': content,
'discussion_data': map(utils.safe_content, threads),
})
else:
recent_active_threads = cc.search_recent_active_threads(
course_id,
recursive=False,
query_params={'follower_id': request.user.id},
)
trending_tags = cc.search_trending_tags(
course_id,
)
context = {
'csrf': csrf(request)['csrf_token'],
'course': course,
'content': content,
'recent_active_threads': recent_active_threads,
'trending_tags': trending_tags,
'staff_access' : has_access(request.user, course, 'staff'),
}
# print "start rendering.."
return render_to_response('discussion/index.html', context)
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:
raise Http404
def render_single_thread(request, discussion_id, course_id, thread_id):
thread = cc.Thread.find(thread_id).retrieve(recursive=True).to_dict()
user_info = cc.User.from_django_user(request.user).to_dict()
annotated_content_info = utils.get_annotated_content_infos(course_id, thread=thread, user=request.user, user_info=user_info)
def infogetter(thread):
return utils.get_annotated_content_infos(course_id, thread, request.user, user_info)
context = {
'discussion_id': discussion_id,
'thread': thread,
'annotated_content_info': json.dumps(annotated_content_info),
'course_id': course_id,
'request': request,
'discussion_data': json.dumps({ discussion_id: [utils.safe_content(thread)] }),
}
return render_to_string('discussion/_single_thread.html', context)
annotated_content_info = reduce(merge_dict, map(infogetter, threads), {})
for thread in threads:
courseware_context = get_courseware_context(thread, course)
if courseware_context:
thread.update(courseware_context)
if request.is_ajax():
return utils.JsonResponse({
'discussion_data': threads, # TODO: Standardize on 'discussion_data' vs 'threads'
'annotated_content_info': annotated_content_info,
'num_pages': query_params['num_pages'],
'page': query_params['page'],
})
else:
#recent_active_threads = cc.search_recent_active_threads(
# course_id,
# recursive=False,
# query_params={'follower_id': request.user.id},
#)
#trending_tags = cc.search_trending_tags(
# course_id,
#)
context = {
'csrf': csrf(request)['csrf_token'],
'course': course,
#'recent_active_threads': recent_active_threads,
#'trending_tags': trending_tags,
'staff_access' : has_access(request.user, course, 'staff'),
'threads': saxutils.escape(json.dumps(threads),escapedict),
'thread_pages': query_params['num_pages'],
'user_info': saxutils.escape(json.dumps(user_info),escapedict),
'annotated_content_info': saxutils.escape(json.dumps(annotated_content_info),escapedict),
'course_id': course.id,
'category_map': category_map,
'roles': saxutils.escape(json.dumps(utils.get_role_ids(course_id)), escapedict),
}
# print "start rendering.."
return render_to_response('discussion/index.html', context)
@login_required
def single_thread(request, course_id, discussion_id, thread_id):
try:
if request.is_ajax():
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()
user_info = cc.User.from_django_user(request.user).to_dict()
try:
thread = cc.Thread.find(thread_id).retrieve(recursive=True)
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info)
context = {'thread': thread.to_dict(), 'course_id': course_id}
html = render_to_string('discussion/_ajax_single_thread.html', context)
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
raise Http404
courseware_context = get_courseware_context(thread, course)
return utils.JsonResponse({
'html': html,
'content': utils.safe_content(thread.to_dict()),
'annotated_content_info': annotated_content_info,
})
annotated_content_info = utils.get_annotated_content_infos(course_id, thread, request.user, user_info=user_info)
context = {'thread': thread.to_dict(), 'course_id': course_id}
# TODO: Remove completely or switch back to server side rendering
# html = render_to_string('discussion/_ajax_single_thread.html', context)
content = utils.safe_content(thread.to_dict())
if courseware_context:
content.update(courseware_context)
return utils.JsonResponse({
#'html': html,
'content': content,
'annotated_content_info': annotated_content_info,
})
else:
course = get_course_with_access(request.user, course_id, 'load')
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:
raise Http404
recent_active_threads = cc.search_recent_active_threads(
course_id,
recursive=False,
query_params={'follower_id': request.user.id},
)
course = get_course_with_access(request.user, course_id, 'load')
trending_tags = cc.search_trending_tags(
course_id,
)
for thread in threads:
courseware_context = get_courseware_context(thread, course)
if courseware_context:
thread.update(courseware_context)
context = {
'discussion_id': discussion_id,
'csrf': csrf(request)['csrf_token'],
'init': '',
'content': render_single_thread(request, discussion_id, course_id, thread_id),
'course': course,
'recent_active_threads': recent_active_threads,
'trending_tags': trending_tags,
'course_id': course.id,
}
threads = [utils.safe_content(thread) for thread in threads]
return render_to_response('discussion/single_thread.html', context)
except (cc.utils.CommentClientError, cc.utils.CommentClientUnknownError) as err:
raise Http404
#recent_active_threads = cc.search_recent_active_threads(
# course_id,
# recursive=False,
# query_params={'follower_id': request.user.id},
#)
#trending_tags = cc.search_trending_tags(
# 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)
annotated_content_info = reduce(merge_dict, map(infogetter, threads), {})
context = {
'discussion_id': discussion_id,
'csrf': csrf(request)['csrf_token'],
'init': '', #TODO: What is this?
'user_info': saxutils.escape(json.dumps(user_info),escapedict),
'annotated_content_info': saxutils.escape(json.dumps(annotated_content_info), escapedict),
'course': course,
#'recent_active_threads': recent_active_threads,
#'trending_tags': trending_tags,
'course_id': course.id, #TODO: Why pass both course and course.id to template?
'thread_id': thread_id,
'threads': saxutils.escape(json.dumps(threads), escapedict),
'category_map': category_map,
'roles': saxutils.escape(json.dumps(utils.get_role_ids(course_id)), escapedict),
'thread_pages': query_params['num_pages'],
}
return render_to_response('discussion/single_thread.html', context)
@login_required
def user_profile(request, course_id, user_id):
course = get_course_with_access(request.user, course_id, 'load')
@@ -281,27 +272,33 @@ def user_profile(request, course_id, user_id):
query_params = {
'page': request.GET.get('page', 1),
'per_page': THREADS_PER_PAGE, # more than threads_per_page to show more activities
}
}
threads, page, num_pages = profiled_user.active_threads(query_params)
query_params['page'] = page
query_params['num_pages'] = num_pages
content = render_user_discussion(request, course_id, threads, user_id=user_id, query_params=query_params)
if request.is_ajax():
return utils.JsonResponse({
'html': content,
'discussion_data': map(utils.safe_content, threads),
})
else:
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)
annotated_content_info = reduce(merge_dict, map(infogetter, threads), {})
context = {
'course': course,
'user': request.user,
'django_user': User.objects.get(id=user_id),
'profiled_user': profiled_user.to_dict(),
'content': content,
'threads': saxutils.escape(json.dumps(threads), escapedict),
'user_info': saxutils.escape(json.dumps(user_info),escapedict),
'annotated_content_info': saxutils.escape(json.dumps(annotated_content_info),escapedict),
# 'content': content,
}
return render_to_response('discussion/user_profile.html', context)

View File

@@ -17,12 +17,6 @@ def pluralize(singular_term, count):
return singular_term + 's'
return singular_term
def show_if(text, condition):
if condition:
return text
else:
return ''
# TODO there should be a better way to handle this
def include_mustache_templates():
mustache_dir = settings.PROJECT_ROOT / 'templates' / 'discussion' / 'mustache'
@@ -35,7 +29,7 @@ def include_mustache_templates():
return '\n'.join(map(wrap_in_tag, map(strip_file_name, file_contents)))
def render_content(content, additional_context={}):
context = {
'content': extend_content(content),
content['type']: True,

View File

@@ -7,8 +7,10 @@ class Command(BaseCommand):
help = 'Seed default permisssions and roles'
def handle(self, *args, **options):
if len(args) != 1:
raise CommandError("The number of arguments does not match. ")
if len(args) == 0:
raise CommandError("Please provide a course id")
if len(args) > 1:
raise CommandError("Too many arguments")
course_id = args[0]
administrator_role = Role.objects.get_or_create(name="Administrator", course_id=course_id)[0]
moderator_role = Role.objects.get_or_create(name="Moderator", course_id=course_id)[0]

View File

@@ -5,12 +5,13 @@ from student.models import CourseEnrollment
import logging
from util.cache import cache
from django.core import cache
cache = cache.get_cache('default')
def cached_has_permission(user, permission, course_id=None):
"""
Call has_permission if it's not cached. A change in a user's role or
a role's permissions will only become effective after CACHE_LIFESPAN seconds.
a role's permissions will only become effective after CACHE_LIFESPAN seconds.
"""
CACHE_LIFESPAN = 60
key = "permission_%d_%s_%s" % (user.id, str(course_id), permission)
@@ -53,8 +54,8 @@ def check_conditions_permissions(user, permissions, course_id, **kwargs):
"""
Accepts a list of permissions and proceed if any of the permission is valid.
Note that ["can_view", "can_edit"] will proceed if the user has either
"can_view" or "can_edit" permission. To use AND operator in between, wrap them in
a list.
"can_view" or "can_edit" permission. To use AND operator in between, wrap them in
a list.
"""
def test(user, per, operator="or"):
@@ -75,18 +76,18 @@ def check_conditions_permissions(user, permissions, course_id, **kwargs):
VIEW_PERMISSIONS = {
'update_thread' : ['edit_content', ['update_thread', 'is_open', 'is_author']],
'create_comment' : [["create_comment", "is_open"]],
'delete_thread' : ['delete_thread'],
'delete_thread' : ['delete_thread', ['update_thread', 'is_author']],
'update_comment' : ['edit_content', ['update_comment', 'is_open', 'is_author']],
'endorse_comment' : ['endorse_comment'],
'openclose_thread' : ['openclose_thread'],
'create_sub_comment': [['create_sub_comment', 'is_open']],
'delete_comment' : ['delete_comment'],
'delete_comment' : ['delete_comment', ['update_comment', 'is_open', 'is_author']],
'vote_for_comment' : [['vote', 'is_open']],
'undo_vote_for_comment': [['unvote', 'is_open']],
'vote_for_thread' : [['vote', 'is_open']],
'undo_vote_for_thread': [['unvote', 'is_open']],
'follow_thread' : ['follow_thread'],
'follow_commentable': ['follow_commentable'],
'follow_commentable': ['follow_commentable'],
'follow_user' : ['follow_user'],
'unfollow_thread' : ['unfollow_thread'],
'unfollow_commentable': ['unfollow_commentable'],

View File

@@ -1,9 +1,12 @@
from collections import defaultdict
import time
from collections import defaultdict
from importlib import import_module
from courseware.models import StudentModuleCache
from courseware.module_render import get_module
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.search import path_to_location
from django.http import HttpResponse
from django.utils import simplejson
from django.db import connection
@@ -23,8 +26,7 @@ import pystache_custom as pystache
# TODO these should be cached via django's caching rather than in-memory globals
_FULLMODULES = None
_DISCUSSIONINFO = None
_DISCUSSIONINFO = defaultdict(dict)
def extract(dic, keys):
return {k: dic.get(k) for k in keys}
@@ -40,6 +42,14 @@ def strip_blank(dic):
def merge_dict(dic1, dic2):
return dict(dic1.items() + dic2.items())
def get_role_ids(course_id):
roles = Role.objects.filter(course_id=course_id)
staff = list(User.objects.filter(is_staff=True).values_list('id', flat=True))
roles_with_ids = {'Staff': staff}
for role in roles:
roles_with_ids[role.name] = list(role.users.values_list('id', flat=True))
return roles_with_ids
def get_full_modules():
global _FULLMODULES
if not _FULLMODULES:
@@ -51,23 +61,60 @@ def get_discussion_id_map(course):
return a dict of the form {category: modules}
"""
global _DISCUSSIONINFO
if not _DISCUSSIONINFO:
if not _DISCUSSIONINFO[course.id]:
initialize_discussion_info(course)
return _DISCUSSIONINFO['id_map']
return _DISCUSSIONINFO[course.id]['id_map']
def get_discussion_title(request, course, discussion_id):
def get_discussion_title(course, discussion_id):
global _DISCUSSIONINFO
if not _DISCUSSIONINFO:
if not _DISCUSSIONINFO[course.id]:
initialize_discussion_info(course)
title = _DISCUSSIONINFO['id_map'].get(discussion_id, {}).get('title', '(no title)')
title = _DISCUSSIONINFO[course.id]['id_map'].get(discussion_id, {}).get('title', '(no title)')
return title
def get_discussion_category_map(course):
global _DISCUSSIONINFO
if not _DISCUSSIONINFO:
if not _DISCUSSIONINFO[course.id]:
initialize_discussion_info(course)
return _DISCUSSIONINFO['category_map']
return filter_unstarted_categories(_DISCUSSIONINFO[course.id]['category_map'])
def filter_unstarted_categories(category_map):
now = time.gmtime()
result_map = {}
unfiltered_queue = [category_map]
filtered_queue = [result_map]
while len(unfiltered_queue) > 0:
unfiltered_map = unfiltered_queue.pop()
filtered_map = filtered_queue.pop()
filtered_map["children"] = []
filtered_map["entries"] = {}
filtered_map["subcategories"] = {}
for child in unfiltered_map["children"]:
if child in unfiltered_map["entries"]:
if unfiltered_map["entries"][child]["start_date"] <= now:
filtered_map["children"].append(child)
filtered_map["entries"][child] = {}
for key in unfiltered_map["entries"][child]:
if key != "start_date":
filtered_map["entries"][child][key] = unfiltered_map["entries"][child][key]
else:
print "filtering %s" % child, unfiltered_map["entries"][child]["start_date"]
else:
if unfiltered_map["subcategories"][child]["start_date"] < now:
filtered_map["children"].append(child)
filtered_map["subcategories"][child] = {}
unfiltered_queue.append(unfiltered_map["subcategories"][child])
filtered_queue.append(filtered_map["subcategories"][child])
return result_map
def sort_map_entries(category_map):
things = []
@@ -78,11 +125,10 @@ def sort_map_entries(category_map):
sort_map_entries(category_map["subcategories"][title])
category_map["children"] = [x[0] for x in sorted(things, key=lambda x: x[1]["sort_key"])]
def initialize_discussion_info(course):
global _DISCUSSIONINFO
if _DISCUSSIONINFO:
if _DISCUSSIONINFO[course.id]:
return
course_id = course.id
@@ -91,45 +137,68 @@ def initialize_discussion_info(course):
all_modules = get_full_modules()[course_id]
discussion_id_map = {}
unexpanded_category_map = defaultdict(list)
for location, module in all_modules.items():
if location.category == 'discussion':
id = module.metadata['id']
category = module.metadata['discussion_category']
title = module.metadata['for']
sort_key = module.metadata.get('sort_key', title)
discussion_id_map[id] = {"location": location, "title": title}
category = " / ".join([x.strip() for x in category.split("/")])
last_category = category.split("/")[-1]
discussion_id_map[id] = {"location": location, "title": last_category + " / " + title}
unexpanded_category_map[category].append({"title": title, "id": id,
"sort_key": sort_key})
"sort_key": sort_key, "start_date": module.start})
category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)}
category_map = {"entries": defaultdict(dict), "subcategories": defaultdict(dict)}
for category_path, entries in unexpanded_category_map.items():
node = category_map["subcategories"]
path = [x.strip() for x in category_path.split("/")]
# Find the earliest start date for the entries in this category
category_start_date = None
for entry in entries:
if category_start_date is None or entry["start_date"] < category_start_date:
category_start_date = entry["start_date"]
for level in path[:-1]:
if level not in node:
node[level] = {"subcategories": defaultdict(dict),
node[level] = {"subcategories": defaultdict(dict),
"entries": defaultdict(dict),
"sort_key": level}
"sort_key": level,
"start_date": category_start_date}
else:
if node[level]["start_date"] > category_start_date:
node[level]["start_date"] = category_start_date
node = node[level]["subcategories"]
level = path[-1]
if level not in node:
node[level] = {"subcategories": defaultdict(dict),
"entries": defaultdict(dict),
"sort_key": level}
for entry in entries:
node[level]["entries"][entry["title"]] = {"id": entry["id"],
"sort_key": entry["sort_key"]}
node[level] = {"subcategories": defaultdict(dict),
"entries": defaultdict(dict),
"sort_key": level,
"start_date": category_start_date}
else:
if node[level]["start_date"] > category_start_date:
node[level]["start_date"] = category_start_date
for entry in entries:
node[level]["entries"][entry["title"]] = {"id": entry["id"],
"sort_key": entry["sort_key"],
"start_date": entry["start_date"]}
default_topics = {'General': 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"],
"sort_key": entry.get("sort_key", topic),
"start_date": time.gmtime()}
sort_map_entries(category_map)
_DISCUSSIONINFO = {}
_DISCUSSIONINFO['id_map'] = discussion_id_map
_DISCUSSIONINFO['category_map'] = category_map
_DISCUSSIONINFO[course.id]['id_map'] = discussion_id_map
_DISCUSSIONINFO[course.id]['category_map'] = category_map
class JsonResponse(HttpResponse):
def __init__(self, data=None):
@@ -138,14 +207,14 @@ class JsonResponse(HttpResponse):
mimetype='application/json; charset=utf8')
class JsonError(HttpResponse):
def __init__(self, error_messages=[]):
def __init__(self, error_messages=[], status=400):
if isinstance(error_messages, str):
error_messages = [error_messages]
content = simplejson.dumps({'errors': error_messages},
indent=2,
ensure_ascii=False)
super(JsonError, self).__init__(content,
mimetype='application/json; charset=utf8', status=400)
mimetype='application/json; charset=utf8', status=status)
class HtmlResponse(HttpResponse):
def __init__(self, html=''):
@@ -228,8 +297,14 @@ def permalink(content):
args=[content['course_id'], content['commentable_id'], content['thread_id']]) + '#' + content['id']
def extend_content(content):
user = User.objects.get(pk=content['user_id'])
roles = dict(('name', role.name.lower()) for role in user.roles.filter(course_id=content['course_id']))
roles = {}
if content.get('user_id'):
try:
user = User.objects.get(pk=content['user_id'])
roles = dict(('name', role.name.lower()) for role in user.roles.filter(course_id=content['course_id']))
except user.DoesNotExist:
logging.error('User ID {0} in comment content {1} but not in our DB.'.format(content.get('user_id'), content.get('id')))
content_info = {
'displayed_title': content.get('highlighted_title') or content.get('title', ''),
'displayed_body': content.get('highlighted_body') or content.get('body', ''),
@@ -242,25 +317,33 @@ def extend_content(content):
def get_courseware_context(content, course):
id_map = get_discussion_id_map(course)
id = content['commentable_id']
id = content['commentable_id']
content_info = None
if id in id_map:
location = id_map[id]["location"].url()
title = id_map[id]["title"]
content_info = { "courseware_location": location, "courseware_title": title}
(course_id, chapter, section, position) = path_to_location(modulestore(), course.id, location)
url = reverse('courseware_position', kwargs={"course_id":course_id,
"chapter":chapter,
"section":section,
"position":position})
content_info = {"courseware_url": url, "courseware_title": title}
return content_info
def safe_content(content):
fields = [
'id', 'title', 'body', 'course_id', 'anonymous', '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',
'id', 'title', 'body', 'course_id', 'anonymous', 'anonymous_to_peers',
'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'
]
if content.get('anonymous') is False:
if (content.get('anonymous') is False) and (content.get('anonymous_to_peers') is False):
fields += ['username', 'user_id']
if 'children' in content:
safe_children = [safe_content(child) for child in content['children']]
content['children'] = safe_children
return strip_none(extract(content, fields))

View File

@@ -27,6 +27,7 @@ from django.views.decorators.cache import cache_control
from courseware import grades
from courseware.access import has_access, get_access_group_name
from courseware.courses import (get_course_with_access, get_courses_by_university)
from psychometrics import psychoanalyze
from student.models import UserProfile
from student.models import UserTestGroup, CourseEnrollment
@@ -51,7 +52,18 @@ def instructor_dashboard(request, course_id):
instructor_access = has_access(request.user, course, 'instructor') # an instructor can manage staff lists
msg = ''
# msg += ('POST=%s' % dict(request.POST)).replace('<','&lt;')
#msg += ('POST=%s' % dict(request.POST)).replace('<','&lt;')
problems = []
plots = []
# the instructor dashboard page is modal: grades, psychometrics, admin
# keep that state in request.session (defaults to grades mode)
idash_mode = request.POST.get('idash_mode','')
if idash_mode:
request.session['idash_mode'] = idash_mode
else:
idash_mode = request.session.get('idash_mode','Grades')
def escape(s):
"""escape HTML special characters in string"""
@@ -149,6 +161,9 @@ def instructor_dashboard(request, course_id):
track.views.server_track(request, 'dump-answer-dist-csv', {}, page='idashboard')
return return_csv('answer_dist_%s.csv' % course_id, get_answers_distribution(request, course_id))
#----------------------------------------
# Admin
elif 'List course staff' in action:
group = get_staff_group(course)
msg += 'Staff group = %s' % group.name
@@ -187,14 +202,31 @@ def instructor_dashboard(request, course_id):
user.groups.remove(group)
track.views.server_track(request, 'remove-staff %s' % user, {}, page='idashboard')
# For now, mostly a static page
#----------------------------------------
# psychometrics
elif action == 'Generate Histogram and IRT Plot':
problem = request.POST['Problem']
nmsg, plots = psychoanalyze.generate_plots_for_problem(problem)
msg += nmsg
track.views.server_track(request, 'psychometrics %s' % problem, {}, page='idashboard')
if idash_mode=='Psychometrics':
problems = psychoanalyze.problems_with_psychometric_data(course_id)
#----------------------------------------
# context for rendering
context = {'course': course,
'staff_access': True,
'admin_access': request.user.is_staff,
'instructor_access': instructor_access,
'datatable': datatable,
'msg': msg,
'modeflag': {idash_mode: 'selectedmode'},
'problems': problems, # psychometrics
'plots': plots, # psychometrics
'course_errors': modulestore().get_item_errors(course.location),
'djangopid' : os.getpid(),
}
return render_to_response('courseware/instructor_dashboard.html', context)

View File

@@ -35,7 +35,17 @@ def getip(request):
ip = request.META.get('REMOTE_ADDR','None')
return ip
def manage_modulestores(request,reload_dir=None):
def get_commit_id(course):
return course.metadata.get('GIT_COMMIT_ID','No commit id')
# getattr(def_ms.courses[reload_dir], 'GIT_COMMIT_ID','No commit id')
def set_commit_id(course,commit_id):
course.metadata['GIT_COMMIT_ID'] = commit_id
# setattr(def_ms.courses[reload_dir], 'GIT_COMMIT_ID', new_commit_id)
def manage_modulestores(request, reload_dir=None, commit_id=None):
'''
Manage the static in-memory modulestores.
@@ -52,8 +62,9 @@ def manage_modulestores(request,reload_dir=None):
ip = getip(request)
if LOCAL_DEBUG:
html += '<h3>IP address: %s ' % ip
html += '<h3>User: %s ' % request.user
html += '<h3>IP address: %s <h3>' % ip
html += '<h3>User: %s </h3>' % request.user
html += '<h3>My pid: %s</h3>' % os.getpid()
log.debug('request from ip=%s, user=%s' % (ip,request.user))
if not (ip in ALLOWED_IPS or 'any' in ALLOWED_IPS):
@@ -66,14 +77,36 @@ def manage_modulestores(request,reload_dir=None):
return HttpResponse(html, status=403)
#----------------------------------------
# reload course if specified
# reload course if specified; handle optional commit_id
if reload_dir is not None:
if reload_dir not in def_ms.courses:
html += '<h2 class="inline-error">Error: "%s" is not a valid course directory</h2>' % reload_dir
else:
html += '<h2>Reloaded course directory "%s"</h2>' % reload_dir
def_ms.try_load_course(reload_dir)
# reloading based on commit_id is needed when running mutiple worker threads,
# so that a given thread doesn't reload the same commit multiple times
current_commit_id = get_commit_id(def_ms.courses[reload_dir])
log.debug('commit_id="%s"' % commit_id)
log.debug('current_commit_id="%s"' % current_commit_id)
if (commit_id is not None) and (commit_id==current_commit_id):
html += "<h2>Already at commit id %s for %s</h2>" % (commit_id, reload_dir)
track.views.server_track(request,
'reload %s skipped already at %s (pid=%s)' % (reload_dir,
commit_id,
os.getpid(),
),
{}, page='migrate')
else:
html += '<h2>Reloaded course directory "%s"</h2>' % reload_dir
def_ms.try_load_course(reload_dir)
gdir = settings.DATA_DIR / reload_dir
new_commit_id = os.popen('cd %s; git log -n 1 | head -1' % gdir).read().strip().split(' ')[1]
set_commit_id(def_ms.courses[reload_dir], new_commit_id)
html += '<p>commit_id=%s</p>' % new_commit_id
track.views.server_track(request, 'reloaded %s now at %s (pid=%s)' % (reload_dir,
new_commit_id,
os.getpid()), {}, page='migrate')
#----------------------------------------
@@ -94,6 +127,8 @@ def manage_modulestores(request,reload_dir=None):
html += '<hr width="100%"/>'
html += '<h2>Course: %s (%s)</h2>' % (course.display_name,cdir)
html += '<p>commit_id=%s</p>' % get_commit_id(course)
for field in dumpfields:
data = getattr(course,field)
html += '<h3>%s</h3>' % field

View File

View File

@@ -0,0 +1,8 @@
'''
django admin pages for courseware model
'''
from psychometrics.models import *
from django.contrib import admin
admin.site.register(PsychometricData)

View File

@@ -0,0 +1,70 @@
#!/usr/bin/python
#
# generate pyschometrics data from tracking logs and student module data
import os, sys, string
import datetime
import json
from courseware.models import *
from track.models import *
from psychometrics.models import *
from xmodule.modulestore import Location
from django.conf import settings
from django.core.management.base import BaseCommand
#db = "ocwtutor" # for debugging
#db = "default"
db = getattr(settings,'DATABASE_FOR_PSYCHOMETRICS','default')
class Command(BaseCommand):
help = "initialize PsychometricData tables from StudentModule instances (and tracking data, if in SQL)."
help += "Note this is done for all courses for which StudentModule instances exist."
def handle(self, *args, **options):
# delete all pmd
#PsychometricData.objects.all().delete()
#PsychometricData.objects.using(db).all().delete()
smset = StudentModule.objects.using(db).exclude(max_grade=None)
for sm in smset:
url = sm.module_state_key
location = Location(url)
if not location.category=="problem":
continue
try:
state = json.loads(sm.state)
done = state['done']
except:
print "Oops, failed to eval state for %s (state=%s)" % (sm,sm.state)
continue
if done: # only keep if problem completed
try:
pmd = PsychometricData.objects.using(db).get(studentmodule=sm)
except PsychometricData.DoesNotExist:
pmd = PsychometricData(studentmodule=sm)
pmd.done = done
pmd.attempts = state['attempts']
# get attempt times from tracking log
uname = sm.student.username
tset = TrackingLog.objects.using(db).filter(username=uname, event_type__contains='save_problem_check')
tset = tset.filter(event_source='server')
tset = tset.filter(event__contains="'%s'" % url)
checktimes = [x.dtcreated for x in tset]
pmd.checktimes = checktimes
if not len(checktimes)==pmd.attempts:
print "Oops, mismatch in number of attempts and check times for %s" % pmd
#print pmd
pmd.save(using=db)
print "%d PMD entries" % PsychometricData.objects.using(db).all().count()

View File

@@ -0,0 +1,45 @@
#
# db model for psychometrics data
#
# this data is collected in real time
#
from django.db import models
from courseware.models import StudentModule
class PsychometricData(models.Model):
"""
This data is a table linking student, module, and module performance,
including number of attempts, grade, max grade, and time of checks.
Links to instances of StudentModule, but only those for capa problems.
Note that StudentModule.module_state_key is nominally a Location instance (url string).
That means it is of the form {tag}://{org}/{course}/{category}/{name}[@{revision}]
and for capa problems, category = "problem".
checktimes is extracted from tracking logs, or added by capa module via psychometrics callback.
"""
studentmodule = models.ForeignKey(StudentModule, db_index=True, unique=True) # contains student, module_state_key, course_id
done = models.BooleanField(default=False)
attempts = models.IntegerField(default=0) # extracted from studentmodule.state
checktimes = models.TextField(null=True, blank=True) # internally stored as list of datetime objects
# keep in mind
# grade = studentmodule.grade
# max_grade = studentmodule.max_grade
# student = studentmodule.student
# course_id = studentmodule.course_id
# location = studentmodule.module_state_key
def __unicode__(self):
sm = self.studentmodule
return "[PsychometricData] %s url=%s, grade=%s, max=%s, attempts=%s, ct=%s" % (sm.student,
sm.module_state_key,
sm.grade,
sm.max_grade,
self.attempts,
self.checktimes)

View File

@@ -0,0 +1,338 @@
#
# File: psychometrics/psychoanalyze.py
#
# generate pyschometrics plots from PsychometricData
from __future__ import division
import datetime
import logging
import json
import math
import numpy as np
from scipy.optimize import curve_fit
from django.conf import settings
from django.db.models import Sum, Max
from psychometrics.models import *
from xmodule.modulestore import Location
log = logging.getLogger("mitx.psychometrics")
#db = "ocwtutor" # for debugging
#db = "default"
db = getattr(settings, 'DATABASE_FOR_PSYCHOMETRICS', 'default')
#-----------------------------------------------------------------------------
# fit functions
def func_2pl(x, a, b):
"""
2-parameter logistic function
"""
D = 1.7
edax = np.exp(D * a * (x - b))
return edax / (1 + edax)
#-----------------------------------------------------------------------------
# statistics class
class StatVar(object):
"""
Simple statistics on floating point numbers: avg, sdv, var, min, max
"""
def __init__(self, unit=1):
self.sum = 0
self.sum2 = 0
self.cnt = 0
self.unit = unit
self.min = None
self.max = None
def add(self, x):
if x is None:
return
if self.min is None:
self.min = x
else:
if x < self.min:
self.min = x
if self.max is None:
self.max = x
else:
if x > self.max:
self.max = x
self.sum += x
self.sum2 += x**2
self.cnt += 1
def avg(self):
if self.cnt is None:
return 0
return self.sum / 1.0 / self.cnt / self.unit
def var(self):
if self.cnt is None:
return 0
return (self.sum2 / 1.0 / self.cnt / (self.unit**2)) - (self.avg()**2)
def sdv(self):
v = self.var()
if v>0:
return math.sqrt(v)
else:
return 0
def __str__(self):
return 'cnt=%d, avg=%f, sdv=%f' % (self.cnt, self.avg(), self.sdv())
def __add__(self, x):
self.add(x)
return self
#-----------------------------------------------------------------------------
# histogram generator
def make_histogram(ydata, bins=None):
'''
Generate histogram of ydata using bins provided, or by default bins
from 0 to 100 by 10. bins should be ordered in increasing order.
returns dict with keys being bins, and values being counts.
special: hist['bins'] = bins
'''
if bins is None:
bins = range(0, 100, 10)
nbins = len(bins)
hist = dict(zip(bins, [0] * nbins))
for y in ydata:
for b in bins[::-1]: # in reverse order
if y>b:
hist[b] += 1
break
# hist['bins'] = bins
return hist
#-----------------------------------------------------------------------------
def problems_with_psychometric_data(course_id):
'''
Return dict of {problems (location urls): count} for which psychometric data is available.
Does this for a given course_id.
'''
pmdset = PsychometricData.objects.using(db).filter(studentmodule__course_id=course_id)
plist = [p['studentmodule__module_state_key'] for p in pmdset.values('studentmodule__module_state_key').distinct()]
problems = dict( (p, pmdset.filter(studentmodule__module_state_key=p).count()) for p in plist )
return problems
#-----------------------------------------------------------------------------
def generate_plots_for_problem(problem):
pmdset = PsychometricData.objects.using(db).filter(studentmodule__module_state_key=problem)
nstudents = pmdset.count()
msg = ""
plots = []
if nstudents < 2:
msg += "%s nstudents=%d --> skipping, too few" % (problem, nstudents)
return msg, plots
max_grade = pmdset[0].studentmodule.max_grade
agdat = pmdset.aggregate(Sum('attempts'), Max('attempts'))
max_attempts = agdat['attempts__max']
total_attempts = agdat['attempts__sum'] # not used yet
msg += "max attempts = %d" % max_attempts
xdat = range(1, max_attempts + 1)
dataset = {'xdat': xdat}
# compute grade statistics
grades = [pmd.studentmodule.grade for pmd in pmdset]
gsv = StatVar()
for g in grades:
gsv += g
msg += "<br><p><font color='blue'>Grade distribution: %s</font></p>" % gsv
# generate grade histogram
ghist = []
axisopts = """{
xaxes: [{
axisLabel: 'Grade'
}],
yaxes: [{
position: 'left',
axisLabel: 'Count'
}]
}"""
if gsv.max > max_grade:
msg += "<br/><p><font color='red'>Something is wrong: max_grade=%s, but max(grades)=%s</font></p>" % (max_grade, gsv.max)
max_grade = gsv.max
if max_grade > 1:
ghist = make_histogram(grades, np.linspace(0, max_grade, max_grade + 1))
ghist_json = json.dumps(ghist.items())
plot = {'title': "Grade histogram for %s" % problem,
'id': 'histogram',
'info': '',
'data': "var dhist = %s;\n" % ghist_json,
'cmd': '[ {data: dhist, bars: { show: true, align: "center" }} ], %s' % axisopts,
}
plots.append(plot)
else:
msg += "<br/>Not generating histogram: max_grade=%s" % max_grade
# histogram of time differences between checks
# Warning: this is inefficient - doesn't scale to large numbers of students
dtset = [] # time differences in minutes
dtsv = StatVar()
for pmd in pmdset:
try:
checktimes = eval(pmd.checktimes) # update log of attempt timestamps
except:
continue
if len(checktimes) < 2:
continue
ct0 = checktimes[0]
for ct in checktimes[1:]:
dt = (ct - ct0).total_seconds() / 60.0
if dt < 20: # ignore if dt too long
dtset.append(dt)
dtsv += dt
ct0 = ct
if dtsv.cnt > 2:
msg += "<br/><p><font color='brown'>Time differences between checks: %s</font></p>" % dtsv
bins = np.linspace(0, 1.5 * dtsv.sdv(), 30)
dbar = bins[1] - bins[0]
thist = make_histogram(dtset, bins)
thist_json = json.dumps(sorted(thist.items(), key=lambda(x): x[0]))
axisopts = """{ xaxes: [{ axisLabel: 'Time (min)'}], yaxes: [{position: 'left',axisLabel: 'Count'}]}"""
plot = {'title': "Histogram of time differences between checks",
'id': 'thistogram',
'info': '',
'data': "var thist = %s;\n" % thist_json,
'cmd': '[ {data: thist, bars: { show: true, align: "center", barWidth:%f }} ], %s' % (dbar, axisopts),
}
plots.append(plot)
# one IRT plot curve for each grade received (TODO: this assumes integer grades)
for grade in range(1, int(max_grade) + 1):
yset = {}
gset = pmdset.filter(studentmodule__grade=grade)
ngset = gset.count()
if ngset == 0:
continue
ydat = []
ylast = 0
for x in xdat:
y = gset.filter(attempts=x).count() / ngset
ydat.append( y + ylast )
ylast = y + ylast
yset['ydat'] = ydat
if len(ydat) > 3: # try to fit to logistic function if enough data points
cfp = curve_fit(func_2pl, xdat, ydat, [1.0, max_attempts / 2.0])
yset['fitparam'] = cfp
yset['fitpts'] = func_2pl(np.array(xdat), *cfp[0])
yset['fiterr'] = [yd - yf for (yd, yf) in zip(ydat, yset['fitpts'])]
fitx = np.linspace(xdat[0], xdat[-1], 100)
yset['fitx'] = fitx
yset['fity'] = func_2pl(np.array(fitx), *cfp[0])
dataset['grade_%d' % grade] = yset
axisopts = """{
xaxes: [{
axisLabel: 'Number of Attempts'
}],
yaxes: [{
max:1.0,
position: 'left',
axisLabel: 'Probability of correctness'
}]
}"""
# generate points for flot plot
for grade in range(1, int(max_grade) + 1):
jsdata = ""
jsplots = []
gkey = 'grade_%d' % grade
if gkey in dataset:
yset = dataset[gkey]
jsdata += "var d%d = %s;\n" % (grade, json.dumps(zip(xdat, yset['ydat'])))
jsplots.append('{ data: d%d, lines: { show: false }, points: { show: true}, color: "red" }' % grade)
if 'fitpts' in yset:
jsdata += 'var fit = %s;\n' % (json.dumps(zip(yset['fitx'], yset['fity'])))
jsplots.append('{ data: fit, lines: { show: true }, color: "blue" }')
(a, b) = yset['fitparam'][0]
irtinfo = "(2PL: D=1.7, a=%6.3f, b=%6.3f)" % (a, b)
else:
irtinfo = ""
plots.append({'title': 'IRT Plot for grade=%s %s' % (grade, irtinfo),
'id': "irt%s" % grade,
'info': '',
'data': jsdata,
'cmd': '[%s], %s' % (','.join(jsplots), axisopts),
})
#log.debug('plots = %s' % plots)
return msg, plots
#-----------------------------------------------------------------------------
def make_psychometrics_data_update_handler(studentmodule):
"""
Construct and return a procedure which may be called to update
the PsychometricsData instance for the given StudentModule instance.
"""
sm = studentmodule
try:
pmd = PsychometricData.objects.using(db).get(studentmodule=sm)
except PsychometricData.DoesNotExist:
pmd = PsychometricData(studentmodule=sm)
def psychometrics_data_update_handler(state):
"""
This function may be called each time a problem is successfully checked
(eg on save_problem_check events in capa_module).
state = instance state (a nice, uniform way to interface - for more future psychometric feature extraction)
"""
try:
state = json.loads(sm.state)
done = state['done']
except:
log.exception("Oops, failed to eval state for %s (state=%s)" % (sm, sm.state))
return
pmd.done = done
pmd.attempts = state['attempts']
try:
checktimes = eval(pmd.checktimes) # update log of attempt timestamps
except:
checktimes = []
checktimes.append(datetime.datetime.now())
pmd.checktimes = checktimes
try:
pmd.save()
except:
log.exception("Error in updating psychometrics data for %s" % sm)
return psychometrics_data_update_handler

View File

@@ -70,6 +70,7 @@ SECRET_KEY = AUTH_TOKENS['SECRET_KEY']
AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"]
AWS_STORAGE_BUCKET_NAME = 'edxuploads'
DATABASES = AUTH_TOKENS['DATABASES']

View File

@@ -71,6 +71,8 @@ MITX_FEATURES = {
'ENABLE_DISCUSSION' : False,
'ENABLE_DISCUSSION_SERVICE': True,
'ENABLE_PSYCHOMETRICS': False, # real-time psychometrics (eg item response theory analysis in instructor dashboard)
'ENABLE_SQL_TRACKING_LOGS': False,
'ENABLE_LMS_MIGRATION': False,
'ENABLE_MANUAL_GIT_RELOAD': False,
@@ -441,12 +443,12 @@ courseware_only_js += [
main_vendor_js = [
'js/vendor/jquery.min.js',
'js/vendor/jquery-ui.min.js',
'js/vendor/swfobject/swfobject.js',
'js/vendor/jquery.cookie.js',
'js/vendor/jquery.qtip.min.js',
'js/vendor/swfobject/swfobject.js',
]
discussion_js = sorted(glob2.glob(PROJECT_ROOT / 'static/coffee/src/discussion/*.coffee'))
discussion_js = sorted(glob2.glob(PROJECT_ROOT / 'static/coffee/src/discussion/**/*.coffee'))
# Load javascript from all of the available xmodules, and
# prep it for use in pipeline js
@@ -619,6 +621,7 @@ INSTALLED_APPS = (
'util',
'certificates',
'instructor',
'psychometrics',
#For the wiki
'wiki', # The new django-wiki from benjaoming

View File

@@ -20,6 +20,8 @@ MITX_FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains-
MITX_FEATURES['SUBDOMAIN_BRANDING'] = True
MITX_FEATURES['FORCE_UNIVERSITY_DOMAIN'] = None # show all university courses if in dev (ie don't use HTTP_HOST)
MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True
MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard)
WIKI_ENABLED = True

View File

@@ -7,15 +7,14 @@ import settings
class Comment(models.Model):
accessible_fields = [
'id', 'body', 'anonymous', 'course_id',
'endorsed', 'parent_id', 'thread_id',
'username', 'votes', 'user_id', 'closed',
'created_at', 'updated_at', 'depth',
'at_position_list', 'type', 'commentable_id',
'id', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'endorsed', 'parent_id', 'thread_id', 'username', 'votes', 'user_id',
'closed', 'created_at', 'updated_at', 'depth', 'at_position_list',
'type', 'commentable_id',
]
updatable_fields = [
'body', 'anonymous', 'course_id', 'closed',
'body', 'anonymous', 'anonymous_to_peers', 'course_id', 'closed',
'user_id', 'endorsed',
]

View File

@@ -6,16 +6,14 @@ import settings
class Thread(models.Model):
accessible_fields = [
'id', 'title', 'body', 'anonymous',
'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',
'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'
]
updatable_fields = [
'title', 'body', 'anonymous', 'course_id',
'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
'closed', 'tags', 'user_id', 'commentable_id',
]
@@ -32,7 +30,7 @@ class Thread(models.Model):
'course_id': query_params['course_id'],
'recursive': False}
params = merge_dict(default_params, strip_blank(strip_none(query_params)))
if query_params.get('text') or query_params.get('tags'):
if query_params.get('text') or query_params.get('tags') or query_params.get('commentable_ids'):
url = cls.url(action='search')
else:
url = cls.url(action='get_all', params=extract(params, 'commentable_id'))
@@ -40,7 +38,7 @@ class Thread(models.Model):
del params['commentable_id']
response = perform_request('get', url, params, *args, **kwargs)
return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1)
@classmethod
def url_for_threads(cls, params={}):
if params.get('commentable_id'):

View File

@@ -8,7 +8,8 @@ class User(models.Model):
accessible_fields = ['username', 'email', 'follower_ids', 'upvoted_ids', 'downvoted_ids',
'id', 'external_id', 'subscribed_user_ids', 'children', 'course_id',
'subscribed_thread_ids', 'subscribed_commentable_ids',
'threads_count', 'comments_count', 'default_sort_key'
'subscribed_course_ids', 'threads_count', 'comments_count',
'default_sort_key'
]
updatable_fields = ['username', 'external_id', 'email', 'default_sort_key']

View File

@@ -28,9 +28,9 @@ def perform_request(method, url, data_or_params=None, *args, **kwargs):
data_or_params['api_key'] = settings.API_KEY
try:
if method in ['post', 'put', 'patch']:
response = requests.request(method, url, data=data_or_params)
response = requests.request(method, url, data=data_or_params, timeout=5)
else:
response = requests.request(method, url, params=data_or_params)
response = requests.request(method, url, params=data_or_params, timeout=5)
except Exception as err:
log.exception("Trying to call {method} on {url} with params {params}".format(
method=method, url=url, params=data_or_params))

View File

@@ -45,6 +45,7 @@ $ ->
removeMath: (text) ->
text = text || ""
@math = []
start = end = last = null
braces = 0
@@ -111,7 +112,7 @@ $ ->
(text) -> _this.replaceMath(text)
if Markdown?
Markdown.getMathCompatibleConverter = (postProcessor) ->
postProcessor ||= ((text) -> text)
converter = Markdown.getSanitizingConverter()
@@ -123,11 +124,9 @@ $ ->
Markdown.makeWmdEditor = (elem, appended_id, imageUploadUrl, postProcessor) ->
$elem = $(elem)
if not $elem.length
console.log "warning: elem for makeWmdEditor doesn't exist"
return
if not $elem.find(".wmd-panel").length
initialText = $elem.html()
$elem.empty()
@@ -162,7 +161,7 @@ $ ->
alert(e)
if startUploadHandler
$('#file-upload').unbind('change').change(startUploadHandler)
imageUploadHandler = (elem, input) ->
ajaxFileUpload(imageUploadUrl, input, imageUploadHandler)

View File

@@ -1,6 +1,9 @@
if Backbone?
class @Content extends Backbone.Model
@contents: {}
@contentInfos: {}
template: -> DiscussionUtil.getTemplate('_content')
actions:
@@ -9,19 +12,20 @@ if Backbone?
can_endorse: '.admin-endorse'
can_delete: '.admin-delete'
can_openclose: '.admin-openclose'
urlMappers: {}
urlFor: (name) ->
@urlMappers[name].apply(@)
can: (action) ->
DiscussionUtil.getContentInfo @id, action
(@get('ability') || {})[action]
updateInfo: (info) ->
@set('ability', info.ability)
@set('voted', info.voted)
@set('subscribed', info.subscribed)
if info
@set('ability', info.ability)
@set('voted', info.voted)
@set('subscribed', info.subscribed)
addComment: (comment, options) ->
options ||= {}
@@ -32,12 +36,14 @@ if Backbone?
@get('children').push comment
model = new Comment $.extend {}, comment, { thread: @get('thread') }
@get('comments').add model
@trigger "comment:add"
model
removeComment: (comment) ->
thread = @get('thread')
comments_count = parseInt(thread.get('comments_count'))
thread.set('comments_count', comments_count - 1 - comment.getCommentsCount())
@trigger "comment:remove"
resetComments: (children) ->
@set 'children', []
@@ -46,364 +52,33 @@ if Backbone?
@addComment comment, { silent: true }
initialize: ->
DiscussionUtil.addContent @id, @
Content.addContent @id, @
if Content.getInfo(@id)
@updateInfo(Content.getInfo(@id))
@set 'user_url', DiscussionUtil.urlFor('user_profile', @get('user_id'))
@resetComments(@get('children'))
class @ContentView extends Backbone.View
remove: ->
$: (selector) ->
@$local.find(selector)
partial:
endorsed: (endorsed) ->
if endorsed
@$el.addClass("endorsed")
else
@$el.removeClass("endorsed")
closed: (closed) -> # we should just re-render the whole thread, or update according to new abilities
if closed
@$el.addClass("closed")
@$(".admin-openclose").text "Re-open Thread"
else
@$el.removeClass("closed")
@$(".admin-openclose").text "Close Thread"
voted: (voted) ->
@$(".discussion-vote-up").removeClass("voted") if voted != "up"
@$(".discussion-vote-down").removeClass("voted") if voted != "down"
@$(".discussion-vote-#{voted}").addClass("voted") if voted in ["up", "down"]
votes_point: (votes_point) ->
@$(".discussion-votes-point").html(votes_point)
comments_count: (comments_count) ->
@$(".comments-count").html(comments_count)
subscribed: (subscribed) ->
if subscribed
@$(".discussion-follow-thread").addClass("discussion-unfollow-thread").html("Unfollow")
else
@$(".discussion-follow-thread").removeClass("discussion-unfollow-thread").html("Follow")
ability: (ability) ->
for action, elemSelector of @model.actions
if not ability[action]
@$(elemSelector).parent().hide()
else
@$(elemSelector).parent().show()
$discussionContent: ->
@_discussionContent ||= @$el.children(".discussion-content")
$showComments: ->
@_showComments ||= @$(".discussion-show-comments")
updateShowComments: ->
if @showed
@$showComments().html @$showComments().html().replace "Show", "Hide"
if @get('type') == 'comment'
@get('thread').removeComment(@)
@get('thread').trigger "comment:remove", @
else
@$showComments().html @$showComments().html().replace "Hide", "Show"
@trigger "thread:remove", @
retrieved: ->
@$showComments().hasClass("retrieved")
hideSingleThread: (event) ->
@$el.children(".comments").hide()
@showed = false
@updateShowComments()
@addContent: (id, content) -> @contents[id] = content
showSingleThread: (event) ->
if @retrieved()
@$el.children(".comments").show()
@showed = true
@updateShowComments()
else
$elem = $.merge @$(".thread-title"), @$showComments()
url = @model.urlFor('retrieve')
DiscussionUtil.safeAjax
$elem: $elem
$loading: @$(".discussion-show-comments")
type: "GET"
url: url
success: (response, textStatus) =>
@showed = true
@updateShowComments()
@$showComments().addClass("retrieved")
@$el.children(".comments").replaceWith response.html
@model.resetComments response.content.children
@initCommentViews()
DiscussionUtil.bulkUpdateContentInfo response.annotated_content_info
@getContent: (id) -> @contents[id]
toggleSingleThread: (event) ->
if @showed
@hideSingleThread(event)
else
@showSingleThread(event)
initCommentViews: ->
@$el.children(".comments").children(".comment").each (index, elem) =>
model = @model.get('comments').find $(elem).attr("_id")
if not model.view
commentView = new CommentView el: elem, model: model
@getInfo: (id) ->
@contentInfos[id]
reply: ->
if @model.get('type') == 'thread'
@showSingleThread()
$replyView = @$(".discussion-reply-new")
if $replyView.length
$replyView.show()
else
view = {}
view.id = @model.id
view.showWatchCheckbox = not @model.get('thread').get('subscribed')
html = Mustache.render DiscussionUtil.getTemplate('_reply'), view
@$discussionContent().append html
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "reply-body"
@$(".discussion-submit-post").click $.proxy(@submitReply, @)
@$(".discussion-cancel-post").click $.proxy(@cancelReply, @)
@$(".discussion-reply").hide()
@$(".discussion-edit").hide()
@loadContentInfos: (infos) ->
for id, info of infos
if @getContent(id)
@getContent(id).updateInfo(info)
$.extend @contentInfos, infos
submitReply: (event) ->
url = @model.urlFor('reply')
body = DiscussionUtil.getWmdContent @$el, $.proxy(@$, @), "reply-body"
anonymous = false || @$(".discussion-post-anonymously").is(":checked")
autowatch = false || @$(".discussion-auto-watch").is(":checked")
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target) if event
url: url
type: "POST"
dataType: 'json'
data:
body: body
anonymous: anonymous
auto_subscribe: autowatch
error: DiscussionUtil.formErrorHandler @$(".discussion-errors")
success: (response, textStatus) =>
DiscussionUtil.clearFormErrors @$(".discussion-errors")
$comment = $(response.html)
@$el.children(".comments").prepend $comment
DiscussionUtil.setWmdContent @$el, $.proxy(@$, @), "reply-body", ""
comment = @model.addComment response.content
commentView = new CommentView el: $comment[0], model: comment
comment.updateInfo response.annotated_content_info
if autowatch
@model.get('thread').set('subscribed', true)
@cancelReply()
cancelReply: ->
$replyView = @$(".discussion-reply-new")
if $replyView.length
$replyView.hide()
@$(".discussion-reply").show()
@$(".discussion-edit").show()
unvote: (event) ->
url = @model.urlFor('unvote')
$elem = @$(".discussion-vote")
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>
@model.set('voted', '')
@model.set('votes_point', response.votes.point)
vote: (event, value) ->
url = @model.urlFor("#{value}vote")
$elem = @$(".discussion-vote")
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>
@model.set('voted', value)
@model.set('votes_point', response.votes.point)
toggleVote: (event) ->
$elem = $(event.target)
value = $elem.attr("value")
if @model.get("voted") == value
@unvote(event)
else
@vote(event, value)
toggleEndorse: (event) ->
$elem = $(event.target)
url = @model.urlFor('endorse')
endorsed = @model.get('endorsed')
data = { endorsed: not endorsed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
success: (response, textStatus) =>
@model.set('endorsed', not endorsed)
toggleFollow: (event) ->
$elem = $(event.target)
subscribed = @model.get('subscribed')
if subscribed
url = @model.urlFor('unfollow')
else
url = @model.urlFor('follow')
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>
@model.set('subscribed', not subscribed)
toggleClosed: (event) ->
$elem = $(event.target)
url = @model.urlFor('close')
closed = @model.get('closed')
data = { closed: not closed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
data: data
success: (response, textStatus) =>
@model.set('closed', not closed)
@model.set('ability', response.ability)
edit: (event) ->
@$(".discussion-content-wrapper").hide()
$editView = @$(".discussion-content-edit")
if $editView.length
$editView.show()
else
view = {}
view.id = @model.id
if @model.get('type') == 'thread'
view.title = @model.get('title')
view.body = @model.get('body')
view.tags = @model.get('tags')
else
view.body = @model.get('body')
@$discussionContent().append Mustache.render DiscussionUtil.getTemplate("_edit_#{@model.get('type')}"), view
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "#{@model.get('type')}-body-edit"
@$(".thread-tags-edit").tagsInput DiscussionUtil.tagsInputOptions()
@$(".discussion-submit-update").unbind("click").click $.proxy(@submitEdit, @)
@$(".discussion-cancel-update").unbind("click").click $.proxy(@cancelEdit, @)
submitEdit: (event) ->
url = @model.urlFor('update')
data = {}
if @model.get('type') == 'thread'
data.title = @$(".thread-title-edit").val()
data.body = DiscussionUtil.getWmdContent @$el, $.proxy(@$, @), "thread-body-edit"
data.tags = @$(".thread-tags-edit").val()
else
data.body = DiscussionUtil.getWmdContent @$el, $.proxy(@$, @), "comment-body-edit"
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target) if event
url: url
type: "POST"
dataType: 'json'
data: data
error: DiscussionUtil.formErrorHandler @$(".discussion-update-errors")
success: (response, textStatus) =>
DiscussionUtil.clearFormErrors @$(".discussion-update-errors")
@$discussionContent().replaceWith(response.html)
if @model.get('type') == 'thread'
@model = new Thread response.content
else
@model = new Comment $.extend {}, response.content, { thread: @model.get('thread') }
@reconstruct()
@model.updateInfo response.annotated_content_info, { forceUpdate: true }
cancelEdit: (event) ->
@$(".discussion-content-edit").hide()
@$(".discussion-content-wrapper").show()
delete: (event) ->
url = @model.urlFor('delete')
if @model.get('type') == 'thread'
c = confirm "Are you sure to delete thread \"#{@model.get('title')}\"?"
else
c = confirm "Are you sure to delete this comment? "
if not c
return
$elem = $(event.target)
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>
@$el.remove()
if @model.get('type') == 'comment'
@model.get('thread').removeComment(@model)
events:
"click .discussion-follow-thread": "toggleFollow"
"click .thread-title": "toggleSingleThread"
"click .discussion-show-comments": "toggleSingleThread"
"click .discussion-reply-thread": "reply"
"click .discussion-reply-comment": "reply"
"click .discussion-cancel-reply": "cancelReply"
"click .discussion-vote-up": "toggleVote"
"click .discussion-vote-down": "toggleVote"
"click .admin-endorse": "toggleEndorse"
"click .admin-openclose": "toggleClosed"
"click .admin-edit": "edit"
"click .admin-delete": "delete"
initLocal: ->
@$local = @$el.children(".local")
@$delegateElement = @$local
initTitle: ->
$contentTitle = @$(".thread-title")
if $contentTitle.length
$contentTitle.html DiscussionUtil.unescapeHighlightTag DiscussionUtil.stripLatexHighlight $contentTitle.html()
initBody: ->
$contentBody = @$(".content-body")
$contentBody.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight $contentBody.html()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, $contentBody.attr("id")]
initTimeago: ->
@$("span.timeago").each (index, element) ->
elem = $(element)
elem.html("posted on #{$.timeago.parse(elem.html()).toLocaleString()}")
@$("span.timeago").timeago()
renderPartial: ->
for attr, value of @model.changedAttributes()
if @partial[attr]
@partial[attr].apply(@, [value])
initBindings: ->
@model.view = @
@model.bind('change', @renderPartial, @)
initialize: ->
@initBindings()
@initLocal()
@initTimeago()
@initTitle()
@initBody()
@initCommentViews()
reconstruct: ->
@initBindings()
@initLocal()
@initTimeago()
@initTitle()
@initBody()
@delegateEvents()
class @Thread extends @Content
urlMappers:
'retrieve' : -> DiscussionUtil.urlFor('retrieve_single_thread', @discussion.id, @id)
@@ -421,7 +96,38 @@ if Backbone?
@set('thread', @)
super()
class @ThreadView extends @ContentView
comment: ->
@set("comments_count", parseInt(@get("comments_count")) + 1)
follow: ->
@set('subscribed', true)
unfollow: ->
@set('subscribed', false)
vote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) + 1
@trigger "change", @
unvote: ->
@get("votes")["up_count"] = parseInt(@get("votes")["up_count"]) - 1
@trigger "change", @
display_body: ->
if @has("highlighted_body")
String(@get("highlighted_body")).replace(/<highlight>/g, '<mark>').replace(/<\/highlight>/g, '</mark>')
else
@get("body")
display_title: ->
if @has("highlighted_title")
String(@get("highlighted_title")).replace(/<highlight>/g, '<mark>').replace(/<\/highlight>/g, '</mark>')
else
@get("title")
toJSON: ->
json_attributes = _.clone(@attributes)
_.extend(json_attributes, { title: @display_title(), body: @display_body() })
class @Comment extends @Content
urlMappers:
@@ -439,8 +145,6 @@ if Backbone?
count += comment.getCommentsCount() + 1
count
class @CommentView extends @ContentView
class @Comments extends Backbone.Collection
model: Comment

View File

@@ -2,185 +2,67 @@ if Backbone?
class @Discussion extends Backbone.Collection
model: Thread
initialize: ->
DiscussionUtil.addDiscussion @id, @
initialize: (models, options={})->
@pages = options['pages'] || 1
@current_page = 1
@bind "add", (item) =>
item.discussion = @
@comparator = @sortByDateRecentFirst
@on "thread:remove", (thread) =>
@remove(thread)
find: (id) ->
_.first @where(id: id)
hasMorePages: ->
@current_page < @pages
addThread: (thread, options) ->
options ||= {}
model = new Thread thread
@add model
model
# TODO: Check for existing thread with same ID in a faster way
if not @find(thread.id)
options ||= {}
model = new Thread thread
@add model
model
class @DiscussionView extends Backbone.View
$: (selector) ->
@$local.find(selector)
initLocal: ->
@$local = @$el.children(".local")
@$delegateElement = @$local
initialize: ->
@initLocal()
@model.id = @$el.attr("_id")
@model.view = @
@$el.children(".threads").children(".thread").each (index, elem) =>
threadView = new ThreadView el: elem, model: @model.find $(elem).attr("_id")
if @$el.hasClass("forum-discussion")
$(".discussion-sidebar").find(".sidebar-new-post-button")
.unbind('click').click $.proxy @newPost, @
else if @$el.hasClass("inline-discussion")
@newPost()
reload: ($elem, url) ->
if not url then return
retrieveAnotherPage: (search_text="", commentable_ids="", sort_key="")->
# TODO: I really feel that this belongs in DiscussionThreadListView
@current_page += 1
url = DiscussionUtil.urlFor 'threads'
data = { page: @current_page }
if search_text
data['text'] = search_text
if sort_key
data['sort_key'] = sort_key
if commentable_ids
data['commentable_ids'] = commentable_ids
DiscussionUtil.safeAjax
$elem: $elem
$loading: $elem
loadingCallback: ->
$(this).parent().append("<span class='discussion-loading'></span>")
loadedCallback: ->
$(this).parent().children(".discussion-loading").remove()
$elem: @$el
url: url
type: "GET"
success: (response, textStatus) =>
$parent = @$el.parent()
@$el.replaceWith(response.html)
$discussion = $parent.find("section.discussion")
@model.reset(response.discussion_data, { silent: false })
view = new DiscussionView el: $discussion[0], model: @model
DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info)
$("html, body").animate({ scrollTop: 0 }, 0)
loadSimilarPost: (event) ->
console.log "loading similar"
$title = @$(".new-post-title")
$wrapper = @$(".new-post-similar-posts-wrapper")
$similarPosts = @$(".new-post-similar-posts")
prevText = $title.attr("prev-text")
text = $title.val()
if text == prevText
if @$(".similar-post").length
$wrapper.show()
else if $.trim(text).length
$elem = $(event.target)
url = DiscussionUtil.urlFor 'search_similar_threads', @model.id
data = { text: @$(".new-post-title").val() }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
dataType: 'json'
success: (response, textStatus) =>
$wrapper.html(response.html)
if $wrapper.find(".similar-post").length
$wrapper.show()
$wrapper.find(".hide-similar-posts").click =>
$wrapper.hide()
else
$wrapper.hide()
$title.attr("prev-text", text)
newPost: ->
if not @$(".wmd-panel").length
view = { discussion_id: @model.id }
@$el.children(".discussion-non-content").append Mustache.render DiscussionUtil.getTemplate("_new_post"), view
$newPostBody = @$(".new-post-body")
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "new-post-body"
$input = DiscussionUtil.getWmdInput @$el, $.proxy(@$, @), "new-post-body"
$input.attr("placeholder", "post a new topic...")
if @$el.hasClass("inline-discussion")
$input.bind 'focus', (e) =>
@$(".new-post-form").removeClass('collapsed')
else if @$el.hasClass("forum-discussion")
@$(".new-post-form").removeClass('collapsed')
@$(".new-post-tags").tagsInput DiscussionUtil.tagsInputOptions()
@$(".new-post-title").blur $.proxy(@loadSimilarPost, @)
@$(".hide-similar-posts").click =>
@$(".new-post-similar-posts-wrapper").hide()
@$(".discussion-submit-post").click $.proxy(@submitNewPost, @)
@$(".discussion-cancel-post").click $.proxy(@cancelNewPost, @)
@$el.children(".blank").hide()
@$(".new-post-form").show()
submitNewPost: (event) ->
title = @$(".new-post-title").val()
body = DiscussionUtil.getWmdContent @$el, $.proxy(@$, @), "new-post-body"
tags = @$(".new-post-tags").val()
anonymous = false || @$(".discussion-post-anonymously").is(":checked")
autowatch = false || @$(".discussion-auto-watch").is(":checked")
url = DiscussionUtil.urlFor('create_thread', @model.id)
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target) if event
url: url
type: "POST"
data: data
dataType: 'json'
data:
title: title
body: body
tags: tags
anonymous: anonymous
auto_subscribe: autowatch
error: DiscussionUtil.formErrorHandler(@$(".new-post-form-errors"))
success: (response, textStatus) =>
DiscussionUtil.clearFormErrors(@$(".new-post-form-errors"))
$thread = $(response.html)
@$el.children(".threads").prepend($thread)
models = @models
new_threads = [new Thread(data) for data in response.discussion_data][0]
new_collection = _.union(models, new_threads)
@reset new_collection
@$el.children(".blank").remove()
sortByDate: (thread) ->
thread.get("created_at")
@$(".new-post-similar-posts").empty()
@$(".new-post-similar-posts-wrapper").hide()
@$(".new-post-title").val("").attr("prev-text", "")
DiscussionUtil.setWmdContent @$el, $.proxy(@$, @), "new-post-body", ""
@$(".new-post-tags").val("")
@$(".new-post-tags").importTags("")
sortByDateRecentFirst: (thread) ->
-(new Date(thread.get("created_at")).getTime())
#return String.fromCharCode.apply(String,
# _.map(thread.get("created_at").split(""),
# ((c) -> return 0xffff - c.charChodeAt()))
#)
thread = @model.addThread response.content
threadView = new ThreadView el: $thread[0], model: thread
thread.updateInfo response.annotated_content_info
@cancelNewPost()
sortByVotes: (thread1, thread2) ->
thread1_count = parseInt(thread1.get("votes")['up_count'])
thread2_count = parseInt(thread2.get("votes")['up_count'])
thread2_count - thread1_count
cancelNewPost: (event) ->
if @$el.hasClass("inline-discussion")
@$(".new-post-form").addClass("collapsed")
else if @$el.hasClass("forum-discussion")
@$(".new-post-form").hide()
@$el.children(".blank").show()
search: (event) ->
event.preventDefault()
$elem = $(event.target)
url = URI($elem.attr("action")).addSearch({text: @$(".search-input").val()})
@reload($elem, url)
sort: (event) ->
$elem = $(event.target)
url = $elem.attr("sort-url")
@reload($elem, url)
page: (event) ->
$elem = $(event.target)
url = $elem.attr("page-url")
@reload($elem, url)
events:
"submit .search-wrapper>.discussion-search-form": "search"
"click .discussion-search-link": "search"
"click .discussion-sort-link": "sort"
"click .discussion-page-link": "page"
sortByComments: (thread1, thread2) ->
thread1_count = parseInt(thread1.get("comments_count"))
thread2_count = parseInt(thread2.get("comments_count"))
thread2_count - thread1_count

View File

@@ -1,34 +0,0 @@
if Backbone?
class @DiscussionModuleView extends Backbone.View
events:
"click .discussion-show": "toggleDiscussion"
toggleDiscussion: (event) ->
if @showed
@$("section.discussion").hide()
$(event.target).html("Show Discussion")
@showed = false
else
if @retrieved
@$("section.discussion").show()
$(event.target).html("Hide Discussion")
@showed = true
else
$elem = $(event.target)
discussion_id = $elem.attr("discussion_id")
url = DiscussionUtil.urlFor 'retrieve_discussion', discussion_id
DiscussionUtil.safeAjax
$elem: $elem
$loading: $elem
url: url
type: "GET"
dataType: 'json'
success: (response, textStatus) =>
@$el.append(response.html)
$discussion = @$el.find("section.discussion")
$(event.target).html("Hide Discussion")
discussion = new Discussion()
discussion.reset(response.discussion_data, {silent: false})
view = new DiscussionView(el: $discussion[0], model: discussion)
DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info)
@retrieved = true
@showed = true

View File

@@ -0,0 +1,124 @@
if Backbone?
class @DiscussionModuleView extends Backbone.View
events:
"click .discussion-show": "toggleDiscussion"
"click .new-post-btn": "toggleNewPost"
"click .new-post-cancel": "hideNewPost"
"click .discussion-paginator a": "navigateToPage"
paginationTemplate: -> DiscussionUtil.getTemplate("_pagination")
page_re: /\?discussion_page=(\d+)/
initialize: ->
@toggleDiscussionBtn = @$(".discussion-show")
# Set the page if it was set in the URL. This is used to allow deep linking to pages
match = @page_re.exec(window.location.href)
if match
@page = parseInt(match[1])
else
@page = 1
toggleNewPost: (event) ->
event.preventDefault()
if !@newPostForm
@toggleDiscussion()
@isWaitingOnNewPost = true;
return
if @showed
@newPostForm.slideDown(300)
else
@newPostForm.show()
@toggleDiscussionBtn.addClass('shown')
@toggleDiscussionBtn.find('.button-text').html("Hide Discussion")
@$("section.discussion").slideDown()
@showed = true
hideNewPost: (event) ->
event.preventDefault()
@newPostForm.slideUp(300)
toggleDiscussion: (event) ->
if @showed
@$("section.discussion").slideUp()
@toggleDiscussionBtn.removeClass('shown')
@toggleDiscussionBtn.find('.button-text').html("Show Discussion")
@showed = false
else
@toggleDiscussionBtn.addClass('shown')
@toggleDiscussionBtn.find('.button-text').html("Hide Discussion")
if @retrieved
@$("section.discussion").slideDown()
@showed = true
else
$elem = @toggleDiscussionBtn
@loadPage $elem
loadPage: ($elem)=>
discussionId = @$el.data("discussion-id")
url = DiscussionUtil.urlFor('retrieve_discussion', discussionId) + "?page=#{@page}"
DiscussionUtil.safeAjax
$elem: $elem
$loading: $elem
url: url
type: "GET"
dataType: 'json'
success: (response, textStatus, jqXHR) => @renderDiscussion($elem, response, textStatus, discussionId)
renderDiscussion: ($elem, response, textStatus, discussionId) =>
window.user = new DiscussionUser(response.user_info)
Content.loadContentInfos(response.annotated_content_info)
DiscussionUtil.loadRoles(response.roles)
allow_anonymous = response.allow_anonymous
allow_anonymous_to_peers = response.allow_anonymous_to_peers
# $elem.html("Hide Discussion")
@discussion = new Discussion()
@discussion.reset(response.discussion_data, {silent: false})
$discussion = $(Mustache.render $("script#_inline_discussion").html(), {'threads':response.discussion_data, 'discussionId': discussionId, 'allow_anonymous_to_peers': allow_anonymous_to_peers, 'allow_anonymous': allow_anonymous})
if @$('section.discussion').length
@$('section.discussion').replaceWith($discussion)
else
$(".discussion-module").append($discussion)
@newPostForm = $('.new-post-article')
@threadviews = @discussion.map (thread) ->
new DiscussionThreadInlineView el: @$("article#thread_#{thread.id}"), model: thread
_.each @threadviews, (dtv) -> dtv.render()
DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info)
@newPostView = new NewPostInlineView el: @$('.new-post-article'), collection: @discussion
@discussion.on "add", @addThread
@retrieved = true
@showed = true
@renderPagination(2, response.num_pages)
if @isWaitingOnNewPost
@newPostForm.show()
addThread: (thread, collection, options) =>
# TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1?
article = $("<article class='discussion-thread' id='thread_#{thread.id}'></article>")
@$('section.discussion > .threads').prepend(article)
threadView = new DiscussionThreadInlineView el: article, model: thread
threadView.render()
@threadviews.unshift threadView
renderPagination: (delta, numPages) =>
minPage = Math.max(@page - delta, 1)
maxPage = Math.min(@page + delta, numPages)
pageUrl = (number) ->
"?discussion_page=#{number}"
params =
page: @page
lowPages: _.range(minPage, @page).map (n) -> {number: n, url: pageUrl(n)}
highPages: _.range(@page+1, maxPage+1).map (n) -> {number: n, url: pageUrl(n)}
previous: if @page-1 >= 1 then {url: pageUrl(@page-1), number: @page-1} else false
next: if @page+1 <= numPages then {url: pageUrl(@page+1), number: @page+1} else false
leftdots: minPage > 2
rightdots: maxPage < numPages-1
first: if minPage > 1 then {url: pageUrl(1)} else false
last: if maxPage < numPages then {number: numPages, url: pageUrl(numPages)} else false
thing = Mustache.render @paginationTemplate(), params
@$('section.pagination').html(thing)
navigateToPage: (event) =>
event.preventDefault()
window.history.pushState({}, window.document.title, event.target.href)
@page = $(event.target).data('page-number')
@loadPage($(event.target))

View File

@@ -0,0 +1,45 @@
if Backbone?
class @DiscussionRouter extends Backbone.Router
routes:
"": "allThreads"
":forum_name/threads/:thread_id" : "showThread"
initialize: (options) ->
@discussion = options['discussion']
@nav = new DiscussionThreadListView(collection: @discussion, el: $(".sidebar"))
@nav.on "thread:selected", @navigateToThread
@nav.on "thread:removed", @navigateToAllThreads
@nav.on "threads:rendered", @setActiveThread
@nav.render()
@newPostView = new NewPostView(el: $(".new-post-article"), collection: @discussion)
@nav.on "thread:created", @navigateToThread
allThreads: ->
@nav.updateSidebar()
setActiveThread: =>
if @thread
@nav.setActiveThread(@thread.get("id"))
showThread: (forum_name, thread_id) ->
@thread = @discussion.get(thread_id)
@setActiveThread()
if(@main)
@main.cleanup()
@main.undelegateEvents()
@main = new DiscussionThreadView(el: $(".discussion-column"), model: @thread)
@main.render()
@main.on "thread:responses:rendered", =>
@nav.updateSidebar()
@main.on "tag:selected", (tag) =>
search = "[#{tag}]"
@nav.setAndSearchFor(search)
navigateToThread: (thread_id) =>
thread = @discussion.get(thread_id)
@navigate("#{thread.get("commentable_id")}/threads/#{thread_id}", trigger: true)
navigateToAllThreads: =>
@navigate("", trigger: true)

View File

@@ -1,18 +1,29 @@
$ ->
window.$$contents = {}
window.$$discussions = {}
$("section.discussion").each (index, elem) ->
discussionData = DiscussionUtil.getDiscussionData($(elem).attr("_id"))
discussion = new Discussion()
discussion.reset(discussionData, {silent: false})
view = new DiscussionView(el: elem, model: discussion)
if window.$$annotated_content_info?
DiscussionUtil.bulkUpdateContentInfo(window.$$annotated_content_info)
$userProfile = $(".discussion-sidebar>.user-profile")
if $userProfile.length
console.log "initialize user profile"
view = new DiscussionUserProfileView(el: $userProfile[0])
if Backbone?
DiscussionApp =
start: (elem)->
# TODO: Perhaps eliminate usage of global variables when possible
DiscussionUtil.loadRolesFromContainer()
element = $(elem)
window.$$course_id = element.data("course-id")
user_info = element.data("user-info")
threads = element.data("threads")
thread_pages = element.data("thread-pages")
content_info = element.data("content-info")
window.user = new DiscussionUser(user_info)
Content.loadContentInfos(content_info)
discussion = new Discussion(threads, pages: thread_pages)
new DiscussionRouter({discussion: discussion})
Backbone.history.start({pushState: true, root: "/courses/#{$$course_id}/discussion/forum/"})
DiscussionProfileApp =
start: (elem) ->
element = $(elem)
window.$$course_id = element.data("course-id")
threads = element.data("threads")
user_info = element.data("user-info")
window.user = new DiscussionUser(user_info)
new DiscussionUserProfileView(el: element, collection: threads)
$ ->
$("section.discussion").each (index, elem) ->
DiscussionApp.start(elem)
$("section.discussion-user-threads").each (index, elem) ->
DiscussionProfileApp.start(elem)

View File

@@ -0,0 +1,15 @@
if Backbone?
class @DiscussionUser extends Backbone.Model
following: (thread) ->
_.include(@get('subscribed_thread_ids'), thread.id)
voted: (thread) ->
_.include(@get('upvoted_ids'), thread.id)
vote: (thread) ->
@get('upvoted_ids').push(thread.id)
thread.vote()
unvote: (thread) ->
@set('upvoted_ids', _.without(@get('upvoted_ids'), thread.id))
thread.unvote()

View File

@@ -1,29 +1,30 @@
class @DiscussionUserProfileView extends Backbone.View
toggleModeratorStatus: (event) ->
confirmValue = confirm("Are you sure?")
if not confirmValue then return
$elem = $(event.target)
if $elem.hasClass("sidebar-promote-moderator-button")
isModerator = true
else if $elem.hasClass("sidebar-revoke-moderator-button")
isModerator = false
else
console.error "unrecognized moderator status"
return
url = DiscussionUtil.urlFor('update_moderator_status', $$profiled_user_id)
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
dataType: 'json'
data:
is_moderator: isModerator
error: (response, textStatus, e) ->
console.log e
success: (response, textStatus) =>
parent = @$el.parent()
@$el.replaceWith(response.html)
view = new DiscussionUserProfileView el: parent.children(".user-profile")
if Backbone?
class @DiscussionUserProfileView extends Backbone.View
toggleModeratorStatus: (event) ->
confirmValue = confirm("Are you sure?")
if not confirmValue then return
$elem = $(event.target)
if $elem.hasClass("sidebar-promote-moderator-button")
isModerator = true
else if $elem.hasClass("sidebar-revoke-moderator-button")
isModerator = false
else
console.error "unrecognized moderator status"
return
url = DiscussionUtil.urlFor('update_moderator_status', $$profiled_user_id)
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
dataType: 'json'
data:
is_moderator: isModerator
error: (response, textStatus, e) ->
console.log e
success: (response, textStatus) =>
parent = @$el.parent()
@$el.replaceWith(response.html)
view = new DiscussionUserProfileView el: parent.children(".user-profile")
events:
"click .sidebar-toggle-moderator-button": "toggleModeratorStatus"
events:
"click .sidebar-toggle-moderator-button": "toggleModeratorStatus"

View File

@@ -1,7 +1,9 @@
$ ->
if !window.$$contents
window.$$contents = {}
$.fn.extend
loading: ->
@$_loading = $("<span class='discussion-loading'></span>")
@$_loading = $("<div class='loading-animation'></div>")
$(this).after(@$_loading)
loaded: ->
@$_loading.remove()
@@ -13,27 +15,26 @@ class @DiscussionUtil
@getTemplate: (id) ->
$("script##{id}").html()
@getDiscussionData: (id) ->
return $$discussion_data[id]
@loadRoles: (roles)->
@roleIds = roles
@addContent: (id, content) -> window.$$contents[id] = content
@loadRolesFromContainer: ->
@loadRoles($("#discussion-container").data("roles"))
@getContent: (id) -> window.$$contents[id]
@isStaff: (user_id) ->
staff = _.union(@roleIds['Staff'], @roleIds['Moderator'], @roleIds['Administrator'])
_.include(staff, parseInt(user_id))
@addDiscussion: (id, discussion) -> window.$$discussions[id] = discussion
@getDiscussion: (id) -> window.$$discussions[id]
@bulkUpdateContentInfo: (infos) ->
for id, info of infos
@getContent(id).updateInfo(info)
Content.getContent(id).updateInfo(info)
@generateDiscussionLink: (cls, txt, handler) ->
$("<a>").addClass("discussion-link")
.attr("href", "javascript:void(0)")
.addClass(cls).html(txt)
.click -> handler(this)
@urlFor: (name, param, param1, param2) ->
{
follow_discussion : "/courses/#{$$course_id}/discussion/#{param}/follow"
@@ -64,26 +65,32 @@ class @DiscussionUtil
openclose_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/close"
permanent_link_thread : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}"
permanent_link_comment : "/courses/#{$$course_id}/discussion/forum/#{param}/threads/#{param1}##{param2}"
user_profile : "/courses/#{$$course_id}/discussion/forum/users/#{param}"
threads : "/courses/#{$$course_id}/discussion/forum"
}[name]
@safeAjax: (params) ->
$elem = params.$elem
if $elem.attr("disabled")
if $elem and $elem.attr("disabled")
return
params["url"] = URI(params["url"]).addSearch ajax: 1
params["beforeSend"] = ->
$elem.attr("disabled", "disabled")
if $elem
$elem.attr("disabled", "disabled")
if params["$loading"]
if params["loadingCallback"]?
params["loadingCallback"].apply(params["$loading"])
else
params["$loading"].loading()
$.ajax(params).always ->
$elem.removeAttr("disabled")
request = $.ajax(params).always ->
if $elem
$elem.removeAttr("disabled")
if params["$loading"]
if params["loadedCallback"]?
params["loadedCallback"].apply(params["$loading"])
else
params["$loading"].loaded()
return request
@get: ($elem, url, data, success) ->
@safeAjax
@@ -108,6 +115,9 @@ class @DiscussionUtil
[event, selector] = eventSelector.split(' ')
$local(selector).unbind(event)[event] handler
@processTag: (text) ->
text.toLowerCase()
@tagsInputOptions: ->
autocomplete_url: @urlFor('tags_autocomplete')
autocomplete:
@@ -117,6 +127,7 @@ class @DiscussionUtil
width: '100%'
defaultText: "Tag your post: press enter after each tag"
removeWithBackspace: true
preprocessTag: @processTag
@formErrorHandler: (errorsField) ->
(xhr, textStatus, error) ->
@@ -124,11 +135,11 @@ class @DiscussionUtil
if response.errors? and response.errors.length > 0
errorsField.empty()
for error in response.errors
errorsField.append($("<li>").addClass("new-post-form-error").html(error))
errorsField.append($("<li>").addClass("new-post-form-error").html(error)).show()
@clearFormErrors: (errorsField) ->
errorsField.empty()
@postMathJaxProcessor: (text) ->
RE_INLINEMATH = /^\$([^\$]*)\$/g
RE_DISPLAYMATH = /^\$\$([^\$]*)\$\$/g
@@ -144,21 +155,26 @@ class @DiscussionUtil
@makeWmdEditor: ($content, $local, cls_identifier) ->
elem = $local(".#{cls_identifier}")
id = $content.attr("_id")
placeholder = elem.data('placeholder')
id = elem.data("id")
appended_id = "-#{cls_identifier}-#{id}"
imageUploadUrl = @urlFor('upload')
_processor = (_this) ->
(text) -> _this.postMathJaxProcessor(text)
editor = Markdown.makeWmdEditor elem, appended_id, imageUploadUrl, _processor(@)
@wmdEditors["#{cls_identifier}-#{id}"] = editor
if placeholder?
elem.find("#wmd-input#{appended_id}").attr('placeholder', placeholder)
editor
@getWmdEditor: ($content, $local, cls_identifier) ->
id = $content.attr("_id")
elem = $local(".#{cls_identifier}")
id = elem.data("id")
@wmdEditors["#{cls_identifier}-#{id}"]
@getWmdInput: ($content, $local, cls_identifier) ->
id = $content.attr("_id")
elem = $local(".#{cls_identifier}")
id = elem.data("id")
$local("#wmd-input-#{cls_identifier}-#{id}")
@getWmdContent: ($content, $local, cls_identifier) ->
@@ -199,9 +215,9 @@ class @DiscussionUtil
unfollowLink()
else
followLink()
@processEachMathAndCode: (text, processor) ->
codeArchive = []
RE_DISPLAYMATH = /^([^\$]*?)\$\$([^\$]*?)\$\$(.*)$/m
@@ -264,5 +280,17 @@ class @DiscussionUtil
@processEachMathAndCode text, @stripHighlight
@markdownWithHighlight: (text) ->
text = text.replace(/^\&gt\;/gm, ">")
converter = Markdown.getMathCompatibleConverter()
@unescapeHighlightTag @stripLatexHighlight converter.makeHtml text
text = @unescapeHighlightTag @stripLatexHighlight converter.makeHtml text
return text.replace(/^>/gm,"&gt;")
@abbreviateString: (text, minLength) ->
# Abbreviates a string to at least minLength characters, stopping at word boundaries
if text.length<minLength
return text
else
while minLength < text.length && text[minLength] != ' '
minLength++
return text.substr(0, minLength) + '...'

View File

@@ -0,0 +1,100 @@
if Backbone?
class @DiscussionContentView extends Backbone.View
attrRenderer:
endorsed: (endorsed) ->
if endorsed
@$(".action-endorse").show().addClass("is-endorsed")
else
if @model.get('ability')?.can_endorse
@$(".action-endorse").show()
else
@$(".action-endorse").hide()
@$(".action-endorse").removeClass("is-endorsed")
closed: (closed) ->
return if not @$(".action-openclose").length
return if not @$(".post-status-closed").length
if closed
@$(".post-status-closed").show()
@$(".action-openclose").html(@$(".action-openclose").html().replace("Close", "Open"))
@$(".discussion-reply-new").hide()
else
@$(".post-status-closed").hide()
@$(".action-openclose").html(@$(".action-openclose").html().replace("Open", "Close"))
@$(".discussion-reply-new").show()
voted: (voted) ->
votes_point: (votes_point) ->
comments_count: (comments_count) ->
subscribed: (subscribed) ->
if subscribed
@$(".dogear").addClass("is-followed")
else
@$(".dogear").removeClass("is-followed")
ability: (ability) ->
for action, selector of @abilityRenderer
if not ability[action]
selector.disable.apply(@)
else
selector.enable.apply(@)
abilityRenderer:
editable:
enable: -> @$(".action-edit").closest("li").show()
disable: -> @$(".action-edit").closest("li").hide()
can_delete:
enable: -> @$(".action-delete").closest("li").show()
disable: -> @$(".action-delete").closest("li").hide()
can_endorse:
enable: ->
@$(".action-endorse").show().css("cursor", "auto")
disable: ->
@$(".action-endorse").css("cursor", "default")
if not @model.get('endorsed')
@$(".action-endorse").hide()
else
@$(".action-endorse").show()
can_openclose:
enable: -> @$(".action-openclose").closest("li").show()
disable: -> @$(".action-openclose").closest("li").hide()
renderPartialAttrs: ->
for attr, value of @model.changedAttributes()
if @attrRenderer[attr]
@attrRenderer[attr].apply(@, [value])
renderAttrs: ->
for attr, value of @model.attributes
if @attrRenderer[attr]
@attrRenderer[attr].apply(@, [value])
$: (selector) ->
@$local.find(selector)
initLocal: ->
@$local = @$el.children(".local")
if not @$local.length
@$local = @$el
@$delegateElement = @$local
makeWmdEditor: (cls_identifier) =>
if not @$el.find(".wmd-panel").length
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), cls_identifier
getWmdEditor: (cls_identifier) =>
DiscussionUtil.getWmdEditor @$el, $.proxy(@$, @), cls_identifier
getWmdContent: (cls_identifier) =>
DiscussionUtil.getWmdContent @$el, $.proxy(@$, @), cls_identifier
setWmdContent: (cls_identifier, text) =>
DiscussionUtil.setWmdContent @$el, $.proxy(@$, @), cls_identifier, text
initialize: ->
@initLocal()
@model.bind('change', @renderPartialAttrs, @)

View File

@@ -0,0 +1,26 @@
if Backbone?
class @DiscussionThreadEditView extends Backbone.View
events:
"click .post-update": "update"
"click .post-cancel": "cancel_edit"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
render: ->
@template = _.template($("#thread-edit-template").html())
@$el.html(@template(@model.toJSON()))
@delegateEvents()
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "edit-post-body"
@$(".edit-post-tags").tagsInput DiscussionUtil.tagsInputOptions()
@
update: (event) ->
@trigger "thread:update", event
cancel_edit: (event) ->
@trigger "thread:cancel_edit", event

View File

@@ -0,0 +1,358 @@
if Backbone?
class @DiscussionThreadListView extends Backbone.View
events:
"click .search": "showSearch"
"click .browse": "toggleTopicDrop"
"keydown .post-search-field": "performSearch"
"click .sort-bar a": "sortThreads"
"click .browse-topic-drop-menu": "filterTopic"
"click .browse-topic-drop-search-input": "ignoreClick"
"click .post-list .list-item a": "threadSelected"
"click .post-list .more-pages a": "loadMorePages"
initialize: ->
@displayedCollection = new Discussion(@collection.models, pages: @collection.pages)
@collection.on "change", @reloadDisplayedCollection
@sortBy = "date"
@discussionIds=""
@collection.on "reset", (discussion) =>
board = $(".current-board").html()
@displayedCollection.current_page = discussion.current_page
@displayedCollection.pages = discussion.pages
@displayedCollection.reset discussion.models
# TODO: filter correctly
# target = _.filter($("a.topic:contains('#{board}')"), (el) -> el.innerText == "General" || el.innerHTML == "General")
# if target.length > 0
# @filterTopic($.Event("filter", {'target': target[0]}))
@collection.on "add", @addAndSelectThread
@sidebar_padding = 10
@sidebar_header_height = 87
@boardName
@template = _.template($("#thread-list-template").html())
@current_search = ""
reloadDisplayedCollection: (thread) =>
thread_id = thread.get('id')
content = @renderThread(thread)
current_el = @$("a[data-id=#{thread_id}]")
active = current_el.hasClass("active")
current_el.replaceWith(content)
if active
@setActiveThread(thread_id)
#TODO fix this entire chain of events
addAndSelectThread: (thread) =>
commentable_id = thread.get("commentable_id")
commentable = @$(".board-name[data-discussion_id]").filter(-> $(this).data("discussion_id").id == commentable_id)
@setTopicHack(commentable)
@retrieveDiscussion commentable_id, =>
@trigger "thread:created", thread.get('id')
updateSidebar: =>
scrollTop = $(window).scrollTop();
windowHeight = $(window).height();
discussionBody = $(".discussion-article")
discussionsBodyTop = if discussionBody[0] then discussionBody.offset().top
discussionsBodyBottom = discussionsBodyTop + discussionBody.outerHeight()
sidebar = $(".sidebar")
if scrollTop > discussionsBodyTop - @sidebar_padding
sidebar.addClass('fixed');
sidebar.css('top', @sidebar_padding);
else
sidebar.removeClass('fixed');
sidebar.css('top', '0');
sidebarWidth = .31 * $(".discussion-body").width();
sidebar.css('width', sidebarWidth + 'px');
sidebarHeight = windowHeight - Math.max(discussionsBodyTop - scrollTop, @sidebar_padding)
topOffset = scrollTop + windowHeight
discussionBottomOffset = discussionsBodyBottom + @sidebar_padding
amount = Math.max(topOffset - discussionBottomOffset, 0)
sidebarHeight = sidebarHeight - @sidebar_padding - amount
sidebarHeight = Math.min(sidebarHeight + 1, discussionBody.outerHeight())
sidebar.css 'height', sidebarHeight
postListWrapper = @$('.post-list-wrapper')
postListWrapper.css('height', (sidebarHeight - @sidebar_header_height - 4) + 'px')
# 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.
# Without this, clicking the search field would also close the menu.
ignoreClick: (event) ->
event.stopPropagation()
render: ->
@timer = 0
@$el.html(@template())
$(window).bind "scroll", @updateSidebar
$(window).bind "resize", @updateSidebar
@displayedCollection.on "reset", @renderThreads
@displayedCollection.on "thread:remove", @renderThreads
@renderThreads()
@
renderThreads: =>
@$(".post-list").html("")
rendered = $("<div></div>")
for thread in @displayedCollection.models
content = @renderThread(thread)
rendered.append content
content.wrap("<li class='list-item' data-id='\"#{thread.get('id')}\"' />")
@$(".post-list").html(rendered.html())
@renderMorePages()
@updateSidebar()
@trigger "threads:rendered"
renderMorePages: ->
if @displayedCollection.hasMorePages()
@$(".post-list").append("<li class='more-pages'><a href='#'>Load more</a></li>")
loadMorePages: (event) ->
event.preventDefault()
@$(".more-pages").html('<div class="loading-animation"></div>')
@$(".more-pages").addClass("loading")
@collection.retrieveAnotherPage(@current_search, @discussionIds, @sortBy)
renderThread: (thread) =>
content = $(_.template($("#thread-list-item-template").html())(thread.toJSON()))
if thread.get('subscribed')
content.addClass("followed")
if thread.get('endorsed')
content.addClass("resolved")
@highlight(content)
highlight: (el) ->
el.html(el.html().replace(/&lt;mark&gt;/g, "<mark>").replace(/&lt;\/mark&gt;/g, "</mark>"))
renderThreadListItem: (thread) =>
view = new ThreadListItemView(model: thread)
view.on "thread:selected", @threadSelected
view.on "thread:removed", @threadRemoved
view.render()
@$(".post-list").append(view.el)
threadSelected: (e) =>
thread_id = $(e.target).closest("a").data("id")
@setActiveThread(thread_id)
@trigger("thread:selected", thread_id)
false
threadRemoved: (thread_id) =>
@trigger("thread:removed", thread_id)
setActiveThread: (thread_id) ->
@$(".post-list a[data-id!='#{thread_id}']").removeClass("active")
@$(".post-list a[data-id='#{thread_id}']").addClass("active")
showSearch: ->
@$(".browse").removeClass('is-dropped')
@hideTopicDrop()
@$(".search").addClass('is-open')
@$(".browse").removeClass('is-open')
setTimeout (-> @$(".post-search-field").focus()), 200
toggleTopicDrop: (event) =>
event.preventDefault()
event.stopPropagation()
if @current_search != ""
@clearSearch()
@$(".search").removeClass('is-open')
@$(".browse").addClass('is-open')
@$(".browse").toggleClass('is-dropped')
if @$(".browse").hasClass('is-dropped')
@$(".browse-topic-drop-menu-wrapper").show()
$(".browse-topic-drop-search-input").focus()
$("body").bind "click", @toggleTopicDrop
$("body").bind "keydown", @setActiveItem
else
@hideTopicDrop()
hideTopicDrop: ->
@$(".browse-topic-drop-menu-wrapper").hide()
$("body").unbind "click", @toggleTopicDrop
$("body").unbind "keydown", @setActiveItem
# TODO get rid of this asap
setTopicHack: (boardNameContainer) ->
item = $(boardNameContainer).closest('a')
boardName = item.find(".board-name").html()
_.each item.parents('ul').not('.browse-topic-drop-menu'), (parent) ->
boardName = $(parent).siblings('a').find('.board-name').html() + ' / ' + boardName
@$(".current-board").html(@fitName(boardName))
setTopic: (event) ->
item = $(event.target).closest('a')
boardName = item.find(".board-name").html()
_.each item.parents('ul').not('.browse-topic-drop-menu'), (parent) ->
boardName = $(parent).siblings('a').find('.board-name').html() + ' / ' + boardName
@$(".current-board").html(@fitName(boardName))
setSelectedTopic: (name) ->
@$(".current-board").html(@fitName(name))
getNameWidth: (name) ->
test = $("<div>")
test.css
"font-size": @$(".current-board").css('font-size')
opacity: 0
position: 'absolute'
left: -1000
top: -1000
$("body").append(test)
test.html(name)
width = test.width()
test.remove()
return width
fitName: (name) ->
@maxNameWidth = (@$el.width() * .8) - 50
width = @getNameWidth(name)
if width < @maxNameWidth
return name
path = (x.replace /^\s+|\s+$/g, "" for x in name.split("/"))
while path.length > 1
path.shift()
partialName = "…/" + path.join("/")
if @getNameWidth(partialName) < @maxNameWidth
return partialName
rawName = path[0]
name = "…/" + rawName
while @getNameWidth(name) > @maxNameWidth
rawName = rawName[0...rawName.length-1]
name = "…/" + rawName + ""
return name
filterTopic: (event) ->
if @current_search != ""
@setTopic(event)
@clearSearch @filterTopic, event
else
@setTopic(event) # just sets the title for the dropdown
item = $(event.target).closest('li')
if item.find("span.board-name").data("discussion_id") == "#all"
@discussionIds = ""
@clearSearch()
else
discussionIds = _.map item.find(".board-name[data-discussion_id]"), (board) -> $(board).data("discussion_id").id
@retrieveDiscussions(discussionIds)
retrieveDiscussion: (discussion_id, callback=null) ->
url = DiscussionUtil.urlFor("retrieve_discussion", discussion_id)
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)
if callback?
callback()
retrieveDiscussions: (discussion_ids) ->
@discussionIds = discussion_ids.join(',')
url = DiscussionUtil.urlFor("search")
DiscussionUtil.safeAjax
data: { 'commentable_ids': @discussionIds }
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")
@sortBy = $(event.target).data("sort")
if @sortBy == "date"
@displayedCollection.comparator = @displayedCollection.sortByDateRecentFirst
else if @sortBy == "votes"
@displayedCollection.comparator = @displayedCollection.sortByVotes
else if @sortBy == "comments"
@displayedCollection.comparator = @displayedCollection.sortByComments
@displayedCollection.sort()
performSearch: (event) ->
if event.which == 13
event.preventDefault()
text = @$(".post-search-field").val()
@searchFor(text)
setAndSearchFor: (text) ->
@showSearch()
@$(".post-search-field").val(text)
@searchFor(text)
searchFor: (text, callback, value) ->
@current_search = text
url = DiscussionUtil.urlFor("search")
DiscussionUtil.safeAjax
$elem: @$(".post-search-field")
data: { text: text }
url: url
type: "GET"
$loading: $
loadingCallback: =>
@$(".post-list").html('<li class="loading"><div class="loading-animation"></div></li>')
loadedCallback: =>
if callback
callback.apply @, [value]
success: (response, textStatus) =>
if textStatus == 'success'
# TODO: Augment existing collection?
@collection.reset(response.discussion_data)
Content.loadContentInfos(response.content_info)
@collection.current_page = response.page
@collection.pages = response.num_pages
# TODO: Perhaps reload user info so that votes can be updated.
# In the future we might not load all of a user's votes at once
# so this would probably be necessary anyway
@displayedCollection.reset(@collection.models)
clearSearch: (callback, value) ->
@$(".post-search-field").val("")
@searchFor("", callback, value)
setActiveItem: (event) ->
if event.which == 13
$(".browse-topic-drop-menu-wrapper .focused").click()
return
if event.which != 40 && event.which != 38
return
event.preventDefault()
items = $.makeArray($(".browse-topic-drop-menu-wrapper a").not(".hidden"))
index = items.indexOf($('.browse-topic-drop-menu-wrapper .focused')[0])
if event.which == 40
index = Math.min(index + 1, items.length - 1)
if event.which == 38
index = Math.max(index - 1, 0)
$(".browse-topic-drop-menu-wrapper .focused").removeClass("focused")
$(items[index]).addClass("focused")
itemTop = $(items[index]).parent().offset().top
scrollTop = $(".browse-topic-drop-menu").scrollTop()
itemFromTop = $(".browse-topic-drop-menu").offset().top - itemTop
scrollTarget = Math.min(scrollTop - itemFromTop, scrollTop)
scrollTarget = Math.max(scrollTop - itemFromTop - $(".browse-topic-drop-menu").height() + $(items[index]).height(), scrollTarget)
$(".browse-topic-drop-menu").scrollTop(scrollTarget)

View File

@@ -0,0 +1,145 @@
if Backbone?
class @DiscussionThreadProfileView extends DiscussionContentView
expanded = false
events:
"click .discussion-vote": "toggleVote"
"click .action-follow": "toggleFollowing"
"click .expand-post": "expandPost"
"click .collapse-post": "collapsePost"
initLocal: ->
@$local = @$el.children(".discussion-article").children(".local")
@$delegateElement = @$local
initialize: ->
super()
@model.on "change", @updateModelDetails
render: ->
@template = DiscussionUtil.getTemplate("_profile_thread")
if not @model.has('abbreviatedBody')
@abbreviateBody()
params = $.extend(@model.toJSON(),{expanded: @expanded, permalink: @model.urlFor('retrieve')})
if not @model.get('anonymous')
params = $.extend(params, user:{username: @model.username, user_url: @model.user_url})
@$el.html(Mustache.render(@template, params))
@initLocal()
@delegateEvents()
@renderDogear()
@renderVoted()
@renderAttrs()
@$("span.timeago").timeago()
@convertMath()
if @expanded
@renderResponses()
@
renderDogear: ->
if window.user.following(@model)
@$(".dogear").addClass("is-followed")
renderVoted: =>
if window.user.voted(@model)
@$("[data-role=discussion-vote]").addClass("is-cast")
else
@$("[data-role=discussion-vote]").removeClass("is-cast")
updateModelDetails: =>
@renderVoted()
@$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"])
convertMath: ->
element = @$(".post-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.html()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
renderResponses: ->
DiscussionUtil.safeAjax
url: "/courses/#{$$course_id}/discussion/forum/#{@model.get('commentable_id')}/threads/#{@model.id}"
$loading: @$el
success: (data, textStatus, xhr) =>
@$el.find(".loading").remove()
Content.loadContentInfos(data['annotated_content_info'])
comments = new Comments(data['content']['children'])
comments.each @renderResponse
@trigger "thread:responses:rendered"
renderResponse: (response) =>
response.set('thread', @model)
view = new ThreadResponseView(model: response)
view.on "comment:add", @addComment
view.render()
@$el.find(".responses").append(view.el)
addComment: =>
@model.comment()
toggleVote: (event) ->
event.preventDefault()
if window.user.voted(@model)
@unvote()
else
@vote()
toggleFollowing: (event) ->
$elem = $(event.target)
url = null
console.log "follow"
if not @model.get('subscribed')
@model.follow()
url = @model.urlFor("follow")
else
@model.unfollow()
url = @model.urlFor("unfollow")
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
vote: ->
window.user.vote(@model)
url = @model.urlFor("upvote")
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
unvote: ->
window.user.unvote(@model)
url = @model.urlFor("unvote")
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
edit: ->
abbreviateBody: ->
abbreviated = DiscussionUtil.abbreviateString @model.get('body'), 140
@model.set('abbreviatedBody', abbreviated)
expandPost: (event) ->
@expanded = true
@$el.addClass('expanded')
@$el.find('.post-body').html(@model.get('body'))
@convertMath()
@$el.find('.expand-post').css('display', 'none')
@$el.find('.collapse-post').css('display', 'block')
@$el.find('.post-extended-content').show()
if @$el.find('.loading').length
@renderResponses()
collapsePost: (event) ->
@expanded = false
@$el.removeClass('expanded')
@$el.find('.post-body').html(@model.get('abbreviatedBody'))
@convertMath()
@$el.find('.collapse-post').css('display', 'none')
@$el.find('.post-extended-content').hide()
@$el.find('.expand-post').css('display', 'block')

View File

@@ -0,0 +1,139 @@
if Backbone?
class @DiscussionThreadShowView extends DiscussionContentView
events:
"click .discussion-vote": "toggleVote"
"click .action-follow": "toggleFollowing"
"click .action-edit": "edit"
"click .action-delete": "delete"
"click .action-openclose": "toggleClosed"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
@model.on "change", @updateModelDetails
renderTemplate: ->
@template = _.template($("#thread-show-template").html())
@template(@model.toJSON())
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderDogear()
@renderVoted()
@renderAttrs()
@$("span.timeago").timeago()
@convertMath()
@highlight @$(".post-body")
@highlight @$("h1,h3")
@
renderDogear: ->
if window.user.following(@model)
@$(".dogear").addClass("is-followed")
renderVoted: =>
if window.user.voted(@model)
@$("[data-role=discussion-vote]").addClass("is-cast")
else
@$("[data-role=discussion-vote]").removeClass("is-cast")
updateModelDetails: =>
@renderVoted()
@$("[data-role=discussion-vote] .votes-count-number").html(@model.get("votes")["up_count"])
convertMath: ->
element = @$(".post-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.html()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
toggleVote: (event) ->
event.preventDefault()
if window.user.voted(@model)
@unvote()
else
@vote()
toggleFollowing: (event) ->
$elem = $(event.target)
url = null
if not @model.get('subscribed')
@model.follow()
url = @model.urlFor("follow")
else
@model.unfollow()
url = @model.urlFor("unfollow")
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
vote: ->
window.user.vote(@model)
url = @model.urlFor("upvote")
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response, {silent: true})
unvote: ->
window.user.unvote(@model)
url = @model.urlFor("unvote")
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response, {silent: true})
edit: (event) ->
@trigger "thread:edit", event
delete: (event) ->
@trigger "thread:delete", event
toggleClosed: (event) ->
$elem = $(event.target)
url = @model.urlFor('close')
closed = @model.get('closed')
data = { closed: not closed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
success: (response, textStatus) =>
@model.set('closed', not closed)
@model.set('ability', response.ability)
toggleEndorse: (event) ->
$elem = $(event.target)
url = @model.urlFor('endorse')
endorsed = @model.get('endorsed')
data = { endorsed: not endorsed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
success: (response, textStatus) =>
@model.set('endorsed', not endorsed)
highlight: (el) ->
if el.html()
el.html(el.html().replace(/&lt;mark&gt;/g, "<mark>").replace(/&lt;\/mark&gt;/g, "</mark>"))
class @DiscussionThreadInlineShowView extends DiscussionThreadShowView
renderTemplate: ->
@template = DiscussionUtil.getTemplate('_inline_thread_show')
params = @model.toJSON()
if @model.get('username')?
params = $.extend(params, user:{username: @model.username, user_url: @model.user_url})
Mustache.render(@template, params)

View File

@@ -0,0 +1,215 @@
if Backbone?
class @DiscussionThreadView extends DiscussionContentView
events:
"click .discussion-submit-post": "submitComment"
# TODO tags
# Until we decide what to do w/ tags, removing them.
#"click .thread-tag": "tagSelected"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
@createShowView()
renderTemplate: ->
@template = _.template($("#thread-template").html())
@template(@model.toJSON())
render: ->
@$el.html(@renderTemplate())
@$el.find(".loading").hide()
@delegateEvents()
@renderShowView()
@renderAttrs()
# TODO tags
# Until we decide what to do w/ tags, removing them.
#@renderTags()
@$("span.timeago").timeago()
@makeWmdEditor "reply-body"
@renderResponses()
@
cleanup: ->
if @responsesRequest?
@responsesRequest.abort()
# TODO tags
# Until we decide what to do w/ tags, removing them.
#renderTags: ->
# # tags
# for tag in @model.get("tags")
# if !tags
# tags = $('<div class="thread-tags">')
# tags.append("<a href='#' class='thread-tag'>#{tag}</a>")
# @$(".post-body").after(tags)
# TODO tags
# Until we decide what to do w tags, removing them.
#tagSelected: (e) ->
# @trigger "tag:selected", $(e.target).html()
renderResponses: ->
setTimeout(=>
@$el.find(".loading").show()
, 200)
@responsesRequest = DiscussionUtil.safeAjax
url: DiscussionUtil.urlFor('retrieve_single_thread', @model.get('commentable_id'), @model.id)
success: (data, textStatus, xhr) =>
@responsesRequest = null
@$el.find(".loading").remove()
Content.loadContentInfos(data['annotated_content_info'])
comments = new Comments(data['content']['children'])
comments.each @renderResponse
@trigger "thread:responses:rendered"
renderResponse: (response) =>
response.set('thread', @model)
view = new ThreadResponseView(model: response)
view.on "comment:add", @addComment
view.on "comment:endorse", @endorseThread
view.render()
@$el.find(".responses").append(view.el)
view.afterInsert()
addComment: =>
@model.comment()
endorseThread: (endorsed) =>
is_endorsed = @$el.find(".is-endorsed").length
@model.set 'endorsed', is_endorsed
submitComment: (event) ->
event.preventDefault()
url = @model.urlFor('reply')
body = @getWmdContent("reply-body")
return if not body.trim().length
@setWmdContent("reply-body", "")
comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), votes: { up_count: 0 }, endorsed: false, user_id: window.user.get("id"))
comment.set('thread', @model.get('thread'))
@renderResponse(comment)
@model.addComment()
DiscussionUtil.safeAjax
$elem: $(event.target)
url: url
type: "POST"
dataType: 'json'
data:
body: body
success: (data, textStatus) =>
comment.updateInfo(data.annotated_content_info)
comment.set(data.content)
edit: (event) =>
@createEditView()
@renderEditView()
update: (event) =>
newTitle = @editView.$(".edit-post-title").val()
newBody = @editView.$(".edit-post-body textarea").val()
# TODO tags
# Until we decide what to do w/ tags, removing them.
#newTags = @editView.$(".edit-post-tags").val()
url = DiscussionUtil.urlFor('update_thread', @model.id)
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target) if event
url: url
type: "POST"
dataType: 'json'
async: false # TODO when the rest of the stuff below is made to work properly..
data:
title: newTitle
body: newBody
# TODO tags
# Until we decide what to do w/ tags, removing them.
#tags: newTags
error: DiscussionUtil.formErrorHandler(@$(".edit-post-form-errors"))
success: (response, textStatus) =>
# TODO: Move this out of the callback, this makes it feel sluggish
@editView.$(".edit-post-title").val("").attr("prev-text", "")
@editView.$(".edit-post-body textarea").val("").attr("prev-text", "")
@editView.$(".edit-post-tags").val("")
@editView.$(".edit-post-tags").importTags("")
@editView.$(".wmd-preview p").html("")
@model.set
title: newTitle
body: newBody
tags: response.content.tags
@createShowView()
@renderShowView()
# TODO tags
# Until we decide what to do w/ tags, removing them.
#@renderTags()
createEditView: () ->
if @showView?
@showView.undelegateEvents()
@showView.$el.empty()
@showView = null
@editView = new DiscussionThreadEditView(model: @model)
@editView.bind "thread:update", @update
@editView.bind "thread:cancel_edit", @cancelEdit
renderSubView: (view) ->
view.setElement(@$('.thread-content-wrapper'))
view.render()
view.delegateEvents()
renderEditView: () ->
@renderSubView(@editView)
createShowView: () ->
if @editView?
@editView.undelegateEvents()
@editView.$el.empty()
@editView = null
@showView = new DiscussionThreadShowView(model: @model)
@showView.bind "thread:delete", @delete
@showView.bind "thread:edit", @edit
renderShowView: () ->
@renderSubView(@showView)
cancelEdit: (event) =>
event.preventDefault()
@createShowView()
@renderShowView()
delete: (event) =>
url = @model.urlFor('delete')
if not @model.can('can_delete')
return
if not confirm "Are you sure to delete thread \"#{@model.get('title')}\"?"
return
@model.remove()
@showView.undelegateEvents()
@undelegateEvents()
@$el.empty()
$elem = $(event.target)
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>

View File

@@ -0,0 +1,124 @@
if Backbone?
class @DiscussionThreadInlineView extends DiscussionThreadView
expanded = false
events:
"click .discussion-submit-post": "submitComment"
"click .expand-post": "expandPost"
"click .collapse-post": "collapsePost"
initialize: ->
super()
initLocal: ->
@$local = @$el.children(".discussion-article").children(".local")
if not @$local.length
@$local = @$el
@$delegateElement = @$local
render: ->
@template = DiscussionUtil.getTemplate("_inline_thread")
if not @model.has('abbreviatedBody')
@abbreviateBody()
params = @model.toJSON()
@$el.html(Mustache.render(@template, params))
#@createShowView()
@initLocal()
@delegateEvents()
@renderShowView()
@renderAttrs()
# TODO tags commenting out til we decide what to do with tags
#@renderTags()
@$("span.timeago").timeago()
@$el.find('.post-extended-content').hide()
if @expanded
@makeWmdEditor "reply-body"
@renderResponses()
@
createShowView: () ->
if @editView?
@editView.undelegateEvents()
@editView.$el.empty()
@editView = null
@showView = new DiscussionThreadInlineShowView(model: @model)
@showView.bind "thread:delete", @delete
@showView.bind "thread:edit", @edit
renderResponses: ->
#TODO: threadview
DiscussionUtil.safeAjax
url: "/courses/#{$$course_id}/discussion/forum/#{@model.get('commentable_id')}/threads/#{@model.id}"
$loading: @$el
success: (data, textStatus, xhr) =>
# @$el.find(".loading").remove()
Content.loadContentInfos(data['annotated_content_info'])
comments = new Comments(data['content']['children'])
comments.each @renderResponse
@trigger "thread:responses:rendered"
@$('.loading').remove()
toggleClosed: (event) ->
#TODO: showview
$elem = $(event.target)
url = @model.urlFor('close')
closed = @model.get('closed')
data = { closed: not closed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
success: (response, textStatus) =>
@model.set('closed', not closed)
@model.set('ability', response.ability)
toggleEndorse: (event) ->
#TODO: showview
$elem = $(event.target)
url = @model.urlFor('endorse')
endorsed = @model.get('endorsed')
data = { endorsed: not endorsed }
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"
success: (response, textStatus) =>
@model.set('endorsed', not endorsed)
abbreviateBody: ->
abbreviated = DiscussionUtil.abbreviateString @model.get('body'), 140
@model.set('abbreviatedBody', abbreviated)
expandPost: (event) =>
@expanded = true
@$el.addClass('expanded')
@$el.find('.post-body').html(@model.get('body'))
@showView.convertMath()
@$el.find('.expand-post').css('display', 'none')
@$el.find('.collapse-post').css('display', 'block')
@$el.find('.post-extended-content').show()
@makeWmdEditor "reply-body"
@renderAttrs()
if @$el.find('.loading').length
@renderResponses()
collapsePost: (event) ->
@expanded = false
@$el.removeClass('expanded')
@$el.find('.post-body').html(@model.get('abbreviatedBody'))
@showView.convertMath()
@$el.find('.collapse-post').css('display', 'none')
@$el.find('.post-extended-content').hide()
@$el.find('.expand-post').css('display', 'block')
createEditView: () ->
super()
@editView.bind "thread:update", @expandPost
@editView.bind "thread:update", @abbreviateBody
@editView.bind "thread:cancel_edit", @expandPost

View File

@@ -0,0 +1,26 @@
if Backbone?
class @DiscussionUserProfileView extends Backbone.View
# events:
# "":""
initialize: (options) ->
@renderThreads @$el, @collection
renderThreads: ($elem, threads) =>
#Content.loadContentInfos(response.annotated_content_info)
console.log threads
@discussion = new Discussion()
@discussion.reset(threads, {silent: false})
$discussion = $(Mustache.render $("script#_user_profile").html(), {'threads':threads})
console.log $discussion
$elem.append($discussion)
@threadviews = @discussion.map (thread) ->
new DiscussionThreadProfileView el: @$("article#thread_#{thread.id}"), model: thread
console.log @threadviews
_.each @threadviews, (dtv) -> dtv.render()
addThread: (thread, collection, options) =>
# TODO: When doing pagination, this will need to repaginate. Perhaps just reload page 1?
article = $("<article class='discussion-thread' id='thread_#{thread.id}'></article>")
@$('section.discussion > .threads').prepend(article)
threadView = new DiscussionThreadInlineView el: article, model: thread
threadView.render()
@threadviews.unshift threadView

View File

@@ -0,0 +1,68 @@
if Backbone?
class @NewPostInlineView extends Backbone.View
initialize: () ->
@topicId = @$(".topic").first().data("discussion-id")
@maxNameWidth = 100
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "new-post-body"
# TODO tags: commenting out til we know what to do with them
#@$(".new-post-tags").tagsInput DiscussionUtil.tagsInputOptions()
events:
"submit .new-post-form": "createPost"
# 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.
# Without this, clicking the search field would also close the menu.
ignoreClick: (event) ->
event.stopPropagation()
createPost: (event) ->
event.preventDefault()
title = @$(".new-post-title").val()
body = @$(".new-post-body").find(".wmd-input").val()
# TODO tags: commenting out til we know what to do with them
#tags = @$(".new-post-tags").val()
anonymous = false || @$("input.discussion-anonymous").is(":checked")
anonymous_to_peers = false || @$("input.discussion-anonymous-to-peers").is(":checked")
follow = false || @$("input.discussion-follow").is(":checked")
url = DiscussionUtil.urlFor('create_thread', @topicId)
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target) if event
url: url
type: "POST"
dataType: 'json'
async: false # TODO when the rest of the stuff below is made to work properly..
data:
title: title
body: body
# TODO tags: commenting out til we know what to do with them
#tags: tags
anonymous: anonymous
anonymous_to_peers: anonymous_to_peers
auto_subscribe: follow
error: DiscussionUtil.formErrorHandler(@$(".new-post-form-errors"))
success: (response, textStatus) =>
# TODO: Move this out of the callback, this makes it feel sluggish
thread = new Thread response['content']
DiscussionUtil.clearFormErrors(@$(".new-post-form-errors"))
@$el.hide()
@$(".new-post-title").val("").attr("prev-text", "")
@$(".new-post-body textarea").val("").attr("prev-text", "")
# TODO tags, commenting out til we know what to do with them
#@$(".new-post-tags").val("")
#@$(".new-post-tags").importTags("")
@collection.add thread

View File

@@ -0,0 +1,179 @@
if Backbone?
class @NewPostView extends Backbone.View
initialize: () ->
@dropdownButton = @$(".topic_dropdown_button")
@topicMenu = @$(".topic_menu_wrapper")
@menuOpen = @dropdownButton.hasClass('dropped')
@topicId = @$(".topic").first().data("discussion_id")
@topicText = @getFullTopicName(@$(".topic").first())
@maxNameWidth = 100
@setSelectedTopic()
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "new-post-body"
@$(".new-post-tags").tagsInput DiscussionUtil.tagsInputOptions()
events:
"submit .new-post-form": "createPost"
"click .topic_dropdown_button": "toggleTopicDropdown"
"click .topic_menu_wrapper": "setTopic"
"click .topic_menu_search": "ignoreClick"
# 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.
# Without this, clicking the search field would also close the menu.
ignoreClick: (event) ->
event.stopPropagation()
toggleTopicDropdown: (event) ->
event.stopPropagation()
if @menuOpen
@hideTopicDropdown()
else
@showTopicDropdown()
showTopicDropdown: () ->
@menuOpen = true
@dropdownButton.addClass('dropped')
@topicMenu.show()
$(".form-topic-drop-search-input").focus()
$("body").bind "keydown", @setActiveItem
$("body").bind "click", @hideTopicDropdown
# Set here because 1) the window might get resized and things could
# change and 2) can't set in initialize because the button is hidden
@maxNameWidth = @dropdownButton.width() * 0.9
# Need a fat arrow because hideTopicDropdown is passed as a callback to bind
hideTopicDropdown: () =>
@menuOpen = false
@dropdownButton.removeClass('dropped')
@topicMenu.hide()
$("body").unbind "keydown", @setActiveItem
$("body").unbind "click", @hideTopicDropdown
setTopic: (event) ->
$target = $(event.target)
if $target.data('discussion_id')
@topicText = $target.html()
@topicText = @getFullTopicName($target)
@topicId = $target.data('discussion_id')
@setSelectedTopic()
setSelectedTopic: ->
@dropdownButton.html(@fitName(@topicText) + ' <span class="drop-arrow">▾</span>')
getFullTopicName: (topicElement) ->
name = topicElement.html()
topicElement.parents('ul').not('.topic_menu').each ->
name = $(this).siblings('a').html() + ' / ' + name
return name
getNameWidth: (name) ->
test = $("<div>")
test.css
"font-size": @dropdownButton.css('font-size')
opacity: 0
position: 'absolute'
left: -1000
top: -1000
$("body").append(test)
test.html(name)
width = test.width()
test.remove()
return width
fitName: (name) ->
width = @getNameWidth(name)
if width < @maxNameWidth
return name
path = (x.replace /^\s+|\s+$/g, "" for x in name.split("/"))
while path.length > 1
path.shift()
partialName = "... / " + path.join(" / ")
if @getNameWidth(partialName) < @maxNameWidth
return partialName
rawName = path[0]
name = "... / " + rawName
while @getNameWidth(name) > @maxNameWidth
rawName = rawName[0...rawName.length-1]
name = "... / " + rawName + " ..."
return name
createPost: (event) ->
event.preventDefault()
title = @$(".new-post-title").val()
body = @$(".new-post-body").find(".wmd-input").val()
tags = @$(".new-post-tags").val()
anonymous = false || @$("input.discussion-anonymous").is(":checked")
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
$elem: $(event.target)
$loading: $(event.target) if event
url: url
type: "POST"
dataType: 'json'
async: false # TODO when the rest of the stuff below is made to work properly..
data:
title: title
body: body
tags: tags
anonymous: anonymous
anonymous_to_peers: anonymous_to_peers
auto_subscribe: follow
error: DiscussionUtil.formErrorHandler(@$(".new-post-form-errors"))
success: (response, textStatus) =>
# TODO: Move this out of the callback, this makes it feel sluggish
thread = new Thread response['content']
DiscussionUtil.clearFormErrors(@$(".new-post-form-errors"))
@$el.hide()
@$(".new-post-title").val("").attr("prev-text", "")
@$(".new-post-body textarea").val("").attr("prev-text", "")
@$(".new-post-tags").val("")
@$(".new-post-tags").importTags("")
@$(".wmd-preview p").html("")
@collection.add thread
setActiveItem: (event) ->
if event.which == 13
$(".topic_menu_wrapper .focused").click()
return
if event.which != 40 && event.which != 38
return
event.preventDefault()
items = $.makeArray($(".topic_menu_wrapper a").not(".hidden"))
index = items.indexOf($('.topic_menu_wrapper .focused')[0])
if event.which == 40
index = Math.min(index + 1, items.length - 1)
if event.which == 38
index = Math.max(index - 1, 0)
$(".topic_menu_wrapper .focused").removeClass("focused")
$(items[index]).addClass("focused")
itemTop = $(items[index]).parent().offset().top
scrollTop = $(".topic_menu").scrollTop()
itemFromTop = $(".topic_menu").offset().top - itemTop
scrollTarget = Math.min(scrollTop - itemFromTop, scrollTop)
scrollTarget = Math.max(scrollTop - itemFromTop - $(".topic_menu").height() + $(items[index]).height() + 20, scrollTarget)
$(".topic_menu").scrollTop(scrollTarget)

View File

@@ -0,0 +1,34 @@
if Backbone?
class @ResponseCommentShowView extends DiscussionContentView
tagName: "li"
render: ->
@template = _.template($("#response-comment-show-template").html())
params = @model.toJSON()
@$el.html(@template(params))
@initLocal()
@delegateEvents()
@renderAttrs()
@markAsStaff()
@$el.find(".timeago").timeago()
@convertMath()
@addReplyLink()
@
addReplyLink: () ->
if @model.hasOwnProperty('parent')
name = @model.parent.get('username') ? "anonymous"
html = "<a href='#comment_#{@model.parent.id}'>@#{name}</a>: "
p = @$('.response-body p:first')
p.prepend(html)
convertMath: ->
body = @$el.find(".response-body")
body.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight body.html()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, body[0]]
markAsStaff: ->
if DiscussionUtil.isStaff(@model.get("user_id"))
@$el.find("a.profile-link").after('<span class="staff-label">staff</span>')

View File

@@ -0,0 +1,31 @@
if Backbone?
class @ResponseCommentView extends DiscussionContentView
tagName: "li"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
@createShowView()
render: ->
@renderShowView()
@
createShowView: () ->
if @editView?
@editView.undelegateEvents()
@editView.$el.empty()
@editView = null
@showView = new ResponseCommentShowView(model: @model)
renderSubView: (view) ->
view.setElement(@$el)
view.render()
view.delegateEvents()
renderShowView: () ->
@renderSubView(@showView)

View File

@@ -0,0 +1,25 @@
if Backbone?
class @ThreadResponseEditView extends Backbone.View
events:
"click .post-update": "update"
"click .post-cancel": "cancel_edit"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
render: ->
@template = _.template($("#thread-response-edit-template").html())
@$el.html(@template(@model.toJSON()))
@delegateEvents()
DiscussionUtil.makeWmdEditor @$el, $.proxy(@$, @), "edit-post-body"
@
update: (event) ->
@trigger "response:update", event
cancel_edit: (event) ->
@trigger "response:cancel_edit", event

View File

@@ -0,0 +1,91 @@
if Backbone?
class @ThreadResponseShowView extends DiscussionContentView
events:
"click .vote-btn": "toggleVote"
"click .action-endorse": "toggleEndorse"
"click .action-delete": "delete"
"click .action-edit": "edit"
$: (selector) ->
@$el.find(selector)
initialize: ->
super()
@model.on "change", @updateModelDetails
renderTemplate: ->
@template = _.template($("#thread-response-show-template").html())
@template(@model.toJSON())
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
if window.user.voted(@model)
@$(".vote-btn").addClass("is-cast")
@renderAttrs()
@$el.find(".posted-details").timeago()
@convertMath()
@markAsStaff()
@
convertMath: ->
element = @$(".response-body")
element.html DiscussionUtil.postMathJaxProcessor DiscussionUtil.markdownWithHighlight element.html()
MathJax.Hub.Queue ["Typeset", MathJax.Hub, element[0]]
markAsStaff: ->
if DiscussionUtil.isStaff(@model.get("user_id"))
@$el.addClass("staff")
@$el.prepend('<div class="staff-banner">staff</div>')
toggleVote: (event) ->
event.preventDefault()
@$(".vote-btn").toggleClass("is-cast")
if @$(".vote-btn").hasClass("is-cast")
@vote()
else
@unvote()
vote: ->
url = @model.urlFor("upvote")
@$(".votes-count-number").html(parseInt(@$(".votes-count-number").html()) + 1)
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
unvote: ->
url = @model.urlFor("unvote")
@$(".votes-count-number").html(parseInt(@$(".votes-count-number").html()) - 1)
DiscussionUtil.safeAjax
$elem: @$(".discussion-vote")
url: url
type: "POST"
success: (response, textStatus) =>
if textStatus == 'success'
@model.set(response)
edit: (event) ->
@trigger "response:edit", event
delete: (event) ->
@trigger "response:delete", event
toggleEndorse: (event) ->
event.preventDefault()
if not @model.can('can_endorse')
return
$elem = $(event.target)
url = @model.urlFor('endorse')
endorsed = @model.get('endorsed')
data = { endorsed: not endorsed }
@model.set('endorsed', not endorsed)
@trigger "comment:endorse", not endorsed
DiscussionUtil.safeAjax
$elem: $elem
url: url
data: data
type: "POST"

View File

@@ -0,0 +1,188 @@
if Backbone?
class @ThreadResponseView extends DiscussionContentView
tagName: "li"
events:
"click .discussion-submit-comment": "submitComment"
"focus .wmd-input": "showEditorChrome"
$: (selector) ->
@$el.find(selector)
initialize: ->
@createShowView()
renderTemplate: ->
@template = _.template($("#thread-response-template").html())
templateData = @model.toJSON()
templateData.wmdId = @model.id ? (new Date()).getTime()
@template(templateData)
render: ->
@$el.html(@renderTemplate())
@delegateEvents()
@renderShowView()
@renderAttrs()
@renderComments()
@
afterInsert: ->
@makeWmdEditor "comment-body"
@hideEditorChrome()
hideEditorChrome: ->
@$('.wmd-button-row').hide()
@$('.wmd-preview').hide()
@$('.wmd-input').css({
height: '35px',
padding: '5px'
})
@$('.comment-post-control').hide()
showEditorChrome: ->
@$('.wmd-button-row').show()
@$('.wmd-preview').show()
@$('.comment-post-control').show()
@$('.wmd-input').css({
height: '125px',
padding: '10px'
})
renderComments: ->
comments = new Comments()
comments.comparator = (comment) ->
comment.get('created_at')
collectComments = (comment) ->
comments.add(comment)
children = new Comments(comment.get('children'))
children.each (child) ->
child.parent = comment
collectComments(child)
@model.get('comments').each collectComments
comments.each (comment) => @renderComment(comment, false, null)
renderComment: (comment) =>
comment.set('thread', @model.get('thread'))
view = new ResponseCommentView(model: comment)
view.render()
@$el.find(".comments .new-comment").before(view.el)
view
submitComment: (event) ->
event.preventDefault()
url = @model.urlFor('reply')
body = @getWmdContent("comment-body")
return if not body.trim().length
@setWmdContent("comment-body", "")
comment = new Comment(body: body, created_at: (new Date()).toISOString(), username: window.user.get("username"), user_id: window.user.get("id"), id:"unsaved")
view = @renderComment(comment)
@hideEditorChrome()
@trigger "comment:add", comment
DiscussionUtil.safeAjax
$elem: $(event.target)
url: url
type: "POST"
dataType: 'json'
data:
body: body
success: (response, textStatus) ->
comment.set(response.content)
view.render() # This is just to update the id for the most part, but might be useful in general
delete: (event) =>
event.preventDefault()
if not @model.can('can_delete')
return
if not confirm "Are you sure to delete this response? "
return
url = @model.urlFor('delete')
@model.remove()
@$el.remove()
$elem = $(event.target)
DiscussionUtil.safeAjax
$elem: $elem
url: url
type: "POST"
success: (response, textStatus) =>
createEditView: () ->
if @showView?
@showView.undelegateEvents()
@showView.$el.empty()
@showView = null
@editView = new ThreadResponseEditView(model: @model)
@editView.bind "response:update", @update
@editView.bind "response:cancel_edit", @cancelEdit
renderSubView: (view) ->
view.setElement(@$('.discussion-response'))
view.render()
view.delegateEvents()
renderEditView: () ->
@renderSubView(@editView)
hideCommentForm: () ->
@$('.comment-form').closest('li').hide()
showCommentForm: () ->
@$('.comment-form').closest('li').show()
createShowView: () ->
if @editView?
@editView.undelegateEvents()
@editView.$el.empty()
@editView = null
@showView = new ThreadResponseShowView(model: @model)
@showView.bind "response:delete", @delete
@showView.bind "response:edit", @edit
renderShowView: () ->
@renderSubView(@showView)
cancelEdit: (event) =>
event.preventDefault()
@createShowView()
@renderShowView()
@showCommentForm()
edit: (event) =>
@createEditView()
@renderEditView()
@hideCommentForm()
update: (event) =>
newBody = @editView.$(".edit-post-body textarea").val()
url = DiscussionUtil.urlFor('update_comment', @model.id)
DiscussionUtil.safeAjax
$elem: $(event.target)
$loading: $(event.target) if event
url: url
type: "POST"
dataType: 'json'
async: false # TODO when the rest of the stuff below is made to work properly..
data:
body: newBody
error: DiscussionUtil.formErrorHandler(@$(".edit-post-form-errors"))
success: (response, textStatus) =>
# TODO: Move this out of the callback, this makes it feel sluggish
@editView.$(".edit-post-body textarea").val("").attr("prev-text", "")
@editView.$(".wmd-preview p").html("")
@model.set
body: newBody
@createShowView()
@renderShowView()
@showCommentForm()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Some files were not shown because too many files have changed in this diff Show More