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