From df7a51c27043eddbf14d0296125f1a2f0b019e56 Mon Sep 17 00:00:00 2001 From: Jonathan Weth <git@jonathanweth.de> Date: Thu, 24 Feb 2022 20:29:42 +0100 Subject: [PATCH] Allow scheduling notifications by setting a send datetime --- CHANGELOG.rst | 1 + .../migrations/0037_notification_send_at.py | 20 +++++ aleksis/core/models.py | 15 +++- aleksis/core/tasks.py | 7 ++ .../core/tests/models/test_notification.py | 75 ++++++++++++++++++- aleksis/core/util/notifications.py | 12 +++ aleksis/core/views.py | 8 +- 7 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 aleksis/core/migrations/0037_notification_send_at.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b8a5948a8..2fc756f23 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. Changed ~~~~~~~ diff --git a/aleksis/core/migrations/0037_notification_send_at.py b/aleksis/core/migrations/0037_notification_send_at.py new file mode 100644 index 000000000..514afa63c --- /dev/null +++ b/aleksis/core/migrations/0037_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', '0036_additionalfields_helptext_required'), + ] + + 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 07fdaca69..8c46a1b9e 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 django_celery_results.models import TaskResult from django_cte import CTEQuerySet, With from dynamic_preferences.models import PerInstancePreferenceModel @@ -739,6 +740,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")) @@ -747,10 +750,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 847fc4128..7b7e529b3 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 1b1a6df05..cdb01a04b 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 13887a197..061b8d3d8 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 7be99d6a0..00c95c47d 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) -- GitLab