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>