diff --git a/src/etools/applications/core/middleware.py b/src/etools/applications/core/middleware.py index b2436c63e5..23e1fa5dbb 100644 --- a/src/etools/applications/core/middleware.py +++ b/src/etools/applications/core/middleware.py @@ -6,7 +6,9 @@ from django.http.response import HttpResponseRedirect from django.template.response import SimpleTemplateResponse from django.urls import reverse +from django.utils import translation from django.utils.deprecation import MiddlewareMixin +from django.utils.translation.trans_real import get_languages from django_tenants.middleware import TenantMainMiddleware from django_tenants.utils import get_public_schema_name @@ -101,3 +103,18 @@ def process_request(self, request): # Do we have a public-specific urlconf? if hasattr(settings, 'PUBLIC_SCHEMA_URLCONF') and request.tenant.schema_name == get_public_schema_name(): request.urlconf = settings.PUBLIC_SCHEMA_URLCONF + + +class EToolsLocaleMiddleware(MiddlewareMixin): + """ + Activates translations for the language persisted in user preferences. + """ + + def process_request(self, request): + if request.user.is_anonymous: + return + preferences = request.user.preferences + if preferences and 'language' in preferences: + language_code = preferences['language'] + if language_code in get_languages() or language_code == settings.LANGUAGE_CODE: + translation.activate(language_code) diff --git a/src/etools/applications/core/tests/test_middleware.py b/src/etools/applications/core/tests/test_middleware.py index 755409033c..2d45a44597 100644 --- a/src/etools/applications/core/tests/test_middleware.py +++ b/src/etools/applications/core/tests/test_middleware.py @@ -1,11 +1,16 @@ from unittest import skip +from unittest.mock import patch from django.conf import settings from django.contrib.auth.models import AnonymousUser from django.test import override_settings, RequestFactory, TestCase from django.urls import reverse -from etools.applications.core.middleware import ANONYMOUS_ALLOWED_URL_FRAGMENTS, EToolsTenantMiddleware +from etools.applications.core.middleware import ( + ANONYMOUS_ALLOWED_URL_FRAGMENTS, + EToolsLocaleMiddleware, + EToolsTenantMiddleware, +) from etools.applications.users.tests.factories import CountryFactory, ProfileLightFactory, UserFactory @@ -89,3 +94,31 @@ def test_public_schema_urlconf(self): self.request.user = superuser EToolsTenantMiddleware().process_request(self.request) self.assertEqual(self.request.urlconf, 'foo') + + +@override_settings(LANGUAGE_CODE="fr") +class EToolsLocaleMiddlewareTest(TestCase): + request_factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.user = ProfileLightFactory().user + cls.request = cls.request_factory.get('/') + cls.request.user = cls.user + + def test_translation_activated(self): + self.assertEqual( + self.user.preferences, + {"language": "fr"} + ) + with patch('etools.applications.core.middleware.translation.activate') as mock_method: + EToolsLocaleMiddleware().process_request(self.request) + mock_method.assert_called_once_with("fr") + + def test_translation_not_activated(self): + self.user.preferences = {"language": "nonsense"} + self.user.save(update_fields=['preferences']) + + with patch('etools.applications.core.middleware.translation.activate') as mock_method: + EToolsLocaleMiddleware().process_request(self.request) + mock_method.not_called() diff --git a/src/etools/applications/users/admin.py b/src/etools/applications/users/admin.py index a907428086..6c12729593 100644 --- a/src/etools/applications/users/admin.py +++ b/src/etools/applications/users/admin.py @@ -5,6 +5,7 @@ from django.http.response import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import reverse +from django.utils.translation import gettext_lazy as _ from admin_extra_urls.decorators import button from admin_extra_urls.mixins import ExtraUrlMixin @@ -176,6 +177,14 @@ def save_model(self, request, obj, form, change): class UserAdminPlus(ExtraUrlMixin, UserAdmin): + fieldsets = UserAdmin.fieldsets + ( + (_('User Preferences'), { + 'fields': + ( + 'preferences', + ) + }), + ) inlines = [ProfileInline] readonly_fields = ('date_joined',) diff --git a/src/etools/applications/users/migrations/0018_user_preferences.py b/src/etools/applications/users/migrations/0018_user_preferences.py new file mode 100644 index 0000000000..6836617889 --- /dev/null +++ b/src/etools/applications/users/migrations/0018_user_preferences.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.6 on 2022-05-30 09:01 + +from django.db import migrations, models +import etools.applications.users.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0017_auto_20220408_1558'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='preferences', + field=models.JSONField(default=etools.applications.users.models.preferences_default_dict), + ), + ] diff --git a/src/etools/applications/users/models.py b/src/etools/applications/users/models.py index fbe3d6f9b8..03edae57e6 100644 --- a/src/etools/applications/users/models.py +++ b/src/etools/applications/users/models.py @@ -23,6 +23,10 @@ logger = logging.getLogger(__name__) +def preferences_default_dict(): + return {'language': settings.LANGUAGE_CODE} + + class User(TimeStampedModel, AbstractBaseUser, PermissionsMixin): USERNAME_FIELD = "username" REQUIRED_FIELDS = ['email'] @@ -39,6 +43,8 @@ class User(TimeStampedModel, AbstractBaseUser, PermissionsMixin): is_staff = models.BooleanField(_('staff'), default=False) is_superuser = models.BooleanField(_('superuser'), default=False) + preferences = models.JSONField(default=preferences_default_dict) + objects = UserManager() class Meta: diff --git a/src/etools/applications/users/serializers_v3.py b/src/etools/applications/users/serializers_v3.py index 9ef04a6cc0..20876b8bd1 100644 --- a/src/etools/applications/users/serializers_v3.py +++ b/src/etools/applications/users/serializers_v3.py @@ -1,3 +1,4 @@ +from django.conf.global_settings import LANGUAGES from django.contrib.auth import get_user_model from django.db import connection @@ -85,8 +86,13 @@ class Meta: ) +class UserPreferencesSerializer(serializers.Serializer): + language = serializers.ChoiceField(choices=dict(LANGUAGES)) + + class ProfileRetrieveUpdateSerializer(serializers.ModelSerializer): countries_available = SimpleCountrySerializer(many=True, read_only=True) + supervisor = serializers.CharField(read_only=True) groups = GroupSerializer(source="user.groups", read_only=True, many=True) supervisees = serializers.PrimaryKeyRelatedField(source='user.supervisee', many=True, read_only=True) @@ -104,6 +110,8 @@ class ProfileRetrieveUpdateSerializer(serializers.ModelSerializer): show_ap = serializers.SerializerMethodField() is_unicef_user = serializers.SerializerMethodField() + preferences = UserPreferencesSerializer(source="user.preferences", allow_null=False) + class Meta: model = UserProfile exclude = ('id',) @@ -120,6 +128,13 @@ def get_show_ap(self, obj): def get_is_unicef_user(self, obj): return obj.user.is_unicef_user() + def update(self, instance, validated_data): + user = validated_data.pop('user', None) + if user and user.get('preferences'): + instance.user.preferences = user.get('preferences') + instance.user.save(update_fields=['preferences']) + return super().update(instance, validated_data) + class SimpleUserSerializer(serializers.ModelSerializer): country = serializers.CharField(source='profile.country', read_only=True) diff --git a/src/etools/applications/users/tests/test_views_v3.py b/src/etools/applications/users/tests/test_views_v3.py index cecbbf1cc9..96c54d89ea 100644 --- a/src/etools/applications/users/tests/test_views_v3.py +++ b/src/etools/applications/users/tests/test_views_v3.py @@ -1,5 +1,6 @@ import json +from django.conf import settings from django.contrib.auth import get_user_model from django.urls import reverse @@ -257,6 +258,80 @@ def test_patch(self): self.assertEqual(response.data["oic"], self.unicef_superuser.id) self.assertEqual(response.data["is_superuser"], "False") + def test_patch_preferences(self): + self.assertEqual( + self.unicef_staff.preferences, + {"language": settings.LANGUAGE_CODE} + ) + data = { + "preferences": { + "language": "fr" + } + } + response = self.forced_auth_req( + 'patch', + self.url, + user=self.unicef_staff, + data=data + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["preferences"], self.unicef_staff.preferences) + self.assertEqual(self.unicef_staff.preferences, data['preferences']) + + response = self.forced_auth_req( + 'get', + self.url, + user=self.unicef_staff, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["preferences"], self.unicef_staff.preferences) + + def test_patch_preferences_unregistered_language(self): + self.assertEqual( + self.unicef_staff.preferences, + {"language": settings.LANGUAGE_CODE} + ) + data = { + "preferences": { + "language": "nonsense" + } + } + response = self.forced_auth_req( + 'patch', + self.url, + user=self.unicef_staff, + data=data + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertDictEqual( + response.data, + { + 'preferences': {'language': ['"nonsense" is not a valid choice.']} + } + ) + + def test_patch_nonexistent_preference(self): + self.assertEqual( + self.unicef_staff.preferences, + {"language": settings.LANGUAGE_CODE} + ) + data = { + "preferences": { + "nonexistent": "fr" + } + } + response = self.forced_auth_req( + 'patch', + self.url, + user=self.unicef_staff, + data=data + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + self.unicef_staff.preferences, + {"language": settings.LANGUAGE_CODE} + ) + class TestExternalUserAPIView(BaseTenantTestCase): def setUp(self): diff --git a/src/etools/config/settings/base.py b/src/etools/config/settings/base.py index 0bc4b37534..086ab6de48 100644 --- a/src/etools/config/settings/base.py +++ b/src/etools/config/settings/base.py @@ -112,8 +112,8 @@ def get_from_secrets_or_env(var_name, default=None): # DJANGO: GLOBALIZATION (I18N/L10N) LANGUAGE_CODE = 'en-us' LANGUAGES = [ - ('en', _('English US')), - ('fr', _('French')), + ('en', _('English')), + ('fr', _('Français')), ] TIME_ZONE = 'UTC' @@ -126,7 +126,6 @@ def get_from_secrets_or_env(var_name, default=None): 'django.contrib.sessions.middleware.SessionMiddleware', 'etools.applications.core.auth.CustomSocialAuthExceptionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', @@ -134,6 +133,7 @@ def get_from_secrets_or_env(var_name, default=None): 'corsheaders.middleware.CorsMiddleware', 'etools.applications.core.middleware.EToolsTenantMiddleware', 'waffle.middleware.WaffleMiddleware', # needs request.tenant from EToolsTenantMiddleware + 'etools.applications.core.middleware.EToolsLocaleMiddleware', ) WSGI_APPLICATION = 'etools.config.wsgi.application'