Skip to content
Snippets Groups Projects
Commit 0d60c9f7 authored by Jonathan Weth's avatar Jonathan Weth :keyboard:
Browse files

Merge branch '165-generalise-notifications' into 'master'

Generalise notifications and implement SMS notifications

Closes #165

See merge request AlekSIS/AlekSIS!137
parents ad4e54c3 413582bb
No related branches found
No related tags found
1 merge request!137Generalise notifications and implement SMS notifications
Pipeline #722 failed
# 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'),
),
]
......@@ -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")
......@@ -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")
......
{% load i18n %}
🔔 {{ notification.title }}
{{ notification.description }}
{{ notification.sender }}{% if notification.link %} · {% endif %}{{ notification.link }}
......@@ -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 %}
......
......@@ -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:
......
""" 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)
......@@ -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:
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment