diff --git a/course/migrations/0110_add_force_lang_field_to_course.py b/course/migrations/0110_add_force_lang_field_to_course.py new file mode 100644 index 0000000000000000000000000000000000000000..9a7ef9bd78f5379f158eac93bd75a4436727ee7e --- /dev/null +++ b/course/migrations/0110_add_force_lang_field_to_course.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2017-12-29 03:26 +from __future__ import unicode_literals + +import course.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0109_add_manage_authentication_tokens_permssion'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='force_lang', + field=models.CharField(blank=True, default='', help_text='Which language is forced to be used for this course.', max_length=200, null=True, validators=[course.models.validate_course_specific_language], verbose_name='Course language forcibly used'), + ), + ] diff --git a/course/models.py b/course/models.py index f33bf50c3711da9427f758188de01bbd79e99ad4..5e6291eaa8d2b7ea520c2b4acd93d47794bcf047 100644 --- a/course/models.py +++ b/course/models.py @@ -74,6 +74,19 @@ from yamlfield.fields import YAMLField # {{{ course +def validate_course_specific_language(value): + # type: (Text) -> None + if not value.strip(): + # the default value is "" + return + if value not in ( + [lang_code for lang_code, lang_descr in settings.LANGUAGES] + + [settings.LANGUAGE_CODE]): + raise ValidationError( + _("'%s' is currently not supported as a course specific " + "language at this site.") % value) + + class Course(models.Model): identifier = models.CharField(max_length=200, unique=True, help_text=_("A course identifier. Alphanumeric with dashes, " @@ -192,6 +205,13 @@ class Course(models.Model): "notifications about the course."), verbose_name=_('Notify email')) + force_lang = models.CharField(max_length=200, blank=True, null=True, + default="", + validators=[validate_course_specific_language], + help_text=_( + "Which language is forced to be used for this course."), + verbose_name=_('Course language forcibly used')) + # {{{ XMPP course_xmpp_id = models.CharField(max_length=200, blank=True, null=True, diff --git a/course/utils.py b/course/utils.py index 0992bb48d3f85412ad3b1f2d7265550b38a13ecc..4a595e5f221278eab6eb624d8cfc7f5a8f97e0f3 100644 --- a/course/utils.py +++ b/course/utils.py @@ -33,6 +33,7 @@ from django.shortcuts import ( # noqa render, get_object_or_404) from django import http from django.core.exceptions import ObjectDoesNotExist +from django.utils import translation from django.utils.translation import ( ugettext as _, pgettext_lazy) @@ -578,6 +579,10 @@ class CoursePageContext(object): self.course_identifier = course_identifier self._permissions_cache = None # type: Optional[FrozenSet[Tuple[Text, Optional[Text]]]] # noqa self._role_identifiers_cache = None # type: Optional[List[Text]] + self.old_language = None + + # using this to prevent nested using as context manager + self._is_in_context_manager = False from course.models import Course # noqa self.course = get_object_or_404(Course, identifier=course_identifier) @@ -659,10 +664,28 @@ class CoursePageContext(object): else: return (perm, argument) in self.permissions() + def _set_course_lang(self, action): + # type: (Text) -> None + if self.course.force_lang and self.course.force_lang.strip(): + if action == "activate": + self.old_language = translation.get_language() + translation.activate(self.course.force_lang) + else: + if self.old_language is not None: + translation.activate(self.old_language) + def __enter__(self): + if self._is_in_context_manager: + raise RuntimeError( + "Nested use of 'course_view' as context manager " + "is not allowed.") + self._is_in_context_manager = True + self._set_course_lang(action="activate") return self def __exit__(self, exc_type, exc_val, exc_tb): + self._is_in_context_manager = False + self._set_course_lang(action="deactivate") self.repo.close() @@ -1106,4 +1129,52 @@ def will_use_masked_profile_for_email(recipient_email): return True return False + +def get_course_specific_language_choices(): + # type: () -> Tuple[Tuple[str, Any], ...] + + from django.conf import settings + from collections import OrderedDict + + all_options = ((settings.LANGUAGE_CODE, None),) + tuple(settings.LANGUAGES) + filtered_options_dict = OrderedDict(all_options) + + def get_default_option(): + # type: () -> Tuple[Text, Text] + # For the default language used, if USE_I18N is True, display + # "Disabled". Otherwise display its lang info. + if not settings.USE_I18N: + formatted_descr = ( + get_formatted_options(settings.LANGUAGE_CODE, None)[1]) + else: + formatted_descr = _("disabled (i.e., displayed language is " + "determined by user's browser preference)") + return "", string_concat("%s: " % _("Default"), formatted_descr) + + def get_formatted_options(lang_code, lang_descr): + # type: (Text, Optional[Text]) -> Tuple[Text, Text] + if lang_descr is None: + lang_descr = OrderedDict(settings.LANGUAGES).get(lang_code) + if lang_descr is None: + try: + lang_info = translation.get_language_info(lang_code) + lang_descr = lang_info["name_translated"] + except KeyError: + return (lang_code.strip(), lang_code) + + return (lang_code.strip(), + string_concat(_(lang_descr), " (%s)" % lang_code)) + + filtered_options = ( + [get_default_option()] + + [get_formatted_options(k, v) + for k, v in six.iteritems(filtered_options_dict)]) + + # filtered_options[1] is the option for settings.LANGUAGE_CODE + # it's already displayed when settings.USE_I18N is False + if not settings.USE_I18N: + filtered_options.pop(1) + + return tuple(filtered_options) + # vim: foldmethod=marker diff --git a/course/versioning.py b/course/versioning.py index 6ff6d3ba3b1f581826ffdd54433718316385e303..d3e207e8300e2bb09664126661b09c3c1c598912 100644 --- a/course/versioning.py +++ b/course/versioning.py @@ -51,7 +51,9 @@ from course.models import ( Participation, ParticipationRole) -from course.utils import course_view, render_course_page +from course.utils import ( + course_view, render_course_page, + get_course_specific_language_choices) import paramiko import paramiko.client @@ -183,10 +185,13 @@ class CourseCreationForm(StyledModelForm): "enrollment_required_email_suffix", "from_email", "notify_email", + "force_lang", ) widgets = { "start_date": DateTimePicker(options={"format": "YYYY-MM-DD"}), "end_date": DateTimePicker(options={"format": "YYYY-MM-DD"}), + "force_lang": forms.Select( + choices=get_course_specific_language_choices()), } def __init__(self, *args, **kwargs): diff --git a/course/views.py b/course/views.py index 680aa951e5631bd2aa6109403f3f62ded7e357d1..e4a1bdfd249fb48ff2e67e7bc1554202b23c573b 100644 --- a/course/views.py +++ b/course/views.py @@ -80,7 +80,8 @@ from course.content import get_course_repo from course.utils import ( # noqa course_view, render_course_page, - CoursePageContext) + CoursePageContext, + get_course_specific_language_choices) # {{{ for mypy @@ -1361,7 +1362,9 @@ class EditCourseForm(StyledModelForm): ) widgets = { "start_date": DateTimePicker(options={"format": "YYYY-MM-DD"}), - "end_date": DateTimePicker(options={"format": "YYYY-MM-DD"}) + "end_date": DateTimePicker(options={"format": "YYYY-MM-DD"}), + "force_lang": forms.Select( + choices=get_course_specific_language_choices()), } @@ -1375,7 +1378,19 @@ def edit_course(pctx): if request.method == 'POST': form = EditCourseForm(request.POST, instance=pctx.course) if form.is_valid(): - form.save() + if form.has_changed(): + form.save() + messages.add_message( + request, messages.SUCCESS, + _("Successfully updated course settings.")) + else: + messages.add_message( + request, messages.INFO, + _("No change was made on the settings.")) + + else: + messages.add_message(request, messages.ERROR, + _("Failed to update course settings.")) else: form = EditCourseForm(instance=pctx.course) diff --git a/local_settings.example.py b/local_settings.example.py index d3cf224b7889fcab5d0d308ee236a268eda1a875..d10ff93c95089a7caeaa5ed3f6e842039589f428 100644 --- a/local_settings.example.py +++ b/local_settings.example.py @@ -305,7 +305,19 @@ RELATE_SITE_ANNOUNCEMENT = None # Make sure you have generated, translate and compile the message file of your # language. If commented, RELATE will use default language 'en-us'. -#LANGUAGE_CODE='en-us' +#LANGUAGE_CODE = 'en-us' + +# It's recommended to configure LANGUAGES if you want to filter languages allowed +# for course-specific languages. The format of languages should be a list/tuple of +# 2-tuples: (language_code, language_description). If there are entries with the +# same language_code, its language_description will use the one presents latest. +# If LANGUAGE is not configured, django.conf.global_settings.LANGUAGES will be used. + +# LANGUAGES = [ +# ('en', 'English'), +# ('zh-hans', 'Simplified Chinese'), +# ('de', 'German'), +# ] # {{{ exams and testing diff --git a/relate/checks.py b/relate/checks.py index a4b59f2e8b2b3820e0ac448a23bad0e8370e9260..b47acf2dcbed115464089ebbcac687b76f6dc5ca 100644 --- a/relate/checks.py +++ b/relate/checks.py @@ -34,6 +34,9 @@ REQUIRED_CONF_ERROR_PATTERN = ( INSTANCE_ERROR_PATTERN = "%(location)s must be an instance of %(types)s." GENERIC_ERROR_PATTERN = "Error in '%(location)s': %(error_type)s: %(error_str)s" +USE_I18N = "USE_I18N" +LANGUAGES = "LANGUAGES" + EMAIL_CONNECTIONS = "EMAIL_CONNECTIONS" RELATE_BASE_URL = "RELATE_BASE_URL" RELATE_EMAIL_APPELATION_PRIORITY_LIST = "RELATE_EMAIL_APPELATION_PRIORITY_LIST" @@ -343,6 +346,51 @@ def check_relate_settings(app_configs, **kwargs): # }}} + # {{{ check LANGUAGES, why this is not done in django? + + languages = settings.LANGUAGES + + from django.utils.itercompat import is_iterable + + if (isinstance(languages, six.string_types) or + not is_iterable(languages)): + errors.append(RelateCriticalCheckMessage( + msg=(INSTANCE_ERROR_PATTERN + % {"location": LANGUAGES, + "types": "an iterable (e.g., a list or tuple)."}), + id="relate_languages.E001") + ) + else: + if any(isinstance(choice, six.string_types) or + not is_iterable(choice) or len(choice) != 2 + for choice in languages): + errors.append(RelateCriticalCheckMessage( + msg=("'%s' must be an iterable containing " + "(language code, language description) tuples, just " + "like the format of LANGUAGES setting (" + "https://docs.djangoproject.com/en/dev/ref/settings/" + "#languages)" % LANGUAGES), + id="relate_languages.E002") + ) + else: + from collections import OrderedDict + all_options = ( + ((settings.LANGUAGE_CODE, None),) + tuple(settings.LANGUAGES)) + filtered_options_dict = OrderedDict(all_options) + all_lang_codes = [lang_code for lang_code, lang_descr in all_options] + for lang_code in filtered_options_dict.keys(): + if all_lang_codes.count(lang_code) > 1: + errors.append(Warning( + msg=( + "Duplicate language entries were found in " + "settings.LANGUAGES for '%s', '%s' will be used " + "as its language_description" + % (lang_code, filtered_options_dict[lang_code])), + id="relate_languages.W001" + )) + + # }}} + return errors diff --git a/tests/base_test_mixins.py b/tests/base_test_mixins.py index ca1b74a75a92ba61380fb869c9560e018f3effda..6608f8b87ea26dbe8f220860b7c44dcff22ff893 100644 --- a/tests/base_test_mixins.py +++ b/tests/base_test_mixins.py @@ -573,10 +573,30 @@ class CoursesTestMixinBase(SuperuserCreateMixin): Course.objects.create(**create_kwargs) assert Course.objects.count() == existing_course_count + 1 + @classmethod + def get_course_view_url(cls, view_name, course_identifier=None): + course_identifier = ( + course_identifier or cls.get_default_course_identifier()) + return reverse(view_name, args=[course_identifier]) + + @classmethod + def get_edit_course_url(cls, course_identifier=None): + return cls.get_course_view_url("relate-edit_course", course_identifier) + + @classmethod + def post_edit_course(cls, data, course=None): + course = course or cls.get_default_course() + edit_course_url = cls.get_edit_course_url(course.identifier) + return cls.c.post(edit_course_url, data) + + @classmethod + def get_edit_course(cls, course=None): + course = course or cls.get_default_course() + return cls.c.get(cls.get_edit_course_url()) + @classmethod def get_course_page_url(cls, course_identifier=None): - course_identifier = course_identifier or cls.get_default_course_identifier() - return reverse("relate-course_page", args=[course_identifier]) + return cls.get_course_view_url("relate-course_page", course_identifier) def get_logged_in_user(self): try: diff --git a/tests/test_checks.py b/tests/test_checks.py index 430f11c1e18e8ee5b1e3ee3e953b3b874cbdfe71..ae1c687115de1e637f59edfcab3f9bd0b5c2b845 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -23,12 +23,9 @@ THE SOFTWARE. """ import os -from django.test import SimpleTestCase +from django.test import SimpleTestCase, mock from django.test.utils import override_settings -try: - from unittest import mock -except Exception: - import mock +from django.utils.translation import ugettext_lazy as _ class CheckRelateSettingsBase(SimpleTestCase): @@ -466,3 +463,109 @@ class CheckGitRoot(CheckRelateSettingsBase): self.assertEqual(len(result), 2) self.assertEqual([r.id for r in result], ["git_root.E004", "git_root.E005"]) + + +class CheckRelateCourseLanguages(CheckRelateSettingsBase): + # For this tests to pass, LANGUAGE_CODE, LANGUAGES, USE_I18N in + # local_settings.example.py should not be configured + + VALID_CONF1 = [ + ('en', _('my English')), + ('zh-hans', _('Simplified Chinese')), + ('de', _('German'))] + VALID_CONF2 = ( + ('en', _('English')), + ('zh-hans', _('Simplified Chinese')), + ('de', _('German'))) + VALID_CONF3 = ( + ('en', 'English'), + ('zh-hans', 'Simplified Chinese'), + ('de', _('German'))) + + VALID_WITH_WARNNING_CONF = ( + ('en', 'English'), + ('zh-hans', 'Simplified Chinese'), + ('zh-hans', 'my Simplified Chinese'), + ('de', _('German'))) + + VALID_CONF4 = [('en', ('English',)), ] + VALID_CONF5 = (['en', 'English'],) + VALID_CONF6 = [(('en',), _('English')), ] + + INVALID_CONF1 = { + 'en': 'English', + 'zh-hans': 'Simplified Chinese', + 'de': _('German')} + INVALID_CONF2 = (('en',),) + INVALID_CONF3 = [('en',), ([], 'English'), ["1", "2"]] + INVALID_CONF4 = "some thing" + + def test_valid(self): + with override_settings(LANGUAGES=self.VALID_CONF1): + self.assertEqual(self.func(None), []) + + with override_settings(LANGUAGES=self.VALID_CONF2): + self.assertEqual(self.func(None), []) + + with override_settings(LANGUAGES=self.VALID_CONF3): + self.assertEqual(self.func(None), []) + + with override_settings(LANGUAGES=self.VALID_CONF4): + self.assertEqual(self.func(None), []) + + with override_settings(LANGUAGES=self.VALID_CONF5): + self.assertEqual(self.func(None), []) + + with override_settings(LANGUAGES=self.VALID_CONF6): + self.assertEqual(self.func(None), []) + + def test_lang_not_list_or_tuple(self): + with override_settings(LANGUAGES=self.INVALID_CONF1): + self.assertEqual([r.id for r in self.func(None)], + ["relate_languages.E002"]) + + def test_lang_item_not_2_tuple(self): + with override_settings(LANGUAGES=self.INVALID_CONF2): + self.assertEqual([r.id for r in self.func(None)], + ["relate_languages.E002"]) + + def test_lang_multiple_error(self): + with override_settings(LANGUAGES=self.INVALID_CONF3): + self.assertEqual([r.id for r in self.func(None)], + ['relate_languages.E002']) + + def test_lang_type_string(self): + with override_settings(LANGUAGES=self.INVALID_CONF4): + self.assertEqual([r.id for r in self.func(None)], + ["relate_languages.E001"]) + + def test_item_having_same_lang_code_with_settings_language_code(self): + with override_settings(LANGUAGES=self.VALID_CONF1, LANGUAGE_CODE="en"): + self.assertEqual([r.id for r in self.func(None)], + ["relate_languages.W001"]) + + # 'my English' is used for language description of 'en' + # instead of 'English' + self.assertEqual([r.msg for r in self.func(None)], + ["Duplicate language entries were found in " + "settings.LANGUAGES for 'en', 'my English' " + "will be used as its language_description"]) + + def test_item_duplicated_inside_settings_languages(self): + with override_settings(LANGUAGES=self.VALID_WITH_WARNNING_CONF): + self.assertEqual([r.id for r in self.func(None)], + ["relate_languages.W001"]) + # 'my Simplified Chinese' is used for language description of 'zh-hans' + # instead of 'Simplified Chinese' + self.assertEqual([r.msg for r in self.func(None)], + ["Duplicate language entries were found in " + "settings.LANGUAGES for 'zh-hans', 'my Simplified " + "Chinese' will be used as its " + "language_description"]) + + def test_item_duplicated_mixed(self): + with override_settings(LANGUAGES=self.VALID_WITH_WARNNING_CONF, + LANGUAGE_CODE="en"): + self.assertEqual([r.id for r in self.func(None)], + ["relate_languages.W001", + "relate_languages.W001"]) diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 0000000000000000000000000000000000000000..495b671ee7f7a45895f7f9afe572818d5336cf8e --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- + +from __future__ import division + +__copyright__ = "Copyright (C) 2017 Dong Zhuang" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import six +import datetime +from django.test import TestCase +from django.test.utils import override_settings +from django.utils.translation import ugettext_lazy as _ + +from course.models import Course +from course.views import EditCourseForm +from course.versioning import CourseCreationForm + +from .base_test_mixins import SingleCourseTestMixin +from .test_views import DATE_TIME_PICKER_TIME_FORMAT + +LANGUAGES = [ + ('en', _('English')), + ('ko', _('Korean')), + ('fr', _('French')), +] + +ASSERSION_ERROR_LANGUAGE_PATTERN = ( + "%s page visiting results don't match in terms of " + "whether the response contains Korean characters." +) + +ASSERSION_ERROR_CONTENT_LANGUAGE_PATTERN = ( + "%s page visiting result don't match in terms of " + "whether the response content-language are restored." +) + +VALIDATION_ERROR_LANG_NOT_SUPPORTED_PATTERN = ( + "'%s' is currently not supported as a course specific language at " + "this site." +) + + +class CourseSpecificLangTestMixin(SingleCourseTestMixin, TestCase): + # {{{ assertion method + def response_contains_korean(self, resp): + # Korean literals for 12th month (December) + return "12ì›”" in resp.content.decode("utf-8") + + def assertResponseContainsChinese(self, resp): # noqa + self.assertTrue(self.response_contains_korean(resp)) + + def assertResponseNotContainsChinese(self, resp): # noqa + self.assertFalse(self.response_contains_korean(resp)) + + # }}} + + # {{{ common tests + def resp_info_with_diff_settings(self, url): + contains_korean_result = [] + response_content_language_result = [] + + with override_settings(USE_I18N=True, LANGUAGE_CODE='en-us'): + resp = self.c.get(url) + self.assertEqual(resp.status_code, 200) + contains_korean_result.append(self.response_contains_korean(resp)) + response_content_language_result.append(resp['content-language']) + + resp = self.c.get(url, HTTP_ACCEPT_LANGUAGE='ko') + self.assertEqual(resp.status_code, 200) + contains_korean_result.append(self.response_contains_korean(resp)) + response_content_language_result.append(resp['content-language']) + + with override_settings(USE_I18N=False): + resp = self.c.get(url) + self.assertEqual(resp.status_code, 200) + contains_korean_result.append(self.response_contains_korean(resp)) + response_content_language_result.append(resp['content-language']) + + resp = self.c.get(url, HTTP_ACCEPT_LANGUAGE='ko') + self.assertEqual(resp.status_code, 200) + contains_korean_result.append(self.response_contains_korean(resp)) + response_content_language_result.append(resp['content-language']) + + return contains_korean_result, response_content_language_result + + def home_resp_contains_korean_with_diff_settings(self): + return self.resp_info_with_diff_settings("/") + + def course_resp_contains_korean_with_diff_settings(self): + return self.resp_info_with_diff_settings(self.course_page_url) + + # }}} + + +class CourseSpecificLangConfigureTest(CourseSpecificLangTestMixin, TestCase): + # By default, self.course.force_lang is None + + def setUp(self): + super(CourseSpecificLangConfigureTest, self).setUp() + # We use faked time header to find out whether the expected Chinese + # characters are rendered + self.c.force_login(self.instructor_participation.user) + fake_time = datetime.datetime(2038, 12, 31, 0, 0, 0, 0) + set_fake_time_data = { + "time": fake_time.strftime(DATE_TIME_PICKER_TIME_FORMAT), + "set": ['']} + self.post_set_fake_time(set_fake_time_data) + + def assertResponseBehaveLikeUnconfigured(self): # noqa + # For each setting combinations, the response behaves the same + # as before this functionality was introduced + expected_result = ([False, True, False, True], + ['en', 'ko', 'en', 'ko']) + self.assertEqual( + self.home_resp_contains_korean_with_diff_settings()[0], + expected_result[0], + ASSERSION_ERROR_LANGUAGE_PATTERN % "Home" + ) + + self.assertEqual( + self.home_resp_contains_korean_with_diff_settings()[1], + expected_result[1], + ASSERSION_ERROR_CONTENT_LANGUAGE_PATTERN % "Home" + ) + + expected_result = ([False, True, False, True], + ['en', 'ko', 'en', 'ko']) + self.assertEqual( + self.course_resp_contains_korean_with_diff_settings()[0], + expected_result[0], + ASSERSION_ERROR_LANGUAGE_PATTERN % "Course" + ) + self.assertEqual( + self.course_resp_contains_korean_with_diff_settings()[1], + expected_result[1], + ASSERSION_ERROR_CONTENT_LANGUAGE_PATTERN % "Course" + ) + + def assertResponseBehaveAsExpectedForCourseWithForceLang(self): # noqa + # For each setting combinations, the response behaves as expected + expected_result = ([False, True, False, True], + ['en', 'ko', 'en', 'ko']) + self.assertEqual( + self.home_resp_contains_korean_with_diff_settings()[0], + expected_result[0], + ASSERSION_ERROR_LANGUAGE_PATTERN % "Home" + ) + + self.assertEqual( + self.home_resp_contains_korean_with_diff_settings()[1], + expected_result[1], + ASSERSION_ERROR_CONTENT_LANGUAGE_PATTERN % "Home" + ) + + expected_result = ([True, True, True, True], + ['en', 'ko', 'en', 'ko']) + self.assertEqual( + self.course_resp_contains_korean_with_diff_settings()[0], + expected_result[0], + ASSERSION_ERROR_LANGUAGE_PATTERN % "Course" + ) + self.assertEqual( + self.course_resp_contains_korean_with_diff_settings()[1], + expected_result[1], + ASSERSION_ERROR_CONTENT_LANGUAGE_PATTERN % "Course" + ) + + def set_course_lang_to_zh_hans(self): + self.course.force_lang = "ko" + self.course.save() + self.course.refresh_from_db() + + def test_recsl_configured_true_lang_not_configured(self): + self.assertResponseBehaveLikeUnconfigured() + + def test_recsl_configured_true_lang_not_configured_course_has_force_lang(self): + self.set_course_lang_to_zh_hans() + self.assertResponseBehaveAsExpectedForCourseWithForceLang() + + @override_settings(LANGUAGES=LANGUAGES) + def test_recsl_configured_true_lang_configured(self): + # because self.course.force_lang is None + self.assertResponseBehaveLikeUnconfigured() + + @override_settings(LANGUAGES=LANGUAGES) + def test_recsl_configured_true_lang_configured_course_has_force_lang(self): + self.set_course_lang_to_zh_hans() + self.assertResponseBehaveAsExpectedForCourseWithForceLang() + + +class CourseSpecificLangFormTest(SingleCourseTestMixin, TestCase): + + def copy_course_dict_and_set_lang_for_post(self, lang): + kwargs = Course.objects.first().__dict__ + kwargs["force_lang"] = lang + for k, v in six.iteritems(kwargs): + if v is None: + kwargs[k] = "" + return kwargs + + def test_edit_course_force_lang_invalid(self): + course_kwargs = self.copy_course_dict_and_set_lang_for_post("foo") + form = EditCourseForm(course_kwargs, instance=self.course) + self.assertTrue("force_lang" in form.fields) + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors["force_lang"][0], + VALIDATION_ERROR_LANG_NOT_SUPPORTED_PATTERN % "foo") + + def test_edit_course_force_lang_valid(self): + course_kwargs = self.copy_course_dict_and_set_lang_for_post("de") + form = EditCourseForm(course_kwargs, instance=self.course) + self.assertTrue(form.is_valid()) + + def test_create_course_force_lang_invalid(self): + course_kwargs = self.copy_course_dict_and_set_lang_for_post("foo") + course_kwargs["identifier"] = "another-test-course" + expected_course_count = Course.objects.count() + form = CourseCreationForm(course_kwargs) + self.assertTrue("force_lang" in form.fields) + self.assertFalse(form.is_valid()) + self.assertEqual(form.errors["force_lang"][0], + VALIDATION_ERROR_LANG_NOT_SUPPORTED_PATTERN % "foo") + self.assertEqual(Course.objects.count(), expected_course_count) + +# vim: foldmethod=marker diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..a1ebbe6ab05d056f748c49d9498d811b9549b8bc --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- + +from __future__ import division + +__copyright__ = "Copyright (C) 2017 Dong Zhuang" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +from django.test import SimpleTestCase, mock +from django.test.utils import override_settings +from course.utils import get_course_specific_language_choices + + +class GetCourseSpecificLanguageChoicesTest(SimpleTestCase): + # test course.utils.get_course_specific_language_choices + + LANGUAGES_CONF1 = [ + ('en', 'English'), + ('zh-hans', 'Simplified Chinese'), + ('de', 'German')] + LANGUAGES_CONF2 = [ + ('en', 'English'), + ('zh-hans', 'Simplified Chinese'), + ('zh-hans', 'my Simplified Chinese'), + ('de', 'German')] + + @override_settings(USE_I18N=False, LANGUAGES=LANGUAGES_CONF1, + LANGUAGE_CODE='ko') + def test_i18n_disabled(self): + choices = get_course_specific_language_choices() + self.assertTrue(choices[0][1].startswith("Default:")) + self.assertNotIn("disabled", choices[0][1]) + self.assertEqual(len(choices), 4) + self.assertIn("(ko)", choices[0][1]) + + @override_settings(USE_I18N=False, LANGUAGES=LANGUAGES_CONF1, + LANGUAGE_CODE='en') + def test_i18n_disabled_lang_items_has_same_lang_code_with_language_code(self): + choices = get_course_specific_language_choices() + self.assertTrue(choices[0][1].startswith("Default:")) + self.assertNotIn("disabled", choices[0][1]) + self.assertEqual(len(choices), 3) + + @override_settings(USE_I18N=False, LANGUAGES=LANGUAGES_CONF2, + LANGUAGE_CODE='en-us') + def test_i18n_disabled_lang_items_having_duplicated_lang_code(self): + choices = get_course_specific_language_choices() + self.assertTrue(choices[0][1].startswith("Default:")) + self.assertNotIn("disabled", choices[0][1]) + self.assertEqual(len(choices), 4) + + @override_settings(USE_I18N=True, LANGUAGES=LANGUAGES_CONF1, + LANGUAGE_CODE='ko') + def test_i18n_enabled(self): + choices = get_course_specific_language_choices() + self.assertTrue(choices[0][1].startswith("Default: disabled")) + self.assertEqual(len(choices), 5) + self.assertIn("(ko)", choices[1][1]) + + @override_settings(USE_I18N=True, LANGUAGES=LANGUAGES_CONF1, + LANGUAGE_CODE='en') + def test_i18n_enabled_lang_items_has_same_lang_code_with_language_code(self): + choices = get_course_specific_language_choices() + self.assertTrue(choices[0][1].startswith("Default: disabled")) + self.assertEqual(len(choices), 4) + + @override_settings(USE_I18N=True, LANGUAGES=LANGUAGES_CONF2, + LANGUAGE_CODE='en-us') + def test_i18n_enabled_lang_items_having_duplicated_lang_code(self): + choices = get_course_specific_language_choices() + self.assertEqual(len(choices), 5) + self.assertTrue(choices[0][1].startswith("Default: disabled")) + + def lang_descr_get_translated(self, choice_count): + with mock.patch("course.utils._") as mock_ugettext, \ + mock.patch("django.utils.translation.ugettext_lazy") \ + as mock_ugettext_lazy: + mock_ugettext.side_effect = lambda x: x + mock_ugettext_lazy.side_effect = lambda x: x + choices = get_course_specific_language_choices() + self.assertEqual(len(choices), choice_count) + + # "English", "Default", "my Simplified Chinese" and "German" are + # called by django.utils.translation.ugettext, for at least once. + # Another language description literals (especially "Simplified Chinese") + # are not called by it. + self.assertTrue(mock_ugettext.call_count >= 4) + simplified_chinese_as_arg_count = 0 + my_simplified_chinese_as_arg_count = 0 + for call in mock_ugettext.call_args_list: + arg, kwargs = call + if "my Simplified Chinese" in arg: + my_simplified_chinese_as_arg_count += 1 + if "Simplified Chinese" in arg: + simplified_chinese_as_arg_count += 1 + self.assertEqual(simplified_chinese_as_arg_count, 0) + self.assertTrue(my_simplified_chinese_as_arg_count > 0) + + def test_lang_descr_translated(self): + with override_settings(USE_I18N=True, LANGUAGES=self.LANGUAGES_CONF2, + LANGUAGE_CODE='en-us'): + self.lang_descr_get_translated(choice_count=5) + + with override_settings(USE_I18N=True, LANGUAGES=self.LANGUAGES_CONF2, + LANGUAGE_CODE='en-us'): + self.lang_descr_get_translated(choice_count=5) + + def test_user_customized_lang_code_as_settings_language_code(self): + with override_settings(USE_I18N=True, LANGUAGES=self.LANGUAGES_CONF2, + LANGUAGE_CODE='user_customized_lang_code'): + with self.assertRaises(IOError): + # because there's no file named "user_customized_lang_code.mo" + get_course_specific_language_choices() + + with mock.patch( + "django.utils.translation.trans_real.do_translate" + ) as mock_do_translate: + mock_do_translate.side_effect = lambda x, y: x + choices = get_course_specific_language_choices() + + # The language description is the language_code, because it can't + # be found in django.conf.locale.LANG_INFO + self.assertEqual(choices[1][1], "user_customized_lang_code") + + with override_settings(USE_I18N=False, LANGUAGES=self.LANGUAGES_CONF2, + LANGUAGE_CODE='user_customized_lang_code'): + with mock.patch( + "django.utils.translation.trans_real.do_translate" + ) as mock_do_translate: + mock_do_translate.side_effect = lambda x, y: x + choices = get_course_specific_language_choices() + + # The language description is the language_code, because it can't + # be found in django.conf.locale.LANG_INFO + self.assertIn("user_customized_lang_code", choices[0][1]) + +# vim: foldmethod=marker diff --git a/tests/test_views.py b/tests/test_views.py index 345ff19adffe6a6e547e4cfb0eec81a477f399fb..4bda143da18b169730d0a798b48481150191bd83 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -22,9 +22,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from django.test import TestCase +from django.test import TestCase, RequestFactory, mock from django.test.utils import override_settings import datetime +from course import views from .base_test_mixins import ( SingleCourseTestMixin, @@ -129,3 +130,143 @@ class TestSetPretendFacilities(SingleCourseTestMixin, TestCase): self.unset_pretend_facilities_data) self.assertEqual(resp.status_code, 200) self.assertSessionPretendFacilitiesIsNone(self.c.session) + + +class TestEditCourse(SingleCourseTestMixin, TestCase): + def setUp(self): + self.rf = RequestFactory() + + def copy_course_dict_and_set_lang_for_post(self, lang): + from course.models import Course + kwargs = Course.objects.first().__dict__ + kwargs["force_lang"] = lang + + import six + for k, v in six.iteritems(kwargs): + if v is None: + kwargs[k] = "" + return kwargs + + def test_non_auth_edit_get(self): + with self.temporarily_switch_to_user(None): + resp = self.get_edit_course() + self.assertTrue(resp.status_code, 404) + + def test_non_auth_edit_post(self): + with self.temporarily_switch_to_user(None): + resp = self.post_edit_course(data={}) + self.assertTrue(resp.status_code, 404) + + def test_student_edit_get(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.get_edit_course() + self.assertTrue(resp.status_code, 404) + + def test_student_edit_post(self): + with self.temporarily_switch_to_user(self.student_participation.user): + resp = self.post_edit_course(data={}) + self.assertTrue(resp.status_code, 404) + + def test_instructor_edit_get(self): + with self.temporarily_switch_to_user(self.instructor_participation.user): + resp = self.get_edit_course() + self.assertTrue(resp.status_code, 200) + + def test_instructor_edit_post_unchanged(self): + with mock.patch('course.views.EditCourseForm.is_valid') as mock_is_valid, \ + mock.patch('course.views.EditCourseForm.has_changed') as mock_changed, \ + mock.patch('course.views.messages') as mock_messages,\ + mock.patch("course.views.render_course_page"),\ + mock.patch("course.views._") as mock_gettext: + + mock_is_valid.return_value = True + mock_changed.return_value = False + mock_gettext.side_effect = lambda x: x + request = self.rf.post(self.get_edit_course_url(), + data=mock.MagicMock()) + request.user = self.instructor_participation.user + course_identifier = self.get_default_course_identifier() + resp = views.edit_course(request, course_identifier) + self.assertTrue(resp.status_code, 200) + self.assertTrue(mock_messages.add_message.call_count, 1) + self.assertIn("No change was made on the settings.", + mock_messages.add_message.call_args[0]) + + def test_instructor_edit_post_saved(self): + with mock.patch('course.views.EditCourseForm.is_valid') as mock_is_valid, \ + mock.patch('course.views.EditCourseForm.has_changed') as mock_changed, \ + mock.patch('course.views.EditCourseForm.save'), \ + mock.patch('course.views.messages') as mock_messages,\ + mock.patch("course.views.render_course_page"),\ + mock.patch("course.views._") as mock_gettext: + + mock_is_valid.return_value = True + mock_changed.return_value = True + mock_gettext.side_effect = lambda x: x + request = self.rf.post(self.get_edit_course_url(), + data=mock.MagicMock()) + request.user = self.instructor_participation.user + course_identifier = self.get_default_course_identifier() + resp = views.edit_course(request, course_identifier) + self.assertTrue(resp.status_code, 200) + self.assertTrue(mock_messages.add_message.call_count, 1) + self.assertIn("Successfully updated course settings.", + mock_messages.add_message.call_args[0]) + + def test_instructor_edit_post_saved_default(self): + data = self.copy_course_dict_and_set_lang_for_post("") + with mock.patch('course.views.EditCourseForm.has_changed') as mock_changed, \ + mock.patch('course.views.EditCourseForm.save'), \ + mock.patch('course.views.messages') as mock_messages,\ + mock.patch("course.views.render_course_page"),\ + mock.patch("course.views._") as mock_gettext: + + mock_changed.return_value = True + mock_gettext.side_effect = lambda x: x + request = self.rf.post(self.get_edit_course_url(), + data=data) + request.user = self.instructor_participation.user + course_identifier = self.get_default_course_identifier() + resp = views.edit_course(request, course_identifier) + self.assertTrue(resp.status_code, 200) + self.assertTrue(mock_messages.add_message.call_count, 1) + self.assertIn("Successfully updated course settings.", + mock_messages.add_message.call_args[0]) + + def test_instructor_edit_post_saved_spaces_as_lang(self): + data = self.copy_course_dict_and_set_lang_for_post(" ") + with mock.patch('course.views.EditCourseForm.has_changed') as mock_changed, \ + mock.patch('course.views.EditCourseForm.save'), \ + mock.patch('course.views.messages') as mock_messages,\ + mock.patch("course.views.render_course_page"),\ + mock.patch("course.views._") as mock_gettext: + + mock_changed.return_value = True + mock_gettext.side_effect = lambda x: x + request = self.rf.post(self.get_edit_course_url(), + data=data) + request.user = self.instructor_participation.user + course_identifier = self.get_default_course_identifier() + resp = views.edit_course(request, course_identifier) + self.assertTrue(resp.status_code, 200) + self.assertTrue(mock_messages.add_message.call_count, 1) + self.assertIn("Successfully updated course settings.", + mock_messages.add_message.call_args[0]) + + def test_instructor_edit_post_form_invalid(self): + with mock.patch('course.views.EditCourseForm.is_valid') as mock_is_valid, \ + mock.patch('course.views.messages') as mock_messages,\ + mock.patch("course.views.render_course_page"),\ + mock.patch("course.views._") as mock_gettext: + + mock_is_valid.return_value = False + mock_gettext.side_effect = lambda x: x + request = self.rf.post(self.get_edit_course_url(), + data=mock.MagicMock()) + request.user = self.instructor_participation.user + course_identifier = self.get_default_course_identifier() + resp = views.edit_course(request, course_identifier) + self.assertTrue(resp.status_code, 200) + self.assertTrue(mock_messages.add_message.call_count, 1) + self.assertIn("Failed to update course settings.", + mock_messages.add_message.call_args[0])