diff --git a/cms/envs/aws.py b/cms/envs/aws.py index afdd18288d..555fa39ec0 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -155,7 +155,6 @@ TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) # Translation overrides LANGUAGES = ENV_TOKENS.get('LANGUAGES', LANGUAGES) -RELEASED_LANGUAGES = ENV_TOKENS.get('RELEASED_LANGUAGES', LANGUAGES) LANGUAGE_CODE = ENV_TOKENS.get('LANGUAGE_CODE', LANGUAGE_CODE) USE_I18N = ENV_TOKENS.get('USE_I18N', USE_I18N) diff --git a/cms/envs/common.py b/cms/envs/common.py index 808bf925ca..bd19b365b9 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -248,14 +248,9 @@ TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_ LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html LANGUAGES = ( - ('en@pirate', 'Pirate English'), ('eo', 'Esperanto'), ) -# This is the list of language codes for languanges which are released to all users. -# See dark_lang/README.rst for more details. -RELEASED_LANGUAGES = () - USE_I18N = True USE_L10N = True @@ -448,7 +443,10 @@ INSTALLED_APPS = ( 'django.contrib.admin', # for managing course modes - 'course_modes' + 'course_modes', + + # Dark-launching languages + 'dark_lang', ) diff --git a/common/djangoapps/config_models/models.py b/common/djangoapps/config_models/models.py index 4a46e384f2..3c1d5d6061 100644 --- a/common/djangoapps/config_models/models.py +++ b/common/djangoapps/config_models/models.py @@ -27,7 +27,7 @@ class ConfigurationModel(models.Model): cache_timeout = 600 change_date = models.DateTimeField(auto_now_add=True) - changed_by = models.ForeignKey(User, editable=False) + changed_by = models.ForeignKey(User, editable=False, null=True, on_delete=models.PROTECT) enabled = models.BooleanField(default=False) def save(self, *args, **kwargs): diff --git a/common/djangoapps/dark_lang/README.rst b/common/djangoapps/dark_lang/README.rst deleted file mode 100644 index 75d3224c20..0000000000 --- a/common/djangoapps/dark_lang/README.rst +++ /dev/null @@ -1,16 +0,0 @@ -Language Translation Dark Launching -=================================== - -This app adds the ability to launch language translations that -are only accessible through the use of a specific query parameter -(and are not activated by browser settings). - -Installation ------------- - -Add the ``.DarkLangMiddleware`` to your list of ``MIDDLEWARE_CLASSES``. -It must come after the ``SessionMiddleware``, and before the ``LocaleMiddleware``. - -Add the ``RELEASED_LANGUAGES`` setting to your settings file. This -should be a list of all language codes which can be selected via a -user's browser settings. \ No newline at end of file diff --git a/common/djangoapps/dark_lang/__init__.py b/common/djangoapps/dark_lang/__init__.py index e69de29bb2..d56fa38068 100644 --- a/common/djangoapps/dark_lang/__init__.py +++ b/common/djangoapps/dark_lang/__init__.py @@ -0,0 +1,19 @@ +""" +Language Translation Dark Launching +=================================== + +This app adds the ability to launch language translations that +are only accessible through the use of a specific query parameter +(and are not activated by browser settings). + +Installation +------------ + +Add the ``DarkLangMiddleware`` to your list of ``MIDDLEWARE_CLASSES``. +It must come after the ``SessionMiddleware``, and before the ``LocaleMiddleware``. + +Run migrations to install the configuration table. + +Use the admin site to add a new ``DarkLangConfig`` that is enabled, and lists the +languages that should be released. +""" diff --git a/common/djangoapps/dark_lang/admin.py b/common/djangoapps/dark_lang/admin.py new file mode 100644 index 0000000000..cc80e49b25 --- /dev/null +++ b/common/djangoapps/dark_lang/admin.py @@ -0,0 +1,10 @@ +""" +Admin site bindings for dark_lang +""" + +from django.contrib import admin + +from config_models.admin import ConfigurationModelAdmin +from dark_lang.models import DarkLangConfig + +admin.site.register(DarkLangConfig, ConfigurationModelAdmin) diff --git a/common/djangoapps/dark_lang/middleware.py b/common/djangoapps/dark_lang/middleware.py index 9afddbc8ea..88783328be 100644 --- a/common/djangoapps/dark_lang/middleware.py +++ b/common/djangoapps/dark_lang/middleware.py @@ -11,28 +11,33 @@ This middleware must be placed before the LocaleMiddleware, but after the SessionMiddleware. """ -from django.conf import settings -from django.core.exceptions import MiddlewareNotUsed from django.utils.translation.trans_real import parse_accept_lang_header +from dark_lang.models import DarkLangConfig + class DarkLangMiddleware(object): """ Middleware for dark-launching languages. - This middleware will only be active if the RELEASED_LANGUAGES setting is set. - This setting should contain a list of language codes for languages which - are considered to be dark-launched, and those won't activate based on a - users browser settings. + This is configured by creating ``DarkLangConfig`` rows in the database, + using the django admin site. """ - def __init__(self): - self.released_langs = getattr(settings, 'RELEASED_LANGUAGES', None) - - if self.released_langs is None: - raise MiddlewareNotUsed() + @property + def released_langs(self): + """ + Current list of released languages + """ + return DarkLangConfig.current().released_languages_list def process_request(self, request): + """ + Prevent user from requesting un-released languages except by using the preview-lang query string. + """ + if not DarkLangConfig.current().enabled: + return + self._clean_accept_headers(request) self._activate_preview_language(request) diff --git a/common/djangoapps/dark_lang/migrations/0001_initial.py b/common/djangoapps/dark_lang/migrations/0001_initial.py new file mode 100644 index 0000000000..cc715fe8e5 --- /dev/null +++ b/common/djangoapps/dark_lang/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'DarkLangConfig' + db.create_table('dark_lang_darklangconfig', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('change_date', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('changed_by', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True, on_delete=models.PROTECT)), + ('enabled', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('released_languages', self.gf('django.db.models.fields.TextField')(blank=True)), + )) + db.send_create_signal('dark_lang', ['DarkLangConfig']) + + + def backwards(self, orm): + # Deleting model 'DarkLangConfig' + db.delete_table('dark_lang_darklangconfig') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'dark_lang.darklangconfig': { + 'Meta': {'object_name': 'DarkLangConfig'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'released_languages': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + } + } + + complete_apps = ['dark_lang'] \ No newline at end of file diff --git a/common/djangoapps/dark_lang/migrations/0002_enable_on_install.py b/common/djangoapps/dark_lang/migrations/0002_enable_on_install.py new file mode 100644 index 0000000000..c794a156ce --- /dev/null +++ b/common/djangoapps/dark_lang/migrations/0002_enable_on_install.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import DataMigration +from django.db import models + +class Migration(DataMigration): + + def forwards(self, orm): + """ + Enable DarkLang by default when it is installed, to prevent accidental + release of testing languages. + """ + orm.DarkLangConfig(enabled=True).save() + + def backwards(self, orm): + "Write your backwards methods here." + raise RuntimeError("Cannot reverse this migration.") + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'dark_lang.darklangconfig': { + 'Meta': {'object_name': 'DarkLangConfig'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'released_languages': ('django.db.models.fields.TextField', [], {'blank': 'True'}) + } + } + + complete_apps = ['dark_lang'] + symmetrical = True diff --git a/common/djangoapps/dark_lang/migrations/__init__.py b/common/djangoapps/dark_lang/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/dark_lang/models.py b/common/djangoapps/dark_lang/models.py new file mode 100644 index 0000000000..9912287b4e --- /dev/null +++ b/common/djangoapps/dark_lang/models.py @@ -0,0 +1,26 @@ +""" +Models for the dark-launching languages +""" +from django.db import models + +from config_models.models import ConfigurationModel + + +class DarkLangConfig(ConfigurationModel): + """ + Configuration for the dark_lang django app + """ + released_languages = models.TextField( + blank=True, + help_text="A comma-separated list of language codes to release to the public." + ) + + @property + def released_languages_list(self): + """ + ``released_languages`` as a list of language codes. + """ + if not self.released_languages.strip(): # pylint: disable=no-member + return [] + + return [lang.strip() for lang in self.released_languages.split(',')] # pylint: disable=no-member diff --git a/common/djangoapps/dark_lang/tests.py b/common/djangoapps/dark_lang/tests.py index e1de64cb3e..7667102b90 100644 --- a/common/djangoapps/dark_lang/tests.py +++ b/common/djangoapps/dark_lang/tests.py @@ -2,58 +2,70 @@ Tests of DarkLangMiddleware """ -from django.core.exceptions import MiddlewareNotUsed -from django.http import HttpRequest, QueryDict +from django.contrib.auth.models import User +from django.http import HttpRequest from django.test import TestCase -from django.test.utils import override_settings from mock import Mock from dark_lang.middleware import DarkLangMiddleware +from dark_lang.models import DarkLangConfig UNSET = object() -def set_if_set(dict, key, value): +def set_if_set(dct, key, value): """ - Sets ``key`` in ``dict`` to ``value`` + Sets ``key`` in ``dct`` to ``value`` unless ``value`` is ``UNSET`` """ if value is not UNSET: - dict[key] = value + dct[key] = value -@override_settings(RELEASED_LANGUAGES=('rel')) class DarkLangMiddlewareTests(TestCase): """ Tests of DarkLangMiddleware """ + def setUp(self): + self.user = User() + self.user.save() + DarkLangConfig( + released_languages='rel', + changed_by=self.user, + enabled=True + ).save() + def process_request(self, django_language=UNSET, accept=UNSET, preview_lang=UNSET, clear_lang=UNSET): + """ + Build a request and then process it using the ``DarkLangMiddleware``. + + Args: + django_language (str): The language code to set in request.session['django_language'] + accept (str): The accept header to set in request.META['HTTP_ACCEPT_LANGUAGE'] + preview_lang (str): The value to set in request.GET['preview_lang'] + clear_lang (str): The value to set in request.GET['clear_lang'] + """ session = {} set_if_set(session, 'django_language', django_language) - META = {} - set_if_set(META, 'HTTP_ACCEPT_LANGUAGE', accept) + meta = {} + set_if_set(meta, 'HTTP_ACCEPT_LANGUAGE', accept) - GET = {} - set_if_set(GET, 'preview-lang', preview_lang) - set_if_set(GET, 'clear-lang', clear_lang) + get = {} + set_if_set(get, 'preview-lang', preview_lang) + set_if_set(get, 'clear-lang', clear_lang) request = Mock( spec=HttpRequest, session=session, - META=META, - GET=GET + META=meta, + GET=get ) self.assertIsNone(DarkLangMiddleware().process_request(request)) return request - @override_settings(RELEASED_LANGUAGES=None) - def test_inactive_middleware(self): - with self.assertRaises(MiddlewareNotUsed): - DarkLangMiddleware() - def assertAcceptEquals(self, value, request): """ Assert that the HTML_ACCEPT_LANGUAGE header in request @@ -82,8 +94,12 @@ class DarkLangMiddlewareTests(TestCase): self.process_request(accept='rel;q=1.0, unrel;q=0.5') ) - @override_settings(RELEASED_LANGUAGES=('rel', 'unrel')) def test_accept_multiple_released_langs(self): + DarkLangConfig( + released_languages=('rel, unrel'), + changed_by=self.user, + enabled=True + ).save() self.assertAcceptEquals( 'rel;q=1.0, unrel;q=0.5', @@ -153,3 +169,25 @@ class DarkLangMiddlewareTests(TestCase): self.process_request(clear_lang=True, django_language='unrel') ) + def test_disabled(self): + DarkLangConfig(enabled=False, changed_by=self.user).save() + + self.assertAcceptEquals( + 'notrel;q=0.3, rel;q=1.0, unrel;q=0.5', + self.process_request(accept='notrel;q=0.3, rel;q=1.0, unrel;q=0.5') + ) + + self.assertSessionLangEquals( + 'rel', + self.process_request(clear_lang=True, django_language='rel') + ) + + self.assertSessionLangEquals( + 'unrel', + self.process_request(clear_lang=True, django_language='unrel') + ) + + self.assertSessionLangEquals( + 'rel', + self.process_request(preview_lang='unrel', django_language='rel') + ) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index a536ec59be..0f1873e688 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -203,7 +203,6 @@ TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) # Translation overrides LANGUAGES = ENV_TOKENS.get('LANGUAGES', LANGUAGES) -RELEASED_LANGUAGES = ENV_TOKENS.get('RELEASED_LANGUAGES', LANGUAGES) LANGUAGE_CODE = ENV_TOKENS.get('LANGUAGE_CODE', LANGUAGE_CODE) USE_I18N = ENV_TOKENS.get('USE_I18N', USE_I18N) diff --git a/lms/envs/common.py b/lms/envs/common.py index 5557ce9c9e..f453ab0369 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -494,14 +494,9 @@ TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_ LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html LANGUAGES = ( - ('en@pirate', 'Pirate English'), ('eo', 'Esperanto'), ) -# This is the list of language codes for languanges which are released to all users. -# See dark_lang/README.rst for more details. -RELEASED_LANGUAGES = () - USE_I18N = True USE_L10N = True @@ -1064,6 +1059,9 @@ INSTALLED_APPS = ( # Student Identity Verification 'verify_student', + + # Dark-launching languages + 'dark_lang', ) ######################### MARKETING SITE ###############################