From 4c7bc98391accb60492627258ea57324bb50c820 Mon Sep 17 00:00:00 2001 From: Tanner Prestegard <tanner.prestegard@ligo.org> Date: Wed, 6 Mar 2019 09:43:48 -0600 Subject: [PATCH] Overhaul phone and email alerts --- config/settings/base.py | 14 ++- gracedb/alerts/email.py | 216 ++++++++++++++++++++++------------- gracedb/alerts/main.py | 147 +++++++----------------- gracedb/alerts/phone.py | 152 ++++++++++++++++-------- gracedb/alerts/recipients.py | 179 +++++++++++++++++++++++++++++ gracedb/alerts/utils.py | 15 +++ 6 files changed, 489 insertions(+), 234 deletions(-) create mode 100644 gracedb/alerts/recipients.py diff --git a/config/settings/base.py b/config/settings/base.py index 74e02545c..4f7fc80a1 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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', } diff --git a/gracedb/alerts/email.py b/gracedb/alerts/email.py index 026827375..3abeefe51 100644 --- a/gracedb/alerts/email.py +++ b/gracedb/alerts/email.py @@ -1,5 +1,6 @@ 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() diff --git a/gracedb/alerts/main.py b/gracedb/alerts/main.py index d8b76a5d7..6aef17ee2 100644 --- a/gracedb/alerts/main.py +++ b/gracedb/alerts/main.py @@ -1,103 +1,21 @@ 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) diff --git a/gracedb/alerts/phone.py b/gracedb/alerts/phone.py index 118975393..dd98d1724 100644 --- a/gracedb/alerts/phone.py +++ b/gracedb/alerts/phone.py @@ -1,34 +1,42 @@ 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)) diff --git a/gracedb/alerts/recipients.py b/gracedb/alerts/recipients.py new file mode 100644 index 000000000..bed4db365 --- /dev/null +++ b/gracedb/alerts/recipients.py @@ -0,0 +1,179 @@ +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 + email_recipients = contacts.filter(email__isnull=False) + phone_recipients = contacts.filter(phone__isnull=False) + + return email_recipients, phone_recipients + + def get_recipients(self): + # Get trigger query and apply to get baseline set of notifications + trigger_query = self.get_trigger_query() + base_notifications = self.queryset.filter(trigger_query) + + # Get and apply filter query to trim it down + filter_query = self.get_filter_query() + notifications = base_notifications.filter(filter_query) + + # Do label filtering + final_notifications = self.filter_for_labels(notifications) + + # Get email and phone recipients and return + return self.get_contacts_for_notifications(final_notifications) + + +class UpdateRecipientGetter(CreationRecipientGetter): + + def process_kwargs(self, **kwargs): + self.old_far = kwargs.get('old_far', None) + self.old_nscand = kwargs.get('old_nscand', None) + + def get_trigger_query(self): + # Initial query should match no objects + query = Q(pk__in=[]) + + # Then we add other options that could possibly match + if self.event.far is not None: + if self.old_far is None: + query |= Q(far_threshold__gt=self.event.far) + else: + query |= (Q(far_threshold__lte=self.old_far) & + Q(far_threshold__gt=self.event.far)) + if not self.old_nscand and self.event.is_ns_candidate(): + query |= Q(ns_candidate=True) + return query + + +class LabelAddedRecipientGetter(CreationRecipientGetter): + + def process_kwargs(self, **kwargs): + self.label = kwargs.get('label', None) + if self.label is None: + raise ValueError('label must be provided') + + def get_recipients(self): + + # Any notification that might be triggered by a label_added action + # should have that label in the 'labels' field. This includes + # notifications with a label_query. Part of the Notification creation + # process picks out all labels in the label_query (even negated ones) + # and adds the to the 'labels' field. + base_notifications = self.label.notification_set.all() + + # Get and apply filter query to trim it down + filter_query = self.get_filter_query() + notifications = base_notifications.filter(filter_query) + + # Do label filtering + final_notifications = self.filter_for_labels(notifications) + + # Get email and phone recipients and return + return self.get_contacts_for_notifications(final_notifications) + + +class LabelRemovedRecipientGetter(LabelAddedRecipientGetter): + + def filter_for_labels(self, notifications): + # Only notifications with a label query should be triggered + # by a label_removed alert, since notifications with a + # label set can only have non-negated labels. + notification_pks = [] + for n in notifications: + if 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) + + +# Dict which maps alert types to recipient getter classes +ALERT_TYPE_RECIPIENT_GETTERS = { + 'new': CreationRecipientGetter, + 'update': UpdateRecipientGetter, + 'label_added': LabelAddedRecipientGetter, + 'label_removed': LabelRemovedRecipientGetter, +} diff --git a/gracedb/alerts/utils.py b/gracedb/alerts/utils.py index 0db9afb91..343ec19f2 100644 --- a/gracedb/alerts/utils.py +++ b/gracedb/alerts/utils.py @@ -1,5 +1,6 @@ from __future__ import absolute_import from pyparsing import oneOf, Literal, Optional, ZeroOrMore, StringEnd, Suppress +import re from django.db.models import Q @@ -29,3 +30,17 @@ def parse_label_query(s): ZeroOrMore(im + label) + StringEnd() return labelQ.parseString(s).asList() + + +def convert_superevent_id_to_speech(sid): + """Used for Twilio voice calls""" + grps = list(re.match('^(\w+)(\d{2})(\d{2})(\d{2})(\w+)$', sid).groups()) + + # Add spaces between all letters in prefix and suffix + grps[0] = " ".join(grps[0]) + grps[-1] = " ".join(grps[-1]) + + # Join with spaces, replace leading zeroes with Os + # and make uppercase + twilio_str = " ".join(grps).replace(' 0', ' O').upper() + return twilio_str -- GitLab