Merge remote-tracking branch 'origin/master' into feature/bridger/course_grading
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -330,11 +330,6 @@ section.cal {
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
width: flex-grid(5) + flex-gutter();
|
||||
|
||||
+ section.main-content {
|
||||
width: flex-grid(7);
|
||||
}
|
||||
}
|
||||
|
||||
> header {
|
||||
|
||||
@@ -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)
|
||||
|
||||
8
common/djangoapps/track/admin.py
Normal file
@@ -0,0 +1,8 @@
|
||||
'''
|
||||
django admin pages for courseware model
|
||||
'''
|
||||
|
||||
from track.models import *
|
||||
from django.contrib import admin
|
||||
|
||||
admin.site.register(TrackingLog)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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++){
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
BIN
common/static/images/spinner-on-grey.gif
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.9 KiB |
412
common/static/js/vendor/flot/jquery.flot.axislabels.js
vendored
Normal 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);
|
||||
@@ -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"):
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
'', '', ''
|
||||
|
||||
@@ -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 = {'"': '"'}
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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('<','<')
|
||||
#msg += ('POST=%s' % dict(request.POST)).replace('<','<')
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
0
lms/djangoapps/psychometrics/__init__.py
Normal file
8
lms/djangoapps/psychometrics/admin.py
Normal file
@@ -0,0 +1,8 @@
|
||||
'''
|
||||
django admin pages for courseware model
|
||||
'''
|
||||
|
||||
from psychometrics.models import *
|
||||
from django.contrib import admin
|
||||
|
||||
admin.site.register(PsychometricData)
|
||||
0
lms/djangoapps/psychometrics/management/__init__.py
Normal 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()
|
||||
45
lms/djangoapps/psychometrics/models.py
Normal 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)
|
||||
|
||||
338
lms/djangoapps/psychometrics/psychoanalyze.py
Normal 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
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
124
lms/static/coffee/src/discussion/discussion_module_view.coffee
Normal 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))
|
||||
45
lms/static/coffee/src/discussion/discussion_router.coffee
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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(/^\>\;/gm, ">")
|
||||
converter = Markdown.getMathCompatibleConverter()
|
||||
@unescapeHighlightTag @stripLatexHighlight converter.makeHtml text
|
||||
text = @unescapeHighlightTag @stripLatexHighlight converter.makeHtml text
|
||||
return text.replace(/^>/gm,">")
|
||||
|
||||
@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) + '...'
|
||||
|
||||
|
||||
@@ -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, @)
|
||||
@@ -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
|
||||
@@ -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(/<mark>/g, "<mark>").replace(/<\/mark>/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)
|
||||
@@ -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')
|
||||
@@ -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(/<mark>/g, "<mark>").replace(/<\/mark>/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)
|
||||
@@ -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) =>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
179
lms/static/coffee/src/discussion/views/new_post_view.coffee
Normal 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)
|
||||
@@ -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>')
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
|
||||
BIN
lms/static/images/browse-icon.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
lms/static/images/calendar-icon.png
Normal file
|
After Width: | Height: | Size: 101 B |
BIN
lms/static/images/comment-icon-bottoms.png
Normal file
|
After Width: | Height: | Size: 960 B |
BIN
lms/static/images/endorse-icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
lms/static/images/follow-dog-ear.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
lms/static/images/following-flag.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
lms/static/images/link-icon.png
Normal file
|
After Width: | Height: | Size: 326 B |
BIN
lms/static/images/moderator-delete-icon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
lms/static/images/moderator-edit-icon.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
lms/static/images/nested-icon.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
lms/static/images/new-post-icon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
lms/static/images/new-post-icons-full.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
lms/static/images/new-post-icons.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
lms/static/images/opencourseware.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
lms/static/images/press/baltsun_logo_178x138.jpg
Executable file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
lms/static/images/press/bostinno_logo_178x138.jpg
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
lms/static/images/press/bostonmag_logo_178x138.jpg
Executable file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
lms/static/images/press/csmonitor_logo_178x138.jpg
Executable file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
lms/static/images/press/insidehighered_logo_178x138.jpg
Executable file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
lms/static/images/press/itbriefing_logo_178x138.jpg
Executable file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
lms/static/images/press/radioboston_logo_178x138.jpg
Executable file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
lms/static/images/press/smartplanet_logo_178x138.jpg
Executable file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
lms/static/images/press/techreview_logo_178x138.jpg
Executable file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
lms/static/images/press/thetech_logo_178x138.jpg
Executable file
|
After Width: | Height: | Size: 3.9 KiB |