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])