From 89cadcca1ee6175e78fce35a5b98590cd808737c Mon Sep 17 00:00:00 2001 From: Leo Singer <leo.singer@ligo.org> Date: Fri, 18 Nov 2016 10:25:35 -0500 Subject: [PATCH] Add voice/SMS notifications using Twilio --- gracedb/alert.py | 80 ++++++++++++++++++++++++++-- gracedb/view_logic.py | 7 ++- settings/default.py | 1 + settings/roy.py | 1 + settings/test.py | 1 + templates/profile/notifications.html | 2 +- userprofile/models.py | 27 +++++++++- userprofile/views.py | 3 +- 8 files changed, 114 insertions(+), 8 deletions(-) diff --git a/gracedb/alert.py b/gracedb/alert.py index 88a9754e5..846a248aa 100644 --- a/gracedb/alert.py +++ b/gracedb/alert.py @@ -9,6 +9,8 @@ import json import logging +from django_twilio.client import twilio_client + from utils import gpsToUtc from query import filter_for_labels @@ -24,6 +26,11 @@ if settings.USE_LVALERT_OVERSEER: log = logging.getLogger('gracedb.alert') +def get_twilio_from(): + for from_ in twilio_client.phone_numbers.iter(): + return from_.phone_number + raise RuntimeError('Could not determine "from" Twilio phone number') + def issueAlert(event, location, event_url, serialized_object=None): issueXMPPAlert(event, location, serialized_object=serialized_object) issueEmailAlert(event, event_url) @@ -61,6 +68,7 @@ def issueAlertForLabel(event, label, doxmpp, serialized_event=None, event_url=No issueXMPPAlert(event, "", "label", label, serialized_event) # Email profileRecips = [] + phoneRecips = [] pipeline = event.pipeline # Triggers on given label matching pipeline OR with no pipeline (wildcard type) triggers = label.trigger_set.filter(pipelines=pipeline) @@ -76,7 +84,10 @@ def issueAlertForLabel(event, label, doxmpp, serialized_event=None, event_url=No continue for recip in trigger.contacts.all(): - profileRecips.append(recip.email) + if recip.email: + profileRecips.append(recip.email) + if recip.phone: + phoneRecips.append(recip.phone) if event.search: subject = "[gracedb] %s / %s / %s / %s" % (label.name, event.pipeline.name, event.search.name, event.graceid()) @@ -104,6 +115,35 @@ def issueAlertForLabel(event, label, doxmpp, serialized_event=None, event_url=No email = EmailMessage(subject, message, fromaddress, toaddresses, bccaddresses) email.send() + # twiml_base_url is the URL of a TwiML Bin + # (https://support.twilio.com/hc/en-us/articles/230878368) + # with the following content: + # + # <?xml version="1.0" encoding="UTF-8"?> + # <Response> + # <Say> + # A {{pipeline}} event with Grace DB ID {{graceid}} was labelled with {{label_lower}}. + # </Say> + # <Sms> + # A {{pipeline}} event with GraceDB ID {{graceid}} was labelled with {{label}} + # https://gracedb-test.ligo.org/events/view/{{graceid}} + # </Sms> + # </Response> + twiml_base_url = 'https://handler.twilio.com/twiml/EH7a2cef360c90eec301c3bf325ce1790a' + + twiml_url = '{0}?pipeline={1}&graceid={2}&label={3}&label_lower={4}'.format( + twiml_base_url, event.pipeline.name, event.graceid(), label.name, label.name.lower()) + log.info('phoneRecips: %s' % phoneRecips) + from_ = get_twilio_from() + log.info('from_: %s' % from_) + for recip in phoneRecips: + log.info('in for loop') + try: + log.info('issueAlertForLabel: calling %s', recip) + twilio_client.calls.create(recip, from_, twiml_url, method='GET') + except: + log.exception('Failed to create call') + def issueEmailAlert(event, event_url): @@ -118,6 +158,7 @@ def issueEmailAlert(event, event_url): fromaddress = settings.ALERT_TEST_EMAIL_FROM toaddresses = settings.ALERT_TEST_EMAIL_TO bccaddresses = [] + twilio_recips = [] else: fromaddress = settings.ALERT_EMAIL_FROM toaddresses = settings.ALERT_EMAIL_TO @@ -127,15 +168,22 @@ def issueEmailAlert(event, event_url): # See: https://bugs.ligo.org/redmine/issues/2185 #bccaddresses = settings.ALERT_EMAIL_BCC bccaddresses = [] + twilio_recips = [] pipeline = event.pipeline triggers = pipeline.trigger_set.filter(labels=None) for trigger in triggers: for recip in trigger.contacts.all(): if not trigger.farThresh: - bccaddresses.append(recip.email) + if recip.email: + bccaddresses.append(recip.email) + if recip.phone: + twilio_recips.append(recip.phone) else: if event.far and event.far < trigger.farThresh: - bccaddresses.append(recip.email) + if recip.email: + bccaddresses.append(recip.email) + if recip.phone: + twilio_recips.append(recip.phone) subject = "[gracedb] %s event. ID: %s" % (event.pipeline.name, event.graceid()) message = """ New Event @@ -161,6 +209,32 @@ Event Summary: #send_mail(subject, message, fromaddress, toaddresses) + # twiml_base_url is the URL of a TwiML Bin + # (https://support.twilio.com/hc/en-us/articles/230878368) + # with the following content: + # + # <?xml version="1.0" encoding="UTF-8"?> + # <Response> + # <Say> + # A {{pipeline}} event with Grace DB ID {{graceid}} was created. + # </Say> + # <Sms> + # A {{pipeline}} event with GraceDB ID {{graceid}} was created. + # https://gracedb-test.ligo.org/events/view/{{graceid}} + # </Sms> + # </Response> + twiml_base_url = 'https://handler.twilio.com/twiml/EHe8ac043be47528c50558954791fb11fe' + + twiml_url = '{0}?pipeline={1}&graceid={2}'.format( + twiml_base_url, event.pipeline.name, event.graceid()) + from_ = get_twilio_from() + for recip in twilio_recips: + try: + log.info('issueEmailAlert: calling %s', recip) + twilio_client.calls.create(recip, from_, twiml_url, method='GET') + except: + log.exception('Failed to create call') + def issueXMPPAlert(event, location, alert_type="new", description="", serialized_object=None): nodename = "%s_%s" % (event.group.name, event.pipeline.name) diff --git a/gracedb/view_logic.py b/gracedb/view_logic.py index b069df54e..863e8eb2a 100644 --- a/gracedb/view_logic.py +++ b/gracedb/view_logic.py @@ -34,10 +34,11 @@ from django.utils import timezone import logging import pytz +logger = logging.getLogger('gracedb.view_logic') + def _createEventFromForm(request, form): saved = False warnings = [] - logger = logging.getLogger(__name__) try: group = Group.objects.get(name=form.cleaned_data['group']) pipeline = Pipeline.objects.get(name=form.cleaned_data['pipeline']) @@ -176,11 +177,13 @@ def create_label(event, request, labelName, doAlert=True, doXMPP=True): log.save() except Exception as e: # XXX This looks a bit odd to me. + logger.exception('Problem saving log message') d['error'] = str(e) try: issueAlertForLabel(event, label, doXMPP, event_url=event_url) - except Exception, e: + except Exception as e: + logger.exception('Problem saving log message') d['warning'] = "Problem issuing alert (%s)" % str(e) # XXX Strange return value. Just warnings. Can really be ignored, I think. return json.dumps(d) diff --git a/settings/default.py b/settings/default.py index 107ebfee3..bc214dd17 100644 --- a/settings/default.py +++ b/settings/default.py @@ -337,6 +337,7 @@ INSTALLED_APPS = ( 'ligoauth', 'rest_framework', 'guardian', + 'django_twilio', ) REST_FRAMEWORK = { diff --git a/settings/roy.py b/settings/roy.py index b1caedc03..640eef2a1 100644 --- a/settings/roy.py +++ b/settings/roy.py @@ -150,6 +150,7 @@ INSTALLED_APPS = ( 'rest_framework', # 'south', 'guardian', + 'django_twilio', ) INTERNAL_IPS = ( diff --git a/settings/test.py b/settings/test.py index afdffde2d..a910a7e36 100644 --- a/settings/test.py +++ b/settings/test.py @@ -87,6 +87,7 @@ INSTALLED_APPS = ( 'ligoauth', 'rest_framework', 'guardian', + 'django_twilio', #'debug_toolbar', #'debug_panel', ) diff --git a/templates/profile/notifications.html b/templates/profile/notifications.html index 225489f46..0b52410bd 100644 --- a/templates/profile/notifications.html +++ b/templates/profile/notifications.html @@ -25,7 +25,7 @@ <li> <!-- <a href="{% url "userprofile-edit-contact" contact.id %}">Edit</a> --> <a href="{% url "userprofile-delete-contact" contact.id %}">Delete</a> - {{ contact.desc }} / {{ contact.email }} + {{ contact.desc }} / {{ contact.email }} {{ contact.phone }} </li> </ul> {% endfor %} diff --git a/userprofile/models.py b/userprofile/models.py index 3642891f2..a4fb2f650 100644 --- a/userprofile/models.py +++ b/userprofile/models.py @@ -3,7 +3,31 @@ from django.db import models from gracedb.models import Label, Pipeline +from django.core.exceptions import ValidationError from django.contrib.auth.models import User +import phonenumbers + +def validate_phone(value): + try: + phone = phonenumbers.parse(value, 'US') + except phonenumbers.NumberParseException: + raise ValidationError('Not a valid phone number: {0}'.format(value)) + if not phonenumbers.is_valid_number(phone): + raise ValidationError('Not a valid phone number: {0}'.format(value)) + return phonenumbers.format_number(phone, phonenumbers.PhoneNumberFormat.E164) + +class PhoneNumberField(models.CharField): + + def __init__(self, *args, **kwargs): + validators = kwargs.get('validators', []) + [validate_phone] + kwargs = dict(kwargs, max_length=255, validators=validators) + super(PhoneNumberField, self).__init__(*args, **kwargs) + + def get_prep_value(self, value): + if value: + return validate_phone(value) + else: + return '' #class Notification(models.Model): # user = models.ForeignKey(User, null=False) @@ -16,7 +40,8 @@ class Contact(models.Model): user = models.ForeignKey(User, null=False) #new_user = models.ForeignKey(DjangoUser, null=True) desc = models.CharField(max_length=20) - email = models.EmailField() + email = models.EmailField(blank=True) + phone = PhoneNumberField(blank=True) def __unicode__(self): #return "%s: %s" % (self.user.name, self.desc) diff --git a/userprofile/views.py b/userprofile/views.py index 023a41426..5772a55f2 100644 --- a/userprofile/views.py +++ b/userprofile/views.py @@ -143,7 +143,8 @@ def createContact(request): c = Contact( user=request.user, desc = form.cleaned_data['desc'], - email = form.cleaned_data['email'] + email = form.cleaned_data['email'], + phone = form.cleaned_data['phone'] ) c.save() request.session['flash_msg'] = "Created: %s" % c -- GitLab