diff --git a/aleksis/core/migrations/0009_rename_fields_notification_activity.py b/aleksis/core/migrations/0009_rename_fields_notification_activity.py new file mode 100644 index 0000000000000000000000000000000000000000..f38db142f83152cbd27872d1221a7d9f881207ba --- /dev/null +++ b/aleksis/core/migrations/0009_rename_fields_notification_activity.py @@ -0,0 +1,39 @@ +# Generated by Django 3.0.2 on 2020-01-22 16:49 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_extended_data'), + ] + + operations = [ + migrations.RenameField( + model_name='notification', + old_name='user', + new_name='recipient', + ), + migrations.RenameField( + model_name='notification', + old_name='app', + new_name='sender', + ), + migrations.RenameField( + model_name='notification', + old_name='mailed', + new_name='sent', + ), + migrations.AlterField( + model_name='activity', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'), + ), + migrations.AlterField( + model_name='notification', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'), + ), + ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index 6b712780bff8dac1c1551f5782ded576b4761898..eceeaa1e1687724b0a929a68dd8f50b30690543a 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -3,14 +3,12 @@ from typing import Optional from django.contrib.auth import get_user_model from django.contrib.auth.models import User from django.db import models -from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from image_cropping import ImageCropField, ImageRatioField from phonenumber_field.modelfields import PhoneNumberField -from .mailer import send_mail_with_template -from templated_email import send_templated_mail from .mixins import ExtensibleModel +from .util.notifications import send_notification from constance import config @@ -195,7 +193,7 @@ class Activity(models.Model): app = models.CharField(max_length=100, verbose_name=_("Application")) - created_at = models.DateTimeField(default=timezone.now, verbose_name=_("Created at")) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) def __str__(self): return self.title @@ -206,37 +204,26 @@ class Activity(models.Model): class Notification(models.Model): - user = models.ForeignKey("Person", on_delete=models.CASCADE, related_name="notifications") + sender = models.CharField(max_length=100, verbose_name=_("Sender")) + recipient = models.ForeignKey("Person", on_delete=models.CASCADE, related_name="notifications") + title = models.CharField(max_length=150, verbose_name=_("Title")) description = models.TextField(max_length=500, verbose_name=_("Description")) link = models.URLField(blank=True, verbose_name=_("Link")) - app = models.CharField(max_length=100, verbose_name=_("Application")) - read = models.BooleanField(default=False, verbose_name=_("Read")) - mailed = models.BooleanField(default=False, verbose_name=_("Mailed")) - created_at = models.DateTimeField(default=timezone.now, verbose_name=_("Created at")) + sent = models.BooleanField(default=False, verbose_name=_("Sent")) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) def __str__(self): return self.title def save(self, **kwargs): + send_notification(self) + self.sent = True super().save(**kwargs) - if not self.mailed: - context = { - "notification": self, - "notification_user": self.user.adressing_name, - } - send_templated_mail( - template_name='notification', - from_email=config.MAIL_OUT, - recipient_list=[self.user.email], - context=context, - ) - self.mailed = True - super().save(**kwargs) - class Meta: verbose_name = _("Notification") verbose_name_plural = _("Notifications") diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index 8b0f6bcd271f599026d7bb43ea5d966f4e407a67..46b070449c3bc6ff46e98ace323953b7f6252ac1 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -9,6 +9,7 @@ from dynaconf import LazySettings from easy_thumbnails.conf import Settings as thumbnail_settings from .util.core_helpers import get_app_packages, lazy_config, merge_app_settings +from .util.notifications import get_notification_choices_lazy ENVVAR_PREFIX_FOR_DYNACONF = "ALEKSIS" DIRS_FOR_DYNACONF = ["/etc/aleksis"] @@ -320,6 +321,10 @@ CONSTANCE_ADDITIONAL_FIELDS = { ("dutch", "Doe John"), ) }], + "notifications-select": ["django.forms.fields.MultipleChoiceField", { + "widget": "django.forms.CheckboxSelectMultiple", + "choices": get_notification_choices_lazy, + }], "weekday_field": ["django.forms.fields.ChoiceField", { 'widget': 'django.forms.Select', "choices": i18n_day_name_choices_lazy @@ -330,15 +335,17 @@ CONSTANCE_CONFIG = { "COLOUR_PRIMARY": ("#007bff", _("Primary colour")), "COLOUR_SECONDARY": ("#007bff", _("Secondary colour")), "MAIL_OUT_NAME": ("AlekSIS", _("Mail out name")), - "MAIL_OUT": ("aleksis@example.com", _("Mail out address"), "email_field"), + "MAIL_OUT": (DEFAULT_FROM_EMAIL, _("Mail out address"), "email_field"), "PRIVACY_URL": ("", _("Link to privacy policy"), "url_field"), "IMPRINT_URL": ("", _("Link to imprint"), "url_field"), - "ADRESSING_NAME_FORMAT": ("german", _("Name format of adresses"), "adressing-select") + "ADRESSING_NAME_FORMAT": ("german", _("Name format of adresses"), "adressing-select"), + "NOTIFICATION_CHANNELS": (["email"], _("Channels to allow for notifications"), "notifications-select"), } CONSTANCE_CONFIG_FIELDSETS = { "General settings": ("SITE_TITLE",), "Theme settings": ("COLOUR_PRIMARY", "COLOUR_SECONDARY"), - "Mail settings": ("MAIL_OUT_NAME", "MAIL_OUT", "ADRESSING_NAME_FORMAT"), + "Mail settings": ("MAIL_OUT_NAME", "MAIL_OUT"), + "Notification settings": ("NOTIFICATION_CHANNELS", "ADRESSING_NAME_FORMAT"), "Footer settings": ("PRIVACY_URL", "IMPRINT_URL"), } @@ -364,19 +371,25 @@ ANONYMIZE_ENABLED = _settings.get("maintenance.anonymisable", True) LOGIN_URL = "two_factor:login" if _settings.get("2fa.call.enabled", False): + if "two_factor.middleware.threadlocals.ThreadLocals" not in MIDDLEWARE: + MIDDLEWARE.insert( + MIDDLEWARE.index("django_otp.middleware.OTPMiddleware") + 1, + "two_factor.middleware.threadlocals.ThreadLocals", + ) TWO_FACTOR_CALL_GATEWAY = "two_factor.gateways.twilio.gateway.Twilio" if _settings.get("2fa.sms.enabled", False): + if "two_factor.middleware.threadlocals.ThreadLocals" not in MIDDLEWARE: + MIDDLEWARE.insert( + MIDDLEWARE.index("django_otp.middleware.OTPMiddleware") + 1, + "two_factor.middleware.threadlocals.ThreadLocals", + ) TWO_FACTOR_SMS_GATEWAY = "two_factor.gateways.twilio.gateway.Twilio" -if _settings.get("2fa.twilio.sid", None): - MIDDLEWARE.insert( - MIDDLEWARE.index("django_otp.middleware.OTPMiddleware") + 1, - "two_factor.middleware.threadlocals.ThreadLocals", - ) - TWILIO_SID = _settings.get("2fa.twilio.sid") - TWILIO_TOKEN = _settings.get("2fa.twilio.token") - TWILIO_CALLER_ID = _settings.get("2fa.twilio.callerid") +if _settings.get("twilio.sid", None): + TWILIO_SID = _settings.get("twilio.sid") + TWILIO_TOKEN = _settings.get("twilio.token") + TWILIO_CALLER_ID = _settings.get("twilio.callerid") if _settings.get("celery.enabled", False): INSTALLED_APPS += ("django_celery_beat", "django_celery_results") diff --git a/aleksis/core/templates/sms/notification.txt b/aleksis/core/templates/sms/notification.txt new file mode 100644 index 0000000000000000000000000000000000000000..db06b797ee8b649a9357fa287591f802177af054 --- /dev/null +++ b/aleksis/core/templates/sms/notification.txt @@ -0,0 +1,6 @@ +{% load i18n %} +🔔 {{ notification.title }} + +{{ notification.description }} + +{{ notification.sender }}{% if notification.link %} · {% endif %}{{ notification.link }} diff --git a/aleksis/core/templates/templated_email/notification.email b/aleksis/core/templates/templated_email/notification.email index 4f8fc26fccfc939acddfdf080e30a40ec956b3fc..bb39f5861876b1dc1c4c39639de649ec6d6590eb 100644 --- a/aleksis/core/templates/templated_email/notification.email +++ b/aleksis/core/templates/templated_email/notification.email @@ -13,8 +13,8 @@ {% endif %} </blockquote> - {% blocktrans with trans_app=notification.app trans_created_at=notification.created_at %} - <p>By {{ trans_app }} at {{ trans_created_at }}</p> + {% blocktrans with trans_sender=notification.sender trans_created_at=notification.created_at %} + <p>By {{ trans_sender }} at {{ trans_created_at }}</p> <i>Your AlekSIS team</i> {% endblocktrans %} diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py index d48564c1865371070bfe7ffbf7d1d272d191fa4b..bc0700b03b8c755220b5bedfd609aa5d810a86d1 100644 --- a/aleksis/core/util/core_helpers.py +++ b/aleksis/core/util/core_helpers.py @@ -114,15 +114,16 @@ def celery_optional(orig: Callable) -> Callable: and it is executed synchronously. """ - if hasattr(settings, "CELERY_RESULT_BACKEND"): - from ..celery import app # noqa - task = app.task(orig) + def wrapped(*args, **kwargs): + if hasattr(settings, "CELERY_RESULT_BACKEND"): + from ..celery import app # noqa + task = app.task(orig) - def wrapped(*args, **kwargs): task.delay(*args, **kwargs) - return wrapped - else: - return orig + else: + orig(*args, **kwargs) + + return wrapped def path_and_rename(instance, filename: str, upload_to: str = "files") -> str: diff --git a/aleksis/core/util/notifications.py b/aleksis/core/util/notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..5f6f8fded9a00323b2695e27268d4d4a744c0525 --- /dev/null +++ b/aleksis/core/util/notifications.py @@ -0,0 +1,107 @@ +""" Utility code for notification system """ + +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.functional import lazy +from django.utils.translation import gettext_lazy as _ + +from templated_email import send_templated_mail + +try: + from twilio.rest import Client as TwilioClient +except ImportError: + TwilioClient = None + +from .core_helpers import celery_optional, lazy_config + + +def send_templated_sms( + template_name: str, from_number: str, recipient_list: Sequence[str], context: dict +) -> None: + """ Render a plan-text template and send via SMS to all recipients. """ + + template = get_template(template_name) + text = template.render(context) + + client = TwilioClient(settings.TWILIO_SID, settings.TWILIO_TOKEN) + for recipient in recipient_list: + client.messages.create(body=text, to=recipient, from_=from_number) + + +def _send_notification_email(notification: "Notification", template: str = "notification") -> None: + context = { + "notification": notification, + "notification_user": notification.recipient.adressing_name, + } + send_templated_mail( + template_name=template, + from_email=lazy_config("MAIL_OUT"), + recipient_list=[notification.recipient.email], + context=context, + ) + + +def _send_notification_sms( + notification: "Notification", template: str = "sms/notification.txt" +) -> None: + context = { + "notification": notification, + "notification_user": notification.recipient.adressing_name, + } + send_templated_sms( + template_name=template, + from_number=settings.TWILIO_CALLER_ID, + recipient_list=[notification.recipient.mobile_number.as_e164], + context=context, + ) + + +# Mapping of channel id to name and two functions: +# - Check for availability +# - Send notification through it +_CHANNELS_MAP = { + "email": (_("E-Mail"), lambda: lazy_config("MAIL_OUT"), _send_notification_email), + "sms": (_("SMS"), lambda: getattr(settings, "TWILIO_SID", None), _send_notification_sms), +} + + +@celery_optional +def send_notification(notification: Union[int, "Notification"], resend: bool = False) -> None: + """ Send a notification through enabled channels. + + If resend is passed as True, the notification is sent even if it was + previously marked as sent. + """ + + channels = lazy_config("NOTIFICATION_CHANNELS") + + if isinstance(notification, int): + Notification = apps.get_model("core", "Notification") + notification = Notification.objects.get(pk=notification) + + if resend or not notification.sent: + for channel in channels: + name, check, send = _CHANNELS_MAP[channel] + if check(): + send(notification) + + +def get_notification_choices() -> list: + """ Return all available channels for notifications. + + This gathers the channels that are technically available as per the + system configuration. Which ones are available to users is defined + by the administrator (by selecting a subset of these choices). + """ + + choices = [] + for channel, (name, check, send) in _CHANNELS_MAP.items(): + if check(): + choices.append((channel, name)) + return choices + + +get_notification_choices_lazy = lazy(get_notification_choices, tuple) diff --git a/aleksis/core/views.py b/aleksis/core/views.py index a320980e51657381d2e358a4314a6ef1e1af7ae9..ff11482f68268d5f5d090502039d1c4be12e99a2 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -255,7 +255,7 @@ def notification_mark_read(request: HttpRequest, id_: int) -> HttpResponse: notification = get_object_or_404(Notification, pk=id_) - if notification.user == request.user: + if notification.recipient.user == request.user: notification.read = True notification.save() else: