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 = "★" @@ -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" & far < 1e-7</code></li> - <li><code>singleinspiral.mchirp >= 1.6 & eff_distance in 40.5,55 </code></li> + <li><code>singleinspiral.mchirp >= 0.5 & singleinspiral.eff_distance in 0.0,55 </code></li> <li><code>(si.channel = "DMT-STRAIN" | si.channel = "DMT-PAIN") & si.snr < 5</code></li> <li><code>mb.snr in 1,3 & mb.central_freq > 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: '&' 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 & ADVOK</li> + <li>H1OK | L1OK & ~INJ & ~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: '&' 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