From 4394d259616f705fde9e64baf3a4510c5f681006 Mon Sep 17 00:00:00 2001 From: Erik Kalkoken Date: Sat, 16 Jan 2021 00:09:49 +0000 Subject: [PATCH] Notifications refresh v2 and session caching --- allianceauth/authentication/views.py | 3 +- allianceauth/notifications/__init__.py | 23 +- .../notifications/context_processors.py | 11 - allianceauth/notifications/managers.py | 101 +++++++++ .../migrations/0004_performance_tuning.py | 27 +++ allianceauth/notifications/models.py | 52 ++++- .../notifications/templatetags/__init__.py | 0 .../templatetags/auth_notifications.py | 43 ++++ allianceauth/notifications/tests/test_init.py | 28 +++ .../notifications/tests/test_managers.py | 206 ++++++++++++++++++ .../notifications/tests/test_models.py | 73 +++++++ .../notifications/tests/test_processors.py | 76 ------- .../notifications/tests/test_templatetags.py | 86 ++++++++ .../notifications/tests/test_views.py | 49 +++++ allianceauth/notifications/urls.py | 5 + allianceauth/notifications/views.py | 65 ++++-- .../project_name/settings/base.py | 5 +- .../static/js/refresh_notifications.js | 75 +++++++ allianceauth/templates/allianceauth/base.html | 20 +- .../allianceauth/notifications_menu_item.html | 13 ++ .../templates/allianceauth/top-menu.html | 15 +- .../bundles/jquery-visibility-js.html | 4 + .../images/features/core/notifications.png | Bin 0 -> 34648 bytes docs/development/tech_docu/api/index.md | 1 + .../tech_docu/api/notifications.rst | 25 +++ docs/features/core/index.md | 1 + docs/features/core/notifications.md | 14 ++ 27 files changed, 869 insertions(+), 152 deletions(-) delete mode 100644 allianceauth/notifications/context_processors.py create mode 100644 allianceauth/notifications/managers.py create mode 100644 allianceauth/notifications/migrations/0004_performance_tuning.py create mode 100644 allianceauth/notifications/templatetags/__init__.py create mode 100644 allianceauth/notifications/templatetags/auth_notifications.py create mode 100644 allianceauth/notifications/tests/test_init.py create mode 100644 allianceauth/notifications/tests/test_managers.py create mode 100644 allianceauth/notifications/tests/test_models.py delete mode 100644 allianceauth/notifications/tests/test_processors.py create mode 100644 allianceauth/notifications/tests/test_templatetags.py create mode 100644 allianceauth/notifications/tests/test_views.py create mode 100644 allianceauth/static/js/refresh_notifications.js create mode 100644 allianceauth/templates/allianceauth/notifications_menu_item.html create mode 100644 allianceauth/templates/bundles/jquery-visibility-js.html create mode 100644 docs/_static/images/features/core/notifications.png create mode 100644 docs/development/tech_docu/api/notifications.rst create mode 100644 docs/features/core/notifications.md 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 0000000000000000000000000000000000000000..8221e967f97411c8be031ddb4650bee367db4697 GIT binary patch literal 34648 zcmeFYd0bQ1+CPd#qz*`1sHjZ#XhlW93Bn+WRMb+Ch>Czff+8X!1er-lqEMxXii&`Y zQKqO2K@gB335bdiAp$~xK!PHK5JG@VWd3ci_4GaGyq|l2_ukLF_m6k~0ekPY_FC(i zpXa+)l8?C_(OR`}m71EGmea3}$JNwU0Qbw}->v}OsM!fzfxpWljvx6&t)k0-4LmFl zbU5muruHm#wfu~Rnp)B~PL2*IV_{rHP-UbC+GO&F7W{mY^cd(#8n%_bp|8;_kv_`rgqFtM$D!e3oUnA2{PPin-;zCo!ac z{qL`iS$(@J?Lf&kpR@0OcXayoO=$!)E0~~Q+T=wr_Gd}ty3k4cdVEbk!$dq3oVTB~ z%*pYem&j`Cs((H?imdwiQ z=NCYj8sCkqey2vEiQbODVq?28x!|!$Z5PT3$+^W{;hpadiTQ`(4aFoxD%CC~Bj7oi zV$$PFIcC|`KP(bwGqKxk(?5VKDd%j#!;RBm8JDs~A7L)>Q5 z>xyxS`o*02M}pzdv*+5AzrhwRG2`>( zSFUseToC~4A(s0FvJH}w6H@VTn{w0EDjf~e&nRqmen#J%lShS&onqEurur#wq%V%0 z{LLbX-`hNWu6iuqK(g3$)&-h+4(0nMP@PwQbJQoZk<|x-5{*fZsk4O3ATPSDs-aYAEqNG-%v zF|dJ!H}R2|ME2oBoy5;Mhb2&z(w&_f(FIMl9ox>nKfO>0iwtQD$Rw0F1FHlHx?c3tKebA5VR2f?3A`; zoh)mZg+Zc19FPd(y2tM;-f0qN{nmG^c z*2sNAbx}>c5xsVrk%?G6?qo@V;KlT5oAKxTDYPodVubh^A2@TAx$q%*(M*SfaTg(} zs0jq#84`O!%9uB9T@s|1&0pfsQT{Ghup=_Xnr%?YplL5_-C9hAwObgKSpt&3)L{QK zXyxx}Gj@org}bF$i%)z>v8~n75aYd-^D(_agAbLD+k76tq?ycQ&02(vBe<>yd*Rra zpOYUx&Rn6+JA~T#u~|nWDbg&moseA3RC3!*>+l@LLV@5d10V1kt7tElMPR9XZ#&VI z6Ot4Y=+iwgX)>LDYsm?&1^uzVc%7wh2zjf6N6o=wRB&m=0;lC}BnWkc0ZB?o%?1!;@DlDsOCp}~a-1S=^ zS;3>1HxA>#nAfX&#)d+WtdAe?MMqr3mi)a48L}}X^T(~L*3O-tya(0YKNF+cK^)Vg z=7r?cwT%;6p_T#r`nq*fNLvTwQo|S(u^Wz6rku%v^3DS{56y!;hg~pu^w!Q5QCIsT0 z$vq_Sy#UHyc@)&bB@<)4U}+~F&aF`9O_+=oLgV5UT=G6yx@}<7HpThb;4na;OnM39 zTn>?GxM!XbV4CnL_T(t8lu%{YE zp)(gVBPB?rg4!h#KW0jU)6^CncGa`5UJY$)h4T+ z4(Bsu1=BH2(Eaj5v=xaB&Mvw>F+rj<`k1;haTqihV|C2V;B811T1SKFicmrt#&w{` z6H-oeyBkY~V2_O+w$V}hGXKk|0Fo(j?9-*36xF_wLGz#<@?j%sg0{Kklb;qj5U%?* z%oJ~3wRMA>n1C-?O)yu%T0SKwP<=6HYEv_dV%|wc78JVb^_vsPbdcM7MD??kcM5+c zT+)BA&AKD_Ox_-gp$P((#jYMt1)JK>7nt_KF?>e!lf?k=wms!9P1tGYwyPZtZ`z|c zDM}k|g=}q%EqDl8FZ&&v1MVh~T$EEZoGm@NsLUG#L`&1&F+7E(D!&qSLSdgsh@U#h z9>LC*2vRImx-HX;@e-wTbU z@k%0;!V^+f@H;2mHtV6_kH2mOL=+Jr)g~j)Ab+icef%kqw22tmZG3OtQmFL%YR<2d z6C_wdv!g>+8pJIO+aQ_3rn#ctUBWqMMVPzju8Cm!f{II-3-m&r-T2(O8-m*e3kgVX zQ6(Ht-TaGdW%~iHje1n(remgV!Cl2>x!bm_@TbN(OMPV0J#(c=>&BIWdJq`D{sq`X z@oFzT9W$oQz*Q*Zbv+>RUYXFESd}M8PADXBrOH5Up--z%?h-!{+pAJrWew?ux7lq^#{n7f7e z4S!yST)4(_!8HZT@k1bcx3K%Pm+iUR9*i>t<1xhj_D%G*S8npol}jN==Zi>Qc*PX> z-Sak~Pb3y-gB3PxeEu$(t=!U<=9_I~J2J6oXQ$_B)x$9%NLQ=fyR9CQqI5LI38P+f zsnQ9_t6(aQ%W_In>|C&y|XP$oI(0}S$JJxPmhEcLBAjZ`1uPrWY@>nL&n zReu?LCIawViv2V57IlO#&KvfZv?mVeXr!%C!dl)97*BAu^Km)(5yF@fG6?c6#Mnje zym|+J#bucc6CfSafVeZyRqJT5Q9um)EVM&cF+#XiyOv^Whad&9;EC`fg5IN*cD*z@ zE$K-ud7lCyA>2M4UEs$i|6KXXv1JS3?F?Q6q%f5}4HJbRJZgy0s+(pEMNyGy;61wq>`X9To9&U<=Rp*xF3{{v^AsX7 zBC|*!PD8)D9TnOz>f7qTMUmpr4T1vvkJ#DRvt52j6=LP*L5fhTTlM{B-`R3%K4ojSVkLYQ|#bS^rIUiHoGu^xLrum|1&CL=9u6Q*P zdrsoSzTeyBoN9}^q=a zT?Dn%q0#$TbdZz}^~o|~mfInA5f?VMrzhoNFvwRy)qL>+h3w;Uai@*jvGH#!t{uc} zd+~8z@OH9cs>3rsC!oT8QA6)qe)aoF``nHJD+jC?_ zix|8~%-4iX0!sS<_p6PG>{auFFA3dh z%(Z(sdKCR}bRM&+L`Z{T7OW$UCw!fq!5wEXgT5)5md(UsEYyhuJ&5IUxk)MR2o{E8v-5qxYdNQ_ZzLyd6_+65ryQlXQY>cBzl^n{+JNk*nlg)+u{3-lJZH;N5P0!ttXr~< ze^j;o&*K+PEdKo;ZhWb`{~O)#3Mv>iR?QjrqQy)cNvz=Ey?HP8qV*O5IJJQX7t2T+ zs|jyrN_WenssxS>QCEzIW0;>G#uVA{l$Zu#7w_7&ZDPy5e?bsrgFhl(xp_-asYSy-;n=AI`3zlxwL=XPz8mxHq`6}K0i<)UCF<2>FUU-Jn_uN#}KDuT5EE)JnV(7?yI>=hL^QE zE;Ho^CA6NX3Hb14alU$K?{ctHC44KZ(Uv!2c}(&71@wnwPYKEGU;D3SzVP(^v-bs; ztBUu&>fM`mN6w2=q8@DX3+UEg$4MDl4~(@*@7DH8BLqM`#g6prP1y(D7kKZ2hUp1XH zv+<7=NsrS0WKs&F(i8qKM33(Je}ZT~x_rdA2q)($4WaGc zSt2)H!7(a;EPVRB3BLb(b52KOo6uu91)k3krOTuN@)2mU-Bvi3)tdPur^5Wd6ZfS) z#}?z_YRu}3#XmEeh#!z+r(=R-Lw6R;ve*HMeO!Fo1aQGM~{D6Rz8RF$`WL1fpH z_B6yb&RrUbfO7z$ojR`F!OT~Fs;izK`$axj@0rM}l!I|c~a&p6er%shebu>KSca-q7Jt`_e9?ustn|3NDKXV3O zIf#bH@?thYAIY+T>lM zgxc55txt`P8^oBe{|;-RYxLXH$CM!^jtqHFUsOM`ur)lfkbK zzN{;5G}rFOaU2^0tH$klw=!NUIa;9d%elWsOh?LuZ1UO+-;9~;T%`>0qD+B=+0Yfb zSs|!>p|UAJvi9Gbdms5?)+3G%HKCR-U65yF6ORq@I5$1cW!i(pGd&2lRR1u5p3(oNuh8`m*VxQg+my?mc*v23K=~Mc< zU3A~+zsOEWZE0#+ z`lE%0tS#Fz+jKM#k|$HNRh_q)@yGoZp8Nr_C+!Hd+Wmf}8uF-;w5T-^7~%U%n@Gg% z=hS7yGKAHR@ ziSt|xT$gp9tDQJk$etIPtZJCcRj#L=83M44 zLzG`iU3E`#jNdpq?EU_j*7G*?8BL&MT_Avlt>%NSwE{5vxzlk44i;$w}V%{ zFNz)0Mr`TRm%Ke%Cf>R~_uS(TI$8h}k1mt-;2E|kE{h?<^=g}_&>X@LNElWy%EUtz ze&=wee|(KKWrg|M7G{&frq5ZGgrj#2j3~c5pBYcTc&T{f9YUMu)8}KL#JRjsO(MkZb3Qby~}MD0?SnvD3ENZZoG_HqJqb~yF`pyWC>d# zz{(Eq7i(EVRh=(Lap!^hl4t=np7K!*nRG;uS$B~_!KpC35bM`&T_FMSc9+1D;I3n}`#uou{vN_zN z{~@(4SSpo;yh*y@bd3RS+p7L1)6(Bb0;Yruv1&ELM;4+Ox4GImT?E#}rLYd80EnPc zEpG3Ey?z)@u?5SI+Wi)$eLYZpa{Sp8fwD{#j_W>m2(H2_D0@z=wRU@-VaH_oeR}9Y z3paLaT$7oz&Dv|~>H=WNgH@I$tEQql$WF%^&C!pCV(d2Tl6`Qn`o6Nu(c#pa0M%iK znA|ZNw_V)~7bK({PFzQ7hiAZGAvE-kGYzg7^%TT@0J=WkhMhvGA{4?4C2f~(`!6MN z))%`UytFOR(cqHDo~7T#O;81Iyl%9}p0GKJ7V+-Rhp1ZIBxt{G+l{U2ww=lwx}58D zTAS9?Uf+JzvH^^pS!kns)MB#Gl%b)cAyJFP9w50Rn7Cgrh}_q9fu_)r>Yo->wP{{w zSs#Y7@>;x?%DOfATA#_P4gu{}9pScjKM3Cc0cHJt51hda^<7M7qEQ8#w#CYsvEHmoNv0K%&30zsmpkQ@$- zQ6@H}l^|STc9Z(puNlo5Ly4mUXzOvUS7J>zz@?UE*>Rw7JiLM*_^JK(me{VRIvOKY zwL6JUNy+D3cKimziINWCgWAffL^<3DnaQeoTrNSgy46ufW9|wn&qhb1bFSc~i|&2g z>BnnCjWZ^zh8z@O;agn6Zo3{k`|{4K#(xKNC#~G*El;}%q(1YyS<(PVh(m<_cv3dC zVk$)u-8R#40`uELA4bIZ2KH6$*wLaj%pkbT6^N^<$SS3JFGS|SD#>h`9u^RkUP8@O zRgkpZlnfrJ7z`AxOjKWF(GRZQ+=t{b83ywpDY8*?)V5)}OAm1+2p-rr8R`N=<3eT{ znBRIIW9`)sfC2|*->3%K!KGyX*{r%iPWew?latEZ3`Yl9RD)229QGF>Z3afP?o;2K zMXE2nw(k5O5Kfx#R&xiObHi`q*Xx`+%jCj6XDpV!uXb79JHoM@Q+ua~=-0t@0WgI}p&$+$E z1BCu$6LTe}D0Bq;lv64@_v+F=9(n;W%6ig?rjX*EjMBa@w|6@h1lD*@SN%v6V)~BN zm1V0v7TM)|yWX6zRr_Uf0$Da9_(K!CN%1y~c~?n7ap~}1c}T|QUWm%1Z+Q#AQ3)&6 zg^CvIou}{?B9U{ct|A2!{y=$2I@IC__@#E{F+MG^MIL@i%*PHWryhm=;*FMf)4ODnZ zJG4(Uh~LEA!mc{w^tV;7FQC!s5JkwV)6;A~3Cv-ZRi^S)=*0f)TT)@sqEeqU;?0V^SX zv(W?0CoyS*g+=^mf$Asw(IZAU#`Eb{FMlWim2qiJ-Ktqr4}A5@adaS&H6du7Jn?<- z-y)}fd2&MX`2$TyEyG}1B?N0gk3_x3Cz4^DAjqv_nfL)#2SAap@A)*1k>v+6oxGkh zFS?lpr;Zfu@UIDl^($-LH)IBnjsNK;qYt1TJi|>PG&}KGWnxC?9mx7~7Gf-rQr1do z{CyM-t_YE)x{p>O(@{_TKaaCTc7b8U!6TM;!`=f6nTeGwKMZPS1{}|r@X-EooY3J} zo|9hV1A`j9dqR2oC)Ku0ClF?#T+J;s012|W0WwJ?!md^Kg?QehC)cK#s#SMi2 zpK_zC3#uz0)r2gm#1((UiQRwdNn3Nt*+1dO+gFdi{!?iT)N^k9iA7qku=-2Q!q!~c z@h5sYx_;o~UplwD^2vigp-g|j|9)WaPl@coRLpVYtO4Fv`C5>eqdF*Mx9#6<|2YWZ z47x6}IUB>wRZgnposqAxt1_0Ug)JvNfbn}TtHP$P8Q+v8pJA4BW0V4Kt6to4wFhH% zyf`o0;;GVH0I<2ud&9XY!${|GO+P7K(!us9?hL0Vg=%e@7ybrO4DpS_8eCf>&-)XH zz1gI~6+3{GNIDNPv&fbxZZ?&XjhU^(-oea&Xs_ypXDW9LQ@@u@t04B#$14KihC9{O zM!y}-1}j+A;Ex;}7Bd2k|G-y`2x=frO3r1-?&-!wH1~EX0nbF8-n;cwY6>x+r)?!o zpDz)32SRJhF@F@GxQ*B>b1-^-=m|NOF@G!G*D3&DSMw?9n4T5lkM1k-kvfbCT0HZS zW70Z;oi8mM|3+?WxPJn}c4u$s(>{83_**S?bpS0`fDx1=ZEnj>gI|B8NV6s$r@?wj~vF zTG3sWl&feEkPr-j`2l*x#-)stQ1oxMiaul4Iv4Sr0yyB(^i!VVRm7BkZun4dZ?C!P zRitDD0F#Idfz+egPj2rW6n#ix-UAEDjf;X|BczO=4$vU;2qQl~JtRU(QSL<7qki=* zycwl{qC%vFsl=b{%gtB^^B3+3N~jp}pov;@{zlxkNwH3wEVMN_;i*c@p)S-rs0d`N z+Lc1#oU_FPfmc_yz>Ad05SK#p;`*u=lr3j`k~_nUJ>-C;XYNkOjVT zZ>#ZMj`+jW%ETF(HED-pE)v3Y#>KXo_HMv~{9Uy%@2JJqaKoDcMD~yXrB{b8YPW?p zPVw0@Y_KN{Fo`SW&!-$)5{=QW$YBcOZbg$Iv$xd*4v`BYdbdL{amY?@=DmF7=jyE# zU{h9{@6AESvlL`E0{>1mJPXF}5*N{$MzhjDP1QY6(i7x>{5-O^*FTZ%Dyj^B|G92~SH~2D!`*%-%zA zdsCteTs+Kq9@!6^o6ICjKOu9{1Me+Ui+%^38Z1QDRY3;P2CCgh$(#-fJj)0a_ZB=p z40+(X1&BxWjgZ{@n4uzQ_z(gw9#JGlis8y=AXp9osus;$S%tz3Ce2+0n@*~^L9m0~+s2KY(;H;sHjW8!aRyg!WUn(=CAQx6c z2ISvyB6o7i1aEOD-v)Ue;#Y{TFw+Pfikv&rSXEiEWQIZG$q8_2wdg3nU%0m6-)0%0TuP+`Jtt6*` zb>+yynU+Y;iY{@F88a5z@Nh@(=a2KN`+`j~pOva4>|$dM*x`K$|AT;He6ens+Q31I ztL|rxNEWIPW=C~7QRBtT8g#4b9+*GDqM@G!!L`&z|2S&00v6>f8;W9QmL?~jfTt^8 z7#23jrab!8rHb}M7to46S>Himzkei+bj(uJ=lbhDP9#?UqZiu}YbQbO%CNMFmYEtB z!c}iCE;`Ul#px!iszA->>7rX9m9CBG$H-PWM#}d9k&PZJ2l||u_mrPgm^m}3O9i7D zsYH8w&J$=*EvL-)ji2PzCnvz>M`8waG3z!;exdwG5G5hPJMa^++36F=V6q1X? zq)$!l!PU$I-NKMAS>Xids^6N6yWHNF^Z6z{F(n-NCPgO)XTwoB%Ic-=Lg2ucX5Erw z0P67;9oStM9KGCBY)sCbDO<97DA7bArK;j(jU!!0$?``NCbGh$&PIq}NJ{$#aH|o$ zDa_Mcj!P{`C+3?N!c(dGo38sPHHREyM8#ROS9LD_WDC}nw z*a9!b*k?h^9t=D+=Fn-~FYwSN&L&nnC>s&ZOfKfdPa8)eZwW>nV&F*f`+qJ;wY)ik z1M!S;LiTI`%q?G;#4c$hIFsX%vZT|lO`C}eO%WzC2v)sjzxKeQs#1aT&qJ)D)RVXNn#uj&40>iH zC$v_75&F2JgQD~rcs@h$+@ubBViY(wc>*`rAUFbYhggcZf=6{td-5(L_YPn?f}l_+ ziV`>kciJ~su8r5L!={Y>gjdB&Yf>3*WG6EF6V6%t35~o>@yQr#L*(_bjDYi?5_Xdw zd5e96RCvUFg9Qd9{XLbeu7(OtPfn=htt*QWg9uPU{Q{fa_7qov5_qxO^5t^DCrG`G za`RQ-M-q=^o8J^d43K=|lInS=T0BR`i#(nyn8hi|1zl_=O&*P8mRelQu%8%?e4~mx z*kBJFTyD`OWrF8!4=#8pVr=1xKsIH>WDRg4?a89ox%8;Taj_Uss}wHUWaYIlDNOUs zAdt*7uiNt_Cu$7*?~|CN{OSKxb`*^%7#%|RtX}$}jq!ge%gPYU5Sr|Wp9?LV^x{Vo zA{jEOo#<)!sqyj-!#>t#kld|d&I6Z8TKG`ili6j2$Z zY8c9A;o#rqaa8Bgw-TWHW=SFQ(&>an$*lGbpGoXVG`j58u){OD>O(;6G1eJ&LGYOH zy17%JYhBJCQN!vU9x8D`XBq!gNg~%F9*PbUl0V|s@ z^Dm>fXVmbjowc)R91El0ai~SA6hyB}jg?)+hXVFLmmTz`&3lIAWXQD1(4nZ*Pp37k zHxyhIhosR=HpVio;?U78i%HbAsjiVXMw2&|-8pUZV%I;WcIs)b8Z)GY=Dlu9wG~ky zj{~&5_(owR85dH2p3-W*+{e%Z4i3#4S-#LbEWS2oQsG?jw5`1Xn)l8>#B~IcMW4`d z!+m_ek2ZCgY^8m6E-KHiNc4b)w|mLo&@Z$WTCmas&JF&?;_VuW?gGDLCA%}3sRCJC zNod`(z}ALxS%A^=h{mp-w0&xA$G+&_7iFZHFV-`a#|AeTL_bZuI(1ReP;Ki~cPjcc z{py`i$%R1v(@|mgoh{LQ=jbgQqUN<{T`?l*%dyn-fciZ!6!)_yt2Rp8_-uBS3mug# z^7P^PREN0Jq)oLf|L4QpdSa0x`PFZ|u1Ax|aokDcV`|Z-WSGum|?x1KkJ1V&Wcv~i=}6cs}(1so^;R|t4*f-kBFZ?ESCyH z#WvAp*JNvkE)MBU)xo`}&qN5A#qBJ_-a#yaUZv=0xS5Xc3CZbuatW6YgCP2qHBAOY z88$jZmq5+14B){1Un>jw)^%n#xTpXgp|=s762UOU(<&n9@^?#dL5=04pv~z zJAw^(vkNF#Fd%rmoH&mNVvvPiZgut-pD*0SGUvGxs+iyopjUD*B zFhWXVt-CTV)J(}nxu`HF8HF9PV=7Mg>=RW~jP~0Zd!Z~|5=By7%!!VxpJ|f0HR2fd zTl5&}^kv2K${)+UdDvQ51d~F0BC=#D$zeDL?{Pw^FsMUu3LZmQ5n|VS9~+yG5;GaK zA>QfZ+jwnp+S)=i5`w1Mw+{CkuJ+sJ-8D_S6)r3UeBbaYt zgL^=5hr5L-voRwiv!%vncR2V6Ih1B>agZnFMg&5 z7FQ6FZj4gaPp8NAML)?*A2;>__1a;(#lP?#IQ!7M!KXW=hc={eEB`VQPHLr*S&T1F2Aw#{e!Nrj?A;yasdLuc(-J%qcO*q+S z7$3yz1Indg{X!Ibnz26|b_1EDxHAt1zaWU~^tsySrlLxOU6dd5$QZjfG6cQMtW{q7 zAl{eu$VN12liG1SmtUzm&TBkv63TBXw@nN_+Yn#2X0Rb^%CnOsWtlXcvKyD|tVmWO zN5BRAefMcK+iAaUSP_*iw!dG)@$~YRNWAKOk(6xz3|9GGq%wh*j-i`f4oQ+-_$&@zQoi2;vaU{HN*uH+#=

K!mnUO51b-u8meXVuX%X$0Vy+ciPI`h==oR>f_Y*A)gW57^!nH-LRY$1gG(qYvVI zZcEJ&(+iR1FJ?~nF>^1HuqjeU+C}_++-u8jIDvlTWW^(l?@)wpUS>R;IPb_j=+$^T zy(S%pOQ#q6oXRw{)r;tY57yQ+aWh`jwxaiSa0o$^$G0Vz6f0lPH+LgOxG_bS>{oZ4 zC@w=043AV8UJ23BYC_~RfxDY!we5eVdpkeyhPBABlC&Ytdn)H`>bG_j=O8-;alUH} zXOCMZ8BU68v#=A@{A}B;Zu*?^N%R2D%bI*t&TT$IU)#lV8FenwqMTle zDc??c$W1{-#$q~Tu@a#pqR}rd(DO~ztpYv`j>m??Na>x3l)>Oz=T(WpTX#5Lo+(&# z2RPNCkUofJ3f+IxMO7Sa*iSL>U|cDSy47L1??wJp_)cpkz`dwW3W#im41m@6CIm#G z=g~WDvE92lv<8KUV(bh32tM5^%&Bds$G>pxKEIk`&GP}tPDdJ^oQmGg=?@~L#6uE; zO&;qv*ZzlSHc@Jqspx`@ICa`S>ZI}Qo+v`HZ(krJdd2J*yUl;Nnb|vII(@;sd!=FA z+jisQ6UOA@M);k*O?%G2)Xe=@Tbdb||FQl;S6`U{G3jbceaLOg7vKhAVv>g1^$t`p z@C(!DaZGcbceQv>NmDG#7TIIZ`I$0FSg3`VnXz|r^T{?24^d!-<&jPzaz^;`wyu!7 zyPDee-PltLXK_6klqtm#amgpoG1<+biz4X-T*=eGIBg!uoW7~IF|+o%cx=AuoXotR zfUzZFhz#?_t`35Ul;+aJl)iXfdo)LzUMXpi{eEaMxcSb+JU4r)NT<1dDB;Fs8{rkU z-k_%QRnj#Xbu7OI>iod?e5HMYqBbD7D`XH5v3NNv!j0JAzlKbeN@<4*$-I(Fi4k^Y z(E9k=t_%7e0o;m@7Y7Blp3i(=ata380AXY7AH&_Qv*+(dn^U29IQbOn9lL&e7orz7 zxO-j3GfBfB+?`QSo)Te0uh@4yv#RrScTH~%bz!&K?Ye(Y(Ko%1Qf_S3Osd$+cx+qf zu35=+x@B}+O0d*UkX$~mee{HH(%$1SCG@HcLr!wIs@_WzS-^RGn6l1@>`S;%jlst> z-Au1YW748Nq=aN{qxJ|ZYP2m``B^m%%1wQF9MP~=SI7yHd?VX_^7*)-k=~UjKd*4f z7K^47b!cWjiIBl-eN7{xe1u9^2-A4Y6!{29`w$9NI)?3@aZ z`+*qmlWo&=2V;pRkz6-lY~STT+L?&ly}en7^5)Tku86+1eO6xH=@gErGeetRI1&?P zj!&~&-7%t*OZ|=aLdQ*(TI(Gg>wB^x_@Nn;VB~J|6dg0}HKQnj_qSu{Jo<$E9Ia&s z_c%d+C#QqnA3P%|RodGqe~yzNX#vN`RrXWiYp77*?vzK#gnZ{hK9EGpeW)@Cc=wAB2_H?=-|qjn!ii4yC#qRS22Zt`m) zkG>d@E{(*oyE_x)j8^$ojaEg{xZIZnqnUFKe~m_QS09;!P8b-s)!$s)N?8OTC^uFx zXwiK6_JsZLMwq!*;SL$0SRj4^Vu;DJTL|oSC*PZ5Z2{{BcXm7BLfpZZI37Zv3m*Uc?2Oe*or4 zA6aPMXQ(9evm-m39`!Qj?RrcQYeaPgOa5J&0A0I$gYtAi;$Mz_2~Ys)dWMIk;R0El z#=aqj-v!4Z#BC`#Sn+W}T1W)TGEq^!W3jl$?BbiOUW>Z-@ZO(GU-~-ZJFO-{iEQk3 zHB{5*GR2VV=s;>}7YNz-@75~YZt<6dp$A=O@Oq2G&x9{!+0utQe*$Rho9$iJ`T2kX z^zA&{`To&W3yv)>n!t@;cbzJK2aGqW8mje9dEoL~aMBRm=A=cA^5a+{HXB&SB71OT zx3Kkp2Pqh|J%)0s$?9(?6?F+qgd^WzM&2iHR#`g!S;sCdz;7iG^MWTg4agM!OdI}1RR02nEPe6+ zEg9wu%^Dv^Wwnn1^ox}OwKqkxr(@NW13C8_cVPbuw_iGgrT5OiSE#o-Ecln3A>5Ts zV32Z(Uy?YJcy(c{?8}?K6C8sbAMutwNq9Q!F5ye#KVOGj1iRnSA{nDXY)bzPK6|=? zUW$9i%vhQDG}k?j;*{WOIRI0fQ%1fVeXxl0#ps!=-0>H9r}n?QO*<$_PS9q^9hO*b zE&6H@|2uoUl|?b=IbZf`ePMCv<+if?gMp@bF}MWA0Ai9hI|L#?oPdEQfb9C`|>bBVsiA@KOj z;?oi=wL~+Zn1Gl8#a!Er$okWVH2*?SW@#2{+J72kixUG(oPDorMq&2b%cuYj3Cqj3 z9tr)&(>5H$P7zz%l}cU^%P1!6jLFtr)TRbc(g2UV&~`**|G|Q)(_2c3V#r@ziVJRK zediZk!+X-Lc!W}6KjqK~oTxw^r4xmk1rslmarufrl<2dklZJ|5!NZjPh&`!M3mDaF zsLvyK_%0K^XHKtA&YakL`q)tM0)tB-!`Dh=_0!dcmE=?6@9|7%Nc|BK8Q7BQKP*jY z6&H5R+CG5ed**3}>zSX3Xx}tGo!NGA91j0bl7zO%HW&+Zoe&=1k8n?jF5@+JM-8J1 zaa+Gx>+tWcqw<*IyJszH-TB|8MP1^!GWMuXg#_;58~|WR*B{>5&URW!4z_<9%cPen z)pntddHWEm)^=|?sTjHoxuQ0mG>D4a_xAXjt$pG@29ce$l%wC^_P3y)Zi`U}+Z$4I z-}xIhJT-&%OYF1ShKeSnJ^);PXQ)_i^wwbTcR2c8({yP+`Z0!@g@M$dKishm1MsG!0;8dI z##mTjTTZJeDEVjPNhDD*}hau@{?k~Ru@h-fdeb&20TAuBKDZ%LMNWB zg-0p%h8wjz+xPcxH5;Vn*cL@+@rn8(J?$2^>6mo%b&mN+lD$hSX*!&;&gN~6TT>mO zc)P4UgiucBySw$L@Hm;GxfzWZD(Qx_@`sJ5NTFu517JTHTix zRA&5QE2qChlTh2sG&AO$Q%ZQ61id!>IaykWCPBR7$QvWIDw>`VwrnRQeJ&9TwmBuf zI}$cp!hEJ>^79G;s0}vr=v-|m8b;^uK0PBECNR&r5?AB?B8wwET)2WKjOLX z4t_=A$9WT_OATROo(eAna=SQ#ATfiKA~F?njB=A;`YuD}7gUqO?$?=t=_S4;^!p*h z-q>V*bIcnu zwjQp?IiELwT7NGKRdkU&wf?oRk6Xu_Py__fSs_-#h!eDuVFfZ~%D+?#H|qcY!Tch$&bbE#XIo&i9gibPnX)UuFDoa_bouu7JCGk*aIDy zz1BG6(NKwfbZl9G*k+oxkDw>a4)v&w5k|#LM;k{_(X@5r;6kgJ)99dX|~rH|(4J_-GGr{dm>nvWIT9^j`|cvo_SCO?mgV z!5ZE7FT>E%Jf4lvIJa^BC;!inmig(A^U8(6t>UH@^ut)3&qDryH{=EWv-o|3ZE*fI7g8HqJQ65U9G@XGUAqAQJ)`lql;NY-gmYWz`#NehtPTE?DMTIF$60PZt(zLPlTMT7)eRTi_MxxbX;(veY)3j| zvNt~V@vFSyg@rNg1D7>1x4Y-x-JFSV!`sDw`{Y%W8CCLO)VfuwZM;#f_Jjat9iM8- z&`c4x&)PpDnsm-!N2D$G#lCe&GjgVDW?etI?o{^JglsJjv{{@DpkSgx@%>YgWgmmb z6+1|(e!6gq8AJh4&$#m8XdfC_Cm`o{@ys*Ks{u(!9knY8+juIrOhc9%PaL+i?oCHU zAH4-Xn9`+qi8<`ax{xz%a6e$xP(XLoE_+NPCgiiULcsY@(-}4rPd?LV@xCU`S6msg zbt?RnFl1=UCAtQenM{9D>d~aa{IB-TJgTXC-TQb@X)6*v&N9T-R#Zj@<{@$%s8vCW z$QU9rsSpO414&RS)Cp`6kVzDjnS{Y4Oi4hhC}9*rARz?A5FjLhKoTJ&+#PJsvBmq| zb?;j5y6=0}^$(PdEA0L3@%P!E@AIqOn$3DcPOnX>lc|!(=Ap?MP<53B`CW+{9z8^o z;W4_^$#Qe9$EkVYKtwyqmT|x#h=p^rcS{?VKGEe?xDV9s4fn~MMQ&BMfYr)zndznb zW*huL7p*>5av$3;&%&s4Emf}8#q!X^|#%q3A(W3!TYUB2Tk|BN3 z&;kdyFTlPF37W!rto=Q{g}&e7^nnAWgl4>}^VYa>L%&9^OJb5!18A!tg&lLr#B>uD zJczDZRRs`-=Y2#Yhe7QG$-=EBtH&FO0uCd2$icO{G69<^DINDUZ$DM6KAMGwH_VUezb|0)R;4U;QuIO`$kK@y zBwk}RUo#3yM1FT*)n{L5?YC$GvUYYec}YI~S8{`7 zY92i{$rdlpLH8g)y9W@vTfh;&YQNIrkGT{;$1JDsQY($=`wYOBeX%#C>QjCnls4m4 z8q+421VpRk^*dxmRPGd6du{v)q9~p{FY_KkUdo7hZTV6>Drxb|Iwp`3dA0(Zdy4CM ztJJaULh729Nq@0ff6L#(k@Z#WwFVMFd1~b>0}jDMbu9bxkV(w+Z5sZM&F!M6zUx3n zWsl)}bH**l7^@lSybU`9yXnuT0InN2U_jx;X08I{LsT)9F=Pc49^> zqso#!U&KIn;k`hr&7s{>K^KxdI5EM2iJ&7QF>_&^xmc;CXHmkFO9N-Dog3`MLz@_~ zG)Z~6aT$>%Y4@Pjp zIx4sF50Uj<=T*E9@8$WOO&f2R9Xj_lHe;-&XCVoyEaDE}+_&T5&*1f!>WheZvtI^pSa}&b@|gR;M404} z&wXH2qQ=NmyEsr;b~uN+XbM#^?};!z8g3iZ(Bs61(74_apsU?gH(P>eI`lHufn3%B zo;eA5T$|L7hY|9iti`FJ`C`>h&qAd0e1^6!{C*_9K9s5D6&#o)WJ?9%9Skuya&lD? zfsc${BvvDXFf8sJNz*&zHN%VD_1N=VM@A$U#BzvHhKl@UF~K;2Q>60YO{bV{@uIcL zVZ@lxeyfWG1o9iwB*IqmA~pvz*j>3*WR!=`+)pp6!#eT|qh%6UHUW*67N|8}FPWqP z2U%x2Cp@T?HsqEc1H)pdl_iL|&&@Q8Yw~>!NKagg)Pca zX|-vSh;}0Bwq&sNqHMT^DfxaH#p?YItp_=AVN6)}NFx%FyR$YqjJKIw7@BdZ+CEJ{TgH zqHWadQ!rh{A_38w3A0;!`r6WZ?Sz!o0CiPFk1j*}|M;9`+OP~wBSy7*!agi@_`fxp zO|Xlh2)@s4IMC0!{SgSRvo_n{{lWj1+MbozGi-Qi$i6^l2z`9vJ&_4E2$VBV5$*8p zIW&OQmMm{YQ(LaC>D1?tU?x9#Tc@>V@JqeO=Rl@ZxTl9!nEMaY4`7oH1(pN5Te<{q zPFbD00;D=N+p8Q-Q5Lawy+-SuGu}76Y0c;#CH_cR0HR23w>C1AD!+V*)x3&kpCtw? zBqoF8R}^RxC=U|ohU)pxsK7GArG!mGa&^DZtsna8IJmua@l9FP{q2i06ZmUj|JJ{FXG5TZrItI1X4x>42NcCN zVg<+Q3iU+Oo0JEo({v$nG`grcKt@{JaS7|xA;XAZ?qn+!<=~2Wd zr`~4t@b=^(++)rmobesa9tcM?;Kz?{YcZ4jdTIU=(DTl7w^VB~ozoU}vbMvmvn`Ea z9ILj7tWDOJrHAGN#yZn87=0qDWMG~z}{90`syI5 zy->_~@A?SYSzgS~N|Jn;W$iUfyf*CRvmm4Not%Ogi@sXGSGR7X6aW*J5PE$+khf}1K$3Nvy$vhszuB035Nx-Pw=UlCq zxUVX%tbBxP{6~085YXE2RN_0=?#Fbz%GW7WaWQZ;m{OK6?>{+Uq$Z%J3(t zfEKL)gS_VQ;}1$N>hyacRxV?IvOoP299f0Q(~F$R2=y3wDWeWHW@AC%Mro~zaQqzZ z9yq5FQFG_|trBIoa#*CdHh@vp;1>PedSKqq9$Okb)PDL6lNn$UMU-qf(08EI0~WPd zS*7dr${(Af!5zf&N0jXx(lEqPmAgXGSU<%oxUH|e(?7=g1cmvCajIk#$2I2lTB;t# zYRa0|HF~tF_`PV#6An2HaS|lN1^EHwQis5XDSI-HCUgwTyXtA%D0h73L;UEnWRdc1 zWSiK5`fPttOm22jvL^FXJbsc^&*bzgh5(mKuRw}fC?Bg9IystX>RTAu$)h)z+C8(n zK*XMm_HDV^O00l5W#I$?xuY$v#N>yxO4AELkl8d5Z361|vX)EFi^SXseM?sB$e!zI z9liJ9nW;f)gI-HVcs{EDWFcuAF*515MMKzCBhh8QOP`kLO>m+xqFs7dCMYk^x-(?b zS`{J1zyvwTZ2GgU$XvT#A098X1J{d$NjlgSsAhZ)PXLIYQbwC9*Xasgz^krFLr?%8 zV}Lakdyh&$5-nM&!`bPr5j>bhsCa$F?v;0~xSL~ zH+c+wZVuPhGT=u(zrQ(jUE|0&=%A56B#Uv3UfM4>C&N-~O64@B?CW|1sw7Uxja{M| z^Mu!!(Gq?HgeX0w-vEh_)WyaHeB(+OS_&W*7~ID?@civq7e}n)@vSg0SFlV-)7I1R z4v$95+O=V}vY!e_&3H|W8^0bt8jy1p#uSb_uagYqa4*&Kon?sjJ{^f0Fc{2|i=aC6 z8vi>3q}%3dbYeifaQhun;Ng~b40XX2T1&4>W8I187E|lRJL5%MgbJssA9FZDF04Av zxg-v+M|q@u>VU=r4rsTaXG@1uy*z41CYFOt?&TeL+aX&pTFbQ+tedC(`x>alx1ZHO zKltPjtD4Qm53A%*JX%brVlU*-e@Q~yGv=2y(4#$bp1Tl_i`p$t)Rx5#KitQFKLH4x zDud)mxKhs>cG%FI_%P$$pbz#QSQ`@APMYQAC+<>GyZQlT(a`xoFu}IEy7rvpdgP&V{(QcxfHiD`8w{T*NdUs zb5bg$!eC~e*1Fbhn*K~=f=?NP=o;B%mh~0Az%;TuWRg}js*7=ep`*i4??NxYooK~@ zDc@mgJ07pklBM%{u-rv%fdkE_%A+&8j)q|0>TpC&&2y@p#vB>XJT9c6y_u&4Ku>5t z!9$s_6O$7ly9_S(mF3-SlK8?D5?D=aO!Ajp$Ne4lOLUhCy+*aqyjWLU^~CQgzKy&C6gdgeJQ?@m{4}c-Z2M3f9v?&(QTzMmMq}dT)jvjD%sE zVCcN>CX(aAx2A+V{1 zFME)Ql9nRT@(}ReE3|TgzlXY2WVg||?D`750+!p1ktQ`XIIxh?(t5uGvQUVac)N&B8ik_Rf9HkL_kj+o~cob^LW85FNs)AeYcKVMF+dPFNIj3{Ln> z@_kM62@kR_aR$GatX=T$`=9tbD|Lmf08Wdxt*HF)bpEG<1}lK$Rv-{c1ZrNpa@ zIx(f?yB4(WxGjRu^=e7h`gx3K-O`YldqJ?u>lUr>ghVsF+LGe{SY`pGH+@!J^l5LT z{5&BmkF_^oKj$YF*Fmc7JJi_C;4N@%yHgO*f93p+=w0-~07KuySz zRElk@G1Mb~d^y)kvbbfzf;oSW5qb=o@Frs5KmzjljN$-ASxvG-`5XWn@Iw2OFZcTB zim7|)C06Ke>lglcrjed*y*?#*E<}o2Hz5tYdFB`TE}&2s0@JU#Kr!R)ZDwd6MxPMx z_AchzLEHQWc_#kuJZhTuuV-QAozhT!`F#MbP>A6z6J7D?_FO7)+;2C!z-QN+_K zc-lZN?G1Qi#MAV*)*I$pEe@4YjzQ(9`7z)!JV#3#zX;Op(H}8~`^7#@!gVL6DbD(j z*wXOtcoijXR;c|1s2~+M=VQVE`%})UZ#gDOT(&>8=zrLstZBKz{v6u!C;O8v^`oj) zdmZChN5Hf&ZoOX51zkTU;l$S4G)l1poN4LTHG6J{Zos{qirH(OOf+A^o}!u6>DywG z?aoW0X&o13a*0nNjP{El{l)MimHk?xIaTQNs0b51KSnr?Z7ao}swl(Qlah~f49tCH zt^^ipzFANXoarMAXBh9{*6xK0^*cKflZDi)Q`;Y2{~(kv{%7$x?|8GMr6@?Mrq@ zyW+9q&`5J<@W05U%)DK-^LRDc#S8)Z3OHJ0w-*xxeITuGm7Lp$k=C@BT=-BAC5Ap_ zRkIY1zF0^E-&OsY&9H~5&uuJW!_DI5N5dPMh(&<;spSHgpP<=~=I7F~`3Vwa0r3#& zg^;otbFjcd|@2P)MO#Gl!lP7Pe((IU@9Wr)_&al zc;y(cdas~u+F4Ww@XQEZY+@0dE`G$%2YgS$KKoj%%lujjSLiF%O@`_VpgPs|M^(j) zwDK#ID!xMcgx3^#Fb^cK43I?s3Mwno0pDbF@KfnCACNv>p`VjJldv;YsB-bco=}Hu zRspffOIO!xHoTf#(s0~u8|NAxdgZCUPZloEztBH-#!D#x3!sX>4A}waQ&qh=@irzO za6U^MITfty_p~l9V5yp|Lm{`5)1B%%E7Y$`GBy$rnf%96>SZ4i_qd-O#%WR&?rIFRL2C28e!($Lu8q@q8FYcOst%tz6 z_q8CVeI`+oTkMgshxem^jK)r}Vt^(itxSD&#cRNPeC9ssZm2pmocA5i3ZKg4)(h89 znp`2DYOUhHss5CLSfccrePnXbd7upr9c$au(KC7m(;WEYF7v(@NVSEHwQ_tl03_cE z&4HI7;qD&{q z4O`2>E<}o~Q`4~$;-+3~KysbNjFEK_tW?J#6qUd_N{#68(%1DuVK_*^VeWDEn^YQg z&U|m*9c<1mzJ|W3+Ol!2u;~W7lP(v8wdgth#MManNX@dpk7NeBOe5G;Z>lymaaB@& zmlgX_DQe>xM3rB?#{1I_ARa3#^;>x<e2#_SR@5g8M0AlEuHd zL3s-$5Y~|qH7RR;AL_>GIjs+Cf#5=8H8=+|0fAE-546`9HUzE={|O$ckVU0UK#Pvx z?Z9&}PS@;Ya0{^-W*g?L_(h6E4U&a0wAl{BH!_p#?RI|$pd^o0$jFnGeZ7Jy4&2K< z-7~UefjvoUQ#a@7k#{47uP&l>T9qW=!TEZP0cp7KF`7Y(J+|S8G+S@n<+PsY% zQ6x(}Dyo_QTU&nsS~7i9YUkD0Bzpc4?p@||dOb}`u~oY&rVu*I#1HE?MO&X^Duf+< zu)8B>oGm14-?7e8g8Td)Rj5BMGrF>!XjM0iL+xOQ}LmtY(23ljm zDCSxHWnYxEY2p#Sx7l9RJ7o>b>YT2{iXzJ@05^83b2}@9vSq|P@#~L1t@c}JKKa5p zaHKZpd|UJ%W@;52=YNcsxpE-*JEmF*9+rRbU!brfH$d(i)JQ>h6r$kond&5!;bh{; z+7)aOh-6<`%Y+ze`~>Nv14?5jH*^$h9b`Ukz5QoM$Hyg~W}%}bPD4ao?*5`yXetET zX&d+<|GyPRe&FXq6^*fh23Y^qXnKZ(xEm@Y!A{zvS)I@R?0&KkEsVGAozk0g&vNB7 zMf36K#WVkLHht*KhS*w-`;dYHcJ_mqZQ7rN$%{}2$y!lMjlsL*&i)iPposRelQ*t* z$)*O5EMB<0yyR0`+|vzmrmJ{4!d|=xpa0gTSYPt=69E+es`PC%2yqs1mfez@VZ9Zj ztkJe*Vhdylm1{NrG6bE!fW?Zo@QR%^}=Ga1` zjyHHn?l1o2w1)D^8`PItq!xtoMV!amg)JsSKXeUP@5vJ*7@K@4w3wQQR@rS3aIH^A zyE$*I5?gZetXh)#P=2~)M35}8z^TE^HR9l5(M|)_#>}}* zPP}dHdVWlSr5Sv_a$RmkQ>Zb9Pt)&?bSE)sA@{x7NSy_YwCX2)6ZNbz~a zVTU3HiZ8-ad0Tk$3mJPq^k>B<{x;Ms?^pgoj&Og~py35_y90h<-ahk63y2@%=&OOOO^pA@d*O{XQmzf#ifSq1b;mGT>aR;?(re&5Fp zG=u1=VzHc|bF?~nf>`fFlOt=0z-zd)BMw*Q`UrVnbW#Ns*`Gy1~I7!?)( z93QHh+WwMD^zo4uNCQ03y*IHtv2ILOM(Pu;iipb)sEDKuIc6LSfdkH$Q)e19D|Ybb z(k;kr(cu_4B0EoY9(RF;D;d4a!ZMu5Wil?#Ql(k&i;czV@Yj+7T&T!3)cT!2Y9zcr zvoCakKK*dSn(H6b9lh!z>oa4gLyjsW{t;VEfs0?=)Mbt`TZYX~%-}zP-?BqW&-M!% zJ0G+_9cR0`BNn+{Yd}WpD2$-e?t@g2?H%yJy2QK;xpA|~v%GtSoM8MJmsbezZeHIM zgaNCEH=ybR5Dk;u!~)whtvxx_9_q@CCyvQ}`7zX9>_zQ=+#h_=yJbps;6egg20DC8Iznjs47BzO6Mt<|{$ZcMJ6)6i%$&hgJ~@AcYBD~E&5Qc} zHh6gd%cB8LV(;(JbLf8b1d$^bw;%0_ido-r9GCoV@PSVWVMnojSRj}7;@}*3a=%Z^ zU4o8Mwk|U+{msaBfkRr$8wxd((dm4ia4YZbxo9Ekyht!4pBzn=^c38<_DWXzIO*9X z7!{=zK5|H#(e9CzYG&;us$`hoXe(Vp~7{AriEAzPayG zJKl?n#bZyq^^L@P%-?so1+LiNt^DS41_E@n62Sn9NtPk%px8)|@ zke?kC=wR=C?}43UwWCU9M!-yhy^9t>oyFqqo|-qf4Uy};X)pWD9#XNFXYB;^R*H)| z<9ZG6L{jyz$it1mvTd$Qf_f?Le=A1CKxH&IIsCfn!NvUX(%7@( z;iPT*#Hpnc3yIDCF6#%X1a%#^MT7%qB)+X*dGsLY*AV0@@T>X=u!Ed5^5i=T%R{9( z5%0-Ro$QL~S{--B*>@UOQ;3t@j0!$fkEKdp%Zb4O!(jzxeE}@Rspg7)KSURW){Q!` z*Ga)l7WDSQ4wrelOeD1#MW#Hm0b`0d$FQvKRHW;W&~!H=885m~=Lp;8b9cceoK>*5 zr-5|7s3CCAf|U1M3hN@5Q(Vd&cm#&6V>%yqB_~rR0(#`Or}$qKE){jiglY_;g%j1^ zi*pgMpV}=3tc6~`@!pz@2leI6T5)#naL>KhG!w)7Qt94j>a-{7VVMQxW&D|?>)%90 z?X?(UHov;rEVCDBeSdIw&W9fs?;F}X^i{%N$fzx#NpLFe?|oi36G=AG)jSLPV6{`C zkQ1$cPkmAx6sDHoqyBuPKYC4Eif@K=div4UAwj&kcMo6nkb4fiTI;Q{7FqKP0xwHiWVH*0rZz5PeL?4#dw`l~q&=}BNAK7+ z$N7hiSyrgK7@H?n>y^g5cp3zi(VyV;2mFDbiqiAVB^2X6KF{9LSS)YB28A8~?c3%NY4nlws(I;%YN{ z@H{t7!sl}<&BKtle51)&>Vv{a0eH^875+Ek_2JfvMDJE;C_rE{ zzcaa2tNQY+2dj`r>E%T1$BzEo0P2;0i*`y|+_{f|aCZ{~=H_G1Jf~ffyM||D*4ITm z(hs7T`x8c@xm3UR8gmON@dqD-8zglzmW~mT(f1^saDgQ*^V(e;r$SbLO^nG{aHeE$ zQWBqB`A*W%Aw21hT7>uX25Ll!x}$B~WYjjzNkq=eoT;%@R|;t7u(+IsADM3iTK5xL z_DQnTtyOgiWmt5Zd`9Rq>JzY8q|;$r&58|vT=AP(Stsm3aJQRy6u8IZK$)p7>JNMj zv?ljV0u~$w=;;tNH@!y5HqbcE+>q#}Cb)v*53$PASu~-jj&xQa->G5JV5@k3xGHN< zbvn&wMZ*GWuvOsmGQ^8#DA8G2I9ZFzrOLMsb*doAo>o$1>qtlmdp=}R#9&wB_36=7 zWCF34?t80li_cVg;W+|S=2}fgG4L%~6HaVlsG*L7!=oz$w$j9-?eYb#32&2ot`9hZ zeu8J5B9vf$@jxbU${DOF+&hgnsn&$9`H8-usg4*j?+$Pjx;ed)&l{xjU|L>zL!Cm- znm8GdZ3L(31lHzjcAuU|a>IN}$Az(!FzsVP7OY1jy}v0X&wdgq~S&wMg2*@t?>H zt{1TtO`T|w-!Oa97UWIdO+FJC2ID5oYVZvZDqR*$8c%jDKJOUT4ohleJPKX}XD5g5 zu$uZ?V@SGe^IU;ofM*ZqKz@N*h#}7_lkm*hI-Cf*P(#dpo$E^IOwSq3wx*wafd1hH z0DzFdsFxo22y*;B$KOPSf!(*v6&@)JwgGP&g1~2z1!1QFrGkh@$~`lUVRkr ztW+m)(x(R!8#`^k=_Bmn&YFCs*8DF+Mj@jW#4pWz+guzFUHF*3?(D~^n}@t7C+z(z z27HNWdJj4 z8(FM-;{H*p(|&6OFHX$e{(8?k5+ctz!f=IAqwp^Wau1M3({x^hs~ue~%|*MX^(WBf zUBFSFXmRrFict4opAQvu;D3JLcjeevvEBcb-13VamL&9_+2KxYBIjrZogRHDU77hh(14zjo#~ z*ED{-$8_@gU~7JE8niSGnuuX7q3Sp)kjt=-9q*UF{xW+1>J({bGZvWYvHb^N{>Q