From 06949a5e6a6c473385ce632d449ba81594dfa7f9 Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Mon, 30 May 2022 17:19:34 +0300 Subject: [PATCH 1/6] [ch30064] Add User.preferences field and PATCH support --- .../users/migrations/0018_user_preferences.py | 19 ++++++++++++ src/etools/applications/users/models.py | 6 ++++ .../applications/users/serializers_v3.py | 18 ++++++++++++ .../applications/users/tests/test_views_v3.py | 29 +++++++++++++++++++ src/etools/config/settings/base.py | 4 +-- 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 src/etools/applications/users/migrations/0018_user_preferences.py 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 000000000..683661788 --- /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 3ad2123af..a47d06fd4 100644 --- a/src/etools/applications/users/models.py +++ b/src/etools/applications/users/models.py @@ -22,6 +22,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'] @@ -38,6 +42,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 9ef04a6cc..4b5ae3ec9 100644 --- a/src/etools/applications/users/serializers_v3.py +++ b/src/etools/applications/users/serializers_v3.py @@ -1,3 +1,4 @@ +from django.conf import settings 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=settings.LANGUAGES) + + class ProfileRetrieveUpdateSerializer(serializers.ModelSerializer): countries_available = SimpleCountrySerializer(many=True, read_only=True) + languages_available = serializers.SerializerMethodField() 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,16 @@ def get_show_ap(self, obj): def get_is_unicef_user(self, obj): return obj.user.is_unicef_user() + def get_languages_available(self, obj): + return dict(settings.LANGUAGES) + + 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 cecbbf1cc..1f3e800ed 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,34 @@ 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) + class TestExternalUserAPIView(BaseTenantTestCase): def setUp(self): diff --git a/src/etools/config/settings/base.py b/src/etools/config/settings/base.py index 0bc4b3753..6ed0ed44f 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' From bfdb60a31a20be44b31c6ce973ab4cb7809a7c9d Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Tue, 31 May 2022 15:40:58 +0300 Subject: [PATCH 2/6] [ch30107] added more tests on updating preferences --- .../applications/users/serializers_v3.py | 6 +-- .../applications/users/tests/test_views_v3.py | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/etools/applications/users/serializers_v3.py b/src/etools/applications/users/serializers_v3.py index 4b5ae3ec9..3ae951968 100644 --- a/src/etools/applications/users/serializers_v3.py +++ b/src/etools/applications/users/serializers_v3.py @@ -1,6 +1,6 @@ -from django.conf import settings from django.contrib.auth import get_user_model from django.db import connection +from django.utils.translation.trans_real import get_languages from rest_framework import serializers @@ -87,7 +87,7 @@ class Meta: class UserPreferencesSerializer(serializers.Serializer): - language = serializers.ChoiceField(choices=settings.LANGUAGES) + language = serializers.ChoiceField(choices=get_languages()) class ProfileRetrieveUpdateSerializer(serializers.ModelSerializer): @@ -129,7 +129,7 @@ def get_is_unicef_user(self, obj): return obj.user.is_unicef_user() def get_languages_available(self, obj): - return dict(settings.LANGUAGES) + return get_languages() def update(self, instance, validated_data): user = validated_data.pop('user', None) diff --git a/src/etools/applications/users/tests/test_views_v3.py b/src/etools/applications/users/tests/test_views_v3.py index 1f3e800ed..96c54d89e 100644 --- a/src/etools/applications/users/tests/test_views_v3.py +++ b/src/etools/applications/users/tests/test_views_v3.py @@ -286,6 +286,52 @@ def test_patch_preferences(self): 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): From e779bec1bd04eed527a709acc146ba1b9abea55a Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Tue, 31 May 2022 16:57:40 +0300 Subject: [PATCH 3/6] [ch30107] Added EToolsLocaleMiddleware --- src/etools/applications/core/middleware.py | 17 +++++++++ .../core/tests/test_middleware.py | 35 ++++++++++++++++++- src/etools/config/settings/base.py | 2 +- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/etools/applications/core/middleware.py b/src/etools/applications/core/middleware.py index b2436c63e..23e1fa5db 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 755409033..2d45a4459 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/config/settings/base.py b/src/etools/config/settings/base.py index 6ed0ed44f..086ab6de4 100644 --- a/src/etools/config/settings/base.py +++ b/src/etools/config/settings/base.py @@ -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' From ea31d2b5c34d7ce1ab1b7032f7cce3216d45efa7 Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Tue, 31 May 2022 18:06:42 +0300 Subject: [PATCH 4/6] [ch30107] Replace get_languages() limited choices with an extensive list --- src/etools/applications/users/serializers_v3.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/etools/applications/users/serializers_v3.py b/src/etools/applications/users/serializers_v3.py index 3ae951968..47b524ab3 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 from django.utils.translation.trans_real import get_languages @@ -87,12 +88,12 @@ class Meta: class UserPreferencesSerializer(serializers.Serializer): - language = serializers.ChoiceField(choices=get_languages()) + language = serializers.ChoiceField(choices=dict(LANGUAGES)) class ProfileRetrieveUpdateSerializer(serializers.ModelSerializer): countries_available = SimpleCountrySerializer(many=True, read_only=True) - languages_available = serializers.SerializerMethodField() + 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) @@ -128,9 +129,6 @@ def get_show_ap(self, obj): def get_is_unicef_user(self, obj): return obj.user.is_unicef_user() - def get_languages_available(self, obj): - return get_languages() - def update(self, instance, validated_data): user = validated_data.pop('user', None) if user and user.get('preferences'): From 9c593a75b352307639da2bbf4afc2cbcba490ef0 Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Mon, 6 Jun 2022 10:25:32 +0300 Subject: [PATCH 5/6] flake8 --- src/etools/applications/users/serializers_v3.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/etools/applications/users/serializers_v3.py b/src/etools/applications/users/serializers_v3.py index 47b524ab3..20876b8bd 100644 --- a/src/etools/applications/users/serializers_v3.py +++ b/src/etools/applications/users/serializers_v3.py @@ -1,7 +1,6 @@ from django.conf.global_settings import LANGUAGES from django.contrib.auth import get_user_model from django.db import connection -from django.utils.translation.trans_real import get_languages from rest_framework import serializers From 234b8a89e0db21bbcb2cfd63057d45cc752a5d6f Mon Sep 17 00:00:00 2001 From: Ema Ciupe Date: Mon, 6 Jun 2022 13:58:48 +0300 Subject: [PATCH 6/6] Add user.preferences to admin --- src/etools/applications/users/admin.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/etools/applications/users/admin.py b/src/etools/applications/users/admin.py index d4e8d1b67..cba69f5e2 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 @@ -175,6 +176,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',)