Gitlab will migrate to a new storage backend starting 0300 UTC on 2020-04-04. We do not anticipate a maintenance window for this migration. Performance may be impacted over the weekend. Thanks for your patience.

Commit 8cee1ae4 authored by Tanner Prestegard's avatar Tanner Prestegard Committed by GraceDB

Updates to notification form views, cleaning, etc.

* Filter available contacts for a notification to be verified
  contacts belonging to the user
* New better code for handling label queries
* Some better help text for notification forms
* Remove old notification forms and views
parent c0dbe5ab
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'
......
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()
......@@ -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 ###############################################################
###############################################################################
......
......@@ -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 = {
......
......@@ -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>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment