diff --git a/.gitignore b/.gitignore index 87a0778a6f..d01baf055a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ :2e# .AppleDouble database.sqlite +private-requirements.txt courseware/static/js/mathjax/* flushdb.sh build diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index ed95d81d67..07b7032e60 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -264,13 +264,13 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_delete(self): import_from_xml(modulestore(), 'common/test/data/', ['full']) - module_store = modulestore('direct') + direct_store = modulestore('direct') - sequential = module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) + sequential = direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) - chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None])) + chapter = direct_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None])) - # make sure the parent no longer points to the child object which was deleted + # make sure the parent points to the child object which is to be deleted self.assertTrue(sequential.location.url() in chapter.children) self.client.post( @@ -281,18 +281,19 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): found = False try: - module_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) + direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) found = True except ItemNotFoundError: pass self.assertFalse(found) - chapter = module_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None])) + chapter = direct_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None])) # make sure the parent no longer points to the child object which was deleted self.assertFalse(sequential.location.url() in chapter.children) + def test_about_overrides(self): ''' This test case verifies that a course can use specialized override for about data, e.g. /about/Fall_2012/effort.html diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 603010f5b4..caf3901e03 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -615,25 +615,14 @@ def delete_item(request): delete_children = request.POST.get('delete_children', False) delete_all_versions = request.POST.get('delete_all_versions', False) - item = modulestore().get_item(item_location) + store = modulestore() - store = get_modulestore(item_loc) - - # @TODO: this probably leaves draft items dangling. My preferance would be for the semantic to be - # if item.location.revision=None, then delete both draft and published version - # if caller wants to only delete the draft than the caller should put item.location.revision='draft' + item = store.get_item(item_location) if delete_children: - _xmodule_recurse(item, lambda i: store.delete_item(i.location)) + _xmodule_recurse(item, lambda i: store.delete_item(i.location, delete_all_versions)) else: - store.delete_item(item.location) - - # cdodge: this is a bit of a hack until I can talk with Cale about the - # semantics of delete_item whereby the store is draft aware. Right now calling - # delete_item on a vertical tries to delete the draft version leaving the - # requested delete to never occur - if item.location.revision is None and item.location.category == 'vertical' and delete_all_versions: - modulestore('direct').delete_item(item.location) + store.delete_item(item.location, delete_all_versions) # cdodge: we need to remove our parent's pointer to us so that it is no longer dangling if delete_all_versions: @@ -1498,6 +1487,12 @@ def create_new_course(request): new_course = modulestore('direct').clone_item(template, dest_location) + # clone a default 'about' module as well + + about_template_location = Location(['i4x', 'edx', 'templates', 'about', 'overview']) + dest_about_location = dest_location._replace(category='about', name='overview') + modulestore('direct').clone_item(about_template_location, dest_about_location) + if display_name is not None: new_course.display_name = display_name diff --git a/cms/templates/base.html b/cms/templates/base.html index 65e08b3cc5..3f286c2582 100644 --- a/cms/templates/base.html +++ b/cms/templates/base.html @@ -30,7 +30,7 @@ <%include file="courseware_vendor_js.html"/> - + diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 3923c0f905..0a647c632e 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -179,7 +179,7 @@ from contentstore import utils
  • - Introductions, prerequisites, FAQs that are used on your course summary page + Introductions, prerequisites, FAQs that are used on your course summary page (formatted in HTML)
  • diff --git a/common/lib/capa/capa/calc.py b/common/lib/capa/capa/calc.py index c3fe6b656b..bb1fb97153 100644 --- a/common/lib/capa/capa/calc.py +++ b/common/lib/capa/capa/calc.py @@ -24,7 +24,9 @@ default_functions = {'sin': numpy.sin, 'arccos': numpy.arccos, 'arcsin': numpy.arcsin, 'arctan': numpy.arctan, - 'abs': numpy.abs + 'abs': numpy.abs, + 'fact': math.factorial, + 'factorial': math.factorial } default_variables = {'j': numpy.complex(0, 1), 'e': numpy.e, @@ -112,18 +114,18 @@ def evaluator(variables, functions, string, cs=False): return float('nan') ops = {"^": operator.pow, - "*": operator.mul, - "/": operator.truediv, - "+": operator.add, - "-": operator.sub, - } + "*": operator.mul, + "/": operator.truediv, + "+": operator.add, + "-": operator.sub, + } # We eliminated extreme ones, since they're rarely used, and potentially # confusing. They may also conflict with variables if we ever allow e.g. # 5R instead of 5*R suffixes = {'%': 0.01, 'k': 1e3, 'M': 1e6, 'G': 1e9, - 'T': 1e12, # 'P':1e15,'E':1e18,'Z':1e21,'Y':1e24, - 'c': 1e-2, 'm': 1e-3, 'u': 1e-6, - 'n': 1e-9, 'p': 1e-12} # ,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24} + 'T': 1e12, # 'P':1e15,'E':1e18,'Z':1e21,'Y':1e24, + 'c': 1e-2, 'm': 1e-3, 'u': 1e-6, + 'n': 1e-9, 'p': 1e-12} # ,'f':1e-15,'a':1e-18,'z':1e-21,'y':1e-24} def super_float(text): ''' Like float, but with si extensions. 1k goes to 1000''' @@ -246,4 +248,9 @@ if __name__ == '__main__': print evaluator({}, {}, "5+1*j") print evaluator({}, {}, "j||1") print evaluator({}, {}, "e^(j*pi)") - print evaluator({}, {}, "5+7 QWSEKO") + print evaluator({}, {}, "fact(5)") + print evaluator({}, {}, "factorial(5)") + try: + print evaluator({}, {}, "5+7 QWSEKO") + except UndefinedVariable: + print "Successfully caught undefined variable" diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index bf64d3cc69..7a43fff4c9 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -7,6 +7,7 @@ from datetime import datetime import json from nose.plugins.skip import SkipTest import os +import random import unittest import textwrap @@ -14,7 +15,7 @@ from . import test_system import capa.capa_problem as lcp from capa.responsetypes import LoncapaProblemError, \ - StudentInputError, ResponseError + StudentInputError, ResponseError from capa.correctmap import CorrectMap from capa.util import convert_files_to_filenames from capa.xqueue_interface import dateformat @@ -33,10 +34,13 @@ class ResponseTest(unittest.TestCase): xml = self.xml_factory.build_xml(**kwargs) return lcp.LoncapaProblem(xml, '1', system=test_system) - def assert_grade(self, problem, submission, expected_correctness): + def assert_grade(self, problem, submission, expected_correctness, msg=None): input_dict = {'1_2_1': submission} correct_map = problem.grade_answers(input_dict) - self.assertEquals(correct_map.get_correctness('1_2_1'), expected_correctness) + if msg is None: + self.assertEquals(correct_map.get_correctness('1_2_1'), expected_correctness) + else: + self.assertEquals(correct_map.get_correctness('1_2_1'), expected_correctness, msg) def assert_answer_format(self, problem): answers = problem.get_question_answers() @@ -357,6 +361,83 @@ class FormulaResponseTest(ResponseTest): self.assert_grade(problem, '2*x', 'correct') self.assert_grade(problem, '3*x', 'incorrect') + def test_parallel_resistors(self): + """Test parallel resistors""" + sample_dict = {'R1': (10, 10), 'R2': (2, 2), 'R3': (5, 5), 'R4': (1, 1)} + + # Test problem + problem = self.build_problem(sample_dict=sample_dict, + num_samples=10, + tolerance=0.01, + answer="R1||R2") + # Expect answer to be marked correct + input_formula = "R1||R2" + self.assert_grade(problem, input_formula, "correct") + + # Expect random number to be marked incorrect + input_formula = "13" + self.assert_grade(problem, input_formula, "incorrect") + + # Expect incorrect answer marked incorrect + input_formula = "R3||R4" + self.assert_grade(problem, input_formula, "incorrect") + + def test_default_variables(self): + """Test the default variables provided in common/lib/capa/capa/calc.py""" + # which are: j (complex number), e, pi, k, c, T, q + + # Sample x in the range [-10,10] + sample_dict = {'x': (-10, 10)} + default_variables = [('j', 2, 3), ('e', 2, 3), ('pi', 2, 3), ('c', 2, 3), ('T', 2, 3), + ('k', 2 * 10 ** 23, 3 * 10 ** 23), # note k = scipy.constants.k = 1.3806488e-23 + ('q', 2 * 10 ** 19, 3 * 10 ** 19)] # note k = scipy.constants.e = 1.602176565e-19 + for (var, cscalar, iscalar) in default_variables: + # The expected solution is numerically equivalent to cscalar*var + correct = '{0}*x*{1}'.format(cscalar, var) + incorrect = '{0}*x*{1}'.format(iscalar, var) + problem = self.build_problem(sample_dict=sample_dict, + num_samples=10, + tolerance=0.01, + answer=correct) + + # Expect that the inputs are graded correctly + self.assert_grade(problem, correct, 'correct', + msg="Failed on variable {0}; the given, correct answer was {1} but graded 'incorrect'".format(var, correct)) + self.assert_grade(problem, incorrect, 'incorrect', + msg="Failed on variable {0}; the given, incorrect answer was {1} but graded 'correct'".format(var, incorrect)) + + def test_default_functions(self): + """Test the default functions provided in common/lib/capa/capa/calc.py""" + # which are: sin, cos, tan, sqrt, log10, log2, ln, + # arccos, arcsin, arctan, abs, + # fact, factorial + + w = random.randint(3, 10) + sample_dict = {'x': (-10, 10), # Sample x in the range [-10,10] + 'y': (1, 10), # Sample y in the range [1,10] - logs, arccos need positive inputs + 'z': (-1, 1), # Sample z in the range [1,10] - for arcsin, arctan + 'w': (w, w)} # Sample w is a random, positive integer - factorial needs a positive, integer input, + # and the way formularesponse is defined, we can only specify a float range + + default_functions = [('sin', 2, 3, 'x'), ('cos', 2, 3, 'x'), ('tan', 2, 3, 'x'), ('sqrt', 2, 3, 'y'), ('log10', 2, 3, 'y'), + ('log2', 2, 3, 'y'), ('ln', 2, 3, 'y'), ('arccos', 2, 3, 'z'), ('arcsin', 2, 3, 'z'), ('arctan', 2, 3, 'x'), + ('abs', 2, 3, 'x'), ('fact', 2, 3, 'w'), ('factorial', 2, 3, 'w')] + for (func, cscalar, iscalar, var) in default_functions: + print 'func is: {0}'.format(func) + # The expected solution is numerically equivalent to cscalar*func(var) + correct = '{0}*{1}({2})'.format(cscalar, func, var) + incorrect = '{0}*{1}({2})'.format(iscalar, func, var) + problem = self.build_problem(sample_dict=sample_dict, + num_samples=10, + tolerance=0.01, + answer=correct) + + # Expect that the inputs are graded correctly + self.assert_grade(problem, correct, 'correct', + msg="Failed on function {0}; the given, correct answer was {1} but graded 'incorrect'".format(func, correct)) + self.assert_grade(problem, incorrect, 'incorrect', + msg="Failed on function {0}; the given, incorrect answer was {1} but graded 'correct'".format(func, incorrect)) + class StringResponseTest(ResponseTest): from response_xml_factory import StringResponseXMLFactory @@ -904,14 +985,13 @@ class CustomResponseTest(ResponseTest): with self.assertRaises(ResponseError): problem.grade_answers({'1_2_1': '42'}) - def test_module_imports_inline(self): ''' Check that the correct modules are available to custom response scripts ''' - for module_name in ['random', 'numpy', 'math', 'scipy', + for module_name in ['random', 'numpy', 'math', 'scipy', 'calc', 'eia', 'chemcalc', 'chemtools', 'miller', 'draganddrop']: @@ -921,26 +1001,25 @@ class CustomResponseTest(ResponseTest): script = textwrap.dedent(''' correct[0] = 'correct' assert('%s' in globals())''' % module_name) - + # Create the problem problem = self.build_problem(answer=script) - # Expect that we can grade an answer without + # Expect that we can grade an answer without # getting an exception try: problem.grade_answers({'1_2_1': '42'}) except ResponseError: - self.fail("Could not use name '%s' in custom response" - % module_name) - + self.fail("Could not use name '{0}s' in custom response".format(module_name)) + def test_module_imports_function(self): ''' Check that the correct modules are available to custom response scripts ''' - for module_name in ['random', 'numpy', 'math', 'scipy', + for module_name in ['random', 'numpy', 'math', 'scipy', 'calc', 'eia', 'chemcalc', 'chemtools', 'miller', 'draganddrop']: @@ -951,18 +1030,17 @@ class CustomResponseTest(ResponseTest): def check_func(expect, answer_given): assert('%s' in globals()) return True''' % module_name) - + # Create the problem problem = self.build_problem(script=script, cfn="check_func") - # Expect that we can grade an answer without + # Expect that we can grade an answer without # getting an exception try: problem.grade_answers({'1_2_1': '42'}) except ResponseError: - self.fail("Could not use name '%s' in custom response" - % module_name) + self.fail("Could not use name '{0}s' in custom response".format(module_name)) class SchematicResponseTest(ResponseTest): diff --git a/common/lib/xmodule/xmodule/modulestore/draft.py b/common/lib/xmodule/xmodule/modulestore/draft.py index 43eb050129..c3f1b23688 100644 --- a/common/lib/xmodule/xmodule/modulestore/draft.py +++ b/common/lib/xmodule/xmodule/modulestore/draft.py @@ -13,6 +13,12 @@ def as_draft(location): """ return Location(location)._replace(revision=DRAFT) +def as_published(location): + """ + Returns the Location that is the published version for `location` + """ + return Location(location)._replace(revision=None) + def wrap_draft(item): """ @@ -159,13 +165,17 @@ class DraftModuleStore(ModuleStoreBase): return super(DraftModuleStore, self).update_metadata(draft_loc, metadata) - def delete_item(self, location): + def delete_item(self, location, delete_all_versions=False): """ Delete an item from this modulestore location: Something that can be passed to Location """ - return super(DraftModuleStore, self).delete_item(as_draft(location)) + super(DraftModuleStore, self).delete_item(as_draft(location)) + if delete_all_versions: + super(DraftModuleStore, self).delete_item(as_published(location)) + + return def get_parent_locations(self, location, course_id): '''Find all locations that are the parents of this location. Needed diff --git a/common/lib/xmodule/xmodule/modulestore/mongo.py b/common/lib/xmodule/xmodule/modulestore/mongo.py index 28ea1f2659..c8256422f8 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo.py @@ -694,11 +694,12 @@ class MongoModuleStore(ModuleStoreBase): self.refresh_cached_metadata_inheritance_tree(loc) self.fire_updated_modulestore_signal(get_course_id_no_run(Location(location)), Location(location)) - def delete_item(self, location): + def delete_item(self, location, delete_all_versions=False): """ Delete an item from this modulestore location: Something that can be passed to Location + delete_all_versions: is here because the DraftMongoModuleStore needs it and we need to keep the interface the same. It is unused. """ # VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so # if we add one then we need to also add it to the policy information (i.e. metadata) diff --git a/common/lib/xmodule/xmodule/modulestore/store_utilities.py b/common/lib/xmodule/xmodule/modulestore/store_utilities.py index e90613d0da..6beffcb71d 100644 --- a/common/lib/xmodule/xmodule/modulestore/store_utilities.py +++ b/common/lib/xmodule/xmodule/modulestore/store_utilities.py @@ -13,10 +13,19 @@ def clone_course(modulestore, contentstore, source_location, dest_location, dele if not modulestore.has_item(dest_location): raise Exception("An empty course at {0} must have already been created. Aborting...".format(dest_location)) - # verify that the dest_location really is an empty course, which means only one + # verify that the dest_location really is an empty course, which means only one with an optional 'overview' dest_modules = modulestore.get_items([dest_location.tag, dest_location.org, dest_location.course, None, None, None]) - if len(dest_modules) != 1: + basically_empty = True + for module in dest_modules: + if module.location.category == 'course' or (module.location.category == 'about' + and module.location.name == 'overview'): + continue + + basically_empty = False + break + + if not basically_empty: raise Exception("Course at destination {0} is not an empty course. You can only clone into an empty course. Aborting...".format(dest_location)) # check to see if the source course is actually there diff --git a/common/lib/xmodule/xmodule/templates/about/overview.yaml b/common/lib/xmodule/xmodule/templates/about/overview.yaml new file mode 100644 index 0000000000..0031ebffaf --- /dev/null +++ b/common/lib/xmodule/xmodule/templates/about/overview.yaml @@ -0,0 +1,53 @@ +--- +metadata: + display_name: overview + +data: | +
    +

    About This Course

    +

    Include your long course description here. The long course description should contain 150-400 words.

    + +

    This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags.

    +
    + +
    +

    Prerequisites

    +

    Add information about course prerequisites here.

    +
    + +
    +

    Course Staff

    +
    +
    + +
    + +

    Staff Member #1

    +

    Biography of instructor/staff member #1

    +
    + +
    +
    + +
    + +

    Staff Member #2

    +

    Biography of instructor/staff member #2

    +
    +
    + +
    +
    +

    Frequently Asked Questions

    +
    +

    Do I need to buy a textbook?

    +

    No, a free online version of Chemistry: Principles, Patterns, and Applications, First Edition by Bruce Averill and Patricia Eldredge will be available, though you can purchase a printed version (published by FlatWorld Knowledge) if you’d like.

    +
    + +
    +

    Question #2

    +

    Your answer would be displayed here.

    +
    +
    +
    +children: [] diff --git a/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml b/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml index 303e439439..82d7e8c1ae 100644 --- a/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml +++ b/common/lib/xmodule/xmodule/templates/problem/latex_problem.yaml @@ -188,7 +188,7 @@ data: |

    Include image by using the edXxml macro:

    - +

    Example show/hide explanation

    diff --git a/common/static/images/firecode.jpg b/common/static/images/firecode.jpg new file mode 100644 index 0000000000..a20206559b Binary files /dev/null and b/common/static/images/firecode.jpg differ diff --git a/common/static/images/high_pass_filter.png b/common/static/images/high_pass_filter.png new file mode 100644 index 0000000000..5f425d4671 Binary files /dev/null and b/common/static/images/high_pass_filter.png differ diff --git a/common/static/images/mit_dome.jpg b/common/static/images/mit_dome.jpg new file mode 100644 index 0000000000..75a5bd949f Binary files /dev/null and b/common/static/images/mit_dome.jpg differ diff --git a/common/static/images/pl-course.png b/common/static/images/pl-course.png new file mode 100644 index 0000000000..1a3da9e631 Binary files /dev/null and b/common/static/images/pl-course.png differ diff --git a/common/static/images/pl-faculty.png b/common/static/images/pl-faculty.png new file mode 100644 index 0000000000..b55ba44542 Binary files /dev/null and b/common/static/images/pl-faculty.png differ diff --git a/common/static/images/simple_graph.png b/common/static/images/simple_graph.png new file mode 100644 index 0000000000..f255795025 Binary files /dev/null and b/common/static/images/simple_graph.png differ diff --git a/common/static/images/voltage_divider.png b/common/static/images/voltage_divider.png new file mode 100644 index 0000000000..1fa695b1f3 Binary files /dev/null and b/common/static/images/voltage_divider.png differ diff --git a/conf/locale/config b/conf/locale/config index fe811ee02e..2d01e1ea43 100644 --- a/conf/locale/config +++ b/conf/locale/config @@ -1 +1 @@ -{"locales" : ["en", "fr", "de"]} +{"locales" : ["en"]} diff --git a/conf/locale/en/LC_MESSAGES/messages.po b/conf/locale/en/LC_MESSAGES/messages.po new file mode 100644 index 0000000000..1bb8bf6d7f --- /dev/null +++ b/conf/locale/en/LC_MESSAGES/messages.po @@ -0,0 +1 @@ +# empty diff --git a/github-requirements.txt b/github-requirements.txt index 468d55ce65..0d7b75b89b 100644 --- a/github-requirements.txt +++ b/github-requirements.txt @@ -1,5 +1,10 @@ # Python libraries to install directly from github + +# Third-party: -e git://github.com/MITx/django-staticfiles.git@6d2504e5c8#egg=django-staticfiles -e git://github.com/MITx/django-pipeline.git#egg=django-pipeline -e git://github.com/MITx/django-wiki.git@e2e84558#egg=django-wiki -e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev + +# Our libraries: +-e git+https://github.com/edx/XBlock.git@5ce6f70a#egg=XBlock diff --git a/i18n/dummy.py b/i18n/dummy.py index 798ee525b5..78bfdc3b58 100644 --- a/i18n/dummy.py +++ b/i18n/dummy.py @@ -86,7 +86,7 @@ class Dummy (Converter): def init_msgs(self, msgs): """ Make sure the first msg in msgs has a plural property. - msgs is list of instances of pofile.Msg + msgs is list of instances of polib.POEntry """ if len(msgs)==0: return @@ -100,8 +100,8 @@ class Dummy (Converter): def convert_msg(self, msg): """ - Takes one Msg object and converts it (adds a dummy translation to it) - msg is an instance of pofile.Msg + Takes one POEntry object and converts it (adds a dummy translation to it) + msg is an instance of polib.POEntry """ source = msg.msgid if len(source)==0: diff --git a/i18n/execute.py b/i18n/execute.py new file mode 100644 index 0000000000..3c3416b65d --- /dev/null +++ b/i18n/execute.py @@ -0,0 +1,86 @@ +import os, subprocess, logging, json + +def init_module(): + """ + Initializes module parameters + """ + global BASE_DIR, LOCALE_DIR, CONFIG_FILENAME, SOURCE_MSGS_DIR, SOURCE_LOCALE, LOG + + # BASE_DIR is the working directory to execute django-admin commands from. + # Typically this should be the 'mitx' directory. + BASE_DIR = os.path.normpath(os.path.dirname(os.path.abspath(__file__))+'/..') + + # Source language is English + SOURCE_LOCALE = 'en' + + # LOCALE_DIR contains the locale files. + # Typically this should be 'mitx/conf/locale' + LOCALE_DIR = BASE_DIR + '/conf/locale' + + # CONFIG_FILENAME contains localization configuration in json format + CONFIG_FILENAME = LOCALE_DIR + '/config' + + # SOURCE_MSGS_DIR contains the English po files. + SOURCE_MSGS_DIR = messages_dir(SOURCE_LOCALE) + + # Default logger. + LOG = get_logger() + + +def messages_dir(locale): + """ + Returns the name of the directory holding the po files for locale. + Example: mitx/conf/locale/en/LC_MESSAGES + """ + return os.path.join(LOCALE_DIR, locale, 'LC_MESSAGES') + +def get_logger(): + """Returns a default logger""" + log = logging.getLogger(__name__) + log.setLevel(logging.INFO) + log_handler = logging.StreamHandler() + log_handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')) + log.addHandler(log_handler) + return log + +# Run this after defining messages_dir and get_logger, because it depends on these. +init_module() + +def execute (command, working_directory=BASE_DIR, log=LOG): + """ + Executes shell command in a given working_directory. + Command is a string to pass to the shell. + Output is logged to log. + """ + log.info(command) + subprocess.call(command.split(' '), cwd=working_directory) + +def get_config(): + """Returns data found in config file, or returns None if file not found""" + config_path = os.path.abspath(CONFIG_FILENAME) + if not os.path.exists(config_path): + log.warn("Configuration file cannot be found: %s" % \ + os.path.relpath(config_path, BASE_DIR)) + return None + with open(config_path) as stream: + return json.load(stream) + +def create_dir_if_necessary(pathname): + dirname = os.path.dirname(pathname) + if not os.path.exists(dirname): + os.makedirs(dirname) + + +def remove_file(filename, log=LOG, verbose=True): + """ + Attempt to delete filename. + Log a warning if file does not exist. + Logging filenames are releative to BASE_DIR to cut down on noise in output. + """ + if verbose: + log.info('Deleting file %s' % os.path.relpath(filename, BASE_DIR)) + if not os.path.exists(filename): + log.warn("File does not exist: %s" % os.path.relpath(filename, BASE_DIR)) + else: + os.remove(filename) + diff --git a/i18n/extract.py b/i18n/extract.py new file mode 100755 index 0000000000..c6fedd3bfa --- /dev/null +++ b/i18n/extract.py @@ -0,0 +1,145 @@ +#!/usr/bin/python + +""" +See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow + + This task extracts all English strings from all source code + and produces three human-readable files: + conf/locale/en/LC_MESSAGES/django-partial.po + conf/locale/en/LC_MESSAGES/djangojs.po + conf/locale/en/LC_MESSAGES/mako.po + + This task will clobber any existing django.po file. + This is because django-admin.py makemessages hardcodes this filename + and it cannot be overridden. + +""" + +import os +from datetime import datetime +from polib import pofile +from execute import execute, create_dir_if_necessary, remove_file, \ + BASE_DIR, LOCALE_DIR, SOURCE_MSGS_DIR, LOG + + +# BABEL_CONFIG contains declarations for Babel to extract strings from mako template files +# Use relpath to reduce noise in logs +BABEL_CONFIG = os.path.relpath(LOCALE_DIR + '/babel.cfg', BASE_DIR) + +# Strings from mako template files are written to BABEL_OUT +# Use relpath to reduce noise in logs +BABEL_OUT = os.path.relpath(SOURCE_MSGS_DIR + '/mako.po', BASE_DIR) + + +def main (): + create_dir_if_necessary(LOCALE_DIR) + generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') + + for filename in generated_files: + remove_file(os.path.join(SOURCE_MSGS_DIR, filename)) + + # Extract strings from mako templates + babel_mako_cmd = 'pybabel extract -F %s -c "TRANSLATORS:" . -o %s' % (BABEL_CONFIG, BABEL_OUT) + + # Extract strings from django source files + make_django_cmd = 'django-admin.py makemessages -l en --ignore=src/* --ignore=i18n/* ' \ + + '--extension html' + + # Extract strings from javascript source files + make_djangojs_cmd = 'django-admin.py makemessages -l en -d djangojs --ignore=src/* ' \ + + '--ignore=i18n/* --extension js' + execute(babel_mako_cmd, working_directory=BASE_DIR) + execute(make_django_cmd, working_directory=BASE_DIR) + # makemessages creates 'django.po'. This filename is hardcoded. + # Rename it to django-partial.po to enable merging into django.po later. + os.rename(os.path.join(SOURCE_MSGS_DIR, 'django.po'), + os.path.join(SOURCE_MSGS_DIR, 'django-partial.po')) + execute(make_djangojs_cmd, working_directory=BASE_DIR) + + for filename in generated_files: + LOG.info('Cleaning %s' % filename) + po = pofile(os.path.join(SOURCE_MSGS_DIR, filename)) + # replace default headers with edX headers + fix_header(po) + # replace default metadata with edX metadata + fix_metadata(po) + # remove key strings which belong in messages.po + strip_key_strings(po) + po.save() + +# By default, django-admin.py makemessages creates this header: +""" +SOME DESCRIPTIVE TITLE. +Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +This file is distributed under the same license as the PACKAGE package. +FIRST AUTHOR , YEAR. +""" + +def fix_header(po): + """ + Replace default headers with edX headers + """ + header = po.header + fixes = ( + ('SOME DESCRIPTIVE TITLE', 'edX translation file'), + ('Translations template for PROJECT.', 'edX translation file'), + ('YEAR', '%s' % datetime.utcnow().year), + ('ORGANIZATION', 'edX'), + ("THE PACKAGE'S COPYRIGHT HOLDER", "EdX"), + ('This file is distributed under the same license as the PROJECT project.', + 'This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE.'), + ('This file is distributed under the same license as the PACKAGE package.', + 'This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE.'), + ('FIRST AUTHOR ', + 'EdX Team ') + ) + for (src, dest) in fixes: + header = header.replace(src, dest) + po.header = header + +# By default, django-admin.py makemessages creates this metadata: +""" +{u'PO-Revision-Date': u'YEAR-MO-DA HO:MI+ZONE', + u'Language': u'', + u'Content-Transfer-Encoding': u'8bit', + u'Project-Id-Version': u'PACKAGE VERSION', + u'Report-Msgid-Bugs-To': u'', + u'Last-Translator': u'FULL NAME ', + u'Language-Team': u'LANGUAGE ', + u'POT-Creation-Date': u'2013-04-25 14:14-0400', + u'Content-Type': u'text/plain; charset=UTF-8', + u'MIME-Version': u'1.0'} +""" + +def fix_metadata(po): + """ + Replace default metadata with edX metadata + """ + fixes = {'PO-Revision-Date': datetime.utcnow(), + 'Report-Msgid-Bugs-To': 'translation_team@edx.org', + 'Project-Id-Version': '0.1a', + 'Language' : 'en', + 'Language-Team': 'translation team ', + } + if po.metadata.has_key('Last-Translator'): + del po.metadata['Last-Translator'] + po.metadata.update(fixes) + +def strip_key_strings(po): + """ + Removes all entries in PO which are key strings. + These entries should appear only in messages.po, not in any other po files. + """ + newlist = [entry for entry in po if not is_key_string(entry.msgid)] + del po[:] + po += newlist + +def is_key_string(string): + """ + returns True if string is a key string. + Key strings begin with underscore. + """ + return len(string)>1 and string[0]=='_' + +if __name__ == '__main__': + main() diff --git a/i18n/generate.py b/i18n/generate.py new file mode 100755 index 0000000000..ddbaadfa70 --- /dev/null +++ b/i18n/generate.py @@ -0,0 +1,64 @@ +#!/usr/bin/python + +""" + See https://edx-wiki.atlassian.net/wiki/display/ENG/PO+File+workflow + + + This task merges and compiles the human-readable .pofiles on the + local filesystem into machine-readable .mofiles. This is typically + necessary as part of the build process since these .mofiles are + needed by Django when serving the web app. + + The configuration file (in mitx/conf/locale/config) specifies which + languages to generate. +""" + +import os +from execute import execute, get_config, messages_dir, remove_file, \ + BASE_DIR, LOG, SOURCE_LOCALE + +def merge(locale, target='django.po'): + """ + For the given locale, merge django-partial.po, messages.po, mako.po -> django.po + """ + LOG.info('Merging locale={0}'.format(locale)) + locale_directory = messages_dir(locale) + files_to_merge = ('django-partial.po', 'messages.po', 'mako.po') + validate_files(locale_directory, files_to_merge) + + # merged file is merged.po + merge_cmd = 'msgcat -o merged.po ' + ' '.join(files_to_merge) + execute(merge_cmd, working_directory=locale_directory) + + # rename merged.po -> django.po (default) + merged_filename = os.path.join(locale_directory, 'merged.po') + django_filename = os.path.join(locale_directory, target) + os.rename(merged_filename, django_filename) # can't overwrite file on Windows + +def validate_files(dir, files_to_merge): + """ + Asserts that the given files exist. + files_to_merge is a list of file names (no directories). + dir is the directory in which the files should appear. + raises an Exception if any of the files are not in dir. + """ + for path in files_to_merge: + pathname = os.path.join(dir, path) + if not os.path.exists(pathname): + raise Exception("File not found: {0}".format(pathname)) + +def main (): + configuration = get_config() + if configuration == None: + LOG.warn('Configuration file not found, using only English.') + locales = (SOURCE_LOCALE,) + else: + locales = configuration['locales'] + for locale in locales: + merge(locale) + + compile_cmd = 'django-admin.py compilemessages' + execute(compile_cmd, working_directory=BASE_DIR) + +if __name__ == '__main__': + main() diff --git a/i18n/make_dummy.py b/i18n/make_dummy.py index 4ccfb0d5f1..c8dcde861a 100755 --- a/i18n/make_dummy.py +++ b/i18n/make_dummy.py @@ -18,20 +18,12 @@ import os, sys import polib from dummy import Dummy +from execute import create_dir_if_necessary -# Dummy language -# two letter language codes reference: -# see http://www.loc.gov/standards/iso639-2/php/code_list.php -# -# Django will not localize in languages that django itself has not been -# localized for. So we are using a well-known language: 'fr'. - -OUT_LANG = 'fr' - -def main(file): +def main(file, locale): """ Takes a source po file, reads it, and writes out a new po file - containing a dummy translation. + in :param locale: containing a dummy translation. """ if not os.path.exists(file): raise IOError('File does not exist: %s' % file) @@ -40,29 +32,36 @@ def main(file): converter.init_msgs(pofile.translated_entries()) for msg in pofile: converter.convert_msg(msg) - new_file = new_filename(file, OUT_LANG) + new_file = new_filename(file, locale) create_dir_if_necessary(new_file) pofile.save(new_file) - - -def new_filename(original_filename, new_lang): - """Returns a filename derived from original_filename, using new_lang as the locale""" +def new_filename(original_filename, new_locale): + """Returns a filename derived from original_filename, using new_locale as the locale""" orig_dir = os.path.dirname(original_filename) msgs_dir = os.path.basename(orig_dir) orig_file = os.path.basename(original_filename) - return '%s/%s/%s/%s' % (os.path.abspath(orig_dir + '/../..'), - new_lang, - msgs_dir, - orig_file) + return os.path.join(orig_dir, + '/../..', + new_locale, + msgs_dir, + orig_file) -def create_dir_if_necessary(pathname): - dirname = os.path.dirname(pathname) - if not os.path.exists(dirname): - os.makedirs(dirname) +# Dummy language +# two letter language codes reference: +# see http://www.loc.gov/standards/iso639-2/php/code_list.php +# +# Django will not localize in languages that django itself has not been +# localized for. So we are using a well-known language: 'fr'. + +DEFAULT_LOCALE = 'fr' if __name__ == '__main__': if len(sys.argv)<2: raise Exception("missing file argument") - main(sys.argv[1]) + if len(sys.argv)<2: + locale = DEFAULT_LOCALE + else: + locale = sys.argv[2] + main(sys.argv[1], locale) diff --git a/i18n/tests/__init__.py b/i18n/tests/__init__.py new file mode 100644 index 0000000000..d60515c712 --- /dev/null +++ b/i18n/tests/__init__.py @@ -0,0 +1,4 @@ +from test_extract import TestExtract +from test_generate import TestGenerate +from test_converter import TestConverter +from test_dummy import TestDummy diff --git a/i18n/tests/test_converter.py b/i18n/tests/test_converter.py new file mode 100644 index 0000000000..4dd5f02e3f --- /dev/null +++ b/i18n/tests/test_converter.py @@ -0,0 +1,42 @@ +import os +from unittest import TestCase + +import converter + +class UpcaseConverter (converter.Converter): + """ + Converts a string to uppercase. Just used for testing. + """ + def inner_convert_string(self, string): + return string.upper() + + +class TestConverter(TestCase): + """ + Tests functionality of i18n/converter.py + """ + + def test_converter(self): + """ + Tests with a simple converter (converts strings to uppercase). + Assert that embedded HTML and python tags are not converted. + """ + c = UpcaseConverter() + test_cases = ( + # no tags + ('big bad wolf', 'BIG BAD WOLF'), + # one html tag + ('big bad wolf', 'BIG BAD WOLF'), + # two html tags + ('big bad wolf', 'BIG BAD WOLF'), + # one python tag + ('big %(adjective)s wolf', 'BIG %(adjective)s WOLF'), + # two python tags + ('big %(adjective)s %(noun)s', 'BIG %(adjective)s %(noun)s'), + # both kinds of tags + ('big %(adjective)s %(noun)s', + 'BIG %(adjective)s %(noun)s'), + ) + for (source, expected) in test_cases: + result = c.convert(source) + self.assertEquals(result, expected) diff --git a/i18n/tests/test_dummy.py b/i18n/tests/test_dummy.py new file mode 100644 index 0000000000..88addb5a95 --- /dev/null +++ b/i18n/tests/test_dummy.py @@ -0,0 +1,50 @@ +import os, string, random +from unittest import TestCase +from polib import POEntry + +import dummy + + +class TestDummy(TestCase): + """ + Tests functionality of i18n/dummy.py + """ + + def setUp(self): + self.converter = dummy.Dummy() + + def test_dummy(self): + """ + Tests with a dummy converter (adds spurious accents to strings). + Assert that embedded HTML and python tags are not converted. + """ + test_cases = (("hello my name is Bond, James Bond", + u'h\xe9ll\xf6 my n\xe4m\xe9 \xefs B\xf6nd, J\xe4m\xe9s B\xf6nd Lorem i#'), + + ('don\'t convert tag ids', + u'd\xf6n\'t \xe7\xf6nv\xe9rt t\xe4g \xefds Lorem ipsu#'), + + ('don\'t convert %(name)s tags on %(date)s', + u"d\xf6n't \xe7\xf6nv\xe9rt %(name)s t\xe4gs \xf6n %(date)s Lorem ips#") + ) + for (source, expected) in test_cases: + result = self.converter.convert(source) + self.assertEquals(result, expected) + + def test_singular(self): + entry = POEntry() + entry.msgid = 'A lovely day for a cup of tea.' + expected = u'\xc0 l\xf6v\xe9ly d\xe4y f\xf6r \xe4 \xe7\xfcp \xf6f t\xe9\xe4. Lorem i#' + self.converter.convert_msg(entry) + self.assertEquals(entry.msgstr, expected) + + def test_plural(self): + entry = POEntry() + entry.msgid = 'A lovely day for a cup of tea.' + entry.msgid_plural = 'A lovely day for some cups of tea.' + expected_s = u'\xc0 l\xf6v\xe9ly d\xe4y f\xf6r \xe4 \xe7\xfcp \xf6f t\xe9\xe4. Lorem i#' + expected_p = u'\xc0 l\xf6v\xe9ly d\xe4y f\xf6r s\xf6m\xe9 \xe7\xfcps \xf6f t\xe9\xe4. Lorem ip#' + self.converter.convert_msg(entry) + result = entry.msgstr_plural + self.assertEquals(result['0'], expected_s) + self.assertEquals(result['1'], expected_p) diff --git a/i18n/tests/test_extract.py b/i18n/tests/test_extract.py new file mode 100644 index 0000000000..b14ae9872d --- /dev/null +++ b/i18n/tests/test_extract.py @@ -0,0 +1,85 @@ +import os, polib +from unittest import TestCase +from nose.plugins.skip import SkipTest +from datetime import datetime, timedelta + +import extract +from execute import SOURCE_MSGS_DIR + +# Make sure setup runs only once +SETUP_HAS_RUN = False + +class TestExtract(TestCase): + """ + Tests functionality of i18n/extract.py + """ + generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') + + def setUp(self): + # Skip this test because it takes too long (>1 minute) + # TODO: figure out how to declare a "long-running" test suite + # and add this test to it. + raise SkipTest() + + global SETUP_HAS_RUN + + # Subtract 1 second to help comparisons with file-modify time succeed, + # since os.path.getmtime() is not millisecond-accurate + self.start_time = datetime.now() - timedelta(seconds=1) + super(TestExtract, self).setUp() + if not SETUP_HAS_RUN: + # Run extraction script. Warning, this takes 1 minute or more + extract.main() + SETUP_HAS_RUN = True + + def get_files (self): + """ + This is a generator. + Returns the fully expanded filenames for all extracted files + Fails assertion if one of the files doesn't exist. + """ + for filename in self.generated_files: + path = os.path.join(SOURCE_MSGS_DIR, filename) + exists = os.path.exists(path) + self.assertTrue(exists, msg='Missing file: %s' % filename) + if exists: + yield path + + def test_files(self): + """ + Asserts that each auto-generated file has been modified since 'extract' was launched. + Intended to show that the file has been touched by 'extract'. + """ + + for path in self.get_files(): + self.assertTrue(datetime.fromtimestamp(os.path.getmtime(path)) > self.start_time, + msg='File not recently modified: %s' % os.path.basename(path)) + + def test_is_keystring(self): + """ + Verifies is_keystring predicate + """ + entry1 = polib.POEntry() + entry2 = polib.POEntry() + entry1.msgid = "_.lms.admin.warning.keystring" + entry2.msgid = "This is not a keystring" + self.assertTrue(extract.is_key_string(entry1.msgid)) + self.assertFalse(extract.is_key_string(entry2.msgid)) + + def test_headers(self): + """Verify all headers have been modified""" + for path in self.get_files(): + po = polib.pofile(path) + header = po.header + self.assertEqual(header.find('edX translation file'), 0, + msg='Missing header in %s:\n"%s"' % \ + (os.path.basename(path), header)) + + def test_metadata(self): + """Verify all metadata has been modified""" + for path in self.get_files(): + po = polib.pofile(path) + metadata = po.metadata + value = metadata['Report-Msgid-Bugs-To'] + expected = 'translation_team@edx.org' + self.assertEquals(expected, value) diff --git a/i18n/tests/test_generate.py b/i18n/tests/test_generate.py new file mode 100644 index 0000000000..fc22988251 --- /dev/null +++ b/i18n/tests/test_generate.py @@ -0,0 +1,61 @@ +import os, string, random +from unittest import TestCase +from datetime import datetime, timedelta + +import generate +from execute import get_config, messages_dir, SOURCE_MSGS_DIR, SOURCE_LOCALE + +class TestGenerate(TestCase): + """ + Tests functionality of i18n/generate.py + """ + generated_files = ('django-partial.po', 'djangojs.po', 'mako.po') + + def setUp(self): + self.configuration = get_config() + + # Subtract 1 second to help comparisons with file-modify time succeed, + # since os.path.getmtime() is not millisecond-accurate + self.start_time = datetime.now() - timedelta(seconds=1) + + def test_configuration(self): + """ + Make sure we have a valid configuration file, + and that it contains an 'en' locale. + """ + self.assertIsNotNone(self.configuration) + locales = self.configuration['locales'] + self.assertIsNotNone(locales) + self.assertIsInstance(locales, list) + self.assertIn('en', locales) + + def test_merge(self): + """ + Tests merge script on English source files. + """ + filename = os.path.join(SOURCE_MSGS_DIR, random_name()) + generate.merge(SOURCE_LOCALE, target=filename) + self.assertTrue(os.path.exists(filename)) + os.remove(filename) + + def test_main(self): + """ + Runs generate.main() which should merge source files, + then compile all sources in all configured languages. + Validates output by checking all .mo files in all configured languages. + .mo files should exist, and be recently created (modified + after start of test suite) + """ + generate.main() + for locale in self.configuration['locales']: + for filename in ('django.mo', 'djangojs.mo'): + path = os.path.join(messages_dir(locale), filename) + exists = os.path.exists(path) + self.assertTrue(exists, msg='Missing file in locale %s: %s' % (locale, filename)) + self.assertTrue(datetime.fromtimestamp(os.path.getmtime(path)) >= self.start_time, + msg='File not recently modified: %s' % path) + +def random_name(size=6): + """Returns random filename as string, like test-4BZ81W""" + chars = string.ascii_uppercase + string.digits + return 'test-' + ''.join(random.choice(chars) for x in range(size)) diff --git a/i18n/update.py b/i18n/update.py deleted file mode 100755 index 447dcf71d5..0000000000 --- a/i18n/update.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/python - -import os, subprocess, logging, json -from make_dummy import create_dir_if_necessary, main as dummy_main - -''' -Generate or update all translation files - Usage: - $ update.py - - - 1. extracts files from mako templates - 2. extracts files from django templates and python source files - 3. extracts files from django javascript files - 4. generates dummy text translations - 5. compiles po files to mo files - - Configuration (e.g. known languages) declared in mitx/conf/locale/config -''' - -# ----------------------------------- -# BASE_DIR is the working directory to execute django-admin commands from. -# Typically this should be the 'mitx' directory. -BASE_DIR = os.path.abspath(os.path.dirname(os.path.abspath(__file__))+'/..') - -# LOCALE_DIR contains the locale files. -# Typically this should be 'mitx/conf/locale' -LOCALE_DIR = BASE_DIR + '/conf/locale' - -# MSGS_DIR contains the English po files -MSGS_DIR = LOCALE_DIR + '/en/LC_MESSAGES' - -# CONFIG_FILENAME contains localization configuration in json format -CONFIG_FILENAME = LOCALE_DIR + '/config' - -# BABEL_CONFIG contains declarations for Babel to extract strings from mako template files -BABEL_CONFIG = LOCALE_DIR + '/babel.cfg' - -# Strings from mako template files are written to BABEL_OUT -BABEL_OUT = MSGS_DIR + '/mako.po' - -# These are the shell commands invoked by main() -COMMANDS = { - 'babel_mako': 'pybabel extract -F %s -c "TRANSLATORS:" . -o %s' % (BABEL_CONFIG, BABEL_OUT), - 'make_django': 'django-admin.py makemessages --all --ignore=src/* --extension html -l en', - 'make_djangojs': 'django-admin.py makemessages --all -d djangojs --ignore=src/* --extension js -l en', - 'msgcat' : 'msgcat -o merged.po django.po %s' % BABEL_OUT, - 'rename_django' : 'mv django.po django_old.po', - 'rename_merged' : 'mv merged.po django.po', - 'compile': 'django-admin.py compilemessages' - - } - -def execute (command_kwd, log, working_directory=BASE_DIR): - ''' - Executes command_kwd, which references a shell command in COMMANDS. - ''' - full_cmd = COMMANDS[command_kwd] - log.info('%s' % full_cmd) - subprocess.call(full_cmd.split(' '), cwd=working_directory) - -def make_log (): - '''returns a logger''' - log = logging.getLogger(__name__) - log.setLevel(logging.INFO) - log_handler = logging.StreamHandler() - log_handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')) - log.addHandler(log_handler) - return log - -def get_config (): - '''Returns data found in config file, or returns None if file not found''' - config_path = os.path.abspath(CONFIG_FILENAME) - if not os.path.exists(config_path): - return None - with open(config_path) as stream: - return json.load(stream) - -def main (): - log = make_log() - create_dir_if_necessary(LOCALE_DIR) - log.info('Executing all commands from %s' % BASE_DIR) - - remove_files = ['django.po', 'djangojs.po', 'nonesuch'] - for filename in remove_files: - path = MSGS_DIR + '/' + filename - log.info('Deleting file %s' % path) - if not os.path.exists(path): - log.warn("File does not exist: %s" % path) - else: - os.remove(path) - - # Generate or update human-readable .po files from all source code. - execute('babel_mako', log=log) - execute('make_django', log=log) - execute('make_djangojs', log=log) - execute('msgcat', log=log, working_directory=MSGS_DIR) - execute('rename_django', log=log, working_directory=MSGS_DIR) - execute('rename_merged', log=log, working_directory=MSGS_DIR) - - # Generate dummy text files from the English .po files - log.info('Generating dummy text.') - dummy_main(LOCALE_DIR + '/en/LC_MESSAGES/django.po') - dummy_main(LOCALE_DIR + '/en/LC_MESSAGES/djangojs.po') - - # Generate machine-readable .mo files - execute('compile', log) - -if __name__ == '__main__': - main() diff --git a/lms/static/sass/multicourse/_course_about.scss b/lms/static/sass/multicourse/_course_about.scss index d23917fe27..0982577f42 100644 --- a/lms/static/sass/multicourse/_course_about.scss +++ b/lms/static/sass/multicourse/_course_about.scss @@ -272,7 +272,9 @@ } .course-staff { + .teacher { + @include clearfix; margin-bottom: 40px; h3 { @@ -312,7 +314,7 @@ } } } - + .faq { @include clearfix; diff --git a/lms/templates/static_templates/contact.html b/lms/templates/static_templates/contact.html index d848164720..79e2743dbc 100644 --- a/lms/templates/static_templates/contact.html +++ b/lms/templates/static_templates/contact.html @@ -33,6 +33,9 @@

    Universities

    If you are a university wishing to collaborate or with questions about edX, please email university@edx.org.

    +

    Accessibility

    +

    EdX strives to create an innovative online-learning platform that promotes accessibility for everyone, including students with disabilities. We are dedicated to improving the accessibility of the platform and welcome your comments or questions at accessibility@edx.org.

    + diff --git a/local-requirements.txt b/local-requirements.txt index 177897f53d..201467d11e 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -2,8 +2,3 @@ -e common/lib/capa -e common/lib/xmodule -e . - -# XBlock: -# Might change frequently, so put it in local-requirements.txt, -# but conceptually is an external package, so it is in a separate repo. --e git+https://github.com/edx/XBlock.git@96d8f5f4#egg=XBlock diff --git a/rakefile b/rakefile index 766dd8a914..798e1c28bf 100644 --- a/rakefile +++ b/rakefile @@ -174,6 +174,11 @@ end desc "Install all python prerequisites for the lms and cms" task :install_python_prereqs do sh('pip install -r requirements.txt') + # Check for private-requirements.txt: used to install our libs as working dirs, + # or personal-use tools. + if File.file?("private-requirements.txt") + sh('pip install -r private-requirements.txt') + end end task :predjango do @@ -330,6 +335,12 @@ task :migrate, [:env] do |t, args| sh(django_admin(:lms, args.env, 'migrate')) end +desc "Run tests for the internationalization library" +task :test_i18n do + test = File.join(REPO_ROOT, "i18n", "tests") + sh("nosetests #{test}") +end + Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| task_name = "test_#{lib}" @@ -501,6 +512,30 @@ task :autodeploy_properties do end end +# --- Internationalization tasks + +desc "Extract localizable strings from sources" +task :extract_dev_strings do + sh(File.join(REPO_ROOT, "i18n", "extract.py")) +end + +desc "Compile localizable strings from sources. With optional flag 'extract', will extract strings first." +task :generate_i18n do + if ARGV.last.downcase == 'extract' + Rake::Task["extract_dev_strings"].execute + end + sh(File.join(REPO_ROOT, "i18n", "generate.py")) +end + +desc "Simulate international translation by generating dummy strings corresponding to source strings." +task :dummy_i18n do + source_files = Dir["#{REPO_ROOT}/conf/locale/en/LC_MESSAGES/*.po"] + dummy_locale = 'fr' + cmd = File.join(REPO_ROOT, "i18n", "make_dummy.py") + for file in source_files do + sh("#{cmd} #{file} #{dummy_locale}") + end +end # --- Develop and public documentation --- desc "Invoke sphinx 'make build' to generate docs." diff --git a/requirements.txt b/requirements.txt index 77239a4d50..d3fdd46b81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ -r repo-requirements.txt -Babel==0.9.6 beautifulsoup4==4.1.3 beautifulsoup==3.2.1 boto==2.6.0 @@ -62,6 +61,10 @@ newrelic==1.8.0.13 # Used for documentation gathering sphinx==1.1.3 +# Used for Internationalization and localization +Babel==0.9.6 +transifex-client==0.8 + # Used for testing coverage==3.6 factory_boy==2.0.2