From 1aad3e4512c9a42ef38cc87ffb4453fcb81c416f Mon Sep 17 00:00:00 2001 From: Ariel Rin Date: Sat, 26 Feb 2022 06:19:38 +0000 Subject: [PATCH] Persistent User Settings --- allianceauth/authentication/admin.py | 8 +- allianceauth/authentication/middleware.py | 45 +++++ ...profile_language_userprofile_night_mode.py | 23 +++ allianceauth/authentication/models.py | 39 +++- allianceauth/authentication/signals.py | 9 +- allianceauth/authentication/tests/__init__.py | 13 ++ .../authentication/tests/test_middleware.py | 175 ++++++++++++++++++ .../authentication/tests/test_signals.py | 94 ++++++++++ allianceauth/eveonline/tasks.py | 2 +- .../project_name/settings/base.py | 1 + allianceauth/views.py | 11 ++ 11 files changed, 410 insertions(+), 10 deletions(-) create mode 100644 allianceauth/authentication/middleware.py create mode 100644 allianceauth/authentication/migrations/0020_userprofile_language_userprofile_night_mode.py create mode 100644 allianceauth/authentication/tests/test_middleware.py create mode 100644 allianceauth/authentication/tests/test_signals.py diff --git a/allianceauth/authentication/admin.py b/allianceauth/authentication/admin.py index 7dc50768..8e8e7f34 100644 --- a/allianceauth/authentication/admin.py +++ b/allianceauth/authentication/admin.py @@ -13,8 +13,12 @@ from django.utils.html import format_html from django.urls import reverse from django.utils.text import slugify -from allianceauth.authentication.models import State, get_guest_state,\ - CharacterOwnership, UserProfile, OwnershipRecord +from allianceauth.authentication.models import ( + State, + get_guest_state, + CharacterOwnership, + UserProfile, + OwnershipRecord) from allianceauth.hooks import get_hooks from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo,\ EveAllianceInfo, EveFactionInfo diff --git a/allianceauth/authentication/middleware.py b/allianceauth/authentication/middleware.py new file mode 100644 index 00000000..5e2f0409 --- /dev/null +++ b/allianceauth/authentication/middleware.py @@ -0,0 +1,45 @@ +from django.conf import settings +from django.utils.deprecation import MiddlewareMixin + +import logging + +logger = logging.getLogger(__name__) + + +class UserSettingsMiddleware(MiddlewareMixin): + def process_response(self, request, response): + """Django Middleware: User Settings.""" + + # Intercept the built in django /setlang/ view and also save it to Database. + # Note the annoymous user check, only logged in users will ever hit the DB here + if request.path == '/i18n/setlang/' and not request.user.is_anonymous: + try: + request.user.profile.language = request.POST['language'] + request.user.profile.save() + except Exception as e: + logger.exception(e) + + # Only act during the login flow, _after_ user is activated (step 2: post-sso) + elif request.path == '/sso/login' and not request.user.is_anonymous: + # Set the Language Cookie, if it doesnt match the DB + # Null = hasnt been set by the user ever, dont act. + try: + if request.user.profile.language != request.LANGUAGE_CODE and request.user.profile.language is not None: + response.set_cookie(key=settings.LANGUAGE_COOKIE_NAME, + value=request.user.profile.language, + max_age=settings.LANGUAGE_COOKIE_AGE) + except Exception as e: + logger.exception(e) + + # Set our Night mode flag from the DB + # Null = hasnt been set by the user ever, dont act. + # + # Night mode intercept is not needed in this middleware. + # is saved direct to DB in NightModeRedirectView + try: + if request.user.profile.night_mode is not None: + request.session["NIGHT_MODE"] = request.user.profile.night_mode + except Exception as e: + logger.exception(e) + + return response diff --git a/allianceauth/authentication/migrations/0020_userprofile_language_userprofile_night_mode.py b/allianceauth/authentication/migrations/0020_userprofile_language_userprofile_night_mode.py new file mode 100644 index 00000000..040a5855 --- /dev/null +++ b/allianceauth/authentication/migrations/0020_userprofile_language_userprofile_night_mode.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.2 on 2022-02-26 03:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0019_merge_20211026_0919'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='language', + field=models.CharField(blank=True, choices=[('en', 'English'), ('de', 'German'), ('es', 'Spanish'), ('zh-hans', 'Chinese Simplified'), ('ru', 'Russian'), ('ko', 'Korean'), ('fr', 'French'), ('ja', 'Japanese'), ('it', 'Italian')], default='', max_length=10, verbose_name='Language'), + ), + migrations.AddField( + model_name='userprofile', + name='night_mode', + field=models.BooleanField(blank=True, null=True, verbose_name='Night Mode'), + ), + ] diff --git a/allianceauth/authentication/models.py b/allianceauth/authentication/models.py index dd4b674f..13f3249a 100755 --- a/allianceauth/authentication/models.py +++ b/allianceauth/authentication/models.py @@ -5,6 +5,7 @@ from django.db import models, transaction from django.utils.translation import gettext_lazy as _ from allianceauth.eveonline.models import EveCharacter, EveCorporationInfo, EveAllianceInfo, EveFactionInfo from allianceauth.notifications import notify +from django.conf import settings from .managers import CharacterOwnershipManager, StateManager @@ -62,9 +63,39 @@ class UserProfile(models.Model): class Meta: default_permissions = ('change',) - user = models.OneToOneField(User, related_name='profile', on_delete=models.CASCADE) - main_character = models.OneToOneField(EveCharacter, blank=True, null=True, on_delete=models.SET_NULL) - state = models.ForeignKey(State, on_delete=models.SET_DEFAULT, default=get_guest_state_pk) + user = models.OneToOneField( + User, + related_name='profile', + on_delete=models.CASCADE) + main_character = models.OneToOneField( + EveCharacter, + blank=True, + null=True, + on_delete=models.SET_NULL) + state = models.ForeignKey( + State, + on_delete=models.SET_DEFAULT, + default=get_guest_state_pk) + LANGUAGE_CHOICES = [ + ('en', _('English')), + ('de', _('German')), + ('es', _('Spanish')), + ('zh-hans', _('Chinese Simplified')), + ('ru', _('Russian')), + ('ko', _('Korean')), + ('fr', _('French')), + ('ja', _('Japanese')), + ('it', _('Italian')), + ] + language = models.CharField( + _("Language"), max_length=10, + choices=LANGUAGE_CHOICES, + blank=True, + default='') + night_mode = models.BooleanField( + _("Night Mode"), + blank=True, + null=True) def assign_state(self, state=None, commit=True): if not state: @@ -93,8 +124,6 @@ class UserProfile(models.Model): def __str__(self): return str(self.user) - - class CharacterOwnership(models.Model): class Meta: default_permissions = ('change', 'delete') diff --git a/allianceauth/authentication/signals.py b/allianceauth/authentication/signals.py index cf103f18..dfb9ba1b 100644 --- a/allianceauth/authentication/signals.py +++ b/allianceauth/authentication/signals.py @@ -1,6 +1,11 @@ import logging -from .models import CharacterOwnership, UserProfile, get_guest_state, State, OwnershipRecord +from .models import ( + CharacterOwnership, + UserProfile, + get_guest_state, + State, + OwnershipRecord) from django.contrib.auth.models import User from django.db.models import Q from django.db.models.signals import pre_save, post_save, pre_delete, post_delete, m2m_changed @@ -71,7 +76,7 @@ def reassess_on_profile_save(sender, instance, created, *args, **kwargs): @receiver(post_save, sender=User) def create_required_models(sender, instance, created, *args, **kwargs): - # ensure all users have a model + # ensure all users have our Sub-Models if created: logger.debug(f'User {instance} created. Creating default UserProfile.') UserProfile.objects.get_or_create(user=instance) diff --git a/allianceauth/authentication/tests/__init__.py b/allianceauth/authentication/tests/__init__.py index 7c31c2fe..13410df4 100644 --- a/allianceauth/authentication/tests/__init__.py +++ b/allianceauth/authentication/tests/__init__.py @@ -1,4 +1,17 @@ +from django.db.models.signals import ( + m2m_changed, + post_save, + pre_delete, + pre_save +) from django.urls import reverse +from unittest import mock + +MODULE_PATH = 'allianceauth.authentication' + + +def patch(target, *args, **kwargs): + return mock.patch(f'{MODULE_PATH}{target}', *args, **kwargs) def get_admin_change_view_url(obj: object) -> str: diff --git a/allianceauth/authentication/tests/test_middleware.py b/allianceauth/authentication/tests/test_middleware.py new file mode 100644 index 00000000..70335e58 --- /dev/null +++ b/allianceauth/authentication/tests/test_middleware.py @@ -0,0 +1,175 @@ +from unittest import mock +from allianceauth.authentication.middleware import UserSettingsMiddleware +from unittest.mock import Mock +from django.http import HttpResponse + +from django.test.testcases import TestCase + + +class TestUserSettingsMiddlewareSaveLang(TestCase): + + def setUp(self): + self.middleware = UserSettingsMiddleware(HttpResponse) + self.request = Mock() + self.request.headers = { + "User-Agent": "AUTOMATED TEST" + } + self.request.path = '/i18n/setlang/' + self.request.POST = { + 'language': 'fr' + } + self.request.user.profile.language = 'de' + self.request.user.is_anonymous = False + self.response = Mock() + self.response.content = 'hello world' + + def test_middleware_passthrough(self): + """ + Simply tests the middleware runs cleanly + """ + response = self.middleware.process_response( + self.request, + self.response + ) + self.assertEqual(self.response, response) + + def test_middleware_save_language_false_anonymous(self): + """ + Ensures the middleware wont change the usersettings + of a non-existent (anonymous) user + """ + self.request.user.is_anonymous = True + response = self.middleware.process_response( + self.request, + self.response + ) + self.assertEqual(self.request.user.profile.language, 'de') + self.assertFalse(self.request.user.profile.save.called) + self.assertEqual(self.request.user.profile.save.call_count, 0) + + def test_middleware_save_language_new(self): + """ + does the middleware change a language not set in the DB + """ + self.request.user.profile.language = None + response = self.middleware.process_response( + self.request, + self.response + ) + self.assertEqual(self.request.user.profile.language, 'fr') + self.assertTrue(self.request.user.profile.save.called) + self.assertEqual(self.request.user.profile.save.call_count, 1) + + def test_middleware_save_language_changed(self): + """ + Tests the middleware will change a language setting + """ + response = self.middleware.process_response( + self.request, + self.response + ) + self.assertEqual(self.request.user.profile.language, 'fr') + self.assertTrue(self.request.user.profile.save.called) + self.assertEqual(self.request.user.profile.save.call_count, 1) + + +class TestUserSettingsMiddlewareLoginFlow(TestCase): + + def setUp(self): + self.middleware = UserSettingsMiddleware(HttpResponse) + self.request = Mock() + self.request.headers = { + "User-Agent": "AUTOMATED TEST" + } + self.request.path = '/sso/login' + self.request.session = { + 'NIGHT_MODE': False + } + self.request.LANGUAGE_CODE = 'en' + self.request.user.profile.language = 'de' + self.request.user.profile.night_mode = True + self.request.user.is_anonymous = False + self.response = Mock() + self.response.content = 'hello world' + + def test_middleware_passthrough(self): + """ + Simply tests the middleware runs cleanly + """ + middleware_response = self.middleware.process_response( + self.request, + self.response + ) + self.assertEqual(self.response, middleware_response) + + def test_middleware_sets_language_cookie_true_no_cookie(self): + """ + tests the middleware will set a cookie, while none is set + """ + self.request.LANGUAGE_CODE = None + middleware_response = self.middleware.process_response( + self.request, + self.response + ) + self.assertTrue(middleware_response.set_cookie.called) + self.assertEqual(middleware_response.set_cookie.call_count, 1) + args, kwargs = middleware_response.set_cookie.call_args + self.assertEqual(kwargs['value'], 'de') + + def test_middleware_sets_language_cookie_true_wrong_cookie(self): + """ + tests the middleware will set a cookie, while a different value is set + """ + middleware_response = self.middleware.process_response( + self.request, + self.response + ) + self.assertTrue(middleware_response.set_cookie.called) + self.assertEqual(middleware_response.set_cookie.call_count, 1) + args, kwargs = middleware_response.set_cookie.call_args + self.assertEqual(kwargs['value'], 'de') + + def test_middleware_sets_language_cookie_false_anonymous(self): + """ + ensures the middleware wont set a value for a non existent user (anonymous) + """ + self.request.user.is_anonymous = True + middleware_response = self.middleware.process_response( + self.request, + self.response + ) + self.assertFalse = middleware_response.set_cookie.called + self.assertEqual(middleware_response.set_cookie.call_count, 0) + + def test_middleware_sets_language_cookie_false_already_set(self): + """ + tests the middleware skips setting the cookie, if its already set correctly + """ + self.request.user.profile.language = 'en' + middleware_response = self.middleware.process_response( + self.request, + self.response + ) + self.assertFalse = middleware_response.set_cookie.called + self.assertEqual(middleware_response.set_cookie.call_count, 0) + + def test_middleware_sets_night_mode_not_set(self): + """ + tests the middleware will set night_mode if not set + """ + self.request.session = {} + response = self.middleware.process_response( + self.request, + self.response + ) + self.assertEqual(self.request.session["NIGHT_MODE"], True) + + def test_middleware_sets_night_mode_set(self): + """ + tests the middleware will set night_mode if set. + """ + response = self.middleware.process_response( + self.request, + self.response + ) + self.assertEqual(self.request.session["NIGHT_MODE"], True) diff --git a/allianceauth/authentication/tests/test_signals.py b/allianceauth/authentication/tests/test_signals.py new file mode 100644 index 00000000..8f7ba716 --- /dev/null +++ b/allianceauth/authentication/tests/test_signals.py @@ -0,0 +1,94 @@ +from allianceauth.authentication.models import User, UserProfile +from allianceauth.eveonline.models import ( + EveCharacter, + EveCorporationInfo, + EveAllianceInfo +) +from django.db.models.signals import ( + pre_save, + post_save, + pre_delete, + m2m_changed +) +from allianceauth.tests.auth_utils import AuthUtils + +from django.test.testcases import TestCase +from unittest.mock import Mock +from . import patch + + +class TestUserProfileSignals(TestCase): + + def setUp(self): + state = AuthUtils.get_member_state() + + self.char = EveCharacter.objects.create( + character_id='1234', + character_name='test character', + corporation_id='2345', + corporation_name='test corp', + corporation_ticker='tickr', + alliance_id='3456', + alliance_name='alliance name', + ) + + self.alliance = EveAllianceInfo.objects.create( + alliance_id='3456', + alliance_name='alliance name', + alliance_ticker='TIKR', + executor_corp_id='2345', + ) + + self.corp = EveCorporationInfo.objects.create( + corporation_id='2345', + corporation_name='corp name', + corporation_ticker='TIKK', + member_count=10, + alliance=self.alliance, + ) + + state.member_alliances.add(self.alliance) + state.member_corporations.add(self.corp) + + self.member = AuthUtils.create_user('test user') + self.member.profile.main_character = self.char + self.member.profile.save() + + @patch('.signals.create_required_models') + def test_create_required_models_triggered_true( + self, create_required_models): + """ + Create a User object here, + to generate UserProfile models + """ + post_save.connect(create_required_models, sender=User) + AuthUtils.create_user('test_create_required_models_triggered') + self.assertTrue = create_required_models.called + self.assertEqual(create_required_models.call_count, 1) + + user = User.objects.get(username='test_create_required_models_triggered') + self.assertIsNot(UserProfile.objects.get(user=user), False) + + @patch('.signals.create_required_models') + def test_create_required_models_triggered_false( + self, create_required_models): + """ + Only call a User object Update here, + which does not need to generate UserProfile models + """ + post_save.connect(create_required_models, sender=User) + char = EveCharacter.objects.create( + character_id='1266', + character_name='test character2', + corporation_id='2345', + corporation_name='test corp', + corporation_ticker='tickr', + alliance_id='3456', + alliance_name='alliance name', + ) + self.member.profile.main_character = char + self.member.profile.save() + + self.assertTrue = create_required_models.called + self.assertEqual(create_required_models.call_count, 0) + self.assertIsNot(UserProfile.objects.get(user=self.member), False) diff --git a/allianceauth/eveonline/tasks.py b/allianceauth/eveonline/tasks.py index a6b11a24..ce2b4219 100644 --- a/allianceauth/eveonline/tasks.py +++ b/allianceauth/eveonline/tasks.py @@ -40,7 +40,7 @@ def update_character(character_id: int) -> None: def run_model_update(): """Update all alliances, corporations and characters from ESI""" - # update existing corp models + #update existing corp models for corp in EveCorporationInfo.objects.all().values('corporation_id'): update_corp.apply_async(args=[corp['corporation_id']], priority=TASK_PRIORITY) diff --git a/allianceauth/project_template/project_name/settings/base.py b/allianceauth/project_template/project_name/settings/base.py index 755ca4d1..8c3d7e3b 100644 --- a/allianceauth/project_template/project_name/settings/base.py +++ b/allianceauth/project_template/project_name/settings/base.py @@ -68,6 +68,7 @@ BASE_DIR = os.path.dirname(PROJECT_DIR) MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'allianceauth.authentication.middleware.UserSettingsMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', diff --git a/allianceauth/views.py b/allianceauth/views.py index 2f830bc7..d9dacba2 100644 --- a/allianceauth/views.py +++ b/allianceauth/views.py @@ -3,12 +3,23 @@ from django.http import HttpResponseRedirect from django.shortcuts import redirect from django.contrib import messages +import logging + +logger = logging.getLogger(__name__) + class NightModeRedirectView(View): SESSION_VAR = "NIGHT_MODE" def get(self, request, *args, **kwargs): request.session[self.SESSION_VAR] = not self.night_mode_state(request) + if not request.user.is_anonymous: + try: + request.user.profile.night_mode = request.session[self.SESSION_VAR] + request.user.profile.save() + except Exception as e: + logger.exception(e) + return HttpResponseRedirect(request.GET.get("next", "/")) @classmethod