From e877e58e7a436bb275be3805d6a35656cd90d259 Mon Sep 17 00:00:00 2001
From: Branson Stephens <branson.stephens@ligo.org>
Date: Fri, 6 Nov 2015 21:21:15 -0600
Subject: [PATCH] Allow for more complex label queries in searches and email
 notifications. This is done by parsing the label query separately and
 applying the filter in a separate step.

---
 gracedb/alert.py                              |  13 +
 gracedb/forms.py                              |   7 +-
 gracedb/query.py                              | 227 ++++++++++++++++--
 gracedb/test/test_label_search.py             |  99 ++++++++
 templates/gracedb/query_help_frag.html        |  26 +-
 templates/profile/createNotification.html     |  12 +
 userprofile/forms.py                          |  17 +-
 .../migrations/0003_trigger_label_query.py    |  19 ++
 userprofile/models.py                         |  11 +-
 userprofile/views.py                          |  28 +++
 10 files changed, 417 insertions(+), 42 deletions(-)
 create mode 100644 gracedb/test/test_label_search.py
 create mode 100644 userprofile/migrations/0003_trigger_label_query.py

diff --git a/gracedb/alert.py b/gracedb/alert.py
index 7a5feaf67..88a9754e5 100644
--- a/gracedb/alert.py
+++ b/gracedb/alert.py
@@ -11,6 +11,10 @@ import logging
 
 from utils import gpsToUtc
 
+from query import filter_for_labels
+
+from gracedb.models import Event
+
 # These imports can be fragile, so they should be brought in only
 # if use of the LVAlert overseer is really intended.
 if settings.USE_LVALERT_OVERSEER:
@@ -62,6 +66,15 @@ def issueAlertForLabel(event, label, doxmpp, serialized_event=None, event_url=No
     triggers = label.trigger_set.filter(pipelines=pipeline)
     triggers = triggers | label.trigger_set.filter(pipelines=None)
     for trigger in triggers:
+        if len(trigger.label_query) > 0:
+            # construct a queryset containing only this event
+            qs = Event.objects.filter(id=event.id)
+            qs = filter_for_labels(qs, trigger.label_query)
+            # If the label query cleans out our query set, we'll continue
+            # without adding the recipient.
+            if qs.count() == 0:
+                continue
+
         for recip in trigger.contacts.all():
             profileRecips.append(recip.email)
 
diff --git a/gracedb/forms.py b/gracedb/forms.py
index 1dae6b981..6ac5ce849 100644
--- a/gracedb/forms.py
+++ b/gracedb/forms.py
@@ -8,7 +8,7 @@ from django.contrib.auth.models import User
 from django.core.exceptions import FieldError
 from django.forms import ModelForm
 
-from query import parseQuery
+from query import parseQuery, filter_for_labels
 from pyparsing import ParseException
 
 htmlEntityStar = "&#9733;"
@@ -24,7 +24,10 @@ class GraceQueryField(forms.CharField):
         from django.db.models import Q
         queryString = forms.CharField.clean(self, queryString)
         try:
-            return Event.objects.filter(parseQuery(queryString)).distinct()
+            #return Event.objects.filter(parseQuery(queryString)).distinct()
+            qs = Event.objects.filter(parseQuery(queryString))
+            qs = filter_for_labels(qs, queryString)
+            return qs.distinct()
         except ParseException, e:
             err = "Error: " + escape(e.pstr[:e.loc]) + errorMarker + escape(e.pstr[e.loc:])
             raise forms.ValidationError(mark_safe(err))
diff --git a/gracedb/query.py b/gracedb/query.py
index 4edc03e2e..e81ef95a6 100644
--- a/gracedb/query.py
+++ b/gracedb/query.py
@@ -19,12 +19,13 @@ nltime = nltime_.setParseAction(lambda toks: toks["calculatedTime"])
 import datetime
 import models
 from django.db.models import Q
+from django.db.models.query import QuerySet
 
 from pyparsing import \
     Word, nums, Literal, CaselessLiteral, delimitedList, Suppress, QuotedString, \
-    Keyword, Combine, Or, Optional, OneOrMore, alphas, alphanums, Regex, \
+    Keyword, Combine, Or, Optional, OneOrMore, ZeroOrMore, alphas, alphanums, Regex, \
     opAssoc, operatorPrecedence, oneOf, \
-    stringStart, stringEnd, FollowedBy
+    stringStart, stringEnd, FollowedBy, ParseResults, ParseException
 
 def maybeRange(name, dbname=None):
     dbname = dbname or name
@@ -86,6 +87,9 @@ runQ = runQ.setParseAction(lambda toks: ("gpstime", Q(gpstime__range=runmap[toks
                           #lambda toks: ("gpstime", Q("gpstime__range": runmap[toks[0]])) )
 
 # Analysis Groups
+# XXX Querying the database at module compile time is a bad idea!
+# See: https://docs.djangoproject.com/en/1.8/topics/testing/overview/
+
 groupNames = [group.name for group in models.Group.objects.all()]
 group = Or(map(CaselessLiteral, groupNames)).setName("analysis group name")
 #groupList = delimitedList(group, delim='|').setName("analysis group list")
@@ -178,24 +182,37 @@ dtrange = dt + Suppress("..") + dt
 createdQ = Optional(Suppress(Keyword("created:"))) + (nltime^nltimeRange^dt^dtrange)
 createdQ = createdQ.setParseAction(maybeRange("created"))
 
-
 # Labels
-labelNames = [l.name for l in models.Label.objects.all()]
-label = Or([CaselessLiteral(n) for n in labelNames]).\
-        setParseAction( lambda toks: Q(labels__name=toks[0]) )
-
-andop   = oneOf(", &").suppress()
-orop    = Literal("|").suppress()
-minusop = oneOf("- ~").suppress()
-
-labelQ_ = operatorPrecedence(label,
-    [(minusop, 1, opAssoc.RIGHT, lambda a,b,toks: ~toks[0][0]),
-     (orop,    2, opAssoc.LEFT,  lambda a,b,toks: reduce(Q.__or__, toks[0].asList(), Q())),
-     (andop,   2, opAssoc.LEFT,  lambda a,b,toks: reduce(Q.__and__, toks[0].asList(), Q())),
-    ]).setParseAction(lambda toks: toks[0])
-
-labelQ = (Optional(Suppress(Keyword("label:"))) + labelQ_.copy())
-labelQ.setParseAction(lambda toks: ("label", toks[0]))
+# NOTE: The label query has been moved inside the parseQuery call to avoid
+# database access at compile time (to get the list of label names).
+# NOTE ALSO: This is an old attempt by Brian to get a more complex label logic
+# search working. It worked for some searches, but not all. That's because, 
+# the method below creates a composite Q object that is applied to each 
+# *individiual* Event, label relationship. So if you search for
+#
+# EM_READY & ADVOK
+#
+# The search will not work correctly since it will look for an event with
+# a label such that the label is named EM_READY and ADVOK. No single label
+# will have both names. filter_for_labels below avoids this problem by applying
+# each label Q filter and combining the resulting querysets as appropriate.
+#
+#labelNames = [l.name for l in models.Label.objects.all()]
+#label = Or([CaselessLiteral(n) for n in labelNames]).\
+#        setParseAction( lambda toks: Q(labels__name=toks[0]) )
+#
+#andop   = oneOf(", &").suppress()
+#orop    = Literal("|").suppress()
+#minusop = oneOf("- ~").suppress()
+#
+#labelQ_ = operatorPrecedence(label,
+#    [(minusop, 1, opAssoc.RIGHT, lambda a,b,toks: ~toks[0][0]),
+#     (orop,    2, opAssoc.LEFT,  lambda a,b,toks: reduce(Q.__or__, toks[0].asList(), Q())),
+#     (andop,   2, opAssoc.LEFT,  lambda a,b,toks: reduce(Q.__and__, toks[0].asList(), Q())),
+#    ]).setParseAction(lambda toks: toks[0])
+#
+#labelQ = (Optional(Suppress(Keyword("label:"))) + labelQ_.copy())
+#labelQ.setParseAction(lambda toks: ("label", toks[0]))
 
 ###########################
 # Query on event attributes
@@ -249,6 +266,10 @@ rangeTerm.setParseAction(lambda toks: Q(**{toks[0]+"__range": toks[1:]}))
 
 term = simpleTerm | rangeTerm
 
+andop   = oneOf(", &").suppress()
+orop    = Literal("|").suppress()
+minusop = oneOf("- ~").suppress()
+
 attrExpressions = operatorPrecedence(term,
     [(minusop, 1, opAssoc.RIGHT, lambda a,b,toks: ~toks[0][0]),
      (orop,    2, opAssoc.LEFT,  lambda a,b,toks: reduce(Q.__or__, toks[0].asList(), Q())),
@@ -273,17 +294,40 @@ ifoQ = ifoListQ | nifoQ
 
 ###########################
 
-#q = (ifoQ | hasfarQ | gidQ | hidQ | tidQ | eidQ | labelQ | atypeQ | groupQ | gpsQ | createdQ | submitterQ | runQ | attributeQ).setName("query term")
-q = (ifoQ | hasfarQ | gidQ | hidQ | tidQ | eidQ | midQ | labelQ | searchQ | pipelineQ | groupQ | gpsQ | createdQ | submitterQ | runQ | attributeQ).setName("query term")
-
 #andTheseTags = ["attr"]
 andTheseTags = ["nevents"]
 
+#--------------------------------------------------------------------------
+# parseQuery now handles all search terms *except* for the labels.
+# The labels have to be handled separately, in filter_for_labels.
+#--------------------------------------------------------------------------
 def parseQuery(s):
+    # labelQ is defined inside in order to avoid a compile-time database query
+    # to get the label names.
+    # Note the parse action for lableQ: Replace all tokens with the empty
+    # string. This basically has the effect of removing any label query terms
+    # from the query string. 
+    labelNames = [l.name for l in models.Label.objects.all()]
+    label = Or([CaselessLiteral(n) for n in labelNames]).\
+            setParseAction( lambda toks: Q(labels__name=toks[0]) )
+    andop   = oneOf(", &")
+    orop    = Literal("|")
+    minusop = oneOf("- ~")
+    op = Or([andop,orop,minusop])
+    oplabel = OneOrMore(op) + label
+    labelQ_ = Optional(minusop) + label + ZeroOrMore(oplabel)
+    labelQ = (Optional(Suppress(Keyword("label:"))) + labelQ_.copy())
+    labelQ.setParseAction(lambda toks: '')
+
+    # Clean the label-related parts of the query out of the query string.
+    s = labelQ.transformString(s)
+
+    # A parser for the non-label-related remainder of the query string.
+    q = (ifoQ | hasfarQ | gidQ | hidQ | tidQ | eidQ | midQ | searchQ | pipelineQ | groupQ | gpsQ | createdQ | submitterQ | runQ | attributeQ).setName("query term")
+
     d={}
     if not s:
         # Empty query return everything not in Test group and not in the MDC group
-        #return ~Q(group__name="Test") 
         return ~Q(group__name="Test") & ~Q(search__name="MDC")
     for (tag, qval) in (stringStart + OneOrMore(q) + stringEnd).parseString(s).asList():
         if tag in andTheseTags:
@@ -318,3 +362,140 @@ def parseQuery(s):
         del d["hid"]
     return reduce(Q.__and__, d.values(), Q())
 
+
+#--------------------------------------------------------------------------
+# Given a query string, separate out the label-related part, and return it
+# as a list of Q objects and separators.
+#--------------------------------------------------------------------------
+def labelQuery(s, names=False):
+    labelNames = [l.name for l in models.Label.objects.all()]
+    label = Or([CaselessLiteral(n) for n in labelNames])
+    # If the filter objects are going to be applied to Lable 
+    # objects to retrieve labels by name, names = True.
+    # This is useful for the label query in userprofile.models.Trigger
+    if names:
+        label.setParseAction( lambda toks: Q(name=toks[0]) )
+    else:
+        label.setParseAction( lambda toks: Q(labels__name=toks[0]) )
+    andop   = oneOf(", &")
+    orop    = Literal("|")
+    minusop = oneOf("- ~")
+    op = Or([andop,orop,minusop])
+    oplabel = OneOrMore(op) + label
+    labelQ_ = Optional(minusop) + label + ZeroOrMore(oplabel)
+    labelQ = (Optional(Suppress(Keyword("label:"))) + labelQ_.copy())
+    toks = labelQ.searchString(s).asList()
+    # This list will have either 1 or 0 elements.
+    if len(toks):
+        return toks[0]
+    return toks
+
+# The following version is used only for validation. Just to check that
+# the query strictly conforms to the requirements of a label query.
+def parseLabelQuery(s):
+    labelNames = [l.name for l in models.Label.objects.all()]
+    label = Or([CaselessLiteral(n) for n in labelNames])
+    andop   = oneOf(", &")
+    orop    = Literal("|")
+    minusop = oneOf("- ~")
+    op = Or([andop,orop,minusop])
+    oplabel = OneOrMore(op) + label
+    labelQ_ = Optional(minusop) + label + ZeroOrMore(oplabel)
+    labelQ = (Optional(Suppress(Keyword("label:"))) + labelQ_.copy())
+    return labelQ.parseString(s).asList()
+
+#--------------------------------------------------------------------------
+# Given a list of the tokens, go through the list until you hit an AND or
+# OR operator. Then apply the operator to the two surrounding query sets
+# and send back a new list. The list will be shorter by 2 elements, since
+# 'QuerySet, op, QuerySet' has been replaced by a single QuerySet.
+#--------------------------------------------------------------------------
+def handle_binary_ops(toks, op="or"):
+
+    # Find the indices of the relevant operators.
+    if op == "or":
+        indices = [i for i, x in enumerate(toks) if x is '|']
+    elif op == "and":
+        indices = [i for i, x in enumerate(toks) if x == '&' or x==',']
+    else:
+        raise ValueError("Unknown operator")
+
+    if len(indices) > 0:
+        # Found the operator we're looking for
+        updated = True
+        i = indices[0]  # index of the first operator in the list
+        leftQS = toks[i-1]
+        rightQS = toks[i+1]
+
+        # Check. The list items surrounding our operator need to be QuerySets
+        if not isinstance(leftQS, QuerySet) or not isinstance(rightQS, QuerySet):
+            raise ValueError("problem with query. Orphaned operator?")
+
+        # Combine the two QuerySets
+        if op=="or":
+            outputQ = leftQS | rightQS
+        elif op=="and":
+            outputQ = leftQS & rightQS
+
+        # Build up the new list of tokens to return. 
+        new_toks = []
+        for j in range(len(toks)):
+            if j == i-1:
+                new_toks.append(outputQ)
+            elif j==i or j==i+1:
+                continue
+            else:
+                new_toks.append(toks[j])
+
+    else:
+        # No such operator found, return the list of tokens unmodified.
+        updated = False
+        new_toks = toks
+
+    return new_toks, updated
+
+#--------------------------------------------------------------------------
+# Given a queryset and a queryString (which may contain label search terms),
+# filter the queryset for those label terms.
+#--------------------------------------------------------------------------
+def filter_for_labels(qs, queryString):
+    import logging
+    if not queryString or len(queryString)==0:
+        return qs
+
+    # Parse the label part of the query string into its individual tokens.
+    toks = labelQuery(queryString)
+    if len(toks)==0:
+        return qs
+
+    # Handle the NOTs first.
+    not_indices = [i for i, x in enumerate(toks) if x == '~' or x=='-']
+    for i in not_indices:
+        if not isinstance(toks[i+1], Q):
+            raise ValueError("NOT operator should preceed a Label name. Bad Query.")
+
+        toks[i+1] = ~toks[i+1]
+
+    # Now that we've applied the NOTs, remove them from the list
+    toks = [x for x in toks if x not in ['-','~']]
+        
+    # Now the list of tokens consists of filter objects and separators. 
+    # So next, we replace the filters with filtered querysets.
+    toks = [ qs.filter(f) if isinstance(f,Q) else f for f in toks ]
+        
+    # Handle the ORs. We take the union of all QuerySets separated by 
+    # OR operators.
+    updated = True
+    while updated:
+        toks, updated = handle_binary_ops(toks,"or")
+
+    # Handle the ANDs. Same kinda thang.
+    updated = True
+    while updated:
+        toks, updated = handle_binary_ops(toks,"and")
+
+    # By this time, the list of tokens should be down to a single QuerySet.
+    if len(toks)>1:
+        raise ValueError("The label query didn't reduce properly.")
+
+    return toks[0]
diff --git a/gracedb/test/test_label_search.py b/gracedb/test/test_label_search.py
new file mode 100644
index 000000000..d7f0db009
--- /dev/null
+++ b/gracedb/test/test_label_search.py
@@ -0,0 +1,99 @@
+from django.test import TestCase
+from django.db.models import Q
+from gracedb.models import Event, Label, Labelling
+from gracedb.models import Group, Pipeline
+from django.contrib.auth.models import User
+from gracedb.query import parseQuery, filter_for_labels
+
+QUERY_CASES = {
+    'all_ors'             : { 'query': 'A_LABEL | B_LABEL | C_LABEL',    'pk_list': [2,3,4,5,6,7,8] },
+    'all_ands'            : { 'query': 'A_LABEL & B_LABEL & C_LABEL',    'pk_list': [8] },
+    'not_a'               : { 'query': '~A_LABEL',                       'pk_list': [1,3,4,7] },
+    'a_and_b'             : { 'query': 'A_LABEL & B_LABEL',              'pk_list': [5,8] },
+    'a_or_b'              : { 'query': 'A_LABEL | B_LABEL',              'pk_list': [ 2,3,5,6,7,8] },
+    'a_or_not_b'          : { 'query': 'A_LABEL | ~B_LABEL',             'pk_list': [1,2,4,5,6,8] },
+    'a_and_not_b'         : { 'query': 'A_LABEL & ~B_LABEL',             'pk_list': [2,6] },
+    'one_or_more_missing' : { 'query': '~A_LABEL | ~B_LABEL | ~C_LABEL', 'pk_list': [1,2,3,4,5,6,7] },
+}
+
+def get_pks_for_query(queryString):
+    qs = Event.objects.filter(parseQuery(queryString))
+    qs = filter_for_labels(qs, queryString)
+    qs = qs.distinct()
+    return [int(obj.id) for obj in qs]
+
+class LabelSearchTestCase(TestCase):
+    def setUp(self):
+
+        a = Label.objects.create(name='A_LABEL')
+        b = Label.objects.create(name='B_LABEL')
+        c = Label.objects.create(name='C_LABEL')
+
+        # Primary keys start with 1. 1 to 8 here.
+        label_lists = [
+            [],
+            [a],
+            [b],
+            [c],
+            [a, b],
+            [a, c],
+            [b, c],
+            [a, b, c],
+        ]
+
+        # The required fields on event are: submitter, group, pipeline
+        submitter = User.objects.create(username='albert.einstein@LIGO.ORG')
+        group     = Group.objects.create(name='Test')
+        pipeline  = Pipeline.objects.create(name='TestPipeline')
+
+        for label_list in label_lists:
+            e = Event.objects.create(submitter=submitter, group=group, pipeline=pipeline)
+            for label in label_list:
+                Labelling.objects.create(event=e, label=label, creator=submitter)
+
+    def test_all_queries(self):
+        for key, d in QUERY_CASES.iteritems():
+            print "Checking %s ... " % key
+            # Explicitly search for test events
+            query = 'Test ' + d['query']
+            self.assertEqual(set(get_pks_for_query(query)), set(d['pk_list']))
+
+    def test_bad_query(self):
+        f = Q(labels__name='A_LABEL') | ~Q(labels__name='B_LABEL')
+        qs = Event.objects.filter(f)
+        results = [int(obj.id) for obj in qs]
+        self.assertEqual(set(results), set([1,2,4,5,6,7,8]))
+
+    def test_other_query(self):
+        f1 = Q(labels__name='A_LABEL') 
+        f2 = ~Q(labels__name='B_LABEL')
+        qs = Event.objects.filter(f1) | Event.objects.filter(f2)
+        results = [int(obj.id) for obj in qs]
+        self.assertEqual(set(results), set([1,2,4,5,6,8]))
+
+    def test_intersect_query(self):
+        f1 = Q(labels__name='A_LABEL') 
+        f2 = ~Q(labels__name='B_LABEL')
+        qs = Event.objects.filter(f1) & Event.objects.filter(f2)
+        results = [int(obj.id) for obj in qs]
+        self.assertEqual(set(results), set([2,6]))
+
+    def test_qs_order_op(self):
+        # This does (A and B) or C
+        f1 = Q(labels__name='A_LABEL') 
+        f2 = Q(labels__name='B_LABEL')
+        f3 = Q(labels__name='C_LABEL')
+        qs = Event.objects.filter(f1) & Event.objects.filter(f2) | Event.objects.filter(f3)
+        results = [int(obj.id) for obj in qs]
+        self.assertEqual(set(results), set([4,5,6,7,8]))
+
+    def test_qs_order_op2(self):
+        # This does A and (B or C)
+        f1 = Q(labels__name='A_LABEL') 
+        f2 = Q(labels__name='B_LABEL')
+        f3 = Q(labels__name='C_LABEL')
+        qs = Event.objects.filter(f2) | Event.objects.filter(f3)
+        qs = Event.objects.filter(f1) & qs
+        results = [int(obj.id) for obj in qs]
+        self.assertEqual(set(results), set([5,6,8]))
+
diff --git a/templates/gracedb/query_help_frag.html b/templates/gracedb/query_help_frag.html
index 21a1226e3..756ffc37f 100644
--- a/templates/gracedb/query_help_frag.html
+++ b/templates/gracedb/query_help_frag.html
@@ -7,7 +7,7 @@
  Relational and range queries can be made on event attributes.
  <ul>
     <li><code>instruments = "H1,L1,V1" &amp; far &lt; 1e-7</code></li>
-    <li><code>singleinspiral.mchirp &gt;= 1.6 &amp; eff_distance in 40.5,55 </code></li>
+    <li><code>singleinspiral.mchirp &gt;= 0.5 &amp; singleinspiral.eff_distance in 0.0,55 </code></li>
     <li><code>(si.channel = "DMT-STRAIN" | si.channel = "DMT-PAIN") &amp; si.snr &lt; 5</code></li>
     <li><code>mb.snr in 1,3 &amp; mb.central_freq &gt; 1000</code></li>
  </ul>
@@ -19,8 +19,7 @@
   keyword optional.
   <ul>
      <li><code>899999000 .. 999999999</code></li>
-     <li><code>gpstime: 999999999</code></li>
-     <li><code>gpstime: 899999000 .. 999999999</code></li>
+     <li><code>gpstime: 899999000.0 .. 999999999.9</code></li>
   </ul>
 
  <h4>By Creation Time</h4>
@@ -32,7 +31,6 @@
   <ul>
      <li><code>created: 2009-10-08 .. 2009-12-04 16:00:00</code></li>
      <li><code>yesterday..now</code></li>
-     <li><code></code></li>
      <li><code>created: 1 week ago .. now</code></li>
   </ul>
 
@@ -51,19 +49,21 @@
    <li>CBC Burst</li>
 <!--   <li>Inspiral CWB</li> -->
    <li>group: Test pipeline: cwb</li>
-   <li>Burst Omega</li>
+   <li>Burst cwb</li>
   </ul>
 
  <h4>By Label</h4>
-  You may request only events that have a certain label.  The <code>label:</code> keyword
-  is optional.  Note that specifying multiple labels is an "OR" operation.  More complex
-  expressiveness is on the 'todo' list.
+  You may request only events with a particular label or set of labels.  The <code>label:</code> keyword
+  is optional. Label names can be combined with binary AND: '&amp;' 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 '-'. 
   <ul>
    <li>label: INJ</li>
-   <li>DQV LUMIN_GO</li>
+   <li>EM_READY &amp; ADVOK</li>
+   <li>H1OK | L1OK &amp; ~INJ &amp; ~DQV</li>
   </ul>
-  Valid labels are:
-    cWB_s, cWB_r, EM_READY, SWIFT_NO, SWIFT_GO, LUMIN_NO, LUMIN_GO, DQV, INJ
+  Labels in current use are:
+    INJ, DQV, EM_READY, PE_READY, H1NO, H1OK, H1OPS, L1NO, L1OK, L1OPS, ADVREQ, ADVOK, ADVNO
 
 
  <h4>By Submitter</h4>
@@ -73,8 +73,8 @@
   that ought to be remedied.
   <ul>
     <li>"waveburst"
-    <li>"gstlalcbc"  "gdb-processor"
-    <li>submitter: "joss.whedon@ligo.org" submitter: "gdb_processor"
+    <li>"gstlalcbc"  "gracedb.processor"
+    <li>submitter: "joss.whedon@ligo.org" submitter: "gracedb.processor"
   </ul>
 
 </div>
diff --git a/templates/profile/createNotification.html b/templates/profile/createNotification.html
index a5a81e1d9..47dd161b2 100644
--- a/templates/profile/createNotification.html
+++ b/templates/profile/createNotification.html
@@ -12,4 +12,16 @@
     </table>
     <input type="submit" value="Submit"/>
 </form>
+
+{% if creating == 'Notification' %}
+<p> <b>NOTE:</b> 
+For the label query, label names can be combined with binary AND: '&amp;' 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>. 
+</p>
+
+{% endif %}
+
 {% endblock %}
diff --git a/userprofile/forms.py b/userprofile/forms.py
index 26208a619..a6f8a3209 100644
--- a/userprofile/forms.py
+++ b/userprofile/forms.py
@@ -1,6 +1,9 @@
 from django import forms
 from models import Trigger, Contact
 
+from gracedb.query import parseLabelQuery
+from gracedb.pyparsing import ParseException
+
 def triggerFormFactory(postdata=None, user=None):
     class TF(forms.ModelForm):
         farThresh = forms.FloatField(label='FAR Threshold (Hz)', required=False,
@@ -8,6 +11,7 @@ def triggerFormFactory(postdata=None, user=None):
         class Meta:
             model = Trigger
             exclude = ['user', 'triggerType']
+            widgets = {'label_query': forms.TextInput(attrs={'size': 50})} 
 
         contacts = forms.ModelMultipleChoiceField(
                         queryset=Contact.objects.filter(user=user),
@@ -18,18 +22,27 @@ def triggerFormFactory(postdata=None, user=None):
         # truth of (atypes or labels)
         # and set field error attributes appropriately.
 
+        def clean(self):
+            cleaned_data = super(TF, self).clean()
+            label_query = self.cleaned_data['label_query']
+            if len(label_query) > 0:
+                # now try parsing it
+                try:
+                    parseLabelQuery(label_query)
+                except ParseException:
+                    raise forms.ValidationError("Invalid label query.")
+            return cleaned_data
+
     if postdata is not None:
         return TF(postdata)
     else:
         return TF()
 
-
 class TriggerForm(forms.ModelForm):
     class Meta:
         model = Trigger
         exclude = ['user', 'triggerType']
 
-
 class ContactForm(forms.ModelForm):
     class Meta:
         model = Contact
diff --git a/userprofile/migrations/0003_trigger_label_query.py b/userprofile/migrations/0003_trigger_label_query.py
new file mode 100644
index 000000000..ee79a23b0
--- /dev/null
+++ b/userprofile/migrations/0003_trigger_label_query.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('userprofile', '0002_auto_20150708_1134'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='trigger',
+            name='label_query',
+            field=models.CharField(max_length=100, blank=True),
+        ),
+    ]
diff --git a/userprofile/models.py b/userprofile/models.py
index b6cd5503e..3642891f2 100644
--- a/userprofile/models.py
+++ b/userprofile/models.py
@@ -5,7 +5,6 @@ from gracedb.models import Label, Pipeline
 
 from django.contrib.auth.models import User
 
-
 #class Notification(models.Model):
 #    user = models.ForeignKey(User, null=False)
 #    onLabel = models.ManyToManyField(Label, blank=True)
@@ -33,6 +32,7 @@ class Trigger(models.Model):
     pipelines = models.ManyToManyField(Pipeline, blank=True)
     contacts = models.ManyToManyField(Contact, blank=True)
     farThresh = models.FloatField(blank=True, null=True)
+    label_query = models.CharField(max_length=100, blank=True)
 
     def __unicode__(self):
         return (u"%s %s: %s") % (
@@ -45,9 +45,16 @@ class Trigger(models.Model):
         thresh = ""
         if self.farThresh:
             thresh = " & (far < %s)" % self.farThresh
+
+        if self.label_query:
+            label_disp = self.label_query
+        else:
+            label_disp = "|".join([a.name for a in self.labels.all()]) or "creating"
+
         return ("(%s) & (%s)%s -> %s") % (
             "|".join([a.name for a in self.pipelines.all()]) or "any pipeline",
-            "|".join([a.name for a in self.labels.all()]) or "creating",
+            label_disp,
             thresh,
             ",".join([x.desc for x in self.contacts.all()])
         )
+
diff --git a/userprofile/views.py b/userprofile/views.py
index ee7a2cd09..fa07e9a16 100644
--- a/userprofile/views.py
+++ b/userprofile/views.py
@@ -2,6 +2,7 @@
 from django.http import HttpResponse
 from django.http import HttpResponseRedirect, HttpResponseNotFound
 from django.http import Http404, HttpResponseForbidden
+from django.http import HttpResponseBadRequest
 
 from django.core.urlresolvers import reverse 
 from django.contrib.auth.models import User
@@ -16,6 +17,10 @@ from gracedb.permission_utils import internal_user_required, lvem_user_required
 
 from datetime import datetime
 
+from gracedb.query import labelQuery
+from gracedb.models import Label
+from django.db.models import Q
+
 # Let's let everybody onto the index view.
 #@internal_user_required
 def index(request):
@@ -52,6 +57,28 @@ def create(request):
             pipelines = form.cleaned_data['pipelines']
             contacts = form.cleaned_data['contacts']
             farThresh = form.cleaned_data['farThresh']
+            label_query = form.cleaned_data['label_query']
+
+
+            if len(label_query) > 0 and labels.count() > 0:
+                msg = "Cannot both select labels and define label query. Choose one or the other." 
+                return HttpResponseBadRequest(msg)
+
+            # If we've got a label query defined for this trigger, then we want 
+            # each label mentioned in the query to be listed in the events 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 contacts and (labels or pipelines):
                 t.save() # Need an id before relations can be set.
@@ -60,6 +87,7 @@ def create(request):
                     t.pipelines = pipelines
                     t.contacts = contacts
                     t.farThresh = farThresh
+                    t.label_query = label_query
                 except:
                     t.delete()
                 t.save()
-- 
GitLab