Merge pull request #6144 from wwitzel3/issue-5745

Support dynamic Inventory
This commit is contained in:
Wayne Witzel III
2017-05-02 13:28:48 -04:00
committed by GitHub
9 changed files with 372 additions and 214 deletions

View File

@@ -45,6 +45,8 @@ from awx.main.fields import ImplicitRoleField
from awx.main.utils import ( from awx.main.utils import (
get_type_for_model, get_model_for_type, timestamp_apiformat, get_type_for_model, get_model_for_type, timestamp_apiformat,
camelcase_to_underscore, getattrd, parse_yaml_or_json) camelcase_to_underscore, getattrd, parse_yaml_or_json)
from awx.main.utils.filters import DynamicFilter
from awx.main.validators import vars_validate_or_raise from awx.main.validators import vars_validate_or_raise
from awx.conf.license import feature_enabled from awx.conf.license import feature_enabled
@@ -1104,7 +1106,7 @@ class InventorySerializer(BaseSerializerWithVariables):
class Meta: class Meta:
model = Inventory model = Inventory
fields = ('*', 'organization', 'variables', 'has_active_failures', fields = ('*', 'organization', 'kind', 'host_filter', 'variables', 'has_active_failures',
'total_hosts', 'hosts_with_active_failures', 'total_groups', 'total_hosts', 'hosts_with_active_failures', 'total_groups',
'groups_with_active_failures', 'has_inventory_sources', 'groups_with_active_failures', 'has_inventory_sources',
'total_inventory_sources', 'inventory_sources_with_failures') 'total_inventory_sources', 'inventory_sources_with_failures')
@@ -1136,6 +1138,17 @@ class InventorySerializer(BaseSerializerWithVariables):
ret['organization'] = None ret['organization'] = None
return ret return ret
def validate(self, attrs):
kind = attrs.get('kind', 'standard')
if kind == 'dynamic':
host_filter = attrs.get('host_filter')
if host_filter is not None:
try:
DynamicFilter().query_from_string(host_filter)
except RuntimeError, e:
raise models.base.ValidationError(e)
return super(InventorySerializer, self).validate(attrs)
class InventoryDetailSerializer(InventorySerializer): class InventoryDetailSerializer(InventorySerializer):

View File

@@ -72,6 +72,8 @@ from awx.main.utils import * # noqa
from awx.main.utils import ( from awx.main.utils import (
callback_filter_out_ansible_extra_vars callback_filter_out_ansible_extra_vars
) )
from awx.main.utils.filters import DynamicFilter
from awx.api.permissions import * # noqa from awx.api.permissions import * # noqa
from awx.api.renderers import * # noqa from awx.api.renderers import * # noqa
from awx.api.serializers import * # noqa from awx.api.serializers import * # noqa
@@ -79,7 +81,6 @@ from awx.api.metadata import RoleMetadata
from awx.main.consumers import emit_channel_notification from awx.main.consumers import emit_channel_notification
from awx.main.models.unified_jobs import ACTIVE_STATES from awx.main.models.unified_jobs import ACTIVE_STATES
from awx.main.scheduler.tasks import run_job_complete from awx.main.scheduler.tasks import run_job_complete
from awx.main.fields import DynamicFilterField
logger = logging.getLogger('awx.api.views') logger = logging.getLogger('awx.api.views')
@@ -1685,6 +1686,15 @@ class InventoryDetail(RetrieveUpdateDestroyAPIView):
model = Inventory model = Inventory
serializer_class = InventoryDetailSerializer serializer_class = InventoryDetailSerializer
def update(self, request, *args, **kwargs):
obj = self.get_object()
kind = self.request.data.get('kind') or kwargs.get('kind')
# Do not allow changes to an Inventory kind.
if kind is not None and obj.kind != kind:
return self.http_method_not_allowed(request, *args, **kwargs)
return super(InventoryDetail, self).update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
with ignore_inventory_computed_fields(): with ignore_inventory_computed_fields():
with ignore_inventory_group_removal(): with ignore_inventory_group_removal():
@@ -1767,7 +1777,7 @@ class HostList(ListCreateAPIView):
qs = super(HostList, self).get_queryset() qs = super(HostList, self).get_queryset()
filter_string = self.request.query_params.get('host_filter', None) filter_string = self.request.query_params.get('host_filter', None)
if filter_string: if filter_string:
filter_q = DynamicFilterField.filter_string_to_q(filter_string) filter_q = DynamicFilter.query_from_string(filter_string)
qs = qs.filter(filter_q) qs = qs.filter(filter_q)
return qs return qs

View File

@@ -4,10 +4,7 @@
# Python # Python
import copy import copy
import json import json
import re
import sys
import six import six
from pyparsing import infixNotation, opAssoc, Optional, Literal, CharsNotIn
from jinja2 import Environment, StrictUndefined from jinja2 import Environment, StrictUndefined
from jinja2.exceptions import UndefinedError from jinja2.exceptions import UndefinedError
@@ -28,7 +25,6 @@ from django.db.models.fields.related import (
ReverseManyRelatedObjectsDescriptor, ReverseManyRelatedObjectsDescriptor,
) )
from django.utils.encoding import smart_text from django.utils.encoding import smart_text
from django.db.models import Q
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
# jsonschema # jsonschema
@@ -39,6 +35,7 @@ from jsonfield import JSONField as upstream_JSONField
from jsonbfield.fields import JSONField as upstream_JSONBField from jsonbfield.fields import JSONField as upstream_JSONBField
# AWX # AWX
from awx.main.utils.filters import DynamicFilter
from awx.main.models.rbac import batch_role_ancestor_rebuilding, Role from awx.main.models.rbac import batch_role_ancestor_rebuilding, Role
from awx.main import utils from awx.main import utils
@@ -332,202 +329,17 @@ class ImplicitRoleField(models.ForeignKey):
Role.rebuild_role_ancestor_list([], child_ids) Role.rebuild_role_ancestor_list([], child_ids)
unicode_spaces = [unichr(c) for c in xrange(sys.maxunicode) if unichr(c).isspace()]
unicode_spaces_other = unicode_spaces + [u'(', u')', u'=', u'"']
def string_to_type(t):
if t == u'true':
return True
elif t == u'false':
return False
if re.search('^[-+]?[0-9]+$',t):
return int(t)
if re.search('^[-+]?[0-9]+\.[0-9]+$',t):
return float(t)
return t
class DynamicFilterField(models.TextField): class DynamicFilterField(models.TextField):
SEARCHABLE_RELATIONSHIP = 'ansible_facts' def get_prep_value(self, value):
# Change any false value to none.
class BoolOperand(object): # https://docs.python.org/2/library/stdtypes.html#truth-value-testing
def __init__(self, t): if not value:
kwargs = dict() return None
k, v = self._extract_key_value(t)
k, v = self._json_path_to_contains(k, v)
kwargs[k] = v
self.result = Q(**kwargs)
def strip_quotes_traditional_logic(self, v):
if type(v) is unicode and v.startswith('"') and v.endswith('"'):
return v[1:-1]
return v
def strip_quotes_json_logic(self, v):
if type(v) is unicode and v.startswith('"') and v.endswith('"') and v != u'"null"':
return v[1:-1]
return v
'''
TODO: We should be able to express this in the grammar and let
pyparsing do the heavy lifting.
TODO: separate django filter requests from our custom json filter
request so we don't process the key any. This could be
accomplished using a whitelist or introspecting the
relationship refered to to see if it's a jsonb type.
'''
def _json_path_to_contains(self, k, v):
if not k.startswith(DynamicFilterField.SEARCHABLE_RELATIONSHIP):
v = self.strip_quotes_traditional_logic(v)
return (k, v)
# Strip off leading relationship key
if k.startswith(DynamicFilterField.SEARCHABLE_RELATIONSHIP + '__'):
strip_len = len(DynamicFilterField.SEARCHABLE_RELATIONSHIP) + 2
else:
strip_len = len(DynamicFilterField.SEARCHABLE_RELATIONSHIP)
k = k[strip_len:]
pieces = k.split(u'__')
assembled_k = u'%s__contains' % (DynamicFilterField.SEARCHABLE_RELATIONSHIP)
assembled_v = None
last_v = None
last_kv = None
for i, piece in enumerate(pieces):
new_kv = dict()
if piece.endswith(u'[]'):
new_v = []
new_kv[piece[0:-2]] = new_v
else:
new_v = dict()
new_kv[piece] = new_v
if last_kv is None:
assembled_v = new_kv
elif type(last_v) is list:
last_v.append(new_kv)
elif type(last_v) is dict:
last_kv[last_kv.keys()[0]] = new_kv
last_v = new_v
last_kv = new_kv
v = self.strip_quotes_json_logic(v)
if type(last_v) is list:
last_v.append(v)
elif type(last_v) is dict:
last_kv[last_kv.keys()[0]] = v
return (assembled_k, assembled_v)
def _extract_key_value(self, t):
t_len = len(t)
k = None
v = None
# key
# "something"=
v_offset = 2
if t_len >= 2 and t[0] == "\"" and t[2] == "\"":
k = t[1]
v_offset = 4
# something=
else:
k = t[0]
# value
# ="something"
if t_len > (v_offset + 2) and t[v_offset] == "\"" and t[v_offset + 2] == "\"":
v = u'"' + unicode(t[v_offset + 1]) + u'"'
#v = t[v_offset + 1]
# empty ""
elif t_len > (v_offset + 1):
v = u""
# no ""
else:
v = string_to_type(t[v_offset])
return (k, v)
class BoolBinOp(object):
def __init__(self, t):
self.result = None
i = 2
while i < len(t[0]):
if not self.result:
self.result = t[0][0].result
right = t[0][i].result
self.result = self.execute_logic(self.result, right)
i += 2
class BoolAnd(BoolBinOp):
def execute_logic(self, left, right):
return left & right
class BoolOr(BoolBinOp):
def execute_logic(self, left, right):
return left | right
class BoolNot(object):
def __init__(self, t):
self.right = t[0][1].result
self.result = self.execute_logic(self.right)
def execute_logic(self, right):
return ~right
@classmethod
def filter_string_to_q(cls, filter_string):
'''
TODO:
* handle values with " via: a.b.c.d="hello\"world"
* handle keys with " via: a.\"b.c="yeah"
* handle key with __ in it
'''
filter_string_raw = filter_string
filter_string = unicode(filter_string)
atom = CharsNotIn(unicode_spaces_other)
atom_inside_quotes = CharsNotIn(u'"')
atom_quoted = Literal('"') + Optional(atom_inside_quotes) + Literal('"')
EQUAL = Literal('=')
grammar = ((atom_quoted | atom) + EQUAL + Optional((atom_quoted | atom)))
grammar.setParseAction(cls.BoolOperand)
boolExpr = infixNotation(grammar, [
("not", 1, opAssoc.RIGHT, cls.BoolNot),
("and", 2, opAssoc.LEFT, cls.BoolAnd),
("or", 2, opAssoc.LEFT, cls.BoolOr),
])
try: try:
res = boolExpr.parseString('(' + filter_string + ')') DynamicFilter().query_from_string(value)
#except ParseException as e: except RuntimeError, e:
except Exception: raise models.base.ValidationError(e)
raise RuntimeError(u"Invalid query %s" % filter_string_raw) return super(DynamicFilterField, self).get_prep_value(value)
if len(res) > 0:
return res[0].result
raise RuntimeError("Parsing the filter_string %s went terribly wrong" % filter_string)
class JSONSchemaField(JSONBField): class JSONSchemaField(JSONBField):

View File

@@ -9,6 +9,10 @@ from django.utils.timezone import now
from django.db.models import Sum from django.db.models import Sum
from django.conf import settings from django.conf import settings
from awx.main.utils.filters import DynamicFilter
___all__ = ['HostManager', 'InstanceManager']
class HostManager(models.Manager): class HostManager(models.Manager):
"""Custom manager class for Hosts model.""" """Custom manager class for Hosts model."""
@@ -20,6 +24,26 @@ class HostManager(models.Manager):
except NotImplementedError: # For unit tests only, SQLite doesn't support distinct('name') except NotImplementedError: # For unit tests only, SQLite doesn't support distinct('name')
return len(set(self.values_list('name', flat=True))) return len(set(self.values_list('name', flat=True)))
def get_queryset(self):
"""When the parent instance of the host query set has a `kind` of dynamic and a `host_filter`
set. Use the `host_filter` to generate the queryset for the hosts.
"""
qs = super(HostManager, self).get_queryset()
if (hasattr(self, 'instance') and
hasattr(self.instance, 'host_filter') and
hasattr(self.instance, 'kind')):
if self.instance.kind == 'dynamic' and self.instance.host_filter is not None:
q = DynamicFilter.query_from_string(self.instance.host_filter)
# If we are using host_filters, disable the core_filters, this allows
# us to access all of the available Host entries, not just the ones associated
# with a specific FK/relation.
#
# If we don't disable this, a filter of {'inventory': self.instance} gets automatically
# injected by the related object mapper.
self.core_filters = {}
return qs.filter(q)
return qs
class InstanceManager(models.Manager): class InstanceManager(models.Manager):
"""A custom manager class for the Instance model. """A custom manager class for the Instance model.

View File

@@ -36,6 +36,16 @@ class Migration(migrations.Migration):
name='inventory', name='inventory',
field=models.ForeignKey(related_name='inventory_sources', default=None, to='main.Inventory', null=True), field=models.ForeignKey(related_name='inventory_sources', default=None, to='main.Inventory', null=True),
), ),
migrations.AddField(
model_name='inventory',
name='host_filter',
field=awx.main.fields.DynamicFilterField(default=None, help_text='Filter that will be applied to the hosts of this inventory.', null=True, blank=True),
),
migrations.AddField(
model_name='inventory',
name='kind',
field=models.CharField(default=b'standard', help_text='Kind of inventory being represented.', max_length=32, choices=[(b'standard', 'Hosts have a direct link to this inventory.'), (b'dynamic', 'Hosts for inventory generated using the host_filter property.')]),
),
# Facts # Facts
migrations.AlterField( migrations.AlterField(

View File

@@ -20,7 +20,11 @@ from django.utils.timezone import now
# AWX # AWX
from awx.api.versioning import reverse from awx.api.versioning import reverse
from awx.main.constants import CLOUD_PROVIDERS from awx.main.constants import CLOUD_PROVIDERS
from awx.main.fields import ImplicitRoleField, JSONBField from awx.main.fields import (
ImplicitRoleField,
JSONBField,
DynamicFilterField,
)
from awx.main.managers import HostManager from awx.main.managers import HostManager
from awx.main.models.base import * # noqa from awx.main.models.base import * # noqa
from awx.main.models.unified_jobs import * # noqa from awx.main.models.unified_jobs import * # noqa
@@ -42,6 +46,11 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin):
an inventory source contains lists and hosts. an inventory source contains lists and hosts.
''' '''
KIND_CHOICES = [
('standard', _('Hosts have a direct link to this inventory.')),
('dynamic', _('Hosts for inventory generated using the host_filter property.')),
]
class Meta: class Meta:
app_label = 'main' app_label = 'main'
verbose_name_plural = _('inventories') verbose_name_plural = _('inventories')
@@ -99,6 +108,20 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin):
editable=False, editable=False,
help_text=_('Number of external inventory sources in this inventory with failures.'), help_text=_('Number of external inventory sources in this inventory with failures.'),
) )
kind = models.CharField(
max_length=32,
choices=KIND_CHOICES,
blank=False,
default='standard',
help_text=_('Kind of inventory being represented.'),
)
host_filter = DynamicFilterField(
blank=True,
null=True,
default=None,
help_text=_('Filter that will be applied to the hosts of this inventory.'),
)
admin_role = ImplicitRoleField( admin_role = ImplicitRoleField(
parent_role='organization.admin_role', parent_role='organization.admin_role',
) )

View File

@@ -2,7 +2,11 @@ import pytest
import mock import mock
# AWX # AWX
from awx.main.models import InventorySource, InventoryUpdate from awx.main.models import (
Inventory,
InventorySource,
InventoryUpdate,
)
@pytest.mark.django_db @pytest.mark.django_db
@@ -31,3 +35,56 @@ class TestSCMUpdateFeatures:
scm_inventory_source.description = "I'm testing this!" scm_inventory_source.description = "I'm testing this!"
scm_inventory_source.save() scm_inventory_source.save()
assert not mck_update.called assert not mck_update.called
@pytest.fixture
def setup_ec2_gce(organization):
ec2_inv = Inventory(name='test_ec2', organization=organization)
ec2_inv.save()
ec2_source = ec2_inv.inventory_sources.create(name='test_ec2_source', source='ec2')
for i in range(2):
ec2_host = ec2_inv.hosts.create(name='test_ec2_{0}'.format(i))
ec2_host.inventory_sources.add(ec2_source)
ec2_inv.save()
gce_inv = Inventory(name='test_gce', organization=organization)
gce_inv.save()
gce_source = gce_inv.inventory_sources.create(name='test_gce_source', source='gce')
gce_host = gce_inv.hosts.create(name='test_gce_host')
gce_host.inventory_sources.add(gce_source)
gce_inv.save()
@pytest.mark.django_db
class TestHostManager:
def test_host_filter_change(self, setup_ec2_gce, organization):
dynamic_inventory = Inventory(name='dynamic',
kind='dynamic',
organization=organization,
host_filter='inventory_sources__source=ec2')
dynamic_inventory.save()
assert len(dynamic_inventory.hosts.all()) == 2
dynamic_inventory.host_filter = 'inventory_sources__source=gce'
dynamic_inventory.save()
assert len(dynamic_inventory.hosts.all()) == 1
def test_host_filter_not_dynamic(self, setup_ec2_gce, organization):
dynamic_inventory = Inventory(name='dynamic',
organization=organization,
host_filter='inventory_sources__source=ec2')
assert len(dynamic_inventory.hosts.all()) == 0
def test_host_objects_manager(self, setup_ec2_gce, organization):
dynamic_inventory = Inventory(kind='dynamic',
name='dynamic',
organization=organization,
host_filter='inventory_sources__source=ec2')
dynamic_inventory.save()
hosts = dynamic_inventory.hosts.all()
assert len(hosts) == 2
assert hosts[0].inventory_sources.first().source == 'ec2'
assert hosts[1].inventory_sources.first().source == 'ec2'

View File

@@ -3,14 +3,13 @@
import pytest import pytest
# AWX # AWX
from awx.main.fields import DynamicFilterField from awx.main.utils.filters import DynamicFilter
# Django # Django
from django.db.models import Q from django.db.models import Q
class TestDynamicFilterQueryFromString():
class TestDynamicFilterFieldFilterStringToQ():
@pytest.mark.parametrize("filter_string,q_expected", [ @pytest.mark.parametrize("filter_string,q_expected", [
('facts__facts__blank=""', Q(**{u"facts__facts__blank": u""})), ('facts__facts__blank=""', Q(**{u"facts__facts__blank": u""})),
('"facts__facts__ space "="f"', Q(**{u"facts__facts__ space ": u"f"})), ('"facts__facts__ space "="f"', Q(**{u"facts__facts__ space ": u"f"})),
@@ -24,7 +23,7 @@ class TestDynamicFilterFieldFilterStringToQ():
#('a__b\"__c="true"', Q(**{u"a__b\"__c": "true"})), #('a__b\"__c="true"', Q(**{u"a__b\"__c": "true"})),
]) ])
def test_query_generated(self, filter_string, q_expected): def test_query_generated(self, filter_string, q_expected):
q = DynamicFilterField.filter_string_to_q(filter_string) q = DynamicFilter.query_from_string(filter_string)
assert unicode(q) == unicode(q_expected) assert unicode(q) == unicode(q_expected)
@pytest.mark.parametrize("filter_string", [ @pytest.mark.parametrize("filter_string", [
@@ -33,7 +32,7 @@ class TestDynamicFilterFieldFilterStringToQ():
]) ])
def test_invalid_filter_strings(self, filter_string): def test_invalid_filter_strings(self, filter_string):
with pytest.raises(RuntimeError) as e: with pytest.raises(RuntimeError) as e:
DynamicFilterField.filter_string_to_q(filter_string) DynamicFilter.query_from_string(filter_string)
assert e.value.message == u"Invalid query " + filter_string assert e.value.message == u"Invalid query " + filter_string
@pytest.mark.parametrize("filter_string,q_expected", [ @pytest.mark.parametrize("filter_string,q_expected", [
@@ -41,7 +40,7 @@ class TestDynamicFilterFieldFilterStringToQ():
(u'(ansible_facts__a=abc\u1F5E3def)', Q(**{u"ansible_facts__contains": {u"a": u"abc\u1F5E3def"}})), (u'(ansible_facts__a=abc\u1F5E3def)', Q(**{u"ansible_facts__contains": {u"a": u"abc\u1F5E3def"}})),
]) ])
def test_unicode(self, filter_string, q_expected): def test_unicode(self, filter_string, q_expected):
q = DynamicFilterField.filter_string_to_q(filter_string) q = DynamicFilter.query_from_string(filter_string)
assert unicode(q) == unicode(q_expected) assert unicode(q) == unicode(q_expected)
@pytest.mark.parametrize("filter_string,q_expected", [ @pytest.mark.parametrize("filter_string,q_expected", [
@@ -55,7 +54,7 @@ class TestDynamicFilterFieldFilterStringToQ():
('a=b or a=d or a=e or a=z and b=h and b=i and b=j and b=k', Q(**{u"a": u"b"}) | Q(**{u"a": u"d"}) | Q(**{u"a": u"e"}) | Q(**{u"a": u"z"}) & Q(**{u"b": u"h"}) & Q(**{u"b": u"i"}) & Q(**{u"b": u"j"}) & Q(**{u"b": u"k"})) ('a=b or a=d or a=e or a=z and b=h and b=i and b=j and b=k', Q(**{u"a": u"b"}) | Q(**{u"a": u"d"}) | Q(**{u"a": u"e"}) | Q(**{u"a": u"z"}) & Q(**{u"b": u"h"}) & Q(**{u"b": u"i"}) & Q(**{u"b": u"j"}) & Q(**{u"b": u"k"}))
]) ])
def test_boolean_parenthesis(self, filter_string, q_expected): def test_boolean_parenthesis(self, filter_string, q_expected):
q = DynamicFilterField.filter_string_to_q(filter_string) q = DynamicFilter.query_from_string(filter_string)
assert unicode(q) == unicode(q_expected) assert unicode(q) == unicode(q_expected)
@pytest.mark.parametrize("filter_string,q_expected", [ @pytest.mark.parametrize("filter_string,q_expected", [
@@ -75,7 +74,7 @@ class TestDynamicFilterFieldFilterStringToQ():
#('a__b\"__c="true"', Q(**{u"a__b\"__c": "true"})), #('a__b\"__c="true"', Q(**{u"a__b\"__c": "true"})),
]) ])
def test_contains_query_generated(self, filter_string, q_expected): def test_contains_query_generated(self, filter_string, q_expected):
q = DynamicFilterField.filter_string_to_q(filter_string) q = DynamicFilter.query_from_string(filter_string)
assert unicode(q) == unicode(q_expected) assert unicode(q) == unicode(q_expected)
@pytest.mark.parametrize("filter_string,q_expected", [ @pytest.mark.parametrize("filter_string,q_expected", [
@@ -85,7 +84,7 @@ class TestDynamicFilterFieldFilterStringToQ():
#('a__b\"__c="true"', Q(**{u"a__b\"__c": "true"})), #('a__b\"__c="true"', Q(**{u"a__b\"__c": "true"})),
]) ])
def test_contains_query_generated_unicode(self, filter_string, q_expected): def test_contains_query_generated_unicode(self, filter_string, q_expected):
q = DynamicFilterField.filter_string_to_q(filter_string) q = DynamicFilter.query_from_string(filter_string)
assert unicode(q) == unicode(q_expected) assert unicode(q) == unicode(q_expected)
@pytest.mark.parametrize("filter_string,q_expected", [ @pytest.mark.parametrize("filter_string,q_expected", [
@@ -93,7 +92,7 @@ class TestDynamicFilterFieldFilterStringToQ():
('ansible_facts__c="null"', Q(**{u"ansible_facts__contains": {u"c": u"\"null\""}})), ('ansible_facts__c="null"', Q(**{u"ansible_facts__contains": {u"c": u"\"null\""}})),
]) ])
def test_contains_query_generated_null(self, filter_string, q_expected): def test_contains_query_generated_null(self, filter_string, q_expected):
q = DynamicFilterField.filter_string_to_q(filter_string) q = DynamicFilter.query_from_string(filter_string)
assert unicode(q) == unicode(q_expected) assert unicode(q) == unicode(q_expected)

210
awx/main/utils/filters.py Normal file
View File

@@ -0,0 +1,210 @@
import re
import sys
from pyparsing import (
infixNotation,
opAssoc,
Optional,
Literal,
CharsNotIn,
)
from django.db import models
__all__ = ['DynamicFilter']
unicode_spaces = [unichr(c) for c in xrange(sys.maxunicode) if unichr(c).isspace()]
unicode_spaces_other = unicode_spaces + [u'(', u')', u'=', u'"']
def string_to_type(t):
if t == u'true':
return True
elif t == u'false':
return False
if re.search('^[-+]?[0-9]+$',t):
return int(t)
if re.search('^[-+]?[0-9]+\.[0-9]+$',t):
return float(t)
return t
class DynamicFilter(object):
SEARCHABLE_RELATIONSHIP = 'ansible_facts'
class BoolOperand(object):
def __init__(self, t):
kwargs = dict()
k, v = self._extract_key_value(t)
k, v = self._json_path_to_contains(k, v)
kwargs[k] = v
self.result = models.Q(**kwargs)
def strip_quotes_traditional_logic(self, v):
if type(v) is unicode and v.startswith('"') and v.endswith('"'):
return v[1:-1]
return v
def strip_quotes_json_logic(self, v):
if type(v) is unicode and v.startswith('"') and v.endswith('"') and v != u'"null"':
return v[1:-1]
return v
'''
TODO: We should be able to express this in the grammar and let
pyparsing do the heavy lifting.
TODO: separate django filter requests from our custom json filter
request so we don't process the key any. This could be
accomplished using a whitelist or introspecting the
relationship refered to to see if it's a jsonb type.
'''
def _json_path_to_contains(self, k, v):
if not k.startswith(DynamicFilter.SEARCHABLE_RELATIONSHIP):
v = self.strip_quotes_traditional_logic(v)
return (k, v)
# Strip off leading relationship key
if k.startswith(DynamicFilter.SEARCHABLE_RELATIONSHIP + '__'):
strip_len = len(DynamicFilter.SEARCHABLE_RELATIONSHIP) + 2
else:
strip_len = len(DynamicFilter.SEARCHABLE_RELATIONSHIP)
k = k[strip_len:]
pieces = k.split(u'__')
assembled_k = u'%s__contains' % (DynamicFilter.SEARCHABLE_RELATIONSHIP)
assembled_v = None
last_v = None
last_kv = None
for i, piece in enumerate(pieces):
new_kv = dict()
if piece.endswith(u'[]'):
new_v = []
new_kv[piece[0:-2]] = new_v
else:
new_v = dict()
new_kv[piece] = new_v
if last_kv is None:
assembled_v = new_kv
elif type(last_v) is list:
last_v.append(new_kv)
elif type(last_v) is dict:
last_kv[last_kv.keys()[0]] = new_kv
last_v = new_v
last_kv = new_kv
v = self.strip_quotes_json_logic(v)
if type(last_v) is list:
last_v.append(v)
elif type(last_v) is dict:
last_kv[last_kv.keys()[0]] = v
return (assembled_k, assembled_v)
def _extract_key_value(self, t):
t_len = len(t)
k = None
v = None
# key
# "something"=
v_offset = 2
if t_len >= 2 and t[0] == "\"" and t[2] == "\"":
k = t[1]
v_offset = 4
# something=
else:
k = t[0]
# value
# ="something"
if t_len > (v_offset + 2) and t[v_offset] == "\"" and t[v_offset + 2] == "\"":
v = u'"' + unicode(t[v_offset + 1]) + u'"'
#v = t[v_offset + 1]
# empty ""
elif t_len > (v_offset + 1):
v = u""
# no ""
else:
v = string_to_type(t[v_offset])
return (k, v)
class BoolBinOp(object):
def __init__(self, t):
self.result = None
i = 2
while i < len(t[0]):
if not self.result:
self.result = t[0][0].result
right = t[0][i].result
self.result = self.execute_logic(self.result, right)
i += 2
class BoolAnd(BoolBinOp):
def execute_logic(self, left, right):
return left & right
class BoolOr(BoolBinOp):
def execute_logic(self, left, right):
return left | right
class BoolNot(object):
def __init__(self, t):
self.right = t[0][1].result
self.result = self.execute_logic(self.right)
def execute_logic(self, right):
return ~right
@classmethod
def query_from_string(cls, filter_string):
'''
TODO:
* handle values with " via: a.b.c.d="hello\"world"
* handle keys with " via: a.\"b.c="yeah"
* handle key with __ in it
'''
filter_string_raw = filter_string
filter_string = unicode(filter_string)
atom = CharsNotIn(unicode_spaces_other)
atom_inside_quotes = CharsNotIn(u'"')
atom_quoted = Literal('"') + Optional(atom_inside_quotes) + Literal('"')
EQUAL = Literal('=')
grammar = ((atom_quoted | atom) + EQUAL + Optional((atom_quoted | atom)))
grammar.setParseAction(cls.BoolOperand)
boolExpr = infixNotation(grammar, [
("not", 1, opAssoc.RIGHT, cls.BoolNot),
("and", 2, opAssoc.LEFT, cls.BoolAnd),
("or", 2, opAssoc.LEFT, cls.BoolOr),
])
try:
res = boolExpr.parseString('(' + filter_string + ')')
#except ParseException as e:
except Exception:
raise RuntimeError(u"Invalid query %s" % filter_string_raw)
if len(res) > 0:
return res[0].result
raise RuntimeError("Parsing the filter_string %s went terribly wrong" % filter_string)