Commit 4c7bc983 authored by Tanner Prestegard's avatar Tanner Prestegard Committed by GraceDB

Overhaul phone and email alerts

parent e4a8b099
Pipeline #52088 passed with stage
in 14 seconds
......@@ -54,8 +54,18 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
TWIML_BASE_URL = 'https://handler.twilio.com/twiml/'
# TwiML bin SIDs (for Twilio)
TWIML_BIN = {
'new': 'EH761b6a35102737e3d21830a484a98a08',
'label_added': 'EHb596a53b9c92a41950ce1a47335fd834',
'event': {
'new': 'EH761b6a35102737e3d21830a484a98a08',
'update': 'EH95d69491c166fbe8888a3b83b8aff4af',
'label_added': 'EHb596a53b9c92a41950ce1a47335fd834',
'label_removed': 'EH071c034f27f714bb7a832e85e6f82119',
},
'superevent': {
'new': 'EH5d4d61f5aee9f8687c5bc7d9d42acab9',
'update': 'EH35356707718e1b9a887c50359c3ab064',
'label_added': 'EH244c07ceeb152c6a374e4ffbd853e7a4',
'label_removed': 'EH9d796ce6a80e282a5c96e757e5c39406',
},
'test': 'EH6c0a168b0c6b011047afa1caeb49b241',
'verify': 'EHfaea274d4d87f6ff152ac39fea3a87d4',
}
......
from __future__ import absolute_import
import logging
import textwrap
from django.conf import settings
from django.core.mail import EmailMessage
......@@ -7,92 +8,145 @@ from django.urls import reverse
from core.time_utils import gpsToUtc
from core.urls import build_absolute_uri
from events.shortcuts import is_event
# Set up logger
logger = logging.getLogger(__name__)
EMAIL_SUBJECT_NEW = "[gracedb] {pipeline} event. ID: {graceid}"
EMAIL_MESSAGE_NEW = """
New Event
{group} / {pipeline}
GRACEID: {graceid}
Info: {url}
Data: {file_url}
Submitter: {submitter}
Event Summary:
{summary}
"""
EMAIL_SUBJECT_LABEL = "[gracedb] {label} / {pipeline} / {search} / {graceid}"
EMAIL_SUBJECT_LABEL_NOSEARCH = "[gracedb] {label} / {pipeline} / {graceid}"
EMAIL_MESSAGE_LABEL = """
A {pipeline} event with graceid {graceid} was labeled with {label}: {url}
"""
def indent(nindent, text):
return "\n".join([(nindent*' ')+line for line in text.split('\n')])
def prepareSummary(event):
gpstime = event.gpstime
utctime = gpsToUtc(gpstime).strftime("%Y-%m-%d %H:%M:%S")
instruments = getattr(event, 'instruments', "")
far = getattr(event, 'far', 1.0)
summary_template = """
Event Time (GPS): %s
Event Time (UTC): %s
Instruments: %s
FAR: %.3E """
summary = summary_template % (gpstime, utctime, instruments, far)
si_set = event.singleinspiral_set.all()
if si_set.count():
si = si_set[0]
summary += """
Component masses: %.2f, %.2f """ % (si.mass1, si.mass2)
return summary
def issue_email_alerts(event, recips, label=None):
# Prepare URLs for email message body
event_url = build_absolute_uri(reverse("view", args=[event.graceid]))
file_url = build_absolute_uri(reverse("file_list", args=[event.graceid]))
# Compile subject and message content
if label is None:
# Alert for new event
subject = EMAIL_SUBJECT_NEW.format(pipeline=event.pipeline.name,
graceid=event.graceid)
message = EMAIL_MESSAGE_NEW.format(group=event.group.name,
pipeline=event.pipeline.name, graceid=event.graceid,
url=event_url, file_url=file_url,
submitter=event.submitter.get_full_name(),
summary=indent(3, prepareSummary(event)))
EMAIL_SUBJECT = {
'event': {
'new': '[gracedb] {pipeline} event created: {graceid}',
'update': '[gracedb] {pipeline} event updated: {graceid}',
'label_added': \
'[gracedb] {pipeline} event labeled with {label}: {graceid}',
'label_removed': ('[gracedb] Label {label} removed from {pipeline} '
'event: {graceid}'),
},
'superevent': {
'new': '[gracedb] Superevent created: {sid}',
'update': '[gracedb] Superevent updated: {sid}',
'label_added': \
'[gracedb] Superevent labeled with {label}: {sid}',
'label_removed': \
'[gracedb] Label {label} removed from superevent: {sid}',
},
}
# es = 'Event' or 'Superevent'
# group_pipeline_ev_or_s = '{group} / {pipeline} event' or 'superevent'
EMAIL_BODY_TEMPLATE = textwrap.dedent("""\
{alert_type_verb} {group_pipeline_ev_or_s}: {graceid}
{es} time (GPS): {gpstime}
{es} time (UTC): {utctime}
{es} page: {detail_url}
{es} data: {file_url}
FAR: {far}
Labels: {labels}
""").rstrip()
ALERT_TYPE_VERBS = {
'new': 'New',
'update': 'Updated',
'label_added': 'Label {label} added to',
'label_removed': 'Label {label} removed from',
}
def prepare_email_body(event_or_superevent, alert_type, label=None):
# Sort out different cases for events or superevents
if is_event(event_or_superevent):
group_pipeline_ev_or_s = '{group} / {pipeline} event'.format(
group=event_or_superevent.group.name,
pipeline=event_or_superevent.pipeline.name)
es = 'Event'
detail_url = build_absolute_uri(reverse('view',
args=[event_or_superevent.graceid]))
file_url = build_absolute_uri(reverse('file_list',
args=[event_or_superevent.graceid]))
else:
group_pipeline_ev_or_s = 'superevent'
es = 'Superevent'
detail_url = build_absolute_uri(reverse('superevents:view',
args=[event_or_superevent.graceid]))
file_url = build_absolute_uri(reverse('superevents:file-list',
args=[event_or_superevent.graceid]))
# Compile email body
alert_type_verb = ALERT_TYPE_VERBS[alert_type]
if label is not None and 'label' in alert_type:
alert_type_verb = alert_type_verb.format(label=label.name)
gpstime = event_or_superevent.gpstime
labels = None
if event_or_superevent.labels.exists():
labels = ", ".join(event_or_superevent.labels.values_list(
'name', flat=True))
email_body = EMAIL_BODY_TEMPLATE.format(
graceid=event_or_superevent.graceid,
alert_type_verb=alert_type_verb,
group_pipeline_ev_or_s=group_pipeline_ev_or_s,
es=es,
detail_url=detail_url,
file_url=file_url,
gpstime=gpstime,
utctime=gpsToUtc(gpstime).isoformat(),
far=event_or_superevent.far,
labels=labels)
# Add some extra information
has_si = False
if is_event(event_or_superevent):
instruments = getattr(event_or_superevent, 'instruments', None)
if instruments:
email_body += '\n Instruments: {inst}'.format(inst=instruments)
if event_or_superevent.singleinspiral_set.exists():
si = event_or_superevent.singleinspiral_set.first()
has_si = True
else:
if event_or_superevent.preferred_event.singleinspiral_set.exists():
si = event_or_superevent.preferred_event.singleinspiral_set.first()
has_si = True
if has_si:
email_body += '\n Component masses: {m1}, {m2}'.format(m1=si.mass1,
m2=si.mass2)
return email_body
def issue_email_alerts(event_or_superevent, alert_type, recipients,
label=None):
# Get subject template
if is_event(event_or_superevent):
event_type = 'event'
else:
event_type = 'superevent'
subject_template = EMAIL_SUBJECT[event_type][alert_type]
# Construct subject
subj_kwargs = {}
if label is not None and 'label' in alert_type:
subj_kwargs['label'] = label.name
if is_event(event_or_superevent):
subj_kwargs['graceid'] = event_or_superevent.graceid
subj_kwargs['pipeline'] = event_or_superevent.pipeline.name
else:
# Alert for label
if event.search:
subject = EMAIL_SUBJECT_LABEL.format(label=label.name,
pipeline=event.pipeline.name, search=event.search.name,
graceid=event.graceid)
else:
subject = EMAIL_SUBJECT_LABEL_NOSEARCH.format(label=label.name,
pipeline=event.pipeline.name, graceid=event.graceid)
message = EMAIL_MESSAGE_LABEL.format(pipeline=event.pipeline.name,
graceid=event.graceid, label=label.name, url=event_url)
# Actual recipients should be BCC'd
bcc_addresses = [recip.email for recip in recips]
# Compile from/to addresses
from_address = settings.ALERT_EMAIL_FROM
to_addresses = settings.ALERT_EMAIL_TO
subj_kwargs['sid'] = event_or_superevent.superevent_id
subject = subject_template.format(**subj_kwargs)
# Get email body
email_body = prepare_email_body(event_or_superevent, alert_type, label)
# Log email recipients
logger.debug("Sending email to {recips}".format(
recips=", ".join(bcc_addresses)))
# Send email
email = EmailMessage(subject, message, from_address, to_addresses,
bcc_addresses)
email.send()
recips=", ".join([r.email for r in recipients])))
# Send mail individually so all emails are not rejected due to a single
# address being blacklisted
for recip in recipients:
# Send email
email = EmailMessage(subject=subject, body=email_body,
from_email=settings.ALERT_EMAIL_FROM, to=[recip.email])
email.send()
from __future__ import absolute_import
import json
import logging
import os
import socket
from subprocess import Popen, PIPE, STDOUT
import sys
from django.conf import settings
from django.contrib.auth.models import Group
from django.core.mail import EmailMessage
from django.db.models import QuerySet, Q
from django.urls import reverse
from core.time_utils import gpsToUtc
from events.models import Event
from events.permission_utils import is_external
from events.shortcuts import is_event
from search.query.labels import filter_for_labels
from superevents.shortcuts import is_superevent
from .models import Contact
from .email import issue_email_alerts
from .phone import issue_phone_alerts
from .recipients import ALERT_TYPE_RECIPIENT_GETTERS
from .xmpp import issue_xmpp_alerts
# Set up logger
logger = logging.getLogger(__name__)
def get_alert_recips(event_or_superevent):
if is_superevent(event_or_superevent):
# TODO: update for superevents
pass
elif is_event(event_or_superevent):
event = event_or_superevent
# Queryset of all notifications for this pipeline
notifications = event.pipeline.notification_set.filter(labels=None)
# Filter on FAR threshold requirements
query = Q(far_threshold__isnull=True)
if event.far:
query |= Q(far_threshold__gt=event.far)
notifications = notifications.filter(query)
# Contacts for all notifications, make sure user is in LVC group (safeguard)
contacts = Contact.objects.filter(notification__in=notifications,
user__groups__name=settings.LVC_GROUP).select_related('user')
email_recips = contacts.exclude(email="")
phone_recips = contacts.exclude(phone="")
return email_recips, phone_recips
def get_alert_recips_for_label(event_or_superevent, label):
# Blank QuerySets for recipients
email_recips = Contact.objects.none()
phone_recips = Contact.objects.none()
# Construct a queryset containing only this object; needed for
# call to filter_for_labels
qs = event_or_superevent._meta.model.objects.filter(
pk=event_or_superevent.pk)
# Notifications on given label matching pipeline OR with no pipeline;
# no pipeline indicates that pipeline is irrelevant
if is_superevent(event_or_superevent):
# TODO: fix this
query = Q()
elif is_event(event_or_superevent):
event = event_or_superevent
query = Q(pipelines=event.pipeline) | Q(pipelines=None)
# Iterate over notifications found from the label query
# TODO: this doesn't work quite correctly since negated labels aren't
# properly handled in the view function which creates notifications.
# Example: a notification with '~INJ' as the label_query has INJ in its labels
# Idea: have filter_for_labels return a Q object generated from the
# label query
notifications = label.notification_set.filter(query).prefetch_related('contacts')
for notification in notifications:
if len(notification.label_query) > 0:
qs_out = filter_for_labels(qs, notification.label_query)
# If the label query cleans out our query set, we'll continue
# without adding the recipient.
if not qs_out.exists():
continue
# Compile a list of recipients from the notification's contacts.
# Require that the user is in the LVC group as a safeguard
contacts = notification.contacts.filter(user__groups__name=
settings.LVC_GROUP)
email_recips |= contacts.exclude(email="").select_related('user')
phone_recips |= contacts.exclude(phone="").select_related('user')
return email_recips, phone_recips
def issue_alerts(event_or_superevent, alert_type, serialized_object,
serialized_parent=None, label=None):
serialized_parent=None, **kwargs):
# Send XMPP alert
if settings.SEND_XMPP_ALERTS:
......@@ -105,37 +23,58 @@ def issue_alerts(event_or_superevent, alert_type, serialized_object,
serialized_parent=serialized_parent)
# Below here, we only do processing for email and phone alerts ------------
if not (settings.SEND_EMAIL_ALERTS or settings.SEND_PHONE_ALERTS):
return
# TODO: make phone and e-mail alerts work for superevents
if is_superevent(event_or_superevent):
# A few checks on whether we should issue a phone and/or email alert ------
if not (settings.SEND_EMAIL_ALERTS or settings.SEND_PHONE_ALERTS):
return
# Phone and email alerts are currently only for "new" and "label_added"
# alert_types, for events only.
if (alert_type not in ['new', 'label_added']):
# Phone and email alerts are only issued for certain alert types
# We check this by looking at the keys of ALERT_TYPE_RECIPIENT_GETTERS
if (alert_type not in ALERT_TYPE_RECIPIENT_GETTERS):
return
# Don't send phone or email alerts for MDC events or Test events
# Don't send phone or email alerts for MDC or Test cases, or offline
if is_event(event_or_superevent):
# Test/MDC events
event = event_or_superevent
if ((event.search and event.search.name == 'MDC') \
or event.group.name == 'Test'):
return
# Don't send them for offline events, either
# Offline events
if event.offline:
return
else:
# Test/MDC superevents
s = event_or_superevent
if (s.category in [s.__class__.SUPEREVENT_CATEGORY_TEST,
s.__class__.SUPEREVENT_CATEGORY_MDC]):
return
# Superevents with offline preferred events
if s.preferred_event.offline:
return
# Looks like we're going to issue phone and/or email alerts ---------------
# Get recipient getter class
rg_class = ALERT_TYPE_RECIPIENT_GETTERS[alert_type]
# Instantiate recipient getter
rg = rg_class(event_or_superevent, **kwargs)
# Get recipients
email_recipients, phone_recipients = rg.get_recipients()
if alert_type == "new":
email_recips, phone_recips = get_alert_recips(event_or_superevent)
elif alert_type == "label_added":
email_recips, phone_recips = \
get_alert_recips_for_label(event_or_superevent, label)
# Try to get label explicitly from kwargs
label = kwargs.get('label', None)
if settings.SEND_EMAIL_ALERTS and email_recips.exists():
issue_email_alerts(event_or_superevent, email_recips, label=label)
# Issue email alerts
if settings.SEND_EMAIL_ALERTS and email_recipients.exists():
issue_email_alerts(event_or_superevent, alert_type, email_recipients,
label=label)
if settings.SEND_PHONE_ALERTS and phone_recips.exists():
issue_phone_alerts(event_or_superevent, phone_recips, label=label)
# Issue phone alerts
if settings.SEND_PHONE_ALERTS and phone_recipients.exists():
issue_phone_alerts(event_or_superevent, alert_type, phone_recipients,
label=label)
from __future__ import absolute_import
import logging
import socket
from django.conf import settings
from django.urls import reverse
from django.utils.http import urlencode
from django_twilio.client import twilio_client
from core.urls import build_absolute_uri
from events.permission_utils import is_external
from events.shortcuts import is_event
from .utils import convert_superevent_id_to_speech
# Set up logger
logger = logging.getLogger(__name__)
# TODO: generalize to superevents
# Dict for managing TwiML bin arguments.
# Should match structure of TWIML_BINS dict in
# config/settings/secret.py.
TWIML_ARG_STR = {
'new': 'pipeline={pipeline}&graceid={graceid}&server={server}',
'label_added': ('pipeline={pipeline}&graceid={graceid}&label_lower={label}'
'&server={server}'),
}
# Dict for managing Twilio message contents.
TWILIO_MSG_CONTENT = {
'new': 'A {pipeline} event with GraceDB ID {graceid} was created. {url}',
'label_added': ('A {pipeline} event with GraceDB ID {graceid} was labeled '
'with {label}. {url}'),
'event': {
'new': ('A {pipeline} event with GraceDB ID {graceid} was created. '
'{url}'),
'update': ('A {pipeline} event with GraceDB ID {graceid} was updated. '
'{url}'),
'label_added': ('A {pipeline} event with GraceDB ID {graceid} was '
'labeled with {label}. {url}'),
'label_removed': ('The label {label} was removed from a {pipeline} '
'event with GraceDB ID {graceid}. {url}'),
},
'superevent': {
'new': 'A superevent with GraceDB ID {sid} was created. {url}',
'update': 'A superevent with GraceDB ID {sid} was updated. {url}',
'label_added': ('A superevent with GraceDB ID {sid} was labeled with '
'{label}. {url}'),
'label_removed': ('The label {label} was removed from a superevent '
'with GraceDB ID {sid}. {url}'),
},
}
......@@ -39,40 +47,84 @@ def get_twilio_from():
raise RuntimeError('Could not determine "from" Twilio phone number')
def issue_phone_alerts(event, contacts, label=None):
"""
USAGE:
------
New event created:
issue_phone_alerts(event, contacts)
New label applied to event (Label is a GraceDB model):
issue_phone_alerts(event, contacts, label=Label)
def get_message_content(event_or_superevent, alert_type, **kwargs):
"""Get content for text messages"""
# kwargs should include 'label' for label_added and label_removed alerts
Note: contacts is a QuerySet of Contact objects.
"""
# Determine alert_type
if label is not None:
alert_type = "label_added"
# Get template
if is_event(event_or_superevent):
event_type = 'event'
else:
event_type = 'superevent'
msg_template = TWILIO_MSG_CONTENT[event_type][alert_type]
# Compile message content
if is_event(event_or_superevent):
event = event_or_superevent
# Get url
url = build_absolute_uri(reverse('view', args=[event.graceid]))
# Compile message
msg = msg_template.format(graceid=event.graceid,
pipeline=event.pipeline.name, url=url, **kwargs)
else:
alert_type = "new"
superevent = event_or_superevent
# get url
url = build_absolute_uri(reverse('superevents:view',
args=[superevent.superevent_id]))
# Compile message
msg = msg_template.format(sid=superevent.superevent_id, url=url,
**kwargs)
return msg
def compile_twiml_url(event_or_superevent, alert_type, **kwargs):
# Try to get label from kwargs - should be a string corresponding
# to the label name here
label = kwargs.get('label', None)
# Compile urlparams
if is_event(event_or_superevent):
urlparams = {
'graceid': event_or_superevent.graceid,
'pipeline': event_or_superevent.pipeline.name,
}
twiml_bin_dict = settings.TWIML_BIN['event']
else:
urlparams = {'sid': convert_superevent_id_to_speech(
event_or_superevent.superevent_id)}
twiml_bin_dict = settings.TWIML_BIN['superevent']
if label is not None:
urlparams['label_lower'] = label.lower()
# Construct TwiML URL
twiml_url = '{base}{twiml_bin}?{params}'.format(
base=settings.TWIML_BASE_URL,
twiml_bin=twiml_bin_dict[alert_type],
params=urlencode(urlparams))
return twiml_url
def issue_phone_alerts(event_or_superevent, alert_type, contacts, label=None):
"""
Note: contacts is a QuerySet of Contact objects.
"""
# Get "from" phone number.
from_ = get_twilio_from()
# Compile Twilio voice URL and message body
msg_params = {
'pipeline': event.pipeline.name,
'graceid': event.graceid,
'server': settings.SERVER_HOSTNAME,
'url': build_absolute_uri(reverse('view', args=[event.graceid])),
}
if alert_type == "label_added":
msg_params['label'] = label.name
twiml_url = settings.TWIML_BASE_URL + settings.TWIML_BIN[alert_type] + \
"?" + TWIML_ARG_STR[alert_type]
twiml_url = twiml_url.format(**msg_params)
msg_body = TWILIO_MSG_CONTENT[alert_type].format(**msg_params)
# Get message content
msg_kwargs = {}
if alert_type in ['label_added', 'label_removed'] and label:
msg_kwargs['label'] = label.name
msg_body = get_message_content(event_or_superevent, alert_type,
**msg_kwargs)
# Compile Twilio voice URL
twiml_url = compile_twiml_url(event_or_superevent, alert_type,
**msg_kwargs)
# Loop over recipients and make calls and/or texts.
for contact in contacts:
......@@ -81,23 +133,29 @@ def issue_phone_alerts(event, contacts, label=None):
# shouldn't even be able to sign up for phone alerts,
# but this is another safety measure.
logger.warning("External user {0} is somehow signed up for"
" phone alerts".format(contact.user.username))
" phone alerts".format(contact.user.username))
continue
try:
# POST to TwiML bin to make voice call.
if contact.call_phone:
if (contact.phone_method in [contact.__class__.CONTACT_PHONE_CALL,
contact.__class__.CONTACT_PHONE_BOTH]):
# POST to TwiML bin to make voice call.
logger.debug("Calling {0} at {1}".format(contact.user.username,
contact.phone))
twilio_client.calls.create(to=contact.phone, from_=from_,
url=twiml_url, method='GET')
except Exception as e:
logger.exception("Failed to call {0} at {1}.".format(
contact.user.username, contact.phone))
try:
# Create Twilio message.
if contact.text_phone:
if (contact.phone_method in [contact.__class__.CONTACT_PHONE_TEXT,
contact.__class__.CONTACT_PHONE_BOTH]):
logger.debug("Texting {0} at {1}".format(contact.user.username,
contact.phone))
twilio_client.messages.create(to=contact.phone, from_=from_,
body=msg_body)
except Exception as e:
logger.exception("Failed to contact {0} at {1}.".format(
logger.exception("Failed to text {0} at {1}.".format(
contact.user.username, contact.phone))
from django.conf import settings
from django.db.models import Q
from events.shortcuts import is_event
from search.query.labels import filter_for_labels
from superevents.shortcuts import is_superevent
from .models import Contact, Notification
class CreationRecipientGetter(object):
queryset = Notification.objects.all()
def __init__(self, event_or_superevent, **kwargs):
self.es = event_or_superevent
self.is_event_alert = is_event(event_or_superevent)
self.event = event_or_superevent if self.is_event_alert \
else event_or_superevent.preferred_event
self.process_kwargs(**kwargs)
# event_or_superevent queryset - used for filtering by labels
self.es_qs = self.es._meta.model.objects.filter(pk=self.es.pk)
def process_kwargs(self, **kwargs):
pass
def get_category_filter(self):
if self.is_event_alert:
return Q(category=Notification.NOTIFICATION_CATEGORY_EVENT)
return Q(category=Notification.NOTIFICATION_CATEGORY_SUPEREVENT)
def get_far_filter(self):
query = Q(far_threshold__isnull=True)
if self.event.far:
query |= Q(far_threshold__gt=self.event.far)
return query
def get_nscand_filter(self):
if self.event.is_ns_candidate():
return Q()
return Q(ns_candidate=False)
def get_group_filter(self):
if self.is_event_alert:
return Q(groups__isnull=True) | Q(groups=self.event.group)
return Q()
def get_pipeline_filter(self):
if self.is_event_alert:
return Q(pipelines__isnull=True) | Q(pipelines=self.event.pipeline)
return Q()
def get_search_filter(self):
if self.is_event_alert:
return Q(searches__isnull=True) | Q(searches=self.event.search)
return Q()
def get_filter_query(self):
filter_list = [getattr(self, method)() for method in dir(self)
if method.startswith('get_') and method.endswith('_filter')]
if filter_list:
return reduce(Q.__and__, filter_list)
return Q()
def get_trigger_query(self):
return Q()
def filter_for_labels(self, notifications):
notification_pks = []
for n in notifications:
if n.labels.exists() and not n.label_query:
if not set(n.labels.all()).issubset(self.es.labels.all()):
continue
elif n.label_query:
qs_out = filter_for_labels(self.es_qs, n.label_query)
if not qs_out.exists():
continue
notification_pks.append(n.pk)
return Notification.objects.filter(pk__in=notification_pks)
def get_contacts_for_notifications(self, notifications):
# Get contacts; make sure contacts are verified and user is in the
# LVC group (safeguards)
contacts = Contact.objects.filter(notification__in=notifications,
verified=True, user__groups__name=settings.LVC_GROUP) \
.select_related('user')
# Separate into email and phone contacts