diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 501baee768..fbdbb611a3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,14 @@ Blades: Fix bug when image response in Firefox does not retain input. BLD-711. Blades: Give numerical response tolerance as a range. BLD-25. +Common: Add a utility app for building databased-backed configuration + for specific application features. Includes admin site customization + for easier administration and tracking. + +Common: Add the ability to dark-launch site translations. These languages + will be unavailable to users except through the use of a specific query + parameter. + Blades: Allow user with BetaTester role correctly use LTI. BLD-641. Blades: Video player persist speed preferences between videos. BLD-237. diff --git a/cms/envs/aws.py b/cms/envs/aws.py index a781576455..555fa39ec0 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -153,6 +153,11 @@ COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", []) #Timezone overrides TIME_ZONE = ENV_TOKENS.get('TIME_ZONE', TIME_ZONE) +# Translation overrides +LANGUAGES = ENV_TOKENS.get('LANGUAGES', LANGUAGES) +LANGUAGE_CODE = ENV_TOKENS.get('LANGUAGE_CODE', LANGUAGE_CODE) +USE_I18N = ENV_TOKENS.get('USE_I18N', USE_I18N) + ENV_FEATURES = ENV_TOKENS.get('FEATURES', ENV_TOKENS.get('MITX_FEATURES', {})) for feature, value in ENV_FEATURES.items(): FEATURES[feature] = value diff --git a/cms/envs/common.py b/cms/envs/common.py index 4dc75f87e9..bd19b365b9 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -167,6 +167,9 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', 'track.middleware.TrackMiddleware', + # Allows us to dark-launch particular languages + 'dark_lang.middleware.DarkLangMiddleware', + # Detects user-requested locale from 'accept-language' header in http request 'django.middleware.locale.LocaleMiddleware', @@ -244,12 +247,11 @@ STATICFILES_DIRS = [ TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html -# We want i18n to be turned off in production, at least until we have full localizations. -# Thus we want the Django translation engine to be disabled. Otherwise even without -# localization files, if the user's browser is set to a language other than us-en, -# strings like "login" and "password" will be translated and the rest of the page will be -# in English, which is confusing. -USE_I18N = False +LANGUAGES = ( + ('eo', 'Esperanto'), +) + +USE_I18N = True USE_L10N = True # Localization strings (e.g. django.po) are under this directory @@ -406,6 +408,9 @@ INSTALLED_APPS = ( 'south', 'method_override', + # Database-backed configuration + 'config_models', + # Monitor the status of services 'service_status', @@ -438,7 +443,10 @@ INSTALLED_APPS = ( 'django.contrib.admin', # for managing course modes - 'course_modes' + 'course_modes', + + # Dark-launching languages + 'dark_lang', ) diff --git a/cms/envs/dev.py b/cms/envs/dev.py index 16efed18c8..cd3e2067f4 100644 --- a/cms/envs/dev.py +++ b/cms/envs/dev.py @@ -9,11 +9,6 @@ from .common import * from logsettings import get_logger_config DEBUG = True -USE_I18N = True -# For displaying the dummy text, we need to provide a language mapping. -LANGUAGES = ( - ('eo', 'Esperanto'), -) TEMPLATE_DEBUG = DEBUG LOGGING = get_logger_config(ENV_ROOT / "log", logging_env="dev", diff --git a/common/djangoapps/config_models/README.rst b/common/djangoapps/config_models/README.rst new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/config_models/__init__.py b/common/djangoapps/config_models/__init__.py new file mode 100644 index 0000000000..3f71f1c98e --- /dev/null +++ b/common/djangoapps/config_models/__init__.py @@ -0,0 +1,62 @@ +""" +Model-Based Configuration +========================= + +This app allows other apps to easily define a configuration model +that can be hooked into the admin site to allow configuration management +with auditing. + +Installation +------------ + +Add ``config_models`` to your ``INSTALLED_APPS`` list. + +Usage +----- + +Create a subclass of ``ConfigurationModel``, with fields for each +value that needs to be configured:: + + class MyConfiguration(ConfigurationModel): + frobble_timeout = IntField(default=10) + frazzle_target = TextField(defalut="debug") + +This is a normal django model, so it must be synced and migrated as usual. + +The default values for the fields in the ``ConfigurationModel`` will be +used if no configuration has yet been created. + +Register that class with the Admin site, using the ``ConfigurationAdminModel``:: + + from django.contrib import admin + + from config_models.admin import ConfigurationModelAdmin + + admin.site.register(MyConfiguration, ConfigurationModelAdmin) + +Use the configuration in your code:: + + def my_view(self, request): + config = MyConfiguration.current() + fire_the_missiles(config.frazzle_target, timeout=config.frobble_timeout) + +Use the admin site to add new configuration entries. The most recently created +entry is considered to be ``current``. + +Configuration +------------- + +The current ``ConfigurationModel`` will be cached in the ``configuration`` django cache, +or in the ``default`` cache if ``configuration`` doesn't exist. You can specify the cache +timeout in each ``ConfigurationModel`` by setting the ``cache_timeout`` property. + +You can change the name of the cache key used by the ``ConfigurationModel`` by overriding +the ``cache_key_name`` function. + +Extension +--------- + +``ConfigurationModels`` are just django models, so they can be extended with new fields +and migrated as usual. Newly added fields must have default values and should be nullable, +so that rollbacks to old versions of configuration work correctly. +""" diff --git a/common/djangoapps/config_models/admin.py b/common/djangoapps/config_models/admin.py new file mode 100644 index 0000000000..378900f1dc --- /dev/null +++ b/common/djangoapps/config_models/admin.py @@ -0,0 +1,80 @@ +""" +Admin site models for managing :class:`.ConfigurationModel` subclasses +""" + +from django.forms import models +from django.contrib import admin +from django.http import HttpResponseRedirect +from django.core.urlresolvers import reverse + +# pylint: disable=protected-access + + +class ConfigurationModelAdmin(admin.ModelAdmin): + """ + :class:`~django.contrib.admin.ModelAdmin` for :class:`.ConfigurationModel` subclasses + """ + date_hierarchy = 'change_date' + + def get_actions(self, request): + return { + 'revert': (ConfigurationModelAdmin.revert, 'revert', 'Revert to the selected configuration') + } + + def get_list_display(self, request): + return self.model._meta.get_all_field_names() + + # Don't allow deletion of configuration + def has_delete_permission(self, request, obj=None): + return False + + # Make all fields read-only when editing an object + def get_readonly_fields(self, request, obj=None): + if obj: # editing an existing object + return self.model._meta.get_all_field_names() + return self.readonly_fields + + def add_view(self, request, form_url='', extra_context=None): + # Prepopulate new configuration entries with the value of the current config + get = request.GET.copy() + get.update(models.model_to_dict(self.model.current())) + request.GET = get + return super(ConfigurationModelAdmin, self).add_view(request, form_url, extra_context) + + # Hide the save buttons in the change view + def change_view(self, request, object_id, form_url='', extra_context=None): + extra_context = extra_context or {} + extra_context['readonly'] = True + return super(ConfigurationModelAdmin, self).change_view( + request, + object_id, + form_url, + extra_context=extra_context + ) + + def save_model(self, request, obj, form, change): + obj.changed_by = request.user + super(ConfigurationModelAdmin, self).save_model(request, obj, form, change) + + def revert(self, request, queryset): + """ + Admin action to revert a configuration back to the selected value + """ + if queryset.count() != 1: + self.message_user(request, "Please select a single configuration to revert to.") + return + + target = queryset[0] + target.id = None + self.save_model(request, target, None, False) + self.message_user(request, "Reverted configuration.") + + return HttpResponseRedirect( + reverse( + 'admin:{}_{}_change'.format( + self.model._meta.app_label, + self.model._meta.module_name, + ), + args=(target.id,), + ) + ) diff --git a/common/djangoapps/config_models/models.py b/common/djangoapps/config_models/models.py new file mode 100644 index 0000000000..3c1d5d6061 --- /dev/null +++ b/common/djangoapps/config_models/models.py @@ -0,0 +1,62 @@ +""" +Django Model baseclass for database-backed configuration. +""" +from django.db import models +from django.contrib.auth.models import User +from django.core.cache import get_cache, InvalidCacheBackendError + +try: + cache = get_cache('configuration') # pylint: disable=invalid-name +except InvalidCacheBackendError: + from django.core.cache import cache + + +class ConfigurationModel(models.Model): + """ + Abstract base class for model-based configuration + + Properties: + cache_timeout (int): The number of seconds that this configuration + should be cached + """ + + class Meta(object): # pylint: disable=missing-docstring + abstract = True + + # The number of seconds + cache_timeout = 600 + + change_date = models.DateTimeField(auto_now_add=True) + changed_by = models.ForeignKey(User, editable=False, null=True, on_delete=models.PROTECT) + enabled = models.BooleanField(default=False) + + def save(self, *args, **kwargs): + """ + Clear the cached value when saving a new configuration entry + """ + super(ConfigurationModel, self).save(*args, **kwargs) + cache.delete(self.cache_key_name()) + + @classmethod + def cache_key_name(cls): + """Return the name of the key to use to cache the current configuration""" + return 'configuration/{}/current'.format(cls.__name__) + + @classmethod + def current(cls): + """ + Return the active configuration entry, either from cache, + from the database, or by creating a new empty entry (which is not + persisted). + """ + cached = cache.get(cls.cache_key_name()) + if cached is not None: + return cached + + try: + current = cls.objects.order_by('-change_date')[0] + except IndexError: + current = cls() + + cache.set(cls.cache_key_name(), current, cls.cache_timeout) + return current diff --git a/common/djangoapps/config_models/templatetags.py b/common/djangoapps/config_models/templatetags.py new file mode 100644 index 0000000000..8641fd11ea --- /dev/null +++ b/common/djangoapps/config_models/templatetags.py @@ -0,0 +1,29 @@ +""" +Override the submit_row template tag to remove all save buttons from the +admin dashboard change view if the context has readonly marked in it. +""" + +from django.contrib.admin.templatetags.admin_modify import register +from django.contrib.admin.templatetags.admin_modify import submit_row as original_submit_row + + +@register.inclusion_tag('admin/submit_line.html', takes_context=True) +def submit_row(context): + """ + Overrides 'django.contrib.admin.templatetags.admin_modify.submit_row'. + + Manipulates the context going into that function by hiding all of the buttons + in the submit row if the key `readonly` is set in the context. + """ + ctx = original_submit_row(context) + + if context.get('readonly', False): + ctx.update({ + 'show_delete_link': False, + 'show_save_as_new': False, + 'show_save_and_add_another': False, + 'show_save_and_continue': False, + 'show_save': False, + }) + else: + return ctx diff --git a/common/djangoapps/config_models/tests.py b/common/djangoapps/config_models/tests.py new file mode 100644 index 0000000000..bb14ad18e8 --- /dev/null +++ b/common/djangoapps/config_models/tests.py @@ -0,0 +1,76 @@ +""" +Tests of ConfigurationModel +""" + +from django.contrib.auth.models import User +from django.db import models +from django.test import TestCase + +from freezegun import freeze_time + +from mock import patch +from config_models.models import ConfigurationModel + + +class ExampleConfig(ConfigurationModel): + """ + Test model for testing ``ConfigurationModels``. + """ + cache_timeout = 300 + + string_field = models.TextField() + int_field = models.IntegerField(default=10) + + +@patch('config_models.models.cache') +class ConfigurationModelTests(TestCase): + """ + Tests of ConfigurationModel + """ + def setUp(self): + self.user = User() + self.user.save() + + def test_cache_deleted_on_save(self, mock_cache): + ExampleConfig(changed_by=self.user).save() + mock_cache.delete.assert_called_with(ExampleConfig.cache_key_name()) + + def test_cache_key_name(self, _mock_cache): + self.assertEquals(ExampleConfig.cache_key_name(), 'configuration/ExampleConfig/current') + + def test_no_config_empty_cache(self, mock_cache): + mock_cache.get.return_value = None + + current = ExampleConfig.current() + self.assertEquals(current.int_field, 10) + self.assertEquals(current.string_field, '') + mock_cache.set.assert_called_with(ExampleConfig.cache_key_name(), current, 300) + + def test_no_config_full_cache(self, mock_cache): + current = ExampleConfig.current() + self.assertEquals(current, mock_cache.get.return_value) + + def test_config_ordering(self, mock_cache): + mock_cache.get.return_value = None + + with freeze_time('2012-01-01'): + first = ExampleConfig(changed_by=self.user) + first.string_field = 'first' + first.save() + + second = ExampleConfig(changed_by=self.user) + second.string_field = 'second' + second.save() + + self.assertEquals(ExampleConfig.current().string_field, 'second') + + def test_cache_set(self, mock_cache): + mock_cache.get.return_value = None + + first = ExampleConfig(changed_by=self.user) + first.string_field = 'first' + first.save() + + ExampleConfig.current() + + mock_cache.set.assert_called_with(ExampleConfig.cache_key_name(), first, 300) diff --git a/common/djangoapps/dark_lang/__init__.py b/common/djangoapps/dark_lang/__init__.py new file mode 100644 index 0000000000..d56fa38068 --- /dev/null +++ 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 new file mode 100644 index 0000000000..88783328be --- /dev/null +++ b/common/djangoapps/dark_lang/middleware.py @@ -0,0 +1,92 @@ +""" +Middleware for dark-launching languages. These languages won't be used +when determining which translation to give a user based on their browser +header, but can be selected by setting the ``preview-lang`` query parameter +to the language code. + +Adding the query parameter ``clear-lang`` will reset the language stored +in the user's session. + +This middleware must be placed before the LocaleMiddleware, but after +the SessionMiddleware. +""" + +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 is configured by creating ``DarkLangConfig`` rows in the database, + using the django admin site. + """ + + @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) + + def _is_released(self, lang_code): + """ + ``True`` iff one of the values in ``self.released_langs`` is a prefix of ``lang_code``. + """ + return any(lang_code.startswith(released_lang) for released_lang in self.released_langs) + + def _format_accept_value(self, lang, priority=1.0): + """ + Formats lang and priority into a valid accept header fragment. + """ + return "{};q={}".format(lang, priority) + + def _clean_accept_headers(self, request): + """ + Remove any language that is not either in ``self.released_langs`` or + a territory of one of those languages. + """ + accept = request.META.get('HTTP_ACCEPT_LANGUAGE', None) + if accept is None or accept == '*': + return + + new_accept = ", ".join( + self._format_accept_value(lang, priority) + for lang, priority + in parse_accept_lang_header(accept) + if self._is_released(lang) + ) + + request.META['HTTP_ACCEPT_LANGUAGE'] = new_accept + + def _activate_preview_language(self, request): + """ + If the request has the get parameter ``preview-lang``, + and that language appears doesn't appear in ``self.released_langs``, + then set the session ``django_language`` to that language. + """ + if 'clear-lang' in request.GET: + if 'django_language' in request.session: + del request.session['django_language'] + + preview_lang = request.GET.get('preview-lang', None) + + if not preview_lang: + return + + if preview_lang in self.released_langs: + return + + request.session['django_language'] = preview_lang 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 new file mode 100644 index 0000000000..7667102b90 --- /dev/null +++ b/common/djangoapps/dark_lang/tests.py @@ -0,0 +1,193 @@ +""" +Tests of DarkLangMiddleware +""" + +from django.contrib.auth.models import User +from django.http import HttpRequest + +from django.test import TestCase +from mock import Mock + +from dark_lang.middleware import DarkLangMiddleware +from dark_lang.models import DarkLangConfig + + +UNSET = object() + + +def set_if_set(dct, key, value): + """ + Sets ``key`` in ``dct`` to ``value`` + unless ``value`` is ``UNSET`` + """ + if value is not UNSET: + dct[key] = value + + +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) + + 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 + ) + self.assertIsNone(DarkLangMiddleware().process_request(request)) + return request + + def assertAcceptEquals(self, value, request): + """ + Assert that the HTML_ACCEPT_LANGUAGE header in request + is equal to value + """ + self.assertEquals( + value, + request.META.get('HTTP_ACCEPT_LANGUAGE', UNSET) + ) + + def test_empty_accept(self): + self.assertAcceptEquals(UNSET, self.process_request()) + + def test_wildcard_accept(self): + self.assertAcceptEquals('*', self.process_request(accept='*')) + + def test_released_accept(self): + self.assertAcceptEquals( + 'rel;q=1.0', + self.process_request(accept='rel;q=1.0') + ) + + def test_unreleased_accept(self): + self.assertAcceptEquals( + 'rel;q=1.0', + self.process_request(accept='rel;q=1.0, unrel;q=0.5') + ) + + 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', + self.process_request(accept='rel;q=1.0, unrel;q=0.5') + ) + + self.assertAcceptEquals( + 'rel;q=1.0, unrel;q=0.5', + self.process_request(accept='rel;q=1.0, notrel;q=0.3, unrel;q=0.5') + ) + + self.assertAcceptEquals( + 'rel;q=1.0, unrel;q=0.5', + self.process_request(accept='notrel;q=0.3, rel;q=1.0, unrel;q=0.5') + ) + + def test_accept_released_territory(self): + self.assertAcceptEquals( + 'rel-ter;q=1.0, rel;q=0.5', + self.process_request(accept='rel-ter;q=1.0, rel;q=0.5') + ) + + def assertSessionLangEquals(self, value, request): + """ + Assert that the 'django_language' set in request.session is equal to value + """ + self.assertEquals( + value, + request.session.get('django_language', UNSET) + ) + + def test_preview_lang_with_released_language(self): + self.assertSessionLangEquals( + UNSET, + self.process_request(preview_lang='rel') + ) + + self.assertSessionLangEquals( + 'notrel', + self.process_request(preview_lang='rel', django_language='notrel') + ) + + def test_preview_lang_with_dark_language(self): + self.assertSessionLangEquals( + 'unrel', + self.process_request(preview_lang='unrel') + ) + + self.assertSessionLangEquals( + 'unrel', + self.process_request(preview_lang='unrel', django_language='notrel') + ) + + def test_clear_lang(self): + self.assertSessionLangEquals( + UNSET, + self.process_request(clear_lang=True) + ) + + self.assertSessionLangEquals( + UNSET, + self.process_request(clear_lang=True, django_language='rel') + ) + + self.assertSessionLangEquals( + UNSET, + 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/common.py b/lms/envs/common.py index 68b4d8b18b..5bf9268388 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -492,14 +492,12 @@ FAVICON_PATH = 'images/favicon.ico' # Locale/Internationalization TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html -LANGUAGES = () -# We want i18n to be turned off in production, at least until we have full localizations. -# Thus we want the Django translation engine to be disabled. Otherwise even without -# localization files, if the user's browser is set to a language other than us-en, -# strings like "login" and "password" will be translated and the rest of the page will be -# in English, which is confusing. -USE_I18N = False +LANGUAGES = ( + ('eo', 'Esperanto'), +) + +USE_I18N = True USE_L10N = True # Localization strings (e.g. django.po) are under this directory @@ -639,6 +637,9 @@ MIDDLEWARE_CLASSES = ( 'course_wiki.course_nav.Middleware', + # Allows us to dark-launch particular languages + 'dark_lang.middleware.DarkLangMiddleware', + # Detects user-requested locale from 'accept-language' header in http request 'django.middleware.locale.LocaleMiddleware', @@ -977,6 +978,9 @@ INSTALLED_APPS = ( 'djcelery', 'south', + # Database-backed configuration + 'config_models', + # Monitor the status of services 'service_status', @@ -1055,6 +1059,9 @@ INSTALLED_APPS = ( # Student Identity Verification 'verify_student', + + # Dark-launching languages + 'dark_lang', ) ######################### MARKETING SITE ############################### diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 8f40887433..fc486726bf 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -16,11 +16,6 @@ from .common import * from logsettings import get_logger_config DEBUG = True -USE_I18N = True -# For displaying the dummy text, we need to provide a language mapping. -LANGUAGES = ( - ('eo', 'Esperanto'), -) TEMPLATE_DEBUG = True FEATURES['DISABLE_START_DATES'] = False diff --git a/rakelib/tests.rake b/rakelib/tests.rake index 868fa5836f..e09c5d0762 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -22,10 +22,15 @@ def run_tests(system, report_dir, test_id=nil, stop_on_failure=true) # to the Djangoapps we want to test. Otherwise, it will # run tests on all installed packages. - default_test_id = "#{system}/djangoapps common/djangoapps" + # We need to use $DIR/*, rather than just $DIR so that + # django-nose will import them early in the test process, + # thereby making sure that we load any django models that are + # only defined in test files. + + default_test_id = "#{system}/djangoapps/* common/djangoapps/*" if system == :lms || system == :cms - default_test_id += " #{system}/lib" + default_test_id += " #{system}/lib/*" end if test_id.nil? diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index bce6479748..3df9a7034a 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -94,21 +94,22 @@ transifex-client==0.10 # Used for testing coverage==3.7 ddt==0.6.0 +django-crum==0.5 +django-debug-toolbar-mongo +django_debug_toolbar==0.9.4 +django_nose==1.1 factory_boy==2.2.1 +freezegun==0.1.11 mock==1.0.1 +nose-exclude +nose-ignore-docstring nosexcover==1.0.7 pep8==1.4.5 pylint==0.28 +python-subunit==0.0.16 rednose==0.3 selenium==2.34.0 splinter==0.5.4 -django_nose==1.1 -django_debug_toolbar==0.9.4 -django-debug-toolbar-mongo -nose-ignore-docstring -nose-exclude -django-crum==0.5 -python-subunit==0.0.16 testtools==0.9.34 git+https://github.com/mfogel/django-settings-context-processor.git