Notifications refresh v2 and session caching

This commit is contained in:
Erik Kalkoken 2021-01-16 00:09:49 +00:00 committed by Ariel Rin
parent aeeb35bc60
commit 4394d25961
27 changed files with 869 additions and 152 deletions

View File

@ -6,8 +6,9 @@ from django.contrib.auth import login, authenticate
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core import signing 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.shortcuts import redirect, render
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from allianceauth.eveonline.models import EveCharacter from allianceauth.eveonline.models import EveCharacter

View File

@ -1,22 +1,9 @@
default_app_config = 'allianceauth.notifications.apps.NotificationsConfig' 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 from .models import Notification
if Notification.objects.filter(user=user).count() > MAX_NOTIFICATIONS: Notification.objects.notify_user(user, title, message, level)
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)

View File

@ -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}

View File

@ -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}'

View File

@ -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),
),
]

View File

@ -1,11 +1,19 @@
import logging
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
import logging
from .managers import NotificationManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Notification(models.Model): class Notification(models.Model):
"""Notification to a user within Auth"""
NOTIFICATIONS_MAX_PER_USER_DEFAULT = 50
NOTIFICATIONS_REFRESH_TIME_DEFAULT = 30
LEVEL_CHOICES = ( LEVEL_CHOICES = (
('danger', 'CRITICAL'), ('danger', 'CRITICAL'),
('danger', 'ERROR'), ('danger', 'ERROR'),
@ -18,19 +26,41 @@ class Notification(models.Model):
level = models.CharField(choices=LEVEL_CHOICES, max_length=10) level = models.CharField(choices=LEVEL_CHOICES, max_length=10)
title = models.CharField(max_length=254) title = models.CharField(max_length=254)
message = models.TextField() message = models.TextField()
timestamp = models.DateTimeField(auto_now_add=True) timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
viewed = models.BooleanField(default=False) 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) logger.info("Marking notification as viewed: %s" % self)
self.viewed = True self.viewed = True
self.save() self.save()
def __str__(self): def set_level(self, level_name: str) -> None:
return "%s: %s" % (self.user, self.title) """set notification level according to level name, e.g. 'CRITICAL'
def set_level(self, level): raised exception on invalid level names
self.level = [item[0] for item in self.LEVEL_CHOICES if item[1] == level][0] """
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)
class Meta: self.level = new_level
ordering = ['-timestamp'] self.save()

View File

@ -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

View File

@ -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)

View File

@ -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)
)

View File

@ -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')

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -9,4 +9,9 @@ urlpatterns = [
url(r'^notifications/delete_all_read/$', views.delete_all_read, name='delete_all_read'), 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/$', views.notification_list, name='list'),
url(r'^notifications/(\w+)/$', views.notification_view, name='view'), url(r'^notifications/(\w+)/$', views.notification_view, name='view'),
url(
r'^user_notifications_count/(?P<user_pk>\d+)/$',
views.user_notifications_count,
name='user_notifications_count'
),
] ]

View File

@ -1,9 +1,12 @@
from django.shortcuts import render, get_object_or_404, redirect import logging
from .models import Notification
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib import messages from django.contrib import messages
from django.utils.translation import ugettext_lazy as _ from django.http import JsonResponse
import logging 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__) logger = logging.getLogger(__name__)
@ -11,9 +14,15 @@ logger = logging.getLogger(__name__)
@login_required @login_required
def notification_list(request): def notification_list(request):
logger.debug("notification_list called by user %s" % request.user) logger.debug("notification_list called by user %s" % request.user)
new_notifs = Notification.objects.filter(user=request.user).filter(viewed=False) notifications_qs = Notification.objects.filter(user=request.user).order_by("-timestamp")
old_notifs = Notification.objects.filter(user=request.user).filter(viewed=True) new_notifs = notifications_qs.filter(viewed=False)
logger.debug("User %s has %s unread and %s read notifications" % (request.user, len(new_notifs), len(old_notifs))) 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 = { context = {
'read': old_notifs, 'read': old_notifs,
'unread': new_notifs, 'unread': new_notifs,
@ -23,39 +32,53 @@ def notification_list(request):
@login_required @login_required
def notification_view(request, notif_id): 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) notif = get_object_or_404(Notification, pk=notif_id)
if notif.user == request.user: 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} context = {'notif': notif}
notif.view() notif.mark_viewed()
return render(request, 'notifications/view.html', context) return render(request, 'notifications/view.html', context)
else: else:
logger.warn( 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.')) messages.error(request, _('You are not authorized to view that notification.'))
return redirect('notifications:list') return redirect('notifications:list')
@login_required @login_required
def remove_notification(request, notif_id): 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) notif = get_object_or_404(Notification, pk=notif_id)
if notif.user == request.user: if notif.user == request.user:
if Notification.objects.filter(id=notif_id).exists(): if Notification.objects.filter(id=notif_id).exists():
notif.delete() 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.')) messages.success(request, _('Deleted notification.'))
else: else:
logger.error( 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.')) messages.error(request, _('Failed to locate notification.'))
return redirect('notifications:list') return redirect('notifications:list')
@login_required @login_required
def mark_all_read(request): 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) Notification.objects.filter(user=request.user).update(viewed=True)
messages.success(request, _('Marked all notifications as read.')) messages.success(request, _('Marked all notifications as read.'))
return redirect('notifications:list') return redirect('notifications:list')
@ -63,7 +86,17 @@ def mark_all_read(request):
@login_required @login_required
def delete_all_read(request): 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() Notification.objects.filter(user=request.user).filter(viewed=True).delete()
messages.success(request, _('Deleted all read notifications.')) messages.success(request, _('Deleted all read notifications.'))
return redirect('notifications:list') 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)

View File

@ -105,7 +105,6 @@ TEMPLATES = [
'django.template.context_processors.media', 'django.template.context_processors.media',
'django.template.context_processors.static', 'django.template.context_processors.static',
'django.template.context_processors.tz', 'django.template.context_processors.tz',
'allianceauth.notifications.context_processors.user_notification_count',
'allianceauth.context_processors.auth_settings', 'allianceauth.context_processors.auth_settings',
], ],
}, },
@ -173,6 +172,8 @@ CACHES = {
} }
} }
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
DEBUG = True DEBUG = True
ALLOWED_HOSTS = ['*'] ALLOWED_HOSTS = ['*']
DATABASES = { DATABASES = {

View File

@ -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 <span class="badge">${unread_count}</span>`
)
}
else {
innerHtml = '<i class="far fa-bell"></i>'
}
$("#menu_item_notifications").html(
`<a href="${notificationsListViewUrl}">${innerHtml}</a>`
);
}
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()
});

View File

@ -1,6 +1,7 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% load navactive %} {% load navactive %}
{% load auth_notifications %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -9,7 +10,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content=""> <meta name="description" content="">
<meta name="author" content=""> <meta name="author" content="">
{% include 'allianceauth/icons.html' %} {% include 'allianceauth/icons.html' %}
<title>{% block title %}{% block page_title %}{% endblock page_title %} - Alliance Auth{% endblock title %}</title> <title>{% block title %}{% block page_title %}{% endblock page_title %} - Alliance Auth{% endblock title %}</title>
@ -27,7 +27,6 @@
<div id="wrapper" class="container"> <div id="wrapper" class="container">
<!-- Navigation --> <!-- Navigation -->
{% include 'allianceauth/top-menu.html' %} {% include 'allianceauth/top-menu.html' %}
<div class="row" id="site-body-wrapper"> <div class="row" id="site-body-wrapper">
{% include 'allianceauth/side-menu.html' %} {% include 'allianceauth/side-menu.html' %}
<div class="col-sm-10"> <div class="col-sm-10">
@ -40,7 +39,6 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% block content %} {% block content %}
{% endblock content %} {% endblock content %}
</div> </div>
@ -48,12 +46,20 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- share data with JS part -->
<div
id="dataExport"
data-notificationsListViewUrl="{% url 'notifications:list' %}"
data-notificationsRefreshTime="{% notifications_refresh_time %}"
data-userNotificationsCountViewUrl="{% url 'notifications:user_notifications_count' request.user.pk %}"
>
</div>
{% include 'bundles/bootstrap-js.html' %} {% include 'bundles/bootstrap-js.html' %}
{% include 'bundles/jquery-visibility-js.html' %}
<script src="{% static 'js/refresh_notifications.js' %}"></script>
{% block extra_javascript %} {% block extra_javascript %}
{% endblock extra_javascript %} {% endblock extra_javascript %}
<script> <script>
{% block extra_script %} {% block extra_script %}
{% endblock extra_script %} {% endblock extra_script %}

View File

@ -0,0 +1,13 @@
{% load auth_notifications %}
{% with unread_count=request.user|user_unread_notification_count %}
{% if unread_count > 0 %}
<a href="{% url 'notifications:list' %}">Notifications
<span class="badge">{{ unread_count }}</span>
</a>
{% else %}
<a href="{% url 'notifications:list' %}">
<i class="far fa-bell"></i>
</a>
{% endif %}
{% endwith %}

View File

@ -20,17 +20,11 @@
<li> <li>
{% include 'allianceauth/night-toggle.html' %} {% include 'allianceauth/night-toggle.html' %}
</li> </li>
{% if notifications %} <li
<li class="{% navactive request 'notifications:' %}"> class="{% navactive request 'notifications:' %}" id="menu_item_notifications"
<a href="{% url 'notifications:list' %}">Notifications >
<span class="badge">{{ notifications }}</span> {% include 'allianceauth/notifications_menu_item.html' %}
</a>
</li> </li>
{% else %}
<li><a href="{% url 'notifications:list' %}">
<i class="far fa-bell"></i></a>
</li>
{% endif %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
{% if user.is_staff %} {% if user.is_staff %}
<li><a href="{% url 'admin:index' %}">{% trans "Admin" %}</a></li> <li><a href="{% url 'admin:index' %}">{% trans "Admin" %}</a></li>
@ -64,3 +58,4 @@
</div> </div>
</div> </div>
</nav> </nav>

View File

@ -0,0 +1,4 @@
{% load static %}
<!-- Start jQuery visibility js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-visibility/1.0.11/jquery-visibility.min.js"></script>
<!-- End jQuery visibility js -->

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -9,5 +9,6 @@ To reduce redundancy and help speed up development we encourage developers to ut
esi esi
evelinks evelinks
eveonline eveonline
notifications
testutils testutils
``` ```

View File

@ -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:

View File

@ -10,4 +10,5 @@ Managing access to applications and services is one of the core functions of **A
states states
groups groups
groupmanagement groupmanagement
notifications
``` ```

View File

@ -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`