diff --git a/gracedb/alerts/forms.py b/gracedb/alerts/forms.py index 3c3603141fb1295304605c38251e63f698ab8917..6933e48313e1c514b5ffd91e4d38d8732bc80fca 100644 --- a/gracedb/alerts/forms.py +++ b/gracedb/alerts/forms.py @@ -1,9 +1,12 @@ +from __future__ import absolute_import from collections import defaultdict import logging -from pyparsing import ParseException +import pyparsing +import textwrap from django import forms from django.core.exceptions import NON_FIELD_ERRORS +from django.db.models import Q from django.forms.utils import ErrorList from django.utils import timezone from django.utils.encoding import force_text @@ -12,152 +15,113 @@ from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from core.forms import MultipleForm -from search.query.labels import parseLabelQuery +from events.models import Group, Search, Label from .models import Notification, Contact +from .utils import parse_label_query # Set up logger logger = logging.getLogger(__name__) -class CleanNotificationFormMixin(object): +############################################################################### +# Notification forms ########################################################## +############################################################################### +class BaseNotificationForm(forms.ModelForm): + """ + Base model for Notification forms. Should not be used on its own + (essentially an abstract model) + """ + class Meta: + model = Notification + fields = ['description'] # dummy placeholder + labels = { + 'far_threshold': 'FAR Threshold (Hz)', + } + help_texts = { + 'contacts': ('If this box is empty, you must create and verify a ' + 'contact.'), + 'label_query': textwrap.dedent("""\ + Label names can be combined with binary AND: ('&' or ',') + or binary OR: '|'. They can also be negated with '~' or '-'. + For N labels, there must be exactly N-1 binary operators. + Parentheses are not allowed. + """).rstrip() + } + + def __init__(self, *args, **kwargs): + user = kwargs.pop('user', None) + super(BaseNotificationForm, self).__init__(*args, **kwargs) + + # Dynamically set contacts queryset to be only contacts that: + # a) belong to the user + # b) are verified + if user is not None: + self.fields['contacts'].queryset = user.contact_set.filter( + verified=True) def clean(self): - data = super(CleanNotificationFormMixin, self).clean() - return data + cleaned_data = super(BaseNotificationForm, self).clean() + + # Dict for holding errors. Keys are class members, + # values are lists of error messages + err_dict = defaultdict(list) + + # Try to get fields from cleaned data + label_query = cleaned_data.get('label_query', None) + labels = cleaned_data.get('labels', None) + + # Can't specify a label from the list and a label query + if label_query is not None and labels is not None: + err_msg = ('Cannot specify both labels and label query, ' + 'choose one or the other.') + err_dict[NON_FIELD_ERRORS].append(err_msg) - def clean_label_query(self): - label_query = self.cleaned_data['label_query'] - return label_query + # If there is a label query, get the labels involved and store them + # in the labels attribute. We use this in the alert generation code + # as an easy way of checking whether a notification might be triggered. + if label_query is not None: + try: + labels = parse_label_query(label_query) + except pyparsing.ParseException: + err_dict['label_query'].append('Invalid label query.') + else: + cleaned_data['labels'] = Label.objects.filter(name__in=labels) + # Raise errors, if any + if err_dict: + raise forms.ValidationError(err_dict) -class SupereventNotificationForm(forms.ModelForm, MultipleForm, - CleanNotificationFormMixin): + return cleaned_data + + +class SupereventNotificationForm(BaseNotificationForm, MultipleForm): key = 'superevent' category = Notification.NOTIFICATION_CATEGORY_SUPEREVENT - class Meta: - model = Notification + class Meta(BaseNotificationForm.Meta): fields = ['description', 'contacts', 'far_threshold', 'labels', 'label_query', 'ns_candidate', 'key_field'] -class EventNotificationForm(forms.ModelForm, MultipleForm, - CleanNotificationFormMixin): +class EventNotificationForm(BaseNotificationForm, MultipleForm): key = 'event' category = Notification.NOTIFICATION_CATEGORY_EVENT - - class Meta: - model = Notification + # Remove 'Test' group + groups = forms.ModelMultipleChoiceField(queryset= + Group.objects.exclude(name='Test')) + # Remove 'MDC' and 'O2VirgoTest' searches + searches = forms.ModelMultipleChoiceField(queryset= + Search.objects.exclude(name__in=['MDC', 'O2VirgoTest'])) + + class Meta(BaseNotificationForm.Meta): fields = ['description', 'contacts', 'far_threshold', 'groups', 'pipelines', 'searches', 'labels', 'label_query', 'ns_candidate', 'key_field'] -def notificationFormFactory(postdata=None, user=None): - class TF(forms.ModelForm): - far_threshold = forms.FloatField(label='FAR Threshold (Hz)', - required=False) - class Meta: - model = Notification - fields = ['contacts', 'pipelines', 'far_threshold', 'labels', 'label_query'] - widgets = {'label_query': forms.TextInput(attrs={'size': 50})} - - help_texts = { - 'label_query': ("Label names can be combined with binary AND: " - "'&' or ','; or binary OR: '|'. For N " - "labels, there must be exactly N-1 binary " - "operators. Parentheses are not allowed. " - "Additionally, any of the labels in a query " - "string can be negated with '~' or '-'. " - "Labels can either be selected with the select" - " box at the top, or a query can be specified," - " <i>but not both</i>."), - } - - contacts = forms.ModelMultipleChoiceField( - queryset=Contact.objects.filter(user=user), - required=True, - help_text="If this box is empty, go back and create a contact first.", - error_messages={'required': 'You must specify at least one contact.'}) - - # XXX should probably override is_valid and check for - # truth of (atypes or labels) - # and set field error attributes appropriately. - - def clean(self, *args, **kwargs): - cleaned_data = super(TF, self).clean(*args, **kwargs) - - # Dict for holding errors. Keys are class members, - # values are lists of error messages - err_dict = defaultdict(list) - - # Can't specify a label from the list and a label query - if (cleaned_data['label_query'] and cleaned_data['labels']): - err_msg = ('Cannot specify both labels and label query, ' - 'choose one or the other.') - err_dict[NON_FIELD_ERRORS].append(err_msg) - - # Notifications currently require a label or a pipeline to be - # specified. In the future, we should also allow the cases which - # have only a FAR threshold or a label query - if not (cleaned_data['labels'] or cleaned_data['pipelines']): - err_msg = ('Choose labels and/or pipelines for this ' - 'notification.') - err_dict[NON_FIELD_ERRORS].append(err_msg) - - # Make sure the label query is valid - if cleaned_data['label_query']: - # now try parsing it - try: - parseLabelQuery(cleaned_data['label_query']) - except ParseException: - err_dict['label_query'].append('Invalid label query') - - # Raise errors, if any - if err_dict: - raise forms.ValidationError(err_dict) - - return cleaned_data - - def as_table(self, *args, **kwargs): - """ - Overriding default as_table method to put non-field errors - at the top of the table. Allows removal of "flash message box". - """ - - # Get non-field errors and remove them from the error list - # to prevent duplicates. - nfe = self.non_field_errors() - self.errors[NON_FIELD_ERRORS] = [] - - # Generate table HTML and add non-field errors to beginning row - table_data = super(TF, self).as_table(*args, **kwargs) - if nfe: - table_data = '\n<tr><td colspan="2">\n' + \ - process_errors(nfe) + '\n</td></tr>\n' + table_data - return mark_safe(table_data) - - if postdata is not None: - return TF(postdata) - else: - return TF() - -def process_errors(err): - """Processes and formats errors in ContactForms.""" - out_errs = [] - if isinstance(err,ErrorList): - for e in err: - out_errs.append('<p class="error">{0}</p>' \ - .format(conditional_escape(e))) - elif isinstance(err,str): - out_errs.append('<p class="error">{0}</p>' \ - .format(conditional_escape(err))) - else: - out_errs.append(force_text(err)) - - return "\n".join(out_errs) - - +############################################################################### +# Contact forms ############################################################### +############################################################################### class PhoneContactForm(forms.ModelForm, MultipleForm): key = 'phone' diff --git a/gracedb/alerts/utils.py b/gracedb/alerts/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..0db9afb91eea639368ea4246b105423b99a0af1e --- /dev/null +++ b/gracedb/alerts/utils.py @@ -0,0 +1,31 @@ +from __future__ import absolute_import +from pyparsing import oneOf, Literal, Optional, ZeroOrMore, StringEnd, Suppress + +from django.db.models import Q + +from events.models import Label + + +OPERATORS = { + 'AND': oneOf(", &"), + 'OR': Literal("|"), + 'NOT': oneOf("- ~"), +} + + +def parse_label_query(s): + """Parses a label query into a list of label names""" + # Parser for one label name + label = oneOf(list(Label.objects.all().values_list('name', flat=True))) + + # "intermediate" parser - between labels should be AND or OR and then + # an optional NOT + im = Suppress((OPERATORS['AND'] ^ OPERATORS['OR']) + + Optional(OPERATORS['NOT'])) + + # Full parser: optional NOT and a label, then zero or more + # "intermediate" + label combos, then string end + labelQ = Suppress(Optional(OPERATORS['NOT'])) + label + \ + ZeroOrMore(im + label) + StringEnd() + + return labelQ.parseString(s).asList() diff --git a/gracedb/alerts/views.py b/gracedb/alerts/views.py index 7eed6139c669fedbe9a0b88c731e7f137103c32d..2d0c93724cf8dcb8e669c51807765d679fbaa816 100644 --- a/gracedb/alerts/views.py +++ b/gracedb/alerts/views.py @@ -5,7 +5,6 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.core.mail import EmailMessage -from django.db.models import Q from django.http import ( HttpResponse, HttpResponseRedirect, HttpResponseNotFound, Http404, HttpResponseForbidden, HttpResponseBadRequest @@ -27,19 +26,16 @@ from core.views import MultipleFormView from events.permission_utils import lvem_user_required, is_external from events.models import Label from ligoauth.decorators import internal_user_required -from search.query.labels import labelQuery from .forms import ( PhoneContactForm, EmailContactForm, VerifyContactForm, EventNotificationForm, SupereventNotificationForm, - notificationFormFactory, ) from .models import Notification, Contact from .phone import get_twilio_from # Set up logger -logger = log = logging.getLogger(__name__) - +logger = logging.getLogger(__name__) @login_required @@ -106,6 +102,12 @@ class CreateNotificationView(MultipleFormView): kwargs['idx'] = idx return kwargs + def get_form_kwargs(self, *args, **kwargs): + kw = super(CreateNotificationView, self).get_form_kwargs( + *args, **kwargs) + kw['user'] = self.request.user + return kw + def form_valid(self, form): if form.cleaned_data.has_key('key_field'): form.cleaned_data.pop('key_field') @@ -139,6 +141,12 @@ class EditNotificationView(UpdateView): else: return SupereventNotificationForm + def get_form_kwargs(self, *args, **kwargs): + kw = super(EditNotificationView, self).get_form_kwargs( + *args, **kwargs) + kw['user'] = self.request.user + return kw + def get_queryset(self): return self.request.user.notification_set.all() @@ -166,66 +174,6 @@ class DeleteNotificationView(DeleteView): return self.request.user.notification_set.all() -@internal_user_required -def create(request): - """Create a notification (Notification) via the web interface""" - - if request.method == "POST": - form = notificationFormFactory(request.POST, user=request.user) - if form.is_valid(): - # Create the Notification - t = Notification(user=request.user) - labels = form.cleaned_data['labels'] - pipelines = form.cleaned_data['pipelines'] - contacts = form.cleaned_data['contacts'] - far_threshold = form.cleaned_data['far_threshold'] - label_query = form.cleaned_data['label_query'] - - # TODO: properly handle negated labels - # If we've got a label query defined for this notification, then we want - # each label mentioned in the query to be listed in the event's - # labels. It would be smarter to make sure the label isn't being - # negated, but we can just leave that for later. - if len(label_query) > 0: - toks = labelQuery(label_query, names=True) - f = Q() - for tok in toks: - # Note that all labels are being combined with OR - if isinstance(tok,Q): - f = f | tok - if len(f)==0: - return HttpResponseBadRequest("Please enter a valid label query.") - labels = Label.objects.filter(f) - - # If the form is valid, then we have at least one contact and - # either a label or pipeline. - # So let's create the contact object. - - # Can't access many-to-many relationships before object is saved - t.save() - - # Now populate fields - try: - t.labels = labels - t.pipelines = pipelines - t.contacts = contacts - t.far_threshold = far_threshold - t.label_query = label_query - t.save() - messages.info(request, 'Created notification: {n}.'.format( - n=t.display())) - except Exception as e: - messages.error(request, ('Error creating notification {n}: ' - '{e}.').format(n=t.display(), e=e)) - t.delete() - - return HttpResponseRedirect(reverse('alerts:index')) - else: - form = notificationFormFactory(user=request.user) - return render(request, 'profile/createNotification.html', - context={"form": form}) - - ############################################################################### # Contact views ############################################################### ############################################################################### diff --git a/gracedb/core/views.py b/gracedb/core/views.py index e6d616878b8e7d793e3510b594982c1ae69d02a3..bd26f7c1242533771dd883c2ebf607bbc1123f35 100644 --- a/gracedb/core/views.py +++ b/gracedb/core/views.py @@ -22,7 +22,8 @@ class MultipleFormView(FormView): def get_forms(self, form_classes=None): if (form_classes is None): form_classes = self.form_classes - return [form(**self.get_form_kwargs(form.key)) for form in form_classes] + return [form(**self.get_form_kwargs(form.key)) for form in + form_classes] def get_form_kwargs(self, form_key): kwargs = { diff --git a/gracedb/templates/alerts/create_notification.html b/gracedb/templates/alerts/create_notification.html index 37c67fd22297230ca95d24fdd95c2bbb32f83f1e..62ab978779fe150c2b9b0f40f3b016f6d1a8c39f 100644 --- a/gracedb/templates/alerts/create_notification.html +++ b/gracedb/templates/alerts/create_notification.html @@ -42,7 +42,7 @@ $(document).ready(function() { <li>Phone and email notifications will not be issued outside of observing runs, in order to avoid potential accidents.</li> </ul> </div> -<div id="tabs" style="min-width: 300px; max-width: 400px; display: none;"> +<div id="tabs" style="min-width: 300px; max-width: 500px; display: none;"> <ul> {% for form in forms %} <li><a href="#tab-{{ forloop.counter }}">{{ form.key|title }} alert</a></li>