diff --git a/allianceauth/authentication/views.py b/allianceauth/authentication/views.py index 02c4b2d5..7645193b 100644 --- a/allianceauth/authentication/views.py +++ b/allianceauth/authentication/views.py @@ -6,8 +6,9 @@ from django.contrib.auth import login, authenticate from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.core import signing -from django.urls import reverse, reverse_lazy +from django.http import JsonResponse from django.shortcuts import redirect, render +from django.urls import reverse, reverse_lazy from django.utils.translation import gettext_lazy as _ from allianceauth.eveonline.models import EveCharacter diff --git a/allianceauth/notifications/__init__.py b/allianceauth/notifications/__init__.py index 323a9848..6e044162 100644 --- a/allianceauth/notifications/__init__.py +++ b/allianceauth/notifications/__init__.py @@ -1,22 +1,9 @@ default_app_config = 'allianceauth.notifications.apps.NotificationsConfig' -import logging - -logger = logging.getLogger(__name__) - -MAX_NOTIFICATIONS = 50 -def notify(user, title, message=None, level='info'): +def notify( + user: object, title: str, message: str = None, level: str = 'info' +) -> None: + """Sends a new notification to user. Convenience function to manager pendant.""" from .models import Notification - if Notification.objects.filter(user=user).count() > MAX_NOTIFICATIONS: - for n in Notification.objects.filter(user=user)[MAX_NOTIFICATIONS-1:]: - n.delete() - notif = Notification() - notif.user = user - notif.title = title - if not message: - message = title - notif.message = message - notif.level = level - notif.save() - logger.info("Created notification %s" % notif) + Notification.objects.notify_user(user, title, message, level) diff --git a/allianceauth/notifications/context_processors.py b/allianceauth/notifications/context_processors.py deleted file mode 100644 index 12a7e685..00000000 --- a/allianceauth/notifications/context_processors.py +++ /dev/null @@ -1,11 +0,0 @@ -from .models import Notification -from django.core.cache import cache - -def user_notification_count(request): - user_id = request.user.id - notification_count = cache.get("u-note:{}".format(user_id), -1) - if notification_count<0: - notification_count = Notification.objects.filter(user__id=user_id).filter(viewed=False).count() - cache.set("u-note:{}".format(user_id),notification_count,5) - - return {'notifications': notification_count} diff --git a/allianceauth/notifications/managers.py b/allianceauth/notifications/managers.py new file mode 100644 index 00000000..f6856c9e --- /dev/null +++ b/allianceauth/notifications/managers.py @@ -0,0 +1,101 @@ +import logging + +from django.conf import settings +from django.core.cache import cache +from django.db import models +from django.contrib.auth.models import User + +logger = logging.getLogger(__name__) + + +class NotificationQuerySet(models.QuerySet): + """Custom QuerySet for Notification model""" + + def update(self, *args, **kwargs): + # overriden update to ensure cache is invaidated on very call + super().update(*args, **kwargs) + user_pks = set(self.select_related("user").values_list('user__pk', flat=True)) + for user_pk in user_pks: + NotificationManager.invalidate_user_notification_cache(user_pk) + + +class NotificationManager(models.Manager): + + USER_NOTIFICATION_COUNT_PREFIX = 'USER_NOTIFICATION_COUNT' + USER_NOTIFICATION_COUNT_CACHE_DURATION = 86_400 + + def get_queryset(self): + return NotificationQuerySet(self.model, using=self._db) + + def notify_user( + self, user: object, title: str, message: str = None, level: str = 'info' + ) -> object: + """Sends a new notification to user. Returns newly created notification object. + """ + max_notifications = self._max_notifications_per_user() + if self.filter(user=user).count() >= max_notifications: + to_be_deleted_qs = self.filter(user=user).order_by( + "-timestamp" + )[max_notifications - 1:] + for notification in to_be_deleted_qs: + notification.delete() + + if not message: + message = title + + obj = self.create(user=user, title=title, message=message, level=level) + logger.info("Created notification %s", obj) + return obj + + def _max_notifications_per_user(self): + """return the maximum number of notifications allowed per user""" + max_notifications = getattr(settings, 'NOTIFICATIONS_MAX_PER_USER', None) + if ( + max_notifications is None + or not isinstance(max_notifications, int) + or max_notifications < 0 + ): + logger.warning( + 'NOTIFICATIONS_MAX_PER_USER setting is invalid. Using default.' + ) + max_notifications = self.model.NOTIFICATIONS_MAX_PER_USER_DEFAULT + + return max_notifications + + def user_unread_count(self, user_pk: int) -> int: + """returns the cached unread count for a user given by user PK + + Will return -1 if user can not be found + """ + cache_key = self._user_notification_cache_key(user_pk) + unread_count = cache.get(key=cache_key) + if not unread_count: + try: + user = User.objects.get(pk=user_pk) + except User.DoesNotExist: + unread_count = -1 + else: + logger.debug( + 'Updating notification cache for user with pk %s', user_pk + ) + unread_count = user.notification_set.filter(viewed=False).count() + cache.set( + key=cache_key, + value=unread_count, + timeout=self.USER_NOTIFICATION_COUNT_CACHE_DURATION + ) + else: + logger.debug( + 'Returning notification count from cache for user with pk %s', user_pk + ) + + return unread_count + + @classmethod + def invalidate_user_notification_cache(cls, user_pk: int) -> None: + cache.delete(key=cls._user_notification_cache_key(user_pk)) + logger.debug('Invalided notification cache for user with pk %s', user_pk) + + @classmethod + def _user_notification_cache_key(cls, user_pk: int) -> str: + return f'{cls.USER_NOTIFICATION_COUNT_PREFIX}_{user_pk}' diff --git a/allianceauth/notifications/migrations/0004_performance_tuning.py b/allianceauth/notifications/migrations/0004_performance_tuning.py new file mode 100644 index 00000000..4d3b2e7d --- /dev/null +++ b/allianceauth/notifications/migrations/0004_performance_tuning.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.5 on 2021-01-07 21:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0003_make_strings_more_stringy'), + ] + + operations = [ + migrations.AlterModelOptions( + name='notification', + options={}, + ), + migrations.AlterField( + model_name='notification', + name='timestamp', + field=models.DateTimeField(auto_now_add=True, db_index=True), + ), + migrations.AlterField( + model_name='notification', + name='viewed', + field=models.BooleanField(db_index=True, default=False), + ), + ] diff --git a/allianceauth/notifications/models.py b/allianceauth/notifications/models.py index 7e432e30..cc3ea113 100644 --- a/allianceauth/notifications/models.py +++ b/allianceauth/notifications/models.py @@ -1,11 +1,19 @@ +import logging + from django.db import models from django.contrib.auth.models import User -import logging + +from .managers import NotificationManager logger = logging.getLogger(__name__) class Notification(models.Model): + """Notification to a user within Auth""" + + NOTIFICATIONS_MAX_PER_USER_DEFAULT = 50 + NOTIFICATIONS_REFRESH_TIME_DEFAULT = 30 + LEVEL_CHOICES = ( ('danger', 'CRITICAL'), ('danger', 'ERROR'), @@ -18,19 +26,41 @@ class Notification(models.Model): level = models.CharField(choices=LEVEL_CHOICES, max_length=10) title = models.CharField(max_length=254) message = models.TextField() - timestamp = models.DateTimeField(auto_now_add=True) - viewed = models.BooleanField(default=False) + timestamp = models.DateTimeField(auto_now_add=True, db_index=True) + viewed = models.BooleanField(default=False, db_index=True) - def view(self): + objects = NotificationManager() + + def __str__(self) -> str: + return "%s: %s" % (self.user, self.title) + + def save(self, *args, **kwargs): + # overriden save to ensure cache is invaidated on very call + super().save(*args, **kwargs) + Notification.objects.invalidate_user_notification_cache(self.user.pk) + + def delete(self, *args, **kwargs): + # overriden delete to ensure cache is invaidated on very call + super().delete(*args, **kwargs) + Notification.objects.invalidate_user_notification_cache(self.user.pk) + + def mark_viewed(self) -> None: + """mark notification as viewed""" logger.info("Marking notification as viewed: %s" % self) self.viewed = True self.save() - def __str__(self): - return "%s: %s" % (self.user, self.title) + def set_level(self, level_name: str) -> None: + """set notification level according to level name, e.g. 'CRITICAL' + + raised exception on invalid level names + """ + try: + new_level = [ + item[0] for item in self.LEVEL_CHOICES if item[1] == level_name + ][0] + except IndexError: + raise ValueError('Invalid level name: %s' % level_name) - def set_level(self, level): - self.level = [item[0] for item in self.LEVEL_CHOICES if item[1] == level][0] - - class Meta: - ordering = ['-timestamp'] + self.level = new_level + self.save() diff --git a/allianceauth/notifications/templatetags/__init__.py b/allianceauth/notifications/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/allianceauth/notifications/templatetags/auth_notifications.py b/allianceauth/notifications/templatetags/auth_notifications.py new file mode 100644 index 00000000..ddfabec7 --- /dev/null +++ b/allianceauth/notifications/templatetags/auth_notifications.py @@ -0,0 +1,43 @@ +"""Templatetags for notifications + +These template tags are required to enable the notifications refresh functionality +in the browser. +""" + +import logging + +from django import template +from django.conf import settings +from django.contrib.auth.models import User + +from allianceauth.notifications.models import Notification + + +logger = logging.getLogger(__name__) +register = template.Library() + + +@register.filter +def user_unread_notification_count(user: object) -> int: + """returns the number of unread notifications for user + + Will return -1 on error + """ + if not isinstance(user, User): + unread_count = -1 + else: + unread_count = Notification.objects.user_unread_count(user.pk) + + return unread_count + + +@register.simple_tag +def notifications_refresh_time() -> int: + refresh_time = getattr(settings, 'NOTIFICATIONS_REFRESH_TIME', None) + if ( + refresh_time is None or not isinstance(refresh_time, int) or refresh_time < 0 + ): + logger.warning('NOTIFICATIONS_REFRESH_TIME setting is invalid. Using default.') + refresh_time = Notification.NOTIFICATIONS_REFRESH_TIME_DEFAULT + + return refresh_time diff --git a/allianceauth/notifications/tests/test_init.py b/allianceauth/notifications/tests/test_init.py new file mode 100644 index 00000000..b4e56625 --- /dev/null +++ b/allianceauth/notifications/tests/test_init.py @@ -0,0 +1,28 @@ +from django.test import TestCase +from allianceauth.tests.auth_utils import AuthUtils + +from .. import notify +from ..models import Notification + +MODULE_PATH = 'allianceauth.notifications' + + +class TestUserNotificationCount(TestCase): + + @classmethod + def setUpTestData(cls): + cls.user = AuthUtils.create_user('magic_mike') + AuthUtils.add_main_character( + cls.user, + 'Magic Mike', + '1', + corp_id='2', + corp_name='Pole Riders', + corp_ticker='PRIDE', + alliance_id='3', + alliance_name='RIDERS' + ) + + def test_can_notify(self): + notify(self.user, 'dummy') + self.assertEqual(Notification.objects.filter(user=self.user).count(), 1) diff --git a/allianceauth/notifications/tests/test_managers.py b/allianceauth/notifications/tests/test_managers.py new file mode 100644 index 00000000..e7feff62 --- /dev/null +++ b/allianceauth/notifications/tests/test_managers.py @@ -0,0 +1,206 @@ +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase, override_settings + +from allianceauth.tests.auth_utils import AuthUtils +from ..models import Notification + + +MODULE_PATH = 'allianceauth.notifications.models' + +NOTIFICATIONS_MAX_PER_USER_DEFAULT = 42 + + +class TestQuerySet(TestCase): + + @classmethod + def setUpTestData(cls): + cls.user_1 = AuthUtils.create_user('Peter Parker') + cls.user_2 = AuthUtils.create_user('Clark Kent') + + @patch(MODULE_PATH + '.Notification.objects.invalidate_user_notification_cache') + def test_update_will_invalidate_cache( + self, mock_invalidate_user_notification_cache + ): + Notification.objects.notify_user(self.user_1, 'dummy_1') + Notification.objects.notify_user(self.user_2, 'dummy_2') + Notification.objects.update(viewed=True) + self.assertEquals(mock_invalidate_user_notification_cache.call_count, 2) + + +class TestUserNotify(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = AuthUtils.create_user('magic_mike') + AuthUtils.add_main_character( + cls.user, + 'Magic Mike', + '1', + corp_id='2', + corp_name='Pole Riders', + corp_ticker='PRIDE', + alliance_id='3', + alliance_name='RIDERS' + ) + + def test_can_notify(self): + title = 'dummy_title' + message = 'dummy message' + level = 'danger' + Notification.objects.notify_user(self.user, title, message, level) + self.assertEqual(Notification.objects.filter(user=self.user).count(), 1) + obj = Notification.objects.first() + self.assertEqual(obj.user, self.user) + self.assertEqual(obj.title, title) + self.assertEqual(obj.message, message) + self.assertEqual(obj.level, level) + + def test_use_message_as_title_if_missing(self): + title = 'dummy_title' + Notification.objects.notify_user(self.user, title) + self.assertEqual(Notification.objects.filter(user=self.user).count(), 1) + obj = Notification.objects.first() + self.assertEqual(obj.user, self.user) + self.assertEqual(obj.title, title) + self.assertEqual(obj.message, title) + + @override_settings(NOTIFICATIONS_MAX_PER_USER=3) + def test_remove_when_too_many_notifications(self): + Notification.objects.notify_user(self.user, 'dummy') + obj_2 = Notification.objects.notify_user(self.user, 'dummy') + obj_3 = Notification.objects.notify_user(self.user, 'dummy') + obj_4 = Notification.objects.notify_user(self.user, 'dummy') + expected = {obj_2.pk, obj_3.pk, obj_4.pk} + result = set( + Notification.objects.filter(user=self.user).values_list("pk", flat=True) + ) + self.assertSetEqual(result, expected) + obj_5 = Notification.objects.notify_user(self.user, 'dummy') + expected = {obj_3.pk, obj_4.pk, obj_5.pk} + result = set( + Notification.objects.filter(user=self.user).values_list("pk", flat=True) + ) + self.assertSetEqual(result, expected) + + +@patch( + MODULE_PATH + '.Notification.NOTIFICATIONS_MAX_PER_USER_DEFAULT', + NOTIFICATIONS_MAX_PER_USER_DEFAULT +) +class TestMaxNotificationsPerUser(TestCase): + + @override_settings(NOTIFICATIONS_MAX_PER_USER=None) + def test_reset_to_default_if_not_defined(self): + result = Notification.objects._max_notifications_per_user() + expected = NOTIFICATIONS_MAX_PER_USER_DEFAULT + self.assertEquals(result, expected) + + @override_settings(NOTIFICATIONS_MAX_PER_USER='11') + def test_reset_to_default_if_not_int(self): + result = Notification.objects._max_notifications_per_user() + expected = NOTIFICATIONS_MAX_PER_USER_DEFAULT + self.assertEquals(result, expected) + + @override_settings(NOTIFICATIONS_MAX_PER_USER=-1) + def test_reset_to_default_if_lt_zero(self): + result = Notification.objects._max_notifications_per_user() + expected = NOTIFICATIONS_MAX_PER_USER_DEFAULT + self.assertEquals(result, expected) + + +@patch('allianceauth.notifications.managers.cache') +class TestUnreadCount(TestCase): + + @classmethod + def setUpTestData(cls): + cls.user_1 = AuthUtils.create_user('magic_mike') + AuthUtils.add_main_character( + cls.user_1, + 'Magic Mike', + '1', + corp_id='2', + corp_name='Pole Riders', + corp_ticker='PRIDE', + alliance_id='3', + alliance_name='RIDERS' + ) + + # test notifications for mike + Notification.objects.all().delete() + Notification.objects.create( + user=cls.user_1, + level="INFO", + title="Job 1 Failed", + message="Because it was broken", + viewed=True + ) + Notification.objects.create( + user=cls.user_1, + level="INFO", + title="Job 2 Failed", + message="Because it was broken" + ) + Notification.objects.create( + user=cls.user_1, + level="INFO", + title="Job 3 Failed", + message="Because it was broken" + ) + + cls.user_2 = AuthUtils.create_user('teh_kid') + AuthUtils.add_main_character( + cls.user_2, + 'The Kid', '2', + corp_id='2', + corp_name='Pole Riders', + corp_ticker='PRIDE', + alliance_id='3', + alliance_name='RIDERS' + ) + + # Notifications for kid + Notification.objects.create( + user=cls.user_2, + level="INFO", + title="Job 6 Failed", + message="Because it was broken" + ) + + def test_update_cache_when_not_in_cache(self, mock_cache): + mock_cache.get.return_value = None + + result = Notification.objects.user_unread_count(self.user_1.pk) + expected = 2 + self.assertEqual(result, expected) + self.assertTrue(mock_cache.set.called) + args, kwargs = mock_cache.set.call_args + self.assertEqual( + kwargs['key'], + Notification.objects._user_notification_cache_key(self.user_1.pk) + ) + self.assertEqual(kwargs['value'], expected) + + def test_return_from_cache_when_in_cache(self, mock_cache): + mock_cache.get.return_value = 42 + result = Notification.objects.user_unread_count(self.user_1.pk) + expected = 42 + self.assertEqual(result, expected) + self.assertFalse(mock_cache.set.called) + + def test_return_error_code_when_user_not_found(self, mock_cache): + mock_cache.get.return_value = None + invalid_user_id = max([user.pk for user in User.objects.all()]) + 1 + result = Notification.objects.user_unread_count(invalid_user_id) + expected = -1 + self.assertEqual(result, expected) + self.assertFalse(mock_cache.set.called) + + def test_can_invalidate_cache(self, mock_cache): + Notification.objects.invalidate_user_notification_cache(self.user_1.pk) + self.assertTrue(mock_cache.delete) + args, kwargs = mock_cache.delete.call_args + self.assertEqual( + kwargs['key'], + Notification.objects._user_notification_cache_key(self.user_1.pk) + ) diff --git a/allianceauth/notifications/tests/test_models.py b/allianceauth/notifications/tests/test_models.py new file mode 100644 index 00000000..32866386 --- /dev/null +++ b/allianceauth/notifications/tests/test_models.py @@ -0,0 +1,73 @@ +from unittest.mock import patch + +from django.test import TestCase +from allianceauth.tests.auth_utils import AuthUtils + +from ..models import Notification + + +MODULE_PATH = 'allianceauth.notifications.models' + + +class TestUserNotify(TestCase): + + @classmethod + def setUpTestData(cls): + cls.user = AuthUtils.create_user('magic_mike') + AuthUtils.add_main_character( + cls.user, + 'Magic Mike', + '1', + corp_id='2', + corp_name='Pole Riders', + corp_ticker='PRIDE', + alliance_id='3', + alliance_name='RIDERS' + ) + + @patch(MODULE_PATH + '.Notification.objects.invalidate_user_notification_cache') + def test_save_will_invalidate_cache(self, mock_invalidate_user_notification_cache): + obj = Notification.objects.notify_user(self.user, 'dummy') + self.assertTrue(Notification.objects.filter(pk=obj.pk).exists()) + self.assertEquals(mock_invalidate_user_notification_cache.call_count, 1) + + @patch(MODULE_PATH + '.Notification.objects.invalidate_user_notification_cache') + def test_delete_will_invalidate_cache( + self, mock_invalidate_user_notification_cache + ): + obj = Notification.objects.notify_user(self.user, 'dummy') + obj.delete() + self.assertFalse(Notification.objects.filter(pk=obj.pk).exists()) + self.assertEquals(mock_invalidate_user_notification_cache.call_count, 2) + + def test_can_view(self): + obj = Notification.objects.notify_user(self.user, 'dummy') + self.assertFalse(obj.viewed) + obj.mark_viewed() + obj.refresh_from_db() + self.assertTrue(obj.viewed) + + def test_can_set_level(self): + obj = Notification.objects.notify_user(self.user, 'dummy', level='info') + obj.set_level('ERROR') + obj.refresh_from_db() + self.assertEqual(obj.level, 'danger') + + obj.set_level('CRITICAL') + obj.refresh_from_db() + self.assertEqual(obj.level, 'danger') + + obj.set_level('WARN') + obj.refresh_from_db() + self.assertEqual(obj.level, 'warning') + + obj.set_level('INFO') + obj.refresh_from_db() + self.assertEqual(obj.level, 'info') + + obj.set_level('DEBUG') + obj.refresh_from_db() + self.assertEqual(obj.level, 'success') + + with self.assertRaises(ValueError): + obj.set_level('XXX') diff --git a/allianceauth/notifications/tests/test_processors.py b/allianceauth/notifications/tests/test_processors.py deleted file mode 100644 index 6c3c2480..00000000 --- a/allianceauth/notifications/tests/test_processors.py +++ /dev/null @@ -1,76 +0,0 @@ -from unittest import mock -from django.test import TestCase -from allianceauth.notifications.context_processors import user_notification_count -from allianceauth.tests.auth_utils import AuthUtils -from django.core.cache import cache -from allianceauth.notifications.models import Notification - -class TestNotificationCount(TestCase): - - @classmethod - def setUpTestData(cls): - cls.user = AuthUtils.create_user('magic_mike') - AuthUtils.add_main_character(cls.user, 'Magic Mike', '1', corp_id='2', corp_name='Pole Riders', corp_ticker='PRIDE', alliance_id='3', alliance_name='RIDERS') - cls.user.profile.refresh_from_db() - - ### test notifications for mike - Notification.objects.all().delete() - Notification.objects.create(user=cls.user, - level="INFO", - title="Job 1 Failed", - message="Because it was broken", - viewed=True) - Notification.objects.create(user=cls.user, - level="INFO", - title="Job 2 Failed", - message="Because it was broken") - Notification.objects.create(user=cls.user, - level="INFO", - title="Job 3 Failed", - message="Because it was broken") - Notification.objects.create(user=cls.user, - level="INFO", - title="Job 4 Failed", - message="Because it was broken") - Notification.objects.create(user=cls.user, - level="INFO", - title="Job 5 Failed", - message="Because it was broken") - Notification.objects.create(user=cls.user, - level="INFO", - title="Job 6 Failed", - message="Because it was broken") - - cls.user2 = AuthUtils.create_user('teh_kid') - AuthUtils.add_main_character(cls.user, 'The Kid', '2', corp_id='2', corp_name='Pole Riders', corp_ticker='PRIDE', alliance_id='3', alliance_name='RIDERS') - cls.user2.profile.refresh_from_db() - - # Noitification for kid - Notification.objects.create(user=cls.user2, - level="INFO", - title="Job 6 Failed", - message="Because it was broken") - - - def test_no_cache(self): - mock_req = mock.MagicMock() - mock_req.user.id = self.user.id - - cache.delete("u-note:{}".format(self.user.id)) # force the db to be hit - context_dict = user_notification_count(mock_req) - self.assertIsInstance(context_dict, dict) - self.assertEqual(context_dict.get('notifications'), 5) # 5 only - - - @mock.patch('allianceauth.notifications.models.Notification.objects') - def test_cache(self, mock_foo): - mock_foo.filter.return_value = mock_foo - mock_foo.count.return_value = 5 - mock_req = mock.MagicMock() - mock_req.user.id = self.user.id - - cache.set("u-note:{}".format(self.user.id),10,5) - context_dict = user_notification_count(mock_req) - self.assertIsInstance(context_dict, dict) - self.assertEqual(context_dict.get('notifications'), 10) # cached value - self.assertEqual(mock_foo.called, 0) # ensure the DB was not hit diff --git a/allianceauth/notifications/tests/test_templatetags.py b/allianceauth/notifications/tests/test_templatetags.py new file mode 100644 index 00000000..c39cf85b --- /dev/null +++ b/allianceauth/notifications/tests/test_templatetags.py @@ -0,0 +1,86 @@ +from unittest.mock import patch, Mock + +from django.test import TestCase, override_settings + +from allianceauth.tests.auth_utils import AuthUtils +from ..templatetags.auth_notifications import ( + user_unread_notification_count, notifications_refresh_time +) + +MODULE_PATH = 'allianceauth.notifications.templatetags.auth_notifications' + +NOTIFICATIONS_REFRESH_TIME_DEFAULT = 66 +MY_NOTIFICATIONS_REFRESH_TIME = 23 + + +@patch(MODULE_PATH + '.Notification.objects.user_unread_count') +class TestUserNotificationCount(TestCase): + + @classmethod + def setUpTestData(cls): + cls.user = AuthUtils.create_user('magic_mike') + AuthUtils.add_main_character( + cls.user, + 'Magic Mike', + '1', + corp_id='2', + corp_name='Pole Riders', + corp_ticker='PRIDE', + alliance_id='3', + alliance_name='RIDERS' + ) + + def test_return_normal(self, mock_user_unread_count): + unread_count = 42 + mock_user_unread_count.return_value = unread_count + + result = user_unread_notification_count(self.user) + expected = unread_count + self.assertEqual(result, expected) + args, kwargs = mock_user_unread_count.call_args + self.assertEqual(args[0], self.user.pk) + + def test_return_error_if_non_user(self, mock_user_unread_count): + unread_count = -1 + mock_user_unread_count.return_value = unread_count + + result = user_unread_notification_count('invalid') + expected = unread_count + self.assertEqual(result, expected) + + +@patch( + MODULE_PATH + '.Notification.NOTIFICATIONS_REFRESH_TIME_DEFAULT', + NOTIFICATIONS_REFRESH_TIME_DEFAULT +) +class TestNotificationsRefreshTime(TestCase): + + @override_settings(NOTIFICATIONS_REFRESH_TIME=MY_NOTIFICATIONS_REFRESH_TIME) + def test_return_from_setting(self): + result = notifications_refresh_time() + expected = MY_NOTIFICATIONS_REFRESH_TIME + self.assertEqual(result, expected) + + @override_settings(NOTIFICATIONS_REFRESH_TIME=0) + def test_refresh_time_can_be_zero(self): + result = notifications_refresh_time() + expected = 0 + self.assertEqual(result, expected) + + @override_settings(NOTIFICATIONS_REFRESH_TIME=None) + def test_return_default_refresh_time_if_not_exists(self): + result = notifications_refresh_time() + expected = NOTIFICATIONS_REFRESH_TIME_DEFAULT + self.assertEqual(result, expected) + + @override_settings(NOTIFICATIONS_REFRESH_TIME='33') + def test_return_default_refresh_time_if_not_int(self): + result = notifications_refresh_time() + expected = NOTIFICATIONS_REFRESH_TIME_DEFAULT + self.assertEqual(result, expected) + + @override_settings(NOTIFICATIONS_REFRESH_TIME=-1) + def test_return_default_refresh_time_if_lt_0(self): + result = notifications_refresh_time() + expected = NOTIFICATIONS_REFRESH_TIME_DEFAULT + self.assertEqual(result, expected) diff --git a/allianceauth/notifications/tests/test_views.py b/allianceauth/notifications/tests/test_views.py new file mode 100644 index 00000000..8ae87980 --- /dev/null +++ b/allianceauth/notifications/tests/test_views.py @@ -0,0 +1,49 @@ +import json + +from unittest.mock import patch, Mock + +from django.test import TestCase, RequestFactory +from django.urls import reverse + +from allianceauth.tests.auth_utils import AuthUtils + +from ..views import user_notifications_count + + +MODULE_PATH = 'allianceauth.notifications.views' + + +class TestViews(TestCase): + + @classmethod + def setUpTestData(cls): + cls.user = AuthUtils.create_user('magic_mike') + AuthUtils.add_main_character( + cls.user, + 'Magic Mike', + '1', + corp_id='2', + corp_name='Pole Riders', + corp_ticker='PRIDE', + alliance_id='3', + alliance_name='RIDERS' + ) + cls.factory = RequestFactory() + + @patch(MODULE_PATH + '.Notification.objects.user_unread_count') + def test_user_notifications_count(self, mock_user_unread_count): + unread_count = 42 + user_pk = 3 + mock_user_unread_count.return_value = unread_count + + request = self.factory.get( + reverse('notifications:user_notifications_count', args=[user_pk]) + ) + request.user = self.user + + response = user_notifications_count(request, user_pk) + self.assertEqual(response.status_code, 200) + self.assertTrue(mock_user_unread_count.called) + expected = {'unread_count': unread_count} + result = json.loads(response.content.decode(response.charset)) + self.assertDictEqual(result, expected) diff --git a/allianceauth/notifications/urls.py b/allianceauth/notifications/urls.py index e4ac9644..f7e7b948 100644 --- a/allianceauth/notifications/urls.py +++ b/allianceauth/notifications/urls.py @@ -9,4 +9,9 @@ urlpatterns = [ url(r'^notifications/delete_all_read/$', views.delete_all_read, name='delete_all_read'), url(r'^notifications/$', views.notification_list, name='list'), url(r'^notifications/(\w+)/$', views.notification_view, name='view'), + url( + r'^user_notifications_count/(?P\d+)/$', + views.user_notifications_count, + name='user_notifications_count' + ), ] diff --git a/allianceauth/notifications/views.py b/allianceauth/notifications/views.py index dc66fe87..1872038d 100644 --- a/allianceauth/notifications/views.py +++ b/allianceauth/notifications/views.py @@ -1,9 +1,12 @@ -from django.shortcuts import render, get_object_or_404, redirect -from .models import Notification +import logging + from django.contrib.auth.decorators import login_required from django.contrib import messages -from django.utils.translation import ugettext_lazy as _ -import logging +from django.http import JsonResponse +from django.shortcuts import render, get_object_or_404, redirect +from django.utils.translation import gettext_lazy as _ + +from .models import Notification logger = logging.getLogger(__name__) @@ -11,9 +14,15 @@ logger = logging.getLogger(__name__) @login_required def notification_list(request): logger.debug("notification_list called by user %s" % request.user) - new_notifs = Notification.objects.filter(user=request.user).filter(viewed=False) - old_notifs = Notification.objects.filter(user=request.user).filter(viewed=True) - logger.debug("User %s has %s unread and %s read notifications" % (request.user, len(new_notifs), len(old_notifs))) + notifications_qs = Notification.objects.filter(user=request.user).order_by("-timestamp") + new_notifs = notifications_qs.filter(viewed=False) + old_notifs = notifications_qs.filter(viewed=True) + logger.debug( + "User %s has %s unread and %s read notifications", + request.user, + len(new_notifs), + len(old_notifs) + ) context = { 'read': old_notifs, 'unread': new_notifs, @@ -23,39 +32,53 @@ def notification_list(request): @login_required def notification_view(request, notif_id): - logger.debug("notification_view called by user %s for notif_id %s" % (request.user, notif_id)) + logger.debug( + "notification_view called by user %s for notif_id %s", + request.user, + notif_id + ) notif = get_object_or_404(Notification, pk=notif_id) if notif.user == request.user: - logger.debug("Providing notification for user %s" % request.user) + logger.debug("Providing notification for user %s", request.user) context = {'notif': notif} - notif.view() + notif.mark_viewed() return render(request, 'notifications/view.html', context) else: logger.warn( - "User %s not authorized to view notif_id %s belonging to user %s" % (request.user, notif_id, notif.user)) + "User %s not authorized to view notif_id %s belonging to user %s", + request.user, + notif_id, notif.user + ) messages.error(request, _('You are not authorized to view that notification.')) return redirect('notifications:list') @login_required def remove_notification(request, notif_id): - logger.debug("remove notification called by user %s for notif_id %s" % (request.user, notif_id)) + logger.debug( + "remove notification called by user %s for notif_id %s", + request.user, + notif_id + ) notif = get_object_or_404(Notification, pk=notif_id) if notif.user == request.user: if Notification.objects.filter(id=notif_id).exists(): notif.delete() - logger.info("Deleting notif id %s by user %s" % (notif_id, request.user)) + logger.info("Deleting notif id %s by user %s", notif_id, request.user) messages.success(request, _('Deleted notification.')) else: logger.error( - "Unable to delete notif id %s for user %s - notif matching id not found." % (notif_id, request.user)) + "Unable to delete notif id %s for user %s - notif matching id not found.", + notif_id, + request.user + ) messages.error(request, _('Failed to locate notification.')) return redirect('notifications:list') @login_required def mark_all_read(request): - logger.debug('mark all notifications read called by user %s' % request.user) + logger.debug('mark all notifications read called by user %s', request.user) Notification.objects.filter(user=request.user).update(viewed=True) messages.success(request, _('Marked all notifications as read.')) return redirect('notifications:list') @@ -63,7 +86,17 @@ def mark_all_read(request): @login_required def delete_all_read(request): - logger.debug('delete all read notifications called by user %s' % request.user) + logger.debug('delete all read notifications called by user %s', request.user) Notification.objects.filter(user=request.user).filter(viewed=True).delete() messages.success(request, _('Deleted all read notifications.')) return redirect('notifications:list') + + +def user_notifications_count(request, user_pk: int): + """returns to notifications count for the give user as JSON + + This view is public and does not require login + """ + unread_count = Notification.objects.user_unread_count(user_pk) + data = {'unread_count': unread_count} + return JsonResponse(data, safe=False) diff --git a/allianceauth/project_template/project_name/settings/base.py b/allianceauth/project_template/project_name/settings/base.py index 0057905c..ac39c1c6 100644 --- a/allianceauth/project_template/project_name/settings/base.py +++ b/allianceauth/project_template/project_name/settings/base.py @@ -104,8 +104,7 @@ TEMPLATES = [ 'django.template.context_processors.i18n', 'django.template.context_processors.media', 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'allianceauth.notifications.context_processors.user_notification_count', + 'django.template.context_processors.tz', 'allianceauth.context_processors.auth_settings', ], }, @@ -173,6 +172,8 @@ CACHES = { } } +SESSION_ENGINE = "django.contrib.sessions.backends.cached_db" + DEBUG = True ALLOWED_HOSTS = ['*'] DATABASES = { diff --git a/allianceauth/static/js/refresh_notifications.js b/allianceauth/static/js/refresh_notifications.js new file mode 100644 index 00000000..ce6dc324 --- /dev/null +++ b/allianceauth/static/js/refresh_notifications.js @@ -0,0 +1,75 @@ +/* + This script refreshed the unread notification count in the top menu + on a regular basis so to keep the user apprised about newly arrived + notifications without having to reload the page. + + The refresh rate can be changes via the Django setting NOTIFICATIONS_REFRESH_TIME. + See documentation for details. +*/ + +$(function () { + var elem = document.getElementById("dataExport"); + var notificationsListViewUrl = elem.getAttribute("data-notificationsListViewUrl"); + var notificationsRefreshTime = elem.getAttribute("data-notificationsRefreshTime"); + var userNotificationsCountViewUrl = elem.getAttribute( + "data-userNotificationsCountViewUrl" + ); + + // update the notification unread count in the top menu + function update_notifications() { + $.getJSON(userNotificationsCountViewUrl, function (data, status) { + if (status == 'success') { + var innerHtml = ""; + var unread_count = data.unread_count; + if (unread_count > 0) { + innerHtml = ( + `Notifications ${unread_count}` + ) + } + else { + innerHtml = '' + } + $("#menu_item_notifications").html( + `${innerHtml}` + ); + } + else { + console.error( + `Failed to load HTMl to render notifications item. Error: ` + `${xhr.status}': '${xhr.statusText}` + ); + } + }); + } + + var myInterval; + + // activate automatic refreshing every x seconds + function activate_refreshing() { + if (notificationsRefreshTime > 0) { + myInterval = setInterval( + update_notifications, notificationsRefreshTime * 1000 + ); + } + } + + // deactivate automatic refreshing + function deactivate_refreshing() { + if ((notificationsRefreshTime > 0) && (typeof myInterval !== 'undefined')) { + clearInterval(myInterval) + } + } + + // refreshing only happens on active browser tab + $(document).on({ + 'show': function () { + activate_refreshing() + }, + 'hide': function () { + deactivate_refreshing() + } + }); + + // Initial start of refreshing on script loading + activate_refreshing() +}); diff --git a/allianceauth/templates/allianceauth/base.html b/allianceauth/templates/allianceauth/base.html index e976c386..e3d4e85f 100644 --- a/allianceauth/templates/allianceauth/base.html +++ b/allianceauth/templates/allianceauth/base.html @@ -1,6 +1,7 @@ {% load static %} {% load i18n %} {% load navactive %} +{% load auth_notifications %} @@ -9,7 +10,6 @@ - {% include 'allianceauth/icons.html' %} {% block title %}{% block page_title %}{% endblock page_title %} - Alliance Auth{% endblock title %} @@ -27,7 +27,6 @@
{% include 'allianceauth/top-menu.html' %} -
{% include 'allianceauth/side-menu.html' %}
@@ -40,7 +39,6 @@ {% endif %} {% endfor %} {% endif %} - {% block content %} {% endblock content %}
@@ -48,12 +46,20 @@
{% endif %} - + +
+
{% include 'bundles/bootstrap-js.html' %} - - {% block extra_javascript %} + {% include 'bundles/jquery-visibility-js.html' %} + + + {% block extra_javascript %} {% endblock extra_javascript %} - + diff --git a/docs/_static/images/features/core/notifications.png b/docs/_static/images/features/core/notifications.png new file mode 100644 index 00000000..8221e967 Binary files /dev/null and b/docs/_static/images/features/core/notifications.png differ diff --git a/docs/development/tech_docu/api/index.md b/docs/development/tech_docu/api/index.md index a11240a9..be231fec 100644 --- a/docs/development/tech_docu/api/index.md +++ b/docs/development/tech_docu/api/index.md @@ -9,5 +9,6 @@ To reduce redundancy and help speed up development we encourage developers to ut esi evelinks eveonline + notifications testutils ``` diff --git a/docs/development/tech_docu/api/notifications.rst b/docs/development/tech_docu/api/notifications.rst new file mode 100644 index 00000000..03386726 --- /dev/null +++ b/docs/development/tech_docu/api/notifications.rst @@ -0,0 +1,25 @@ +====================== +notifications +====================== + +The notifications package has an API for sending notifications. + +Location: ``allianceauth.notifications`` + +.. automodule:: allianceauth.notifications.__init__ + :members: notify + :undoc-members: + +models +=========== + +.. autoclass:: allianceauth.notifications.models.Notification + :members: view, set_level, LEVEL_CHOICES + :undoc-members: + +managers +=========== + +.. autoclass:: allianceauth.notifications.managers.NotificationManager + :members: notify_user, user_unread_count + :undoc-members: diff --git a/docs/features/core/index.md b/docs/features/core/index.md index 16a91e9a..4fa74139 100644 --- a/docs/features/core/index.md +++ b/docs/features/core/index.md @@ -10,4 +10,5 @@ Managing access to applications and services is one of the core functions of **A states groups groupmanagement + notifications ``` diff --git a/docs/features/core/notifications.md b/docs/features/core/notifications.md new file mode 100644 index 00000000..1c680e47 --- /dev/null +++ b/docs/features/core/notifications.md @@ -0,0 +1,14 @@ +# Notifications + +Alliance Auth has a build in notification system. The purpose of the notification system is to provide an easy and quick way to send messages to users of Auth. For example some apps are using it to inform users about results after long running tasks have completed and admins will automatically get notifications about system errors. + +The number of unread notifications is shown to the user in the top menu. And the user can click on the notification count to open the notifications app. + +![notification app](/_static/images/features/core/notifications.png) + +## Settings + +The notifications app can be configured through settings. + +- `NOTIFICATIONS_REFRESH_TIME`: The unread count in the top menu is automatically refreshed to keep the user informed about new notifications. This setting allows to set the time between each refresh in seconds. You can also set it to `0` to turn off automatic refreshing. Default: `30` +- `NOTIFICATIONS_MAX_PER_USER`: Maximum number of notifications that are stored per user. Older notifications are replaced by newer once. Default: `50`