From 17e9b3057e8fdab4f7efb8648196518894834a8e Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 24 Apr 2017 15:45:12 -0400 Subject: [PATCH 1/7] Clean-up intiail commit for Host filter / DynamicInventory --- awx/api/views.py | 6 +- awx/main/fields.py | 206 +---------------- awx/main/managers.py | 20 ++ awx/main/migrations/0038_v320_release.py | 5 + awx/main/models/inventory.py | 13 +- awx/main/querysets.py | 218 ++++++++++++++++++ .../tests/functional/models/test_inventory.py | 34 ++- .../{test_fields.py => test_querysets.py} | 21 +- 8 files changed, 309 insertions(+), 214 deletions(-) create mode 100644 awx/main/querysets.py rename awx/main/tests/unit/{test_fields.py => test_querysets.py} (89%) diff --git a/awx/api/views.py b/awx/api/views.py index ad3185d98d..5337b7d3e8 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -79,7 +79,7 @@ from awx.api.metadata import RoleMetadata from awx.main.consumers import emit_channel_notification from awx.main.models.unified_jobs import ACTIVE_STATES from awx.main.scheduler.tasks import run_job_complete -from awx.main.fields import DynamicFilterField +from awx.main.querysets import DynamicFilterQuerySet logger = logging.getLogger('awx.api.views') @@ -1764,10 +1764,10 @@ class HostList(ListCreateAPIView): capabilities_prefetch = ['inventory.admin'] def get_queryset(self): - qs = super(HostList, self).get_queryset() + qs = DynamicFilterQuerySet(HostList, using=self._db) filter_string = self.request.query_params.get('host_filter', None) if filter_string: - filter_q = DynamicFilterField.filter_string_to_q(filter_string) + filter_q = qs.query_from_string(filter_string) qs = qs.filter(filter_q) return qs diff --git a/awx/main/fields.py b/awx/main/fields.py index b2f144980f..cfe86a08b7 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -4,10 +4,7 @@ # Python import copy import json -import re -import sys import six -from pyparsing import infixNotation, opAssoc, Optional, Literal, CharsNotIn from jinja2 import Environment, StrictUndefined from jinja2.exceptions import UndefinedError @@ -28,7 +25,6 @@ from django.db.models.fields.related import ( ReverseManyRelatedObjectsDescriptor, ) from django.utils.encoding import smart_text -from django.db.models import Q from django.utils.translation import ugettext_lazy as _ # jsonschema @@ -39,6 +35,7 @@ from jsonfield import JSONField as upstream_JSONField from jsonbfield.fields import JSONField as upstream_JSONBField # AWX +from awx.main.querysets import DynamicFilterQuerySet from awx.main.models.rbac import batch_role_ancestor_rebuilding, Role from awx.main import utils @@ -332,202 +329,15 @@ class ImplicitRoleField(models.ForeignKey): 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): - 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 = 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), - ]) - + def get_prep_value(self, value): + if value is None: + return value 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) + DynamicFilterQuerySet().query_from_string(value) + except RuntimeError, e: + raise models.base.ValidationError(e) + return super(DynamicFilterField, self).get_prep_value(value) class JSONSchemaField(JSONBField): diff --git a/awx/main/managers.py b/awx/main/managers.py index 522157a70f..76f6dc6542 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -9,6 +9,8 @@ from django.utils.timezone import now from django.db.models import Sum from django.conf import settings +from awx.main.querysets import DynamicFilterQuerySet + class HostManager(models.Manager): """Custom manager class for Hosts model.""" @@ -20,6 +22,24 @@ class HostManager(models.Manager): except NotImplementedError: # For unit tests only, SQLite doesn't support distinct('name') return len(set(self.values_list('name', flat=True))) + def get_queryset(self): + """When the Inventory this host belongs to has a `host_filter` set + generate the QuerySet using that filter. Otherwise just return the default filter. + """ + qs = DynamicFilterQuerySet(self.model, using=self._db) + if self.instance is not None: + if hasattr(self.instance, 'host_filter') and self.instance.host_filter is not None: + q = qs.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): """A custom manager class for the Instance model. diff --git a/awx/main/migrations/0038_v320_release.py b/awx/main/migrations/0038_v320_release.py index 60c38009e2..10297a4c61 100644 --- a/awx/main/migrations/0038_v320_release.py +++ b/awx/main/migrations/0038_v320_release.py @@ -36,6 +36,11 @@ class Migration(migrations.Migration): name='inventory', 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), + ), # Facts migrations.AlterField( diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index c0d6af6a28..28d079c61c 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -20,7 +20,11 @@ from django.utils.timezone import now # AWX from awx.api.versioning import reverse 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.models.base import * # noqa from awx.main.models.unified_jobs import * # noqa @@ -99,6 +103,13 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin): editable=False, help_text=_('Number of external inventory sources in this inventory with failures.'), ) + 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( parent_role='organization.admin_role', ) diff --git a/awx/main/querysets.py b/awx/main/querysets.py new file mode 100644 index 0000000000..633f1ea983 --- /dev/null +++ b/awx/main/querysets.py @@ -0,0 +1,218 @@ +import re +import sys +from pyparsing import ( + infixNotation, + opAssoc, + Optional, + Literal, + CharsNotIn, +) + +from django.db import models + + +__all__ = ['DynamicFilterQuerySet'] + + +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 DynamicFilterQuerySet(models.QuerySet): + + + 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) + + ''' + 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): + pieces = k.split('__') + + flag_first_arr_found = False + + assembled_k = '' + assembled_v = v + + last_kv = None + last_v = None + + contains_count = 0 + for i, piece in enumerate(pieces): + if flag_first_arr_found is False and piece.endswith('[]'): + assembled_k += u'%s__contains' % (piece[0:-2]) + contains_count += 1 + flag_first_arr_found = True + elif flag_first_arr_found is False and i == len(pieces) - 1: + assembled_k += u'%s' % piece + elif flag_first_arr_found is False: + assembled_k += u'%s__' % piece + elif flag_first_arr_found is True: + new_kv = dict() + if piece.endswith('[]'): + new_v = [] + new_kv[piece[0:-2]] = new_v + else: + new_v = dict() + new_kv[piece] = new_v + + + if last_v is None: + last_v = [] + assembled_v = last_v + + if 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 + contains_count += 1 + + ''' + Explicit quotes are kept until this point. + Note: we could have totally "ripped" them off earlier when we decided + what type to convert the token to. + ''' + if type(v) is unicode and v.startswith('"') and v.endswith('"') and v != u'"null"': + v = v[1:-1] + + if contains_count == 0: + assembled_v = v + elif contains_count == 1: + assembled_v = [v] + elif contains_count > 1: + if type(last_v) is list: + last_v.append(v) + if 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 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) + + diff --git a/awx/main/tests/functional/models/test_inventory.py b/awx/main/tests/functional/models/test_inventory.py index 8187c9aea6..03e216d74c 100644 --- a/awx/main/tests/functional/models/test_inventory.py +++ b/awx/main/tests/functional/models/test_inventory.py @@ -2,7 +2,11 @@ import pytest import mock # AWX -from awx.main.models import InventorySource, InventoryUpdate +from awx.main.models import ( + Inventory, + InventorySource, + InventoryUpdate, +) @pytest.mark.django_db @@ -31,3 +35,31 @@ class TestSCMUpdateFeatures: scm_inventory_source.description = "I'm testing this!" scm_inventory_source.save() assert not mck_update.called + + +@pytest.mark.django_db +def test_host_objects_manager(organization): + dynamic_inventory = Inventory(organization=organization, name='dynamic', host_filter='inventory_sources__source=ec2') + dynamic_inventory.save() + + 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() + + hosts = dynamic_inventory.hosts.all() + assert len(hosts) == 2 + assert hosts[0].inventory_sources.first() == ec2_source + assert hosts[1].inventory_sources.first() == ec2_source diff --git a/awx/main/tests/unit/test_fields.py b/awx/main/tests/unit/test_querysets.py similarity index 89% rename from awx/main/tests/unit/test_fields.py rename to awx/main/tests/unit/test_querysets.py index 3002ddc057..b6e1514306 100644 --- a/awx/main/tests/unit/test_fields.py +++ b/awx/main/tests/unit/test_querysets.py @@ -3,14 +3,13 @@ import pytest # AWX -from awx.main.fields import DynamicFilterField +from awx.main.querysets import DynamicFilterQuerySet # Django from django.db.models import Q - -class TestDynamicFilterFieldFilterStringToQ(): +class TestDynamicFilterQuerySetQueryFromString(): @pytest.mark.parametrize("filter_string,q_expected", [ ('facts__facts__blank=""', Q(**{u"facts__facts__blank": u""})), ('"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"})), ]) def test_query_generated(self, filter_string, q_expected): - q = DynamicFilterField.filter_string_to_q(filter_string) + q = DynamicFilterQuerySet.query_from_string(filter_string) assert unicode(q) == unicode(q_expected) @pytest.mark.parametrize("filter_string", [ @@ -33,7 +32,7 @@ class TestDynamicFilterFieldFilterStringToQ(): ]) def test_invalid_filter_strings(self, filter_string): with pytest.raises(RuntimeError) as e: - DynamicFilterField.filter_string_to_q(filter_string) + DynamicFilterQuerySet.query_from_string(filter_string) assert e.value.message == u"Invalid query " + filter_string @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"}})), ]) def test_unicode(self, filter_string, q_expected): - q = DynamicFilterField.filter_string_to_q(filter_string) + q = DynamicFilterQuerySet.query_from_string(filter_string) assert unicode(q) == unicode(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"})) ]) def test_boolean_parenthesis(self, filter_string, q_expected): - q = DynamicFilterField.filter_string_to_q(filter_string) + q = DynamicFilterQuerySet.query_from_string(filter_string) assert unicode(q) == unicode(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"})), ]) def test_contains_query_generated(self, filter_string, q_expected): - q = DynamicFilterField.filter_string_to_q(filter_string) + q = DynamicFilterQuerySet.query_from_string(filter_string) assert unicode(q) == unicode(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"})), ]) def test_contains_query_generated_unicode(self, filter_string, q_expected): - q = DynamicFilterField.filter_string_to_q(filter_string) + q = DynamicFilterQuerySet.query_from_string(filter_string) assert unicode(q) == unicode(q_expected) @pytest.mark.parametrize("filter_string,q_expected", [ @@ -93,12 +92,12 @@ class TestDynamicFilterFieldFilterStringToQ(): ('ansible_facts__c="null"', Q(**{u"ansible_facts__contains": {u"c": u"\"null\""}})), ]) def test_contains_query_generated_null(self, filter_string, q_expected): - q = DynamicFilterField.filter_string_to_q(filter_string) + q = DynamicFilterQuerySet.query_from_string(filter_string) assert unicode(q) == unicode(q_expected) ''' #('"facts__quoted_val"="f\"oo"', 1), #('facts__facts__arr[]="foo"', 1), -#('facts__facts__arr_nested[]__a[]="foo"', 1), +#('facts__facts__arr_nested[]__a[]="foo"', 1), ''' From a45d41b37962cb6c7f4d66c99a60606183c345df Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 24 Apr 2017 17:03:25 -0400 Subject: [PATCH 2/7] DynamicFilterQuerySet -> DynamicFilter --- awx/main/fields.py | 4 ++-- awx/main/managers.py | 6 +++--- .../test_filters.py} | 18 +++++++++--------- awx/main/{querysets.py => utils/filters.py} | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) rename awx/main/tests/unit/{test_querysets.py => utils/test_filters.py} (90%) rename awx/main/{querysets.py => utils/filters.py} (98%) diff --git a/awx/main/fields.py b/awx/main/fields.py index cfe86a08b7..25f4a85e66 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -35,7 +35,7 @@ from jsonfield import JSONField as upstream_JSONField from jsonbfield.fields import JSONField as upstream_JSONBField # AWX -from awx.main.querysets import DynamicFilterQuerySet +from awx.main.utils.filters import DynamicFilter from awx.main.models.rbac import batch_role_ancestor_rebuilding, Role from awx.main import utils @@ -334,7 +334,7 @@ class DynamicFilterField(models.TextField): if value is None: return value try: - DynamicFilterQuerySet().query_from_string(value) + DynamicFilter().query_from_string(value) except RuntimeError, e: raise models.base.ValidationError(e) return super(DynamicFilterField, self).get_prep_value(value) diff --git a/awx/main/managers.py b/awx/main/managers.py index 76f6dc6542..436a6f3918 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -9,7 +9,7 @@ from django.utils.timezone import now from django.db.models import Sum from django.conf import settings -from awx.main.querysets import DynamicFilterQuerySet +from awx.main.utils.filters import DynamicFilter class HostManager(models.Manager): @@ -26,10 +26,10 @@ class HostManager(models.Manager): """When the Inventory this host belongs to has a `host_filter` set generate the QuerySet using that filter. Otherwise just return the default filter. """ - qs = DynamicFilterQuerySet(self.model, using=self._db) + qs = super(HostManager, self).get_queryset() if self.instance is not None: if hasattr(self.instance, 'host_filter') and self.instance.host_filter is not None: - q = qs.query_from_string(self.instance.host_filter) + 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. diff --git a/awx/main/tests/unit/test_querysets.py b/awx/main/tests/unit/utils/test_filters.py similarity index 90% rename from awx/main/tests/unit/test_querysets.py rename to awx/main/tests/unit/utils/test_filters.py index b6e1514306..5147e4fbc2 100644 --- a/awx/main/tests/unit/test_querysets.py +++ b/awx/main/tests/unit/utils/test_filters.py @@ -3,13 +3,13 @@ import pytest # AWX -from awx.main.querysets import DynamicFilterQuerySet +from awx.main.utils.filters import DynamicFilter # Django from django.db.models import Q -class TestDynamicFilterQuerySetQueryFromString(): +class TestDynamicFilterQueryFromString(): @pytest.mark.parametrize("filter_string,q_expected", [ ('facts__facts__blank=""', Q(**{u"facts__facts__blank": u""})), ('"facts__facts__ space "="f"', Q(**{u"facts__facts__ space ": u"f"})), @@ -23,7 +23,7 @@ class TestDynamicFilterQuerySetQueryFromString(): #('a__b\"__c="true"', Q(**{u"a__b\"__c": "true"})), ]) def test_query_generated(self, filter_string, q_expected): - q = DynamicFilterQuerySet.query_from_string(filter_string) + q = DynamicFilter.query_from_string(filter_string) assert unicode(q) == unicode(q_expected) @pytest.mark.parametrize("filter_string", [ @@ -32,7 +32,7 @@ class TestDynamicFilterQuerySetQueryFromString(): ]) def test_invalid_filter_strings(self, filter_string): with pytest.raises(RuntimeError) as e: - DynamicFilterQuerySet.query_from_string(filter_string) + DynamicFilter.query_from_string(filter_string) assert e.value.message == u"Invalid query " + filter_string @pytest.mark.parametrize("filter_string,q_expected", [ @@ -40,7 +40,7 @@ class TestDynamicFilterQuerySetQueryFromString(): (u'(ansible_facts__a=abc\u1F5E3def)', Q(**{u"ansible_facts__contains": {u"a": u"abc\u1F5E3def"}})), ]) def test_unicode(self, filter_string, q_expected): - q = DynamicFilterQuerySet.query_from_string(filter_string) + q = DynamicFilter.query_from_string(filter_string) assert unicode(q) == unicode(q_expected) @pytest.mark.parametrize("filter_string,q_expected", [ @@ -54,7 +54,7 @@ class TestDynamicFilterQuerySetQueryFromString(): ('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): - q = DynamicFilterQuerySet.query_from_string(filter_string) + q = DynamicFilter.query_from_string(filter_string) assert unicode(q) == unicode(q_expected) @pytest.mark.parametrize("filter_string,q_expected", [ @@ -74,7 +74,7 @@ class TestDynamicFilterQuerySetQueryFromString(): #('a__b\"__c="true"', Q(**{u"a__b\"__c": "true"})), ]) def test_contains_query_generated(self, filter_string, q_expected): - q = DynamicFilterQuerySet.query_from_string(filter_string) + q = DynamicFilter.query_from_string(filter_string) assert unicode(q) == unicode(q_expected) @pytest.mark.parametrize("filter_string,q_expected", [ @@ -84,7 +84,7 @@ class TestDynamicFilterQuerySetQueryFromString(): #('a__b\"__c="true"', Q(**{u"a__b\"__c": "true"})), ]) def test_contains_query_generated_unicode(self, filter_string, q_expected): - q = DynamicFilterQuerySet.query_from_string(filter_string) + q = DynamicFilter.query_from_string(filter_string) assert unicode(q) == unicode(q_expected) @pytest.mark.parametrize("filter_string,q_expected", [ @@ -92,7 +92,7 @@ class TestDynamicFilterQuerySetQueryFromString(): ('ansible_facts__c="null"', Q(**{u"ansible_facts__contains": {u"c": u"\"null\""}})), ]) def test_contains_query_generated_null(self, filter_string, q_expected): - q = DynamicFilterQuerySet.query_from_string(filter_string) + q = DynamicFilter.query_from_string(filter_string) assert unicode(q) == unicode(q_expected) diff --git a/awx/main/querysets.py b/awx/main/utils/filters.py similarity index 98% rename from awx/main/querysets.py rename to awx/main/utils/filters.py index 633f1ea983..347facccc0 100644 --- a/awx/main/querysets.py +++ b/awx/main/utils/filters.py @@ -11,7 +11,7 @@ from pyparsing import ( from django.db import models -__all__ = ['DynamicFilterQuerySet'] +__all__ = ['DynamicFilter'] unicode_spaces = [unichr(c) for c in xrange(sys.maxunicode) if unichr(c).isspace()] @@ -33,7 +33,7 @@ def string_to_type(t): return t -class DynamicFilterQuerySet(models.QuerySet): +class DynamicFilter(object): class BoolOperand(object): From 8a599d9754190e0cbee87f4317611150286ae167 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Fri, 28 Apr 2017 11:48:24 -0400 Subject: [PATCH 3/7] Add Inventory.kind field --- awx/main/managers.py | 25 ++++++++++++------------ awx/main/migrations/0038_v320_release.py | 5 +++++ awx/main/models/inventory.py | 12 ++++++++++++ 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/awx/main/managers.py b/awx/main/managers.py index 436a6f3918..57d518d270 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -23,21 +23,22 @@ class HostManager(models.Manager): return len(set(self.values_list('name', flat=True))) def get_queryset(self): - """When the Inventory this host belongs to has a `host_filter` set - generate the QuerySet using that filter. Otherwise just return the default filter. + """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 self.instance is not None: - if hasattr(self.instance, 'host_filter') 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) + if hasattr(self.instance, 'kind') and self.instance.kind == 'dynamic': + if hasattr(self.instance, 'host_filter') 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 diff --git a/awx/main/migrations/0038_v320_release.py b/awx/main/migrations/0038_v320_release.py index 10297a4c61..fb0b6bcb8c 100644 --- a/awx/main/migrations/0038_v320_release.py +++ b/awx/main/migrations/0038_v320_release.py @@ -41,6 +41,11 @@ class Migration(migrations.Migration): 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 migrations.AlterField( diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 28d079c61c..1ddbc36801 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -46,6 +46,11 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin): 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: app_label = 'main' verbose_name_plural = _('inventories') @@ -103,6 +108,13 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin): editable=False, 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, From 1750e5bd2a6b143697b917ec9a8ab684ab1c82e8 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 1 May 2017 12:40:54 -0400 Subject: [PATCH 4/7] Refactor Host manager and dynamic Inventory tests and update validation/serialization --- awx/api/serializers.py | 15 ++++++- awx/api/views.py | 7 +-- awx/main/managers.py | 2 +- .../tests/functional/models/test_inventory.py | 43 +++++++++++++++---- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 5cc40b4e65..69aaad3232 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -45,6 +45,8 @@ from awx.main.fields import ImplicitRoleField from awx.main.utils import ( get_type_for_model, get_model_for_type, timestamp_apiformat, 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.conf.license import feature_enabled @@ -1113,7 +1115,7 @@ class InventorySerializer(BaseSerializerWithVariables): class Meta: 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', 'groups_with_active_failures', 'has_inventory_sources', 'total_inventory_sources', 'inventory_sources_with_failures') @@ -1145,6 +1147,17 @@ class InventorySerializer(BaseSerializerWithVariables): ret['organization'] = None 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): diff --git a/awx/api/views.py b/awx/api/views.py index 5337b7d3e8..f493a9ae0d 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -72,6 +72,8 @@ from awx.main.utils import * # noqa from awx.main.utils import ( callback_filter_out_ansible_extra_vars ) +from awx.main.utils.filters import DynamicFilter + from awx.api.permissions import * # noqa from awx.api.renderers 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.models.unified_jobs import ACTIVE_STATES from awx.main.scheduler.tasks import run_job_complete -from awx.main.querysets import DynamicFilterQuerySet logger = logging.getLogger('awx.api.views') @@ -1764,10 +1765,10 @@ class HostList(ListCreateAPIView): capabilities_prefetch = ['inventory.admin'] def get_queryset(self): - qs = DynamicFilterQuerySet(HostList, using=self._db) + qs = super(HostList, self).get_queryset() filter_string = self.request.query_params.get('host_filter', None) if filter_string: - filter_q = qs.query_from_string(filter_string) + filter_q = DynamicFilter.query_from_string(filter_string) qs = qs.filter(filter_q) return qs diff --git a/awx/main/managers.py b/awx/main/managers.py index 57d518d270..5884c88938 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -27,7 +27,7 @@ class HostManager(models.Manager): set. Use the `host_filter` to generate the queryset for the hosts. """ qs = super(HostManager, self).get_queryset() - if self.instance is not None: + if hasattr(self, 'instance') and self.instance is not None: if hasattr(self.instance, 'kind') and self.instance.kind == 'dynamic': if hasattr(self.instance, 'host_filter') and self.instance.host_filter is not None: q = DynamicFilter.query_from_string(self.instance.host_filter) diff --git a/awx/main/tests/functional/models/test_inventory.py b/awx/main/tests/functional/models/test_inventory.py index 03e216d74c..129c4e0c98 100644 --- a/awx/main/tests/functional/models/test_inventory.py +++ b/awx/main/tests/functional/models/test_inventory.py @@ -37,11 +37,8 @@ class TestSCMUpdateFeatures: assert not mck_update.called -@pytest.mark.django_db -def test_host_objects_manager(organization): - dynamic_inventory = Inventory(organization=organization, name='dynamic', host_filter='inventory_sources__source=ec2') - dynamic_inventory.save() - +@pytest.fixture +def setup_ec2_gce(organization): ec2_inv = Inventory(name='test_ec2', organization=organization) ec2_inv.save() @@ -59,7 +56,35 @@ def test_host_objects_manager(organization): gce_host.inventory_sources.add(gce_source) gce_inv.save() - hosts = dynamic_inventory.hosts.all() - assert len(hosts) == 2 - assert hosts[0].inventory_sources.first() == ec2_source - assert hosts[1].inventory_sources.first() == ec2_source + +@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' From bdb13ecd714a962b67a64b118be2ba598e2b95f4 Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Mon, 1 May 2017 13:38:39 -0400 Subject: [PATCH 5/7] Merge in latest DynamicFilter changes --- awx/main/utils/filters.py | 102 ++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 55 deletions(-) diff --git a/awx/main/utils/filters.py b/awx/main/utils/filters.py index 347facccc0..9d1e5a7aa4 100644 --- a/awx/main/utils/filters.py +++ b/awx/main/utils/filters.py @@ -13,7 +13,6 @@ 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'"'] @@ -34,7 +33,7 @@ def string_to_type(t): class DynamicFilter(object): - + SEARCHABLE_RELATIONSHIP = 'ansible_facts' class BoolOperand(object): def __init__(self, t): @@ -44,6 +43,16 @@ class DynamicFilter(object): 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. @@ -53,66 +62,50 @@ class DynamicFilter(object): relationship refered to to see if it's a jsonb type. ''' def _json_path_to_contains(self, k, v): - pieces = k.split('__') + if not k.startswith(DynamicFilter.SEARCHABLE_RELATIONSHIP): + v = self.strip_quotes_traditional_logic(v) + return (k, v) - flag_first_arr_found = False + # 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:] - assembled_k = '' - assembled_v = v + pieces = k.split(u'__') + + assembled_k = u'%s__contains' % (DynamicFilter.SEARCHABLE_RELATIONSHIP) + assembled_v = None - last_kv = None last_v = None + last_kv = None - contains_count = 0 for i, piece in enumerate(pieces): - if flag_first_arr_found is False and piece.endswith('[]'): - assembled_k += u'%s__contains' % (piece[0:-2]) - contains_count += 1 - flag_first_arr_found = True - elif flag_first_arr_found is False and i == len(pieces) - 1: - assembled_k += u'%s' % piece - elif flag_first_arr_found is False: - assembled_k += u'%s__' % piece - elif flag_first_arr_found is True: - new_kv = dict() - if piece.endswith('[]'): - new_v = [] - new_kv[piece[0:-2]] = new_v - else: - new_v = dict() - new_kv[piece] = new_v + 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 - if last_v is None: - last_v = [] - assembled_v = last_v + last_v = new_v + last_kv = new_kv - if type(last_v) is list: - last_v.append(new_kv) - elif type(last_v) is dict: - last_kv[last_kv.keys()[0]] = new_kv + v = self.strip_quotes_json_logic(v) - last_v = new_v - last_kv = new_kv - contains_count += 1 - - ''' - Explicit quotes are kept until this point. - Note: we could have totally "ripped" them off earlier when we decided - what type to convert the token to. - ''' - if type(v) is unicode and v.startswith('"') and v.endswith('"') and v != u'"null"': - v = v[1:-1] - - if contains_count == 0: - assembled_v = v - elif contains_count == 1: - assembled_v = [v] - elif contains_count > 1: - if type(last_v) is list: - last_v.append(v) - if type(last_v) is dict: - last_kv[last_kv.keys()[0]] = 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) @@ -181,12 +174,12 @@ class DynamicFilter(object): @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) @@ -207,6 +200,7 @@ class DynamicFilter(object): try: res = boolExpr.parseString('(' + filter_string + ')') + #except ParseException as e: except Exception: raise RuntimeError(u"Invalid query %s" % filter_string_raw) @@ -214,5 +208,3 @@ class DynamicFilter(object): return res[0].result raise RuntimeError("Parsing the filter_string %s went terribly wrong" % filter_string) - - From af35838affd36ae171fe1d6b777e6f1712611b2a Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 2 May 2017 13:00:17 -0400 Subject: [PATCH 6/7] Make kind read-only for PUT/PATCH, use isinstance in Host Manager, update field fasly check --- awx/api/views.py | 9 +++++++++ awx/main/fields.py | 6 ++++-- awx/main/managers.py | 5 ++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index f493a9ae0d..cc7599db78 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -1686,6 +1686,15 @@ class InventoryDetail(RetrieveUpdateDestroyAPIView): model = Inventory 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): with ignore_inventory_computed_fields(): with ignore_inventory_group_removal(): diff --git a/awx/main/fields.py b/awx/main/fields.py index 25f4a85e66..a492932b43 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -331,8 +331,10 @@ class ImplicitRoleField(models.ForeignKey): class DynamicFilterField(models.TextField): def get_prep_value(self, value): - if value is None: - return value + # Change any false value to none. + # https://docs.python.org/2/library/stdtypes.html#truth-value-testing + if not value: + return None try: DynamicFilter().query_from_string(value) except RuntimeError, e: diff --git a/awx/main/managers.py b/awx/main/managers.py index 5884c88938..0969ad311a 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -27,9 +27,8 @@ class HostManager(models.Manager): set. Use the `host_filter` to generate the queryset for the hosts. """ qs = super(HostManager, self).get_queryset() - if hasattr(self, 'instance') and self.instance is not None: - if hasattr(self.instance, 'kind') and self.instance.kind == 'dynamic': - if hasattr(self.instance, 'host_filter') and self.instance.host_filter is not None: + if hasattr(self, 'instance') and isinstance(self.instance, models.Inventory): + 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 From e46a043213672cb3fdc9057dee4c4d6f8e64652b Mon Sep 17 00:00:00 2001 From: Wayne Witzel III Date: Tue, 2 May 2017 13:16:53 -0400 Subject: [PATCH 7/7] Revert isinstance, circular imports due to when the HostManager exists for an Inventory --- awx/main/managers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/awx/main/managers.py b/awx/main/managers.py index 0969ad311a..2bb1ebece4 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -11,6 +11,8 @@ from django.conf import settings from awx.main.utils.filters import DynamicFilter +___all__ = ['HostManager', 'InstanceManager'] + class HostManager(models.Manager): """Custom manager class for Hosts model.""" @@ -27,7 +29,9 @@ class HostManager(models.Manager): set. Use the `host_filter` to generate the queryset for the hosts. """ qs = super(HostManager, self).get_queryset() - if hasattr(self, 'instance') and isinstance(self.instance, models.Inventory): + 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