From eb42cb1f387967eb40eefdba50890f1f71de9788 Mon Sep 17 00:00:00 2001 From: Robert Raposa Date: Fri, 11 Dec 2015 10:06:04 -0500 Subject: [PATCH 01/67] Add safe template linter - Initial lint of Mako templates - Initial lint of Underscore.js templates --- scripts/__init__.py | 0 scripts/safe_template_checker.py | 498 ++++++++++++++++++++ scripts/tests/__init__.py | 0 scripts/tests/test_safe_template_checker.py | 313 ++++++++++++ 4 files changed, 811 insertions(+) create mode 100644 scripts/__init__.py create mode 100755 scripts/safe_template_checker.py create mode 100644 scripts/tests/__init__.py create mode 100644 scripts/tests/test_safe_template_checker.py diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/safe_template_checker.py b/scripts/safe_template_checker.py new file mode 100755 index 0000000000..8fc5363caa --- /dev/null +++ b/scripts/safe_template_checker.py @@ -0,0 +1,498 @@ +#!/usr/bin/env python +""" +a tool to check if templates are safe +""" +from enum import Enum +import os +import re +import sys + +_skip_dirs = ( + '/node_modules', + '/vendor', + '/spec', + '/.pycharm_helpers', + '/test_root', + '/reports/diff_quality', + '/common/static/xmodule/modules', +) +_skip_mako_dirs = _skip_dirs +_skip_underscore_dirs = _skip_dirs + ('/test',) + + +def _is_skip_dir(skip_dirs, directory): + for skip_dir in skip_dirs: + if (directory.find(skip_dir + '/') >= 0) or directory.endswith(skip_dir): + return True + return False + + +def _load_file(self, file_full_path): + input_file = open(file_full_path, 'r') + try: + file_contents = input_file.read() + finally: + input_file.close() + + if not file_contents: + return False + return file_contents.decode(encoding='utf-8') + + +def _get_line_breaks(self, file_string): + line_breaks = [0] + index = 0 + while True: + index = file_string.find('\n', index) + if index < 0: + break + index += 1 + line_breaks.append(index) + return line_breaks + + +def _get_line_number(self, line_breaks, index): + current_line_number = 0 + for line_break_index in line_breaks: + if line_break_index <= index: + current_line_number += 1 + else: + break + return current_line_number + + +def _get_line(self, file_string, line_breaks, line_number): + start_index = line_breaks[line_number - 1] + if len(line_breaks) == line_number: + line = file_string[start_index:] + else: + end_index = line_breaks[line_number] + line = file_string[start_index:end_index - 1] + return line.encode(encoding='utf-8') + + +def _get_column_number(self, line_breaks, line_number, index): + start_index = line_breaks[line_number - 1] + column = index - start_index + 1 + return column + + +class Rules(Enum): + mako_missing_default = ('mako-missing-default', 'The default page directive with h filter is missing.') + mako_unparsable_expression = ('mako-unparsable-expression', 'The expression could not be properly parsed.') + mako_unwanted_html_filter = ('mako-unwanted-html-filter', 'Remove explicit h filters when it is provided by the page directive.') + mako_invalid_html_filter = ('mako-invalid-html-filter', 'The expression is using an invalid filter in an HTML context.') + mako_invalid_js_filter = ('mako-invalid-js-filter', 'The expression is using an invalid filter in a JavaScript context.') + mako_js_string_missing_quotes = ('mako-js-string-missing-quotes', 'An expression using the js_escape_string filter must have surrounding quotes.') + + underscore_not_escaped = ('underscore-not-escaped', 'Expressions should be escaped using <%- expression %>.') + + def __init__(self, rule_id, rule_summary): + self.rule_id = rule_id + self.rule_summary = rule_summary + + +class BrokenRule(object): + + def __init__(self, rule): + self.rule = rule + self.full_path = '' + + def prepare_results(self, full_path, file_string, line_breaks): + self.full_path = full_path + + def print_results(self, options): + print "{}: {}".format(self.full_path, self.rule.rule_id) + + +class BrokenExpressionRule(BrokenRule): + + def __init__(self, rule, expression): + super(BrokenExpressionRule, self).__init__(rule) + self.expression = expression + self.start_line = 0 + self.start_column = 0 + self.end_line = 0 + self.end_column = 0 + self.lines = [] + + def prepare_results(self, full_path, file_string, line_breaks): + self.full_path = full_path + start_index = self.expression['start_index'] + self.start_line = _get_line_number(self, line_breaks, start_index) + self.start_column = _get_column_number(self, line_breaks, self.start_line, start_index) + end_index = self.expression['end_index'] + if end_index > 0: + self.end_line = _get_line_number(self, line_breaks, end_index) + self.end_column = _get_column_number(self, line_breaks, self.end_line, end_index) + else: + self.end_line = self.start_line + self.end_column = '?' + for line_number in range(self.start_line, self.end_line + 1): + self.lines.append(_get_line(self, file_string, line_breaks, line_number)) + + def print_results(self, options): + for line_number in range(self.start_line, self.end_line + 1): + if (line_number == self.start_line): + column = self.start_column + rule_id = self.rule.rule_id + ":" + else: + column = 1 + rule_id = " " * (len(self.rule.rule_id) + 1) + print "{}: {}:{}: {} {}".format( + self.full_path, + line_number, + column, + rule_id, + self.lines[line_number - self.start_line - 1] + ) + + +class FileResults(object): + + def __init__(self, full_path): + self.full_path = full_path + self.errors = [] + + def prepare_results(self, file_string): + line_breaks = _get_line_breaks(self, file_string) + for error in self.errors: + error.prepare_results(self.full_path, file_string, line_breaks) + + def print_results(self, options): + if options['is_quiet']: + print self.full_path + else: + for error in self.errors: + error.print_results(options) + + +class MakoTemplateChecker(object): + + _skip_mako_dirs = _skip_dirs + + _results = [] + + def process_file(self, directory, file_name): + """ + Process file to determine if it is a Mako template file and + if it is safe. + + Arguments: + directory (string): The directory of the file to be checked + file_name (string): A filename for a potential Mako file + + Side effects: + Adds detailed results to internal data structure for + later reporting + + """ + if not self._is_mako_directory(directory): + return + + # TODO: When safe-by-default is turned on at the platform level, will we: + # 1. Turn it on for .html only, or + # 2. Turn it on for all files, and have different rulesets that have + # different rules of .xml, .html, .js, .txt Mako templates (e.g. use + # the n filter to turn off h for some of these)? + # For now, we only check .html and .xml files + if not (file_name.lower().endswith('.html') or file_name.lower().endswith('.xml')): + return + + self._load_and_check_mako_file_is_safe(directory + '/' + file_name) + + def print_results(self, options): + for result in self._results: + result.print_results(options) + + def _is_mako_directory(self, directory): + if _is_skip_dir(self._skip_mako_dirs, directory): + return False + + if (directory.find('/templates/') >= 0) or directory.endswith('/templates'): + return True + + return False + + def _load_and_check_mako_file_is_safe(self, mako_file_full_path): + mako_template = _load_file(self, mako_file_full_path) + results = FileResults(mako_file_full_path) + self._check_mako_file_is_safe(mako_template, results) + if len(results.errors) > 0: + self._results.append(results) + + def _check_mako_file_is_safe(self, mako_template, results): + has_page_default = self._has_page_default(mako_template, results) + if not has_page_default: + results.errors.append(BrokenRule(Rules.mako_missing_default)) + self._check_mako_expressions(mako_template, has_page_default, results) + results.prepare_results(mako_template) + + def _has_page_default(self, mako_template, results): + page_h_filter_regex = re.compile('<%page expression_filter=(?:"h"|\'h\')\s*/>') + page_match = page_h_filter_regex.search(mako_template) + return page_match + + def _check_mako_expressions(self, mako_template, has_page_default, results): + expressions = self._find_mako_expressions(mako_template) + contexts = self._get_contexts(mako_template) + for expression in expressions: + if expression['expression'] is None: + results.errors.append(BrokenExpressionRule( + Rules.mako_unparsable_expression, expression + )) + continue + + context = self._get_context(contexts, expression['start_index']) + self._check_filters(mako_template, expression, context, has_page_default, results) + + def _check_filters(self, mako_template, expression, context, has_page_default, results): + # finds "| n, h}" when given "${x | n, h}" + filters_regex = re.compile('\|[a-zA-Z_,\s]*\}') + filters_match = filters_regex.search(expression['expression']) + if filters_match is None: + if context == 'javascript': + results.errors.append(BrokenExpressionRule( + Rules.mako_invalid_js_filter, expression + )) + return + + filters = filters_match.group()[1:-1].replace(" ", "").split(",") + if context == 'html': + if (len(filters) == 1) and (filters[0] == 'h'): + if has_page_default: + # suppress this error if the page default hasn't been set, + # otherwise the template might get less safe + results.errors.append(BrokenExpressionRule( + Rules.mako_unwanted_html_filter, expression + )) + elif (len(filters) == 2) and (filters[0] == 'n') and (filters[1] == 'dump_html_escaped_json'): + # {x | n, dump_html_escaped_json} is valid + pass + else: + results.errors.append(BrokenExpressionRule( + Rules.mako_invalid_html_filter, expression + )) + + else: + if (len(filters) == 2) and (filters[0] == 'n') and (filters[1] == 'dump_js_escaped_json'): + # {x | n, dump_js_escaped_json} is valid + pass + elif (len(filters) == 2) and (filters[0] == 'n') and (filters[1] == 'js_escaped_string'): + # {x | n, js_escaped_string} is valid, if surrounded by quotes + prior_character = mako_template[expression['start_index'] - 1] + next_character = mako_template[expression['end_index'] + 1] + has_surrounding_quotes = (prior_character == '\'' and next_character == '\'') or \ + (prior_character == '"' and next_character == '"') + if not has_surrounding_quotes: + results.errors.append(BrokenExpressionRule( + Rules.mako_js_string_missing_quotes, expression + )) + else: + results.errors.append(BrokenExpressionRule( + Rules.mako_invalid_js_filter, expression + )) + + def _get_contexts(self, mako_template): + """ + Returns a data structure that represents the indices at which the + template changes from HTML context to JavaScript and back. + + Return: + A list of dicts where each dict contains the 'index' of the context + and the context 'type' (e.g. 'html' or 'javascript'). + """ + contexts_re = re.compile(r""" + | # script tag start + | # script tag end + <%static:require_module.*?>| # require js script tag start + # require js script tag end""", re.VERBOSE + re.IGNORECASE) + media_type_re = re.compile(r"""type=['"].*?['"]""", re.IGNORECASE) + + contexts = [{'index': 0, 'type': 'html'}] + for context in contexts_re.finditer(mako_template): + match_string = context.group().lower() + if match_string.startswith("= 0) and (open_curly_index < end_curly_index): + if mako_template[open_curly_index - 1] == '$': + # assume if we find "${" it is the start of the next expression + # and we have a parse error + return -1 + else: + return self._find_balanced_end_curly(mako_template, open_curly_index + 1, num_open_curlies + 1) + + if num_open_curlies == 0: + return end_curly_index + else: + return self._find_balanced_end_curly(mako_template, end_curly_index + 1, num_open_curlies - 1) + + +class UnderscoreTemplateChecker(object): + + _skip_underscore_dirs = _skip_dirs + + _results = [] + + def process_file(self, directory, file_name): + """ + Process file to determine if it is an Underscore template file and + if it is safe. + + Arguments: + directory (string): The directory of the file to be checked + file_name (string): A filename for a potential underscore file + + Side effects: + Adds detailed results to internal data structure for + later reporting + + """ + if not self._is_underscore_directory(directory): + return + + if not file_name.lower().endswith('.underscore'): + return + + self._load_and_check_underscore_file_is_safe(directory + '/' + file_name) + + def print_results(self, options): + for result in self._results: + result.print_results(options) + + def _is_underscore_directory(self, directory): + if _is_skip_dir(self._skip_underscore_dirs, directory): + return False + + return True + + def _load_and_check_underscore_file_is_safe(self, file_full_path): + underscore_template = _load_file(self, file_full_path) + results = FileResults(file_full_path) + self._check_underscore_file_is_safe(underscore_template, results) + if len(results.errors) > 0: + self._results.append(results) + + def _check_underscore_file_is_safe(self, underscore_template, results): + self._check_underscore_expressions(underscore_template, results) + results.prepare_results(underscore_template) + + def _check_underscore_expressions(self, underscore_template, results): + expressions = self._find_unescaped_expressions(underscore_template) + for expression in expressions: + results.errors.append(BrokenExpressionRule( + Rules.underscore_not_escaped, expression + )) + + def _find_unescaped_expressions(self, underscore_template): + unescaped_expression_regex = re.compile("<%=.*?%>") + + expressions = [] + for match in unescaped_expression_regex.finditer(underscore_template): + expression = { + 'start_index': match.start(), + 'end_index': match.end(), + 'expression': match.group(), + } + expressions.append(expression) + + return expressions + + +def _process_current_walk(current_walk, template_checkers): + walk_directory = current_walk[0] + walk_files = current_walk[2] + for walk_file in walk_files: + for template_checker in template_checkers: + template_checker.process_file(walk_directory, walk_file) + + +def _process_os_walk(starting_dir, template_checkers): + for current_walk in os.walk(starting_dir): + _process_current_walk(current_walk, template_checkers) + + +def main(): + #TODO: Use click + is_quiet = '--quiet' in sys.argv + + options = { + 'is_quiet': is_quiet, + } + + template_checkers = [MakoTemplateChecker(), UnderscoreTemplateChecker()] + _process_os_walk('.', template_checkers) + + for template_checker in template_checkers: + template_checker.print_results(options) + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/scripts/tests/test_safe_template_checker.py b/scripts/tests/test_safe_template_checker.py new file mode 100644 index 0000000000..b399d2ea2d --- /dev/null +++ b/scripts/tests/test_safe_template_checker.py @@ -0,0 +1,313 @@ +""" +Tests for safe_template_checker.py +""" +from ddt import ddt, data +import textwrap +from unittest import TestCase + +from ..safe_template_checker import ( + FileResults, MakoTemplateChecker, Rules +) + +@ddt +class TestMakoTemplateChecker(TestCase): + """ + Test MakoTemplateChecker + """ + + @data( + {'directory': 'lms/templates', 'expected': True}, + {'directory': 'lms/templates/support', 'expected': True}, + {'directory': 'lms/templates/support', 'expected': True}, + {'directory': './test_root/staticfiles/templates', 'expected': False}, + {'directory': './some/random/path', 'expected': False}, + ) + def test_is_mako_directory(self, data): + """ + Test _is_mako_directory correctly determines mako directories + """ + checker = MakoTemplateChecker() + + self.assertEqual(checker._is_mako_directory(data['directory']), data['expected']) + + def test_check_page_default_with_default_provided(self): + """ + Test _check_mako_file_is_safe with default causes no error + """ + checker = MakoTemplateChecker() + results = FileResults('') + mako_template = """ + <%page expression_filter="h"/> + """ + + checker._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.errors), 0) + + def test_check_page_default_with_no_default_provided(self): + """ + Test _check_mako_file_is_safe with no default causes error + """ + checker = MakoTemplateChecker() + results = FileResults('') + mako_template = "" + + checker._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.errors), 1) + self.assertEqual(results.errors[0].rule, Rules.mako_missing_default) + + def test_check_mako_expressions_in_html(self): + """ + Test _check_mako_file_is_safe in html context provides appropriate errors + """ + checker = MakoTemplateChecker() + results = FileResults('') + + mako_template = textwrap.dedent(""" + <%page expression_filter="h"/> + ${x} + ${'{{unbalanced-nested'} + ${x | n} + ${x | h} + ${x | n, dump_html_escaped_json} + ${x | n, dump_js_escaped_json} + """) + + checker._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.errors), 4) + self.assertEqual(results.errors[0].rule, Rules.mako_unparsable_expression) + start_index = results.errors[0].expression['start_index'] + self.assertEqual(mako_template[start_index:start_index + 24], "${'{{unbalanced-nested'}") + self.assertEqual(results.errors[1].rule, Rules.mako_invalid_html_filter) + self.assertEqual(results.errors[1].expression['expression'], "${x | n}") + self.assertEqual(results.errors[2].rule, Rules.mako_unwanted_html_filter) + self.assertEqual(results.errors[2].expression['expression'], "${x | h}") + self.assertEqual(results.errors[3].rule, Rules.mako_invalid_html_filter) + self.assertEqual(results.errors[3].expression['expression'], "${x | n, dump_js_escaped_json}") + + def test_check_mako_expressions_in_html_without_default(self): + """ + Test _check_mako_file_is_safe in html context without the page level + default h filter suppresses expression level error + """ + checker = MakoTemplateChecker() + results = FileResults('') + + mako_template = textwrap.dedent(""" + ${x | h} + """) + + checker._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.errors), 1) + self.assertEqual(results.errors[0].rule, Rules.mako_missing_default) + + def test_check_mako_expressions_in_javascript(self): + """ + Test _check_mako_file_is_safe in JavaScript script context provides + appropriate errors + """ + checker = MakoTemplateChecker() + results = FileResults('') + + mako_template = textwrap.dedent(""" + <%page expression_filter="h"/> + + """) + + checker._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.errors), 6) + self.assertEqual(results.errors[0].rule, Rules.mako_invalid_js_filter) + self.assertEqual(results.errors[0].expression['expression'], "${x}") + self.assertEqual(results.errors[1].rule, Rules.mako_unparsable_expression) + start_index = results.errors[1].expression['start_index'] + self.assertEqual(mako_template[start_index:start_index + 24], "${'{{unbalanced-nested'}") + self.assertEqual(results.errors[2].rule, Rules.mako_invalid_js_filter) + self.assertEqual(results.errors[2].expression['expression'], "${x | n}") + self.assertEqual(results.errors[3].rule, Rules.mako_invalid_js_filter) + self.assertEqual(results.errors[3].expression['expression'], "${x | h}") + self.assertEqual(results.errors[4].rule, Rules.mako_invalid_js_filter) + self.assertEqual(results.errors[4].expression['expression'], "${x | n, dump_html_escaped_json}") + self.assertEqual(results.errors[5].rule, Rules.mako_js_string_missing_quotes) + self.assertEqual(results.errors[5].expression['expression'], "${x-missing-quotes | n, js_escaped_string}") + + def test_check_mako_expressions_in_require_js(self): + """ + Test _check_mako_file_is_safe in JavaScript require context provides + appropriate errors + """ + checker = MakoTemplateChecker() + results = FileResults('') + + mako_template = textwrap.dedent(""" + <%page expression_filter="h"/> + <%static:require_module module_name="${x}" class_name="TestFactory"> + ${x} + ${x | n, js_escaped_string} + + """) + + checker._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.errors), 2) + self.assertEqual(results.errors[0].rule, Rules.mako_invalid_js_filter) + self.assertEqual(results.errors[0].expression['expression'], "${x}") + self.assertEqual(results.errors[1].rule, Rules.mako_js_string_missing_quotes) + self.assertEqual(results.errors[1].expression['expression'], "${x | n, js_escaped_string}") + + @data( + {'media_type': 'text/javascript', 'expected_errors': 0}, + {'media_type': 'text/ecmascript', 'expected_errors': 0}, + {'media_type': 'application/ecmascript', 'expected_errors': 0}, + {'media_type': 'application/javascript', 'expected_errors': 0}, + {'media_type': 'text/template', 'expected_errors': 1}, + {'media_type': 'unknown/type', 'expected_errors': 1}, + ) + def test_check_mako_expressions_in_script_type(self, data): + """ + Test _check_mako_file_is_safe in script tag with different media types + """ + checker = MakoTemplateChecker() + results = FileResults('') + + mako_template = textwrap.dedent(""" + <%page expression_filter="h"/> + + """).format(data['media_type']) + + checker._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.errors), data['expected_errors']) + + def test_check_mako_expressions_in_mixed_contexts(self): + """ + Test _check_mako_file_is_safe in mixed contexts provides + appropriate errors + """ + checker = MakoTemplateChecker() + results = FileResults('') + + mako_template = textwrap.dedent(""" + <%page expression_filter="h"/> + ${x | h} + + ${x | h} + <%static:require_module module_name="${x}" class_name="TestFactory"> + ${x | h} + + ${x | h} + """) + + checker._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.errors), 5) + self.assertEqual(results.errors[0].rule, Rules.mako_unwanted_html_filter) + self.assertEqual(results.errors[1].rule, Rules.mako_invalid_js_filter) + self.assertEqual(results.errors[2].rule, Rules.mako_unwanted_html_filter) + self.assertEqual(results.errors[3].rule, Rules.mako_invalid_js_filter) + self.assertEqual(results.errors[4].rule, Rules.mako_unwanted_html_filter) + + def test_expression_detailed_results(self): + """ + Test _check_mako_file_is_safe provides detailed results, including line + numbers, columns, and line + """ + checker = MakoTemplateChecker() + results = FileResults('') + + mako_template = textwrap.dedent(""" + ${x | n} +
${( + 'tabbed-multi-line-expression' + ) | n}
+ ${'{{unbalanced-nested' | n} + """) + + checker._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.errors), 4) + self.assertEqual(results.errors[0].rule, Rules.mako_missing_default) + + self.assertEqual(results.errors[1].start_line, 2) + self.assertEqual(results.errors[1].start_column, 1) + self.assertEqual(results.errors[1].end_line, 2) + self.assertEqual(results.errors[1].end_column, 8) + self.assertEqual(len(results.errors[1].lines), 1) + self.assertEqual(results.errors[1].lines[0], "${x | n}") + + self.assertEqual(results.errors[2].start_line, 3) + self.assertEqual(results.errors[2].start_column, 10) + self.assertEqual(results.errors[2].end_line, 5) + self.assertEqual(results.errors[2].end_column, 10) + self.assertEqual(len(results.errors[2].lines), 3) + self.assertEqual(results.errors[2].lines[0], "
${(") + self.assertEqual(results.errors[2].lines[1], + " 'tabbed-multi-line-expression'" + ) + self.assertEqual(results.errors[2].lines[2], " ) | n}
") + + self.assertEqual(results.errors[3].start_line, 6) + self.assertEqual(results.errors[3].start_column, 1) + self.assertEqual(results.errors[3].end_line, 6) + self.assertEqual(results.errors[3].end_column, "?") + self.assertEqual(len(results.errors[3].lines), 1) + self.assertEqual(results.errors[3].lines[0], + "${'{{unbalanced-nested' | n}" + ) + + + def test_find_mako_expressions(self): + """ + Test _find_mako_expressions finds appropriate expressions + """ + checker = MakoTemplateChecker() + + mako_template = textwrap.dedent(""" + ${x} + ${tabbed-x} + ${( + 'tabbed-multi-line-expression' + )} + ${'{{unbalanced-nested'} + ${'{{nested}}'} +
no expression
+ """) + + expressions = checker._find_mako_expressions(mako_template) + + self.assertEqual(len(expressions), 5) + self._validate_expression(mako_template, expressions[0], '${x}') + self._validate_expression(mako_template, expressions[1], '${tabbed-x}') + self._validate_expression(mako_template, expressions[2], "${(\n 'tabbed-multi-line-expression'\n )}") + + # won't parse unbalanced nested {}'s + unbalanced_expression = "${'{{unbalanced-nested'}" + self.assertEqual(expressions[3]['end_index'], -1) + start_index = expressions[3]['start_index'] + self.assertEqual(mako_template[start_index:start_index + len(unbalanced_expression)], unbalanced_expression) + self.assertEqual(expressions[3]['expression'], None) + + self._validate_expression(mako_template, expressions[4], "${'{{nested}}'}") + + def _validate_expression(self, template_string, expression, expected_expression): + start_index = expression['start_index'] + end_index = expression['end_index'] + self.assertEqual(template_string[start_index:end_index + 1], expected_expression) + self.assertEqual(expression['expression'], expected_expression) From 8a9e81fdda941c6e994443ade16b40b2960dcfeb Mon Sep 17 00:00:00 2001 From: Amir Qayyum Khan Date: Mon, 8 Feb 2016 17:37:00 +0500 Subject: [PATCH 02/67] Added validation to ccx create form, If ccxcon url is set then app will ask user to create ccx from ccxcon app --- lms/djangoapps/ccx/tests/test_views.py | 24 ++++++++++++++++++++ lms/djangoapps/ccx/utils.py | 23 +++++++++++++++++++ lms/djangoapps/ccx/views.py | 9 ++++++++ lms/templates/ccx/coach_dashboard.html | 31 ++++++++++++++++++++++---- 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/ccx/tests/test_views.py b/lms/djangoapps/ccx/tests/test_views.py index dfa1deaff0..44b9bc64e2 100644 --- a/lms/djangoapps/ccx/tests/test_views.py +++ b/lms/djangoapps/ccx/tests/test_views.py @@ -17,6 +17,7 @@ from courseware.tests.helpers import LoginEnrollmentTestCase from courseware.tabs import get_course_tab_list from django.conf import settings from django.core.urlresolvers import reverse, resolve +from django.utils.translation import ugettext as _ from django.utils.timezone import UTC from django.test.utils import override_settings from django.test import RequestFactory @@ -264,6 +265,29 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): '
% endif
- +

+ - -
- +
+ +
+
+
+ +
%endif @@ -155,4 +160,22 @@ from django.core.urlresolvers import reverse $('#ccx_std_list_messages')[0].focus(); } }); + function validateForm(form) { + var newCCXName = $(form).find('#ccx_name').val(); + var $errorMessage = $('#ccx-create-message'); + var hasCcxConnector = ${has_ccx_connector}; + + if (!newCCXName && !hasCcxConnector) { + $errorMessage.text("${_('Please enter a valid CCX name.')}"); + $errorMessage.show(); + return false; + } else if (hasCcxConnector) { + $errorMessage.html('${use_ccx_con_error_message}'); + $errorMessage.show(); + return false; + } + + $errorMessage.hide(); + return true; + } From dd2a20367742b3cef8d9595f3ac70e9a76160686 Mon Sep 17 00:00:00 2001 From: cahrens Date: Fri, 12 Feb 2016 14:28:36 -0500 Subject: [PATCH 03/67] Upgrade underscore to newest version. FEDX-24 --- .../js/certificates/views/signatory_editor.js | 4 +- .../js/models/settings/course_grader.js | 7 +- cms/static/js/models/uploads.js | 5 +- cms/static/js/utils/drag_and_drop.js | 12 +- cms/static/js/views/assets.js | 2 +- cms/static/js/views/edit_chapter.js | 8 +- cms/static/js/views/manage_users_and_roles.js | 12 +- cms/static/js/views/overview.js | 242 ------------------ cms/static/js/views/paging_header.js | 4 +- cms/static/js/views/show_textbook.js | 7 +- .../views/video/transcripts/file_uploader.js | 4 +- .../video/transcripts/message_manager.js | 4 +- .../js/validation-error-modal.underscore | 22 +- .../js/spec/video/video_player_spec.js | 22 +- .../xmodule/xmodule/js/src/html/imageModal.js | 2 +- .../thread_response_show_view_spec.coffee | 2 +- .../src/discussion/discussion_filter.coffee | 28 -- .../views/discussion_thread_edit_view.js | 2 +- .../views/discussion_thread_list_view.coffee | 6 +- .../views/discussion_topic_menu_view.js | 40 ++- .../src/discussion/views/new_post_view.coffee | 2 +- .../collections/paging_collection.js | 3 + .../common/js/components/utils/view_utils.js | 2 +- .../js/components/views/paginated_view.js | 2 +- .../js/components/views/paging_footer.js | 2 +- .../js/components/views/paging_header.js | 2 +- .../js/components/views/search_field.js | 2 +- .../discussion/forum-actions.underscore | 4 +- .../response-comment-show.underscore | 2 +- .../thread-response-show.underscore | 2 +- .../discussion/thread-show.underscore | 2 +- common/static/js/src/string_utils.js | 4 +- common/static/js/src/tooltip_manager.js | 2 +- .../test/acceptance/pages/studio/overview.py | 6 - .../static/support/js/views/certificates.js | 2 +- .../static/support/js/views/enrollment.js | 2 +- .../support/js/views/enrollment_modal.js | 2 +- .../static/teams/js/views/team_profile.js | 4 +- .../static/teams/js/views/topic_teams.js | 2 +- .../instructor_dashboard/membership.coffee | 2 +- .../instructor_dashboard/send_email.coffee | 2 +- .../instructor_dashboard/student_admin.coffee | 28 +- lms/static/js/Markdown.Editor.js | 2 +- .../models/certificate_exception.js | 3 +- .../models/certificate_invalidation.js | 4 +- .../views/certificate_invalidation_view.js | 4 +- .../views/certificate_whitelist_editor.js | 4 +- lms/static/js/commerce/views/receipt_view.js | 4 +- .../js/components/tabbed/views/tabbed_view.js | 2 +- lms/static/js/dashboard/donation.js | 2 +- .../views/financial_assistance_form_view.js | 4 +- .../edxnotes/plugins/accessibility_spec.js | 4 +- .../edxnotes/plugins/caret_navigation_spec.js | 4 +- .../js/spec/edxnotes/plugins/events_spec.js | 4 +- .../js/spec/edxnotes/plugins/scroller_spec.js | 4 +- .../spec/edxnotes/views/notes_factory_spec.js | 4 +- .../views/notes_visibility_factory_spec.js | 4 +- .../js/spec/edxnotes/views/shim_spec.js | 4 +- .../views/visibility_decorator_spec.js | 4 +- lms/static/js/spec/main.js | 7 - .../spec/verify_student/reverify_view_spec.js | 1 - lms/static/js/staff_debug_actions.js | 24 +- lms/static/js/student_account/account.js | 4 +- .../js/student_account/views/AccessView.js | 2 +- .../js/student_account/views/FormView.js | 4 +- .../student_account/views/HintedLoginView.js | 2 +- .../views/InstitutionLoginView.js | 2 +- .../js/student_account/views/LoginView.js | 2 +- .../js/student_account/views/RegisterView.js | 2 +- .../views/account_settings_view.js | 2 +- .../views/learner_profile_view.js | 2 +- .../js/verify_student/views/error_view.js | 3 +- .../verify_student/views/image_input_view.js | 2 +- .../views/incourse_reverify_view.js | 3 +- .../js/verify_student/views/step_view.js | 11 +- .../verify_student/views/webcam_photo_view.js | 3 +- lms/static/js/views/message_banner.js | 2 +- lms/templates/help_modal.html | 8 +- package.json | 2 +- 79 files changed, 212 insertions(+), 453 deletions(-) delete mode 100644 cms/static/js/views/overview.js delete mode 100644 common/static/coffee/src/discussion/discussion_filter.coffee diff --git a/cms/static/js/certificates/views/signatory_editor.js b/cms/static/js/certificates/views/signatory_editor.js index 2bd6e2f890..d446ea630f 100644 --- a/cms/static/js/certificates/views/signatory_editor.js +++ b/cms/static/js/certificates/views/signatory_editor.js @@ -129,9 +129,9 @@ function ($, _, Backbone, gettext, if (event && event.preventDefault) { event.preventDefault(); } var model = this.model; var self = this; - var titleText = gettext('Delete "<%= signatoryName %>" from the list of signatories?'); + var titleTextTemplate = _.template(gettext('Delete "<%= signatoryName %>" from the list of signatories?')); var confirm = new PromptView.Warning({ - title: _.template(titleText, {signatoryName: model.get('name')}), + title: titleTextTemplate({signatoryName: model.get('name')}), message: gettext('This action cannot be undone.'), actions: { primary: { diff --git a/cms/static/js/models/settings/course_grader.js b/cms/static/js/models/settings/course_grader.js index 7f7716eb8b..f31e832bac 100644 --- a/cms/static/js/models/settings/course_grader.js +++ b/cms/static/js/models/settings/course_grader.js @@ -66,9 +66,10 @@ var CourseGrader = Backbone.Model.extend({ else attrs.drop_count = intDropCount; } if (_.has(attrs, 'min_count') && _.has(attrs, 'drop_count') && !_.has(errors, 'min_count') && !_.has(errors, 'drop_count') && attrs.drop_count > attrs.min_count) { - errors.drop_count = _.template( - gettext("Cannot drop more <% attrs.types %> than will assigned."), - attrs, {variable: 'attrs'}); + var template = _.template( + gettext("Cannot drop more <%= types %> assignments than are assigned.") + ); + errors.drop_count = template({types: attrs.type}); } if (!_.isEmpty(errors)) return errors; } diff --git a/cms/static/js/models/uploads.js b/cms/static/js/models/uploads.js index 7a19b3c4bd..6eaf020f60 100644 --- a/cms/static/js/models/uploads.js +++ b/cms/static/js/models/uploads.js @@ -15,8 +15,7 @@ var FileUpload = Backbone.Model.extend({ validate: function(attrs, options) { if(attrs.selectedFile && !this.checkTypeValidity(attrs.selectedFile)) { return { - message: _.template( - gettext("Only <%= fileTypes %> files can be uploaded. Please select a file ending in <%= fileExtensions %> to upload."), + message: _.template(gettext("Only <%= fileTypes %> files can be uploaded. Please select a file ending in <%= fileExtensions %> to upload."))( // jshint ignore:line this.formatValidTypes() ), attributes: {selectedFile: true} @@ -64,7 +63,7 @@ var FileUpload = Backbone.Model.extend({ } var or = gettext('or'); var formatTypes = function(types) { - return _.template('<%= initial %> <%= or %> <%= last %>', { + return _.template('<%= initial %> <%= or %> <%= last %>')({ initial: _.initial(types).join(', '), or: or, last: _.last(types) diff --git a/cms/static/js/utils/drag_and_drop.js b/cms/static/js/utils/drag_and_drop.js index 6228a32019..1393c870a3 100644 --- a/cms/static/js/utils/drag_and_drop.js +++ b/cms/static/js/utils/drag_and_drop.js @@ -359,12 +359,12 @@ define(["jquery", "jquery.ui", "underscore", "gettext", "draggabilly", makeDraggable: function (element, options) { var draggable; options = _.defaults({ - type: null, - handleClass: null, - droppableClass: null, - parentLocationSelector: null, - refresh: null, - ensureChildrenRendered: null + type: undefined, + handleClass: undefined, + droppableClass: undefined, + parentLocationSelector: undefined, + refresh: undefined, + ensureChildrenRendered: undefined }, options); if ($(element).data('droppable-class') !== options.droppableClass) { diff --git a/cms/static/js/views/assets.js b/cms/static/js/views/assets.js index 0a1c4967eb..f9694096d3 100644 --- a/cms/static/js/views/assets.js +++ b/cms/static/js/views/assets.js @@ -67,7 +67,7 @@ define(["jquery", "underscore", "gettext", "js/views/baseview", "js/models/asset ViewUtils.hideLoadingIndicator(); // Create the table - this.$el.html(_.template(asset_library_template, {typeData: this.typeData})); + this.$el.html(_.template(asset_library_template)({typeData: this.typeData})); tableBody = this.$('#asset-table-body'); this.tableBody = tableBody; this.pagingHeader = new PagingHeader({view: this, el: $('#asset-paging-header')}); diff --git a/cms/static/js/views/edit_chapter.js b/cms/static/js/views/edit_chapter.js index f70839c2d4..895e932f34 100644 --- a/cms/static/js/views/edit_chapter.js +++ b/cms/static/js/views/edit_chapter.js @@ -1,3 +1,5 @@ +/*global course */ + define(["js/views/baseview", "underscore", "underscore.string", "jquery", "gettext", "js/models/uploads", "js/views/uploads"], function(BaseView, _, str, $, gettext, FileUploadModel, UploadDialogView) { _.str = str; // used in template @@ -52,10 +54,8 @@ define(["js/views/baseview", "underscore", "underscore.string", "jquery", "gette asset_path: this.$("input.chapter-asset-path").val() }); var msg = new FileUploadModel({ - title: _.template( - gettext("Upload a new PDF to “<%= name %>”"), - {name: window.course.escape('name')} - ), + title: _.template(gettext("Upload a new PDF to “<%= name %>”"))( + {name: course.escape('name')}), message: gettext("Please select a PDF file to upload."), mimeTypes: ['application/pdf'] }); diff --git a/cms/static/js/views/manage_users_and_roles.js b/cms/static/js/views/manage_users_and_roles.js index dc22023158..f9d154696e 100644 --- a/cms/static/js/views/manage_users_and_roles.js +++ b/cms/static/js/views/manage_users_and_roles.js @@ -54,8 +54,8 @@ define(['jquery', 'underscore', 'gettext', "js/views/baseview", title: messages.alreadyMember.title, message: _.template( messages.alreadyMember.messageTpl, - {email: email, container: containerName}, - {interpolate: /\{(.+?)}/g} + {interpolate: /\{(.+?)}/g})( + {email: email, container: containerName} ), actions: { primary: { @@ -140,7 +140,9 @@ define(['jquery', 'underscore', 'gettext', "js/views/baseview", roles = _.object(_.pluck(this.roles, 'key'), _.pluck(this.roles, "name")), adminRoleCount = this.getAdminRoleCount(), viewHelpers = { - format: function (template, data) { return _.template(template, data, {interpolate: /\{(.+?)}/g}); } + format: function (template, data) { + return _.template(template, {interpolate: /\{(.+?)}/g})(data); + } }; for (var i = 0; i < this.users.length; i++) { var user = this.users[i], @@ -284,8 +286,8 @@ define(['jquery', 'underscore', 'gettext', "js/views/baseview", title: self.messages.deleteUser.title, message: _.template( self.messages.deleteUser.messageTpl, - {email: email, container: self.containerName}, - {interpolate: /\{(.+?)}/g} + {interpolate: /\{(.+?)}/g})( + {email: email, container: self.containerName} ), actions: { primary: { diff --git a/cms/static/js/views/overview.js b/cms/static/js/views/overview.js deleted file mode 100644 index 12e6728063..0000000000 --- a/cms/static/js/views/overview.js +++ /dev/null @@ -1,242 +0,0 @@ -define(["domReady", "jquery", "jquery.ui", "underscore", "gettext", "common/js/components/views/feedback_notification", - "js/utils/cancel_on_escape", "js/utils/date_utils", "js/utils/module", "common/js/components/utils/view_utils"], - function (domReady, $, ui, _, gettext, NotificationView, CancelOnEscape, - DateUtils, ModuleUtils, ViewUtils) { - - var modalSelector = '.edit-section-publish-settings'; - - var toggleSections = function(e) { - e.preventDefault(); - - var $section = $('.courseware-section'); - var $button = $(this); - var $labelCollapsed = $(' ' + - gettext('Collapse All Sections') + ''); - var $labelExpanded = $(' ' + - gettext('Expand All Sections') + ''); - - var buttonLabel = $button.hasClass('is-activated') ? $labelCollapsed : $labelExpanded; - $button.toggleClass('is-activated').html(buttonLabel); - - if ($button.hasClass('is-activated')) { - $section.addClass('collapsed'); - // first child in order to avoid the icons on the subsection lists which are not in the first child - $section.find('header .expand-collapse').removeClass('collapse').addClass('expand'); - } else { - $section.removeClass('collapsed'); - // first child in order to avoid the icons on the subsection lists which are not in the first child - $section.find('header .expand-collapse').removeClass('expand').addClass('collapse'); - } - }; - - var toggleSubmodules = function(e) { - e.preventDefault(); - $(this).toggleClass('expand collapse'); - $(this).closest('.is-collapsible, .window').toggleClass('collapsed'); - }; - - - var closeModalNew = function (e) { - if (e) { - e.preventDefault(); - } - $('body').removeClass('modal-window-is-shown'); - $('.edit-section-publish-settings').removeClass('is-shown'); - }; - - var editSectionPublishDate = function (e) { - e.preventDefault(); - var $modal = $(modalSelector); - $modal.attr('data-locator', $(this).attr('data-locator')); - $modal.find('.start-date').val($(this).attr('data-date')); - $modal.find('.start-time').val($(this).attr('data-time')); - if ($modal.find('.start-date').val() == '' && $modal.find('.start-time').val() == '') { - $modal.find('.save-button').hide(); - } - $modal.find('.section-name').html('"' + $(this).closest('.courseware-section').find('.section-name-span').text() + '"'); - $('body').addClass('modal-window-is-shown'); - $('.edit-section-publish-settings').addClass('is-shown'); - }; - - var saveSetSectionScheduleDate = function (e) { - e.preventDefault(); - - var datetime = DateUtils.getDate( - $('.edit-section-publish-settings .start-date'), - $('.edit-section-publish-settings .start-time') - ); - - var locator = $(modalSelector).attr('data-locator'); - - analytics.track('Edited Section Release Date', { - 'course': course_location_analytics, - 'id': locator, - 'start': datetime - }); - - var saving = new NotificationView.Mini({ - title: gettext("Saving") - }); - saving.show(); - // call into server to commit the new order - $.ajax({ - url: ModuleUtils.getUpdateUrl(locator), - type: "PUT", - dataType: "json", - contentType: "application/json", - data: JSON.stringify({ - 'metadata': { - 'start': datetime - } - }) - }).success(function() { - var pad2 = function(number) { - // pad a number to two places: useful for formatting months, days, hours, etc - // when displaying a date/time - return (number < 10 ? '0' : '') + number; - }; - - var $thisSection = $('.courseware-section[data-locator="' + locator + '"]'); - var html = _.template( - '' + - '' + gettext("Release date:") + ' ' + - gettext("{month}/{day}/{year} at {hour}:{minute} UTC") + - '' + - ' ' + - gettext("Edit section release date") + - '', - {year: datetime.getUTCFullYear(), month: pad2(datetime.getUTCMonth() + 1), day: pad2(datetime.getUTCDate()), - hour: pad2(datetime.getUTCHours()), minute: pad2(datetime.getUTCMinutes()), - locator: locator}, - {interpolate: /\{(.+?)\}/g}); - $thisSection.find('.section-published-date').html(html); - saving.hide(); - closeModalNew(); - }); - }; - - var addNewSection = function (e) { - e.preventDefault(); - - $(e.target).addClass('disabled'); - - var $newSection = $($('#new-section-template').html()); - var $cancelButton = $newSection.find('.new-section-name-cancel'); - $('.courseware-overview').prepend($newSection); - $newSection.find('.new-section-name').focus().select(); - $newSection.find('.section-name-form').bind('submit', saveNewSection); - $cancelButton.bind('click', cancelNewSection); - CancelOnEscape($cancelButton); - }; - - var saveNewSection = function (e) { - e.preventDefault(); - - var $saveButton = $(this).find('.new-section-name-save'); - var parent = $saveButton.data('parent'); - var category = $saveButton.data('category'); - var display_name = $(this).find('.new-section-name').val(); - - analytics.track('Created a Section', { - 'course': course_location_analytics, - 'display_name': display_name - }); - - $.postJSON(ModuleUtils.getUpdateUrl(), { - 'parent_locator': parent, - 'category': category, - 'display_name': display_name - }, - - function(data) { - if (data.locator != undefined) location.reload(); - }); - }; - - var cancelNewSection = function (e) { - e.preventDefault(); - $('.new-courseware-section-button').removeClass('disabled'); - $(this).parents('section.new-section').remove(); - }; - - var addNewSubsection = function (e) { - e.preventDefault(); - var $section = $(this).closest('.courseware-section'); - var $newSubsection = $($('#new-subsection-template').html()); - $section.find('.subsection-list > ol').append($newSubsection); - $section.find('.new-subsection-name-input').focus().select(); - - var $saveButton = $newSubsection.find('.new-subsection-name-save'); - var $cancelButton = $newSubsection.find('.new-subsection-name-cancel'); - - var parent = $(this).parents("section.courseware-section").data("locator"); - - $saveButton.data('parent', parent); - $saveButton.data('category', $(this).data('category')); - - $newSubsection.find('.new-subsection-form').bind('submit', saveNewSubsection); - $cancelButton.bind('click', cancelNewSubsection); - CancelOnEscape($cancelButton); - }; - - var saveNewSubsection = function (e) { - e.preventDefault(); - - var parent = $(this).find('.new-subsection-name-save').data('parent'); - var category = $(this).find('.new-subsection-name-save').data('category'); - var display_name = $(this).find('.new-subsection-name-input').val(); - - analytics.track('Created a Subsection', { - 'course': course_location_analytics, - 'display_name': display_name - }); - - - $.postJSON(ModuleUtils.getUpdateUrl(), { - 'parent_locator': parent, - 'category': category, - 'display_name': display_name - }, - - function(data) { - if (data.locator != undefined) { - location.reload(); - } - }); - }; - - var cancelNewSubsection = function (e) { - e.preventDefault(); - $(this).parents('li.courseware-subsection').remove(); - }; - - - - domReady(function() { - // toggling overview section details - $(function() { - if ($('.courseware-section').length > 0) { - $('.toggle-button-sections').addClass('is-shown'); - } - }); - $('.toggle-button-sections').bind('click', toggleSections); - $('.expand-collapse').bind('click', toggleSubmodules); - - $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function () { - $('.wrapper-alert-announcement').remove(); - })); - - var $body = $('body'); - $body.on('click', '.section-published-date .edit-release-date', editSectionPublishDate); - $body.on('click', '.edit-section-publish-settings .action-save', saveSetSectionScheduleDate); - $body.on('click', '.edit-section-publish-settings .action-cancel', closeModalNew); - - $('.new-courseware-section-button').bind('click', addNewSection); - $('.new-subsection-item').bind('click', addNewSubsection); - - }); - - return { - saveSetSectionScheduleDate: saveSetSectionScheduleDate - }; - }); diff --git a/cms/static/js/views/paging_header.js b/cms/static/js/views/paging_header.js index 25c23acf48..662faf2139 100644 --- a/cms/static/js/views/paging_header.js +++ b/cms/static/js/views/paging_header.js @@ -22,9 +22,7 @@ define(["underscore", "backbone", "gettext", "text!templates/paging-header.under currentPage = collection.currentPage, lastPage = collection.totalPages - 1, messageHtml = this.messageHtml(); - this.$el.html(_.template(paging_header_template, { - messageHtml: messageHtml - })); + this.$el.html(_.template(paging_header_template)({ messageHtml: messageHtml})); this.$(".previous-page-link").toggleClass("is-disabled", currentPage === 0).attr('aria-disabled', currentPage === 0); this.$(".next-page-link").toggleClass("is-disabled", currentPage === lastPage).attr('aria-disabled', currentPage === lastPage); return this; diff --git a/cms/static/js/views/show_textbook.js b/cms/static/js/views/show_textbook.js index 2dcc470154..9f0ecd316a 100644 --- a/cms/static/js/views/show_textbook.js +++ b/cms/static/js/views/show_textbook.js @@ -27,10 +27,9 @@ define(["js/views/baseview", "underscore", "gettext", "common/js/components/view }, confirmDelete: function(e) { if(e && e.preventDefault) { e.preventDefault(); } - var textbook = this.model, collection = this.model.collection; - var msg = new PromptView.Warning({ - title: _.template( - gettext("Delete “<%= name %>”?"), + var textbook = this.model; + new PromptView.Warning({ + title: _.template(gettext("Delete “<%= name %>”?"))( {name: textbook.get('name')} ), message: gettext("Deleting a textbook cannot be undone and once deleted any reference to it in your courseware's navigation will also be removed."), diff --git a/cms/static/js/views/video/transcripts/file_uploader.js b/cms/static/js/views/video/transcripts/file_uploader.js index 77e967ce92..41f69dd3b7 100644 --- a/cms/static/js/views/video/transcripts/file_uploader.js +++ b/cms/static/js/views/video/transcripts/file_uploader.js @@ -18,7 +18,9 @@ function($, Backbone, _, Utils) { uploadTpl: '#file-upload', initialize: function () { - _.bindAll(this); + _.bindAll(this, + 'changeHandler', 'clickHandler', 'xhrResetProgressBar', 'xhrProgressHandler', 'xhrCompleteHandler' + ); this.file = false; this.render(); diff --git a/cms/static/js/views/video/transcripts/message_manager.js b/cms/static/js/views/video/transcripts/message_manager.js index ef15c9d304..d405b713e2 100644 --- a/cms/static/js/views/video/transcripts/message_manager.js +++ b/cms/static/js/views/video/transcripts/message_manager.js @@ -29,7 +29,9 @@ function($, Backbone, _, Utils, FileUploader, gettext) { }, initialize: function () { - _.bindAll(this); + _.bindAll(this, + 'importHandler', 'replaceHandler', 'chooseHandler', 'useExistingHandler', 'showError', 'hideError' + ); this.component_locator = this.$el.closest('[data-locator]').data('locator'); diff --git a/cms/templates/js/validation-error-modal.underscore b/cms/templates/js/validation-error-modal.underscore index 5d82d7cd06..0347b8a06f 100644 --- a/cms/templates/js/validation-error-modal.underscore +++ b/cms/templates/js/validation-error-modal.underscore @@ -2,17 +2,17 @@

<%= _.template( - ngettext( - "There was {strong_start}{num_errors} validation error{strong_end} while trying to save the course settings in the database.", - "There were {strong_start}{num_errors} validation errors{strong_end} while trying to save the course settings in the database.", - num_errors - ), - { - strong_start:'', - num_errors: num_errors, - strong_end: '' - }, - {interpolate: /\{(.+?)\}/g})%> + ngettext( + "There was {strong_start}{num_errors} validation error{strong_end} while trying to save the course settings in the database.", + "There were {strong_start}{num_errors} validation errors{strong_end} while trying to save the course settings in the database.", + num_errors + ), + {interpolate: /\{(.+?)\}/g})( + { + strong_start:'', + num_errors: num_errors, + strong_end: '' + })%> <%= gettext("Please check the following validation feedbacks and reflect them in your course settings:")%>

diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js index 922908b743..a8f8fd789b 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_player_spec.js @@ -361,9 +361,14 @@ function (VideoPlayer) { describe('onSeek', function () { beforeEach(function () { + // jasmine.Clock can't be used to fake out debounce with newer versions of underscore + spyOn(_, 'debounce').andCallFake(function (func) { + return function () { + func.apply(this, arguments); + }; + }); state = jasmine.initializePlayer(); state.videoEl = $('video, iframe'); - jasmine.Clock.useMock(); spyOn(state.videoPlayer, 'duration').andReturn(120); }); @@ -384,9 +389,6 @@ function (VideoPlayer) { spyOn(state.videoPlayer, 'stopTimer'); spyOn(state.videoPlayer, 'runTimer'); state.videoPlayer.seekTo(10); - // Video player uses _.debounce (with a wait time in 300 ms) for seeking. - // That's why we have to do this tick(300). - jasmine.Clock.tick(300); expect(state.videoPlayer.currentTime).toBe(10); expect(state.videoPlayer.stopTimer).toHaveBeenCalled(); expect(state.videoPlayer.runTimer).toHaveBeenCalled(); @@ -399,9 +401,6 @@ function (VideoPlayer) { state.videoProgressSlider.onSlide( jQuery.Event('slide'), { value: 30 } ); - // Video player uses _.debounce (with a wait time in 300 ms) for seeking. - // That's why we have to do this tick(300). - jasmine.Clock.tick(300); expect(state.videoPlayer.currentTime).toBe(30); expect(state.videoPlayer.player.seekTo).toHaveBeenCalledWith(30, true); }); @@ -413,9 +412,6 @@ function (VideoPlayer) { state.videoProgressSlider.onSlide( jQuery.Event('slide'), { value: 30 } ); - // Video player uses _.debounce (with a wait time in 300 ms) for seeking. - // That's why we have to do this tick(300). - jasmine.Clock.tick(300); expect(state.videoPlayer.currentTime).toBe(30); expect(state.videoPlayer.updatePlayTime).toHaveBeenCalledWith(30, true); }); @@ -426,17 +422,11 @@ function (VideoPlayer) { state.videoProgressSlider.onSlide( jQuery.Event('slide'), { value: 20 } ); - // Video player uses _.debounce (with a wait time in 300 ms) for seeking. - // That's why we have to do this tick(300). - jasmine.Clock.tick(300); state.videoPlayer.pause(); expect(state.videoPlayer.currentTime).toBe(20); state.videoProgressSlider.onSlide( jQuery.Event('slide'), { value: 10 } ); - // Video player uses _.debounce (with a wait time in 300 ms) for seeking. - // That's why we have to do this tick(300). - jasmine.Clock.tick(300); expect(state.videoPlayer.currentTime).toBe(10); }); diff --git a/common/lib/xmodule/xmodule/js/src/html/imageModal.js b/common/lib/xmodule/xmodule/js/src/html/imageModal.js index d357ee5763..5577c57410 100644 --- a/common/lib/xmodule/xmodule/js/src/html/imageModal.js +++ b/common/lib/xmodule/xmodule/js/src/html/imageModal.js @@ -13,7 +13,7 @@ var setupFullScreenModal = function() { "largeALT": smallImageObject.attr('alt'), "largeSRC": largeImageSRC }; - var html = _.template($("#image-modal-tpl").text(), data); + var html = _.template($("#image-modal-tpl").text())(data); $(this).replaceWith(html); } }); diff --git a/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee b/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee index 41f9511e7e..b68c9538da 100644 --- a/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee +++ b/common/static/coffee/spec/discussion/view/thread_response_show_view_spec.coffee @@ -115,7 +115,7 @@ describe "ThreadResponseShowView", -> expect(@view.$(".posted-details").text()).not.toMatch("marked as answer") it "allows a moderator to mark an answer in a question thread", -> - DiscussionUtil.loadRoles({"Moderator": parseInt(window.user.id)}) + DiscussionUtil.loadRoles({"Moderator": [parseInt(window.user.id)]}) @thread.set({ "thread_type": "question", "user_id": (parseInt(window.user.id) + 1).toString() diff --git a/common/static/coffee/src/discussion/discussion_filter.coffee b/common/static/coffee/src/discussion/discussion_filter.coffee deleted file mode 100644 index f9cc710e0d..0000000000 --- a/common/static/coffee/src/discussion/discussion_filter.coffee +++ /dev/null @@ -1,28 +0,0 @@ -class @DiscussionFilter - - # TODO: this helper class duplicates functionality in DiscussionThreadListView.filterTopics - # for use with a very similar category dropdown in the New Post form. The two menus' implementations - # should be merged into a single reusable view. - - @filterDrop: (e) -> - $drop = $(e.target).parents('.topic-menu-wrapper') - query = $(e.target).val() - $items = $drop.find('.topic-menu-item') - - if(query.length == 0) - $items.removeClass('hidden') - return; - - $items.addClass('hidden') - $items.each (i) -> - - path = $(this).parents(".topic-menu-item").andSelf() - pathTitles = path.children(".topic-title").map((i, elem) -> $(elem).text()).get() - pathText = pathTitles.join(" / ").toLowerCase() - - if query.split(" ").every((term) -> pathText.search(term.toLowerCase()) != -1) - $(this).removeClass('hidden') - # show children - $(this).find('.topic-menu-item').removeClass('hidden'); - # show parents - $(this).parents('.topic-menu-item').removeClass('hidden'); diff --git a/common/static/coffee/src/discussion/views/discussion_thread_edit_view.js b/common/static/coffee/src/discussion/views/discussion_thread_edit_view.js index 21e71e79f6..8f954c5668 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_edit_view.js +++ b/common/static/coffee/src/discussion/views/discussion_thread_edit_view.js @@ -19,7 +19,7 @@ this.threadType = this.model.get('thread_type'); this.topicId = this.model.get('commentable_id'); this.context = options.context || 'course'; - _.bindAll(this); + _.bindAll(this, 'updateHandler', 'cancelHandler'); return this; }, diff --git a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee index a01ef013d2..512df63405 100644 --- a/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee +++ b/common/static/coffee/src/discussion/views/discussion_thread_list_view.coffee @@ -39,9 +39,9 @@ if Backbone? @searchAlertCollection.on "add", (searchAlert) => content = _.template( - $("#search-alert-template").html(), + $("#search-alert-template").html())( {'message': searchAlert.attributes.message, 'cid': searchAlert.cid} - ) + ) @$(".search-alerts").append(content) @$("#search-alert-" + searchAlert.cid + " a.dismiss").bind "click", searchAlert, (event) => @removeSearchAlert(event.data.cid) @@ -491,7 +491,7 @@ if Backbone? message = interpolate( _.escape(gettext('Show posts by %(username)s.')), {"username": - _.template('<%- username %>', { + _.template('<%- username %>')({ url: DiscussionUtil.urlFor("user_profile", response.users[0].id), username: response.users[0].username }) diff --git a/common/static/coffee/src/discussion/views/discussion_topic_menu_view.js b/common/static/coffee/src/discussion/views/discussion_topic_menu_view.js index c205a7535e..b7350fb0f7 100644 --- a/common/static/coffee/src/discussion/views/discussion_topic_menu_view.js +++ b/common/static/coffee/src/discussion/views/discussion_topic_menu_view.js @@ -6,7 +6,7 @@ 'click .post-topic-button': 'toggleTopicDropdown', 'click .topic-menu-wrapper': 'handleTopicEvent', 'click .topic-filter-label': 'ignoreClick', - 'keyup .topic-filter-input': this.DiscussionFilter.filterDrop + 'keyup .topic-filter-input': 'filterDrop' }, attributes: { @@ -17,7 +17,9 @@ this.course_settings = options.course_settings; this.currentTopicId = options.topicId; this.maxNameWidth = 100; - _.bindAll(this); + _.bindAll(this, + 'toggleTopicDropdown', 'handleTopicEvent', 'hideTopicDropdown', 'ignoreClick' + ); return this; }, @@ -34,7 +36,7 @@ render: function() { var context = _.clone(this.course_settings.attributes); context.topics_html = this.renderCategoryMap(this.course_settings.get('category_map')); - this.$el.html(_.template($('#topic-template').html(), context)); + this.$el.html(_.template($('#topic-template').html())(context)); this.dropdownButton = this.$('.post-topic-button'); this.topicMenu = this.$('.topic-menu-wrapper'); this.selectedTopic = this.$('.js-selected-topic'); @@ -187,6 +189,38 @@ } } return name; + }, + + // TODO: this helper class duplicates functionality in DiscussionThreadListView.filterTopics + // for use with a very similar category dropdown in the New Post form. The two menus' implementations + // should be merged into a single reusable view. + filterDrop: function (e) { + var $drop, $items, query; + $drop = $(e.target).parents('.topic-menu-wrapper'); + query = $(e.target).val(); + $items = $drop.find('.topic-menu-item'); + + if (query.length === 0) { + $items.removeClass('hidden'); + return; + } + + $items.addClass('hidden'); + $items.each(function (_index, item) { + var path, pathText, pathTitles; + path = $(item).parents(".topic-menu-item").andSelf(); + pathTitles = path.children(".topic-title").map(function (_, elem) { + return $(elem).text(); + }).get(); + pathText = pathTitles.join(" / ").toLowerCase(); + if (query.split(" ").every(function (term) { + return pathText.search(term.toLowerCase()) !== -1; + })) { + $(item).removeClass('hidden'); + $(item).find('.topic-menu-item').removeClass('hidden'); + $(item).parents('.topic-menu-item').removeClass('hidden'); + } + }); } }); } diff --git a/common/static/coffee/src/discussion/views/new_post_view.coffee b/common/static/coffee/src/discussion/views/new_post_view.coffee index 75ad7a91bc..375ec27618 100644 --- a/common/static/coffee/src/discussion/views/new_post_view.coffee +++ b/common/static/coffee/src/discussion/views/new_post_view.coffee @@ -17,7 +17,7 @@ if Backbone? mode: @mode, form_id: @mode + (if @topicId then "-" + @topicId else "") }) - @$el.html(_.template($("#new-post-template").html(), context)) + @$el.html(_.template($("#new-post-template").html())(context)) threadTypeTemplate = _.template($("#thread-type-template").html()); if $('.js-group-select').is(':disabled') $('.group-selector-wrapper').addClass('disabled') diff --git a/common/static/common/js/components/collections/paging_collection.js b/common/static/common/js/components/collections/paging_collection.js index a8af909897..0259b56c2c 100644 --- a/common/static/common/js/components/collections/paging_collection.js +++ b/common/static/common/js/components/collections/paging_collection.js @@ -79,6 +79,9 @@ * underlying server API. */ getPage: function () { + // TODO: this.currentPage is currently returning a function sometimes when it is called. + // It is possible it always did this, but we either need to investigate more, or just wait until + // we replace this code with the pattern library. return this.currentPage + (this.isZeroIndexed ? 1 : 0); }, diff --git a/common/static/common/js/components/utils/view_utils.js b/common/static/common/js/components/utils/view_utils.js index 60226adaae..22f072f99e 100644 --- a/common/static/common/js/components/utils/view_utils.js +++ b/common/static/common/js/components/utils/view_utils.js @@ -244,7 +244,7 @@ if (!validateTotalKeyLength(key_field_selectors)) { $(selectors.errorWrapper).addClass(classes.shown).removeClass(classes.hiding); $(selectors.errorMessage).html( - '

' + _.template(message_tpl, {limit: MAX_SUM_KEY_LENGTH}) + '

' + '

' + _.template(message_tpl)({limit: MAX_SUM_KEY_LENGTH}) + '

' ); $(selectors.save).addClass(classes.disabled); } else { diff --git a/common/static/common/js/components/views/paginated_view.js b/common/static/common/js/components/views/paginated_view.js index 0507d52cbd..7755a93f1d 100644 --- a/common/static/common/js/components/views/paginated_view.js +++ b/common/static/common/js/components/views/paginated_view.js @@ -50,7 +50,7 @@ }, render: function () { - this.$el.html(_.template(paginatedViewTemplate, {type: this.type})); + this.$el.html(_.template(paginatedViewTemplate)({type: this.type})); this.assign(this.listView, '.' + this.type + '-list'); if (this.headerView) { this.assign(this.headerView, '.' + this.type + '-paging-header'); diff --git a/common/static/common/js/components/views/paging_footer.js b/common/static/common/js/components/views/paging_footer.js index 36d2c74f2a..8b796b52b9 100644 --- a/common/static/common/js/components/views/paging_footer.js +++ b/common/static/common/js/components/views/paging_footer.js @@ -30,7 +30,7 @@ this.$el.removeClass('hidden'); } } - this.$el.html(_.template(paging_footer_template, { + this.$el.html(_.template(paging_footer_template)({ current_page: this.collection.getPage(), total_pages: this.collection.totalPages })); diff --git a/common/static/common/js/components/views/paging_header.js b/common/static/common/js/components/views/paging_header.js index c49af8bc4b..b7e8abe531 100644 --- a/common/static/common/js/components/views/paging_header.js +++ b/common/static/common/js/components/views/paging_header.js @@ -33,7 +33,7 @@ context, true ); } - this.$el.html(_.template(headerTemplate, { + this.$el.html(_.template(headerTemplate)({ message: message, srInfo: this.srInfo, sortableFields: this.collection.sortableFields, diff --git a/common/static/common/js/components/views/search_field.js b/common/static/common/js/components/views/search_field.js index 955133fe1c..9cd1a7ca90 100644 --- a/common/static/common/js/components/views/search_field.js +++ b/common/static/common/js/components/views/search_field.js @@ -37,7 +37,7 @@ }, render: function() { - this.$el.html(_.template(searchFieldTemplate, { + this.$el.html(_.template(searchFieldTemplate)({ type: this.type, searchString: this.collection.searchString, searchLabel: this.label diff --git a/common/static/common/templates/discussion/forum-actions.underscore b/common/static/common/templates/discussion/forum-actions.underscore index 5401e41180..ac637a0bca 100644 --- a/common/static/common/templates/discussion/forum-actions.underscore +++ b/common/static/common/templates/discussion/forum-actions.underscore @@ -1,6 +1,6 @@ <% if (!readOnly) { %>