diff --git a/app/core/apps.py b/app/core/apps.py index 26f78a8..8932890 100644 --- a/app/core/apps.py +++ b/app/core/apps.py @@ -3,3 +3,8 @@ class CoreConfig(AppConfig): name = 'core' + + def ready(self): + super(CoreConfig, self).ready() + import core.signals + core.signals.interest_list_notify diff --git a/app/core/management/commands/__init__.py b/app/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/management/commands/clear_old_notifications.py b/app/core/management/commands/clear_old_notifications.py new file mode 100644 index 0000000..a5c487f --- /dev/null +++ b/app/core/management/commands/clear_old_notifications.py @@ -0,0 +1,35 @@ +from datetime import timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils import timezone +from notifications.base.models import is_soft_delete +from notifications.models import Notification + + +class Command(BaseCommand): + """This command deletes notifications that are older than some limit""" + + def handle(self, *args, **options): + expiration_delta = settings.NOTIFICATIONS_EXPIRE_AFTER + if expiration_delta and isinstance(expiration_delta, timedelta): + curr = timezone.now() + limit = curr - expiration_delta + bad_notifications = Notification.objects.all().\ + filter(timestamp__date__lt=limit) + if is_soft_delete(): + bad_notifications.mark_all_as_deleted() + self.stdout.write( + self.style.SUCCESS(f"{bad_notifications.count()} old" + " notifications marked deleted")) + else: + count, _ = bad_notifications.delete() + self.stdout.write( + self.style.SUCCESS(f"{count} old notifications deleted")) + else: + self.stdout.write( + self.style.WARNING( + "NOTIFICATIONS_EXPIRE_AFTER must be set to " + "a timedelta in settings.py " + ) + ) diff --git a/app/core/management/commands/clear_read_notifications.py b/app/core/management/commands/clear_read_notifications.py new file mode 100644 index 0000000..573a03d --- /dev/null +++ b/app/core/management/commands/clear_read_notifications.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand +from notifications.base.models import is_soft_delete +from notifications.models import Notification + + +class Command(BaseCommand): + """This command deletes notifications that have already been read""" + + def handle(self, *args, **options): + bad_notifications = Notification.objects.all().read() + if is_soft_delete(): + bad_notifications.mark_all_as_deleted() + self.stdout.write( + self.style.SUCCESS(f"{bad_notifications.count()} read" + " notifications marked deleted")) + else: + count, _ = bad_notifications.delete() + self.stdout.write( + self.style.SUCCESS(f"{count} read notifications deleted")) diff --git a/app/core/signals.py b/app/core/signals.py new file mode 100644 index 0000000..34fd049 --- /dev/null +++ b/app/core/signals.py @@ -0,0 +1,13 @@ +from django.db.models.signals import m2m_changed +from django.dispatch import receiver +from notifications.signals import notify + +from .models import InterestList + + +@receiver(m2m_changed, sender=InterestList.experiences.through) +def interest_list_notify(sender, instance, action, reverse, pk_set, **kwargs): + if action == 'post_add' and not reverse: + notify.send(instance, recipient=instance.subscribers.all(), + verb='experiences added', added=pk_set, + list_name=instance.name) diff --git a/app/core/tests/test_commands_unit.py b/app/core/tests/test_commands_unit.py new file mode 100644 index 0000000..285ec39 --- /dev/null +++ b/app/core/tests/test_commands_unit.py @@ -0,0 +1,73 @@ +from django.conf import settings +from django.core.management import call_command +from django.test import tag + +from core.models import Experience, InterestList +from users.models import XDSUser + +from .test_setup import TestSetUp + + +@tag('unit') +class CommandTests(TestSetUp): + """Test cases for notification commands """ + + def test_clear_old_notifications(self): + """Test that only old notifications are cleared""" + id = '12345' + course = Experience(metadata_key_hash=id) + course.save() + id_2 = '54321' + course_2 = Experience(metadata_key_hash=id_2) + course_2.save() + user = XDSUser.objects.create_user(self.email, + self.password, + first_name=self.first_name, + last_name=self.last_name) + list = InterestList(owner=user, + name="test list", + description="test desc") + list.save() + list.subscribers.add(user) + list.experiences.add(course) + self.assertEqual(user.notifications.count(), 1) + + expiration_delta = settings.NOTIFICATIONS_EXPIRE_AFTER + + notification_old = user.notifications.first() + notification_old.timestamp = notification_old.timestamp - \ + expiration_delta - expiration_delta + notification_old.mark_as_read() + + list.experiences.add(course_2) + self.assertEqual(user.notifications.count(), 2) + + call_command('clear_old_notifications') + self.assertEqual(user.notifications.count(), 1) + + def test_clear_read_notifications(self): + """Test that only read notifications are cleared""" + id = '12345' + course = Experience(metadata_key_hash=id) + course.save() + id_2 = '54321' + course_2 = Experience(metadata_key_hash=id_2) + course_2.save() + user = XDSUser.objects.create_user(self.email, + self.password, + first_name=self.first_name, + last_name=self.last_name) + list = InterestList(owner=user, + name="test list", + description="test desc") + list.save() + list.subscribers.add(user) + list.experiences.add(course) + self.assertEqual(user.notifications.count(), 1) + + user.notifications.first().mark_as_read() + list.experiences.add(course_2) + self.assertEqual(user.notifications.count(), 2) + + call_command('clear_read_notifications') + self.assertEqual(user.notifications.count(), 1) diff --git a/app/openlxp_xds_project/settings.py b/app/openlxp_xds_project/settings.py index 484fba7..e11a406 100644 --- a/app/openlxp_xds_project/settings.py +++ b/app/openlxp_xds_project/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/ """ +import datetime import mimetypes import os import sys @@ -53,6 +54,7 @@ 'es_api', 'users', 'configurations', + 'notifications', ] MIDDLEWARE = [ @@ -269,4 +271,13 @@ EMAIL_BACKEND = 'django_ses.SESBackend' + +# Django-notifications package settings +DJANGO_NOTIFICATIONS_CONFIG = { + 'USE_JSONFIELD': True, +} + +# when notifications should be automatically deleted, should be days or greater +NOTIFICATIONS_EXPIRE_AFTER = datetime.timedelta(days=30) + DATA_UPLOAD_MAX_MEMORY_SIZE = 5242880 diff --git a/app/openlxp_xds_project/urls.py b/app/openlxp_xds_project/urls.py index 871c36b..89c3676 100644 --- a/app/openlxp_xds_project/urls.py +++ b/app/openlxp_xds_project/urls.py @@ -13,6 +13,7 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +import notifications.urls from django.conf import settings from django.conf.urls import url from django.conf.urls.static import static @@ -29,4 +30,6 @@ path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), url('', include('openlxp_authentication.urls')), + url('^inbox/notifications/', + include(notifications.urls, namespace='notifications')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/requirements.txt b/requirements.txt index 02f43b9..c20253e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,8 @@ Django>=3.2.3,<4.0 djangorestframework>=3.12.2,<3.13.0 +django-notifications-hq>=1.8.3, <2.0 + flake8>=3.8.4,<3.9.0 gunicorn>=20.0.4,<20.1.0