diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1aca5ae741838e1863a7249215003f205b9aa07a..35cc51d5b1a9ab8dcca4e6503d8c1818597d7081 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -19,6 +19,7 @@ Added * Allow to configure if additional field is required * Allow to configure description of additional fields * Allow configuring regex for allowed usernames +* [Dev] Support scheduled notifications. * Implement StaticContentWidget Changed diff --git a/aleksis/core/migrations/0038_notification_send_at.py b/aleksis/core/migrations/0038_notification_send_at.py new file mode 100644 index 0000000000000000000000000000000000000000..edb96f1e87f143d60477a1578aea5d90226389a8 --- /dev/null +++ b/aleksis/core/migrations/0038_notification_send_at.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.12 on 2022-02-23 19:33 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0037_add_static_content_widget'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='send_at', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Send notification at'), + preserve_default=False, + ), + ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 03ea0320f11220dc2c392188d1bf3b7807dfeffd..7c555b409c0aac4a8631df21e6aabadbadd26242 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -29,6 +29,7 @@ import customidenticon import jsonstore from cachalot.api import cachalot_disabled from cache_memoize import cache_memoize +from celery.result import AsyncResult from ckeditor.fields import RichTextField from django_celery_results.models import TaskResult from django_cte import CTEQuerySet, With @@ -740,6 +741,8 @@ class Notification(ExtensibleModel, TimeStampedModel): description = models.TextField(max_length=500, verbose_name=_("Description")) link = models.URLField(blank=True, verbose_name=_("Link")) + send_at = models.DateTimeField(default=timezone.now, verbose_name=_("Send notification at")) + read = models.BooleanField(default=False, verbose_name=_("Read")) sent = models.BooleanField(default=False, verbose_name=_("Sent")) @@ -748,10 +751,14 @@ class Notification(ExtensibleModel, TimeStampedModel): def save(self, **kwargs): super().save(**kwargs) - if not self.sent: - send_notification(self.pk, resend=True) - self.sent = True - super().save(**kwargs) + if not self.sent and self.send_at <= timezone.now(): + self.send() + super().save(**kwargs) + + def send(self, resend: bool = False) -> Optional[AsyncResult]: + """Send the notification to the recipient.""" + if not self.sent or resend: + return send_notification.delay(self.pk, resend=True) class Meta: verbose_name = _("Notification") diff --git a/aleksis/core/tasks.py b/aleksis/core/tasks.py index 847fc41281a0266cff16bbe2117e326e13abc0dd..7b7e529b3a1fa21c5a6620170296d9ee1eb777ec 100644 --- a/aleksis/core/tasks.py +++ b/aleksis/core/tasks.py @@ -4,6 +4,7 @@ from django.conf import settings from django.core import management from .celery import app +from .util.notifications import _send_due_notifications as _send_due_notifications from .util.notifications import send_notification as _send_notification @@ -48,3 +49,9 @@ def clear_oauth_tokens(): from oauth2_provider.models import clear_expired # noqa return clear_expired() + + +@app.task(run_every=timedelta(minutes=5)) +def send_notifications(): + """Send due notifications to users.""" + _send_due_notifications() diff --git a/aleksis/core/tests/models/test_notification.py b/aleksis/core/tests/models/test_notification.py index 1b1a6df054fe24f06bd363e825df3f1259af53e8..cdb01a04b99a398469ddfcd4466101c0b2d79f34 100644 --- a/aleksis/core/tests/models/test_notification.py +++ b/aleksis/core/tests/models/test_notification.py @@ -1,11 +1,81 @@ +from datetime import timedelta +from time import sleep +from unittest.mock import patch + +from django.test import override_settings +from django.utils import timezone + import pytest +from freezegun import freeze_time from aleksis.core.models import Notification, Person +from aleksis.core.util.notifications import _send_due_notifications pytestmark = pytest.mark.django_db -def test_email_notification(mailoutbox): +def test_send_notification(): + email = "doe@example.com" + recipient = Person.objects.create(first_name="Jane", last_name="Doe", email=email) + + sender = "Foo" + title = "There is happened something." + description = "Here you get some more information." + link = "https://aleksis.org/" + + notification = Notification( + sender=sender, + recipient=recipient, + title=title, + description=description, + link=link, + ) + + with patch("aleksis.core.models.Notification.send") as patched_send: + patched_send.assert_not_called() + + notification.save() + + patched_send.assert_called() + + +def test_send_scheduled_notification(): + email = "doe@example.com" + recipient = Person.objects.create(first_name="Jane", last_name="Doe", email=email) + + sender = "Foo" + title = "There is happened something." + description = "Here you get some more information." + link = "https://aleksis.org/" + + notification = Notification( + sender=sender, + recipient=recipient, + title=title, + description=description, + link=link, + send_at=timezone.now() + timedelta(days=1), + ) + notification.save() + + with patch("aleksis.core.models.Notification.send") as patched_send: + patched_send.assert_not_called() + + _send_due_notifications() + + patched_send.assert_not_called() + + with freeze_time(timezone.now() + timedelta(days=1)): + _send_due_notifications() + + patched_send.assert_called() + + +@override_settings(CELERY_BROKER_URL="memory://localhost//") +@pytest.mark.django_db( + databases=["default", "default_oot"], serialized_rollback=True, transaction=True +) +def test_email_notification(mailoutbox, celery_worker): email = "doe@example.com" recipient = Person.objects.create(first_name="Jane", last_name="Doe", email=email) @@ -19,6 +89,9 @@ def test_email_notification(mailoutbox): ) notification.save() + sleep(3) + + notification.refresh_from_db() assert notification.sent assert len(mailoutbox) == 1 diff --git a/aleksis/core/util/notifications.py b/aleksis/core/util/notifications.py index 13887a197defb9e24f5a9b1538561287b80c942f..061b8d3d8fae61f68b1c3f2fa9e9f3f421491292 100644 --- a/aleksis/core/util/notifications.py +++ b/aleksis/core/util/notifications.py @@ -5,6 +5,7 @@ from typing import Sequence, Union from django.apps import apps from django.conf import settings from django.template.loader import get_template +from django.utils import timezone from django.utils.functional import lazy from django.utils.translation import gettext_lazy as _ @@ -82,6 +83,8 @@ def send_notification(notification: Union[int, "Notification"], resend: bool = F name, check, send = _CHANNELS_MAP[channel] if check(): send(notification) + notification.sent = True + notification.save() def get_notification_choices() -> list: @@ -99,3 +102,12 @@ def get_notification_choices() -> list: get_notification_choices_lazy = lazy(get_notification_choices, tuple) + + +def _send_due_notifications(): + """Send all notifications that are due to be sent.""" + Notification = apps.get_model("core", "Notification") + + due_notifications = Notification.objects.filter(sent=False, send_at__lte=timezone.now()) + for notification in due_notifications: + notification.send() diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 7be99d6a0e67aa552f53822f3b9fcbc4abf9e87b..00c95c47d139f28a6be7ca761580b96d98b9da40 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -216,7 +216,9 @@ def index(request: HttpRequest) -> HttpResponse: widgets = [] activities = person.activities.all().order_by("-created")[:5] - notifications = person.notifications.all().order_by("-created")[:5] + notifications = person.notifications.filter(send_at__lte=timezone.now()).order_by("-created")[ + :5 + ] unread_notifications = person.notifications.all().filter(read=False).order_by("-created") context["activities"] = activities @@ -246,7 +248,9 @@ class NotificationsListView(PermissionRequiredMixin, ListView): template_name = "core/notifications.html" def get_queryset(self) -> QuerySet: - return self.request.user.person.notifications.order_by("-created") + return self.request.user.person.notifications.filter(send_at__lte=timezone.now()).order_by( + "-created" + ) def get_context_data(self, **kwargs: Any) -> dict[str, Any]: self.get_queryset().filter(read=False).update(read=True)