diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 397b02bccf..8e127357a3 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -209,9 +209,10 @@ class DashboardView(APIView): groups_inventory_failed = models.Group.objects.filter(inventory_sources__last_job_failed=True).count() data['groups'] = {'url': reverse('api:group_list', request=request), 'total': user_groups.count(), 'inventory_failed': groups_inventory_failed} - user_hosts = get_user_queryset(request.user, models.Host) + user_hosts = get_user_queryset(request.user, models.Host).exclude(inventory__kind='constructed') latest_summary_failed = Subquery(models.JobHostSummary.objects.filter(host_id=OuterRef('pk')).order_by('-id').values('failed')[:1]) user_hosts_failed = user_hosts.annotate(_latest_failed=latest_summary_failed).filter(_latest_failed=True) + data['hosts'] = { 'url': reverse('api:host_list', request=request), 'total': user_hosts.count(), diff --git a/awx/main/managers.py b/awx/main/managers.py index 0500e8b29c..a9425369a7 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -90,23 +90,40 @@ class HostManager(models.Manager.from_queryset(HostLatestSummaryQuerySet)): Construction of query involves: - remove any ordering specified in model's Meta - Exclude hosts sourced from another Tower + - Exclude hosts in constructed inventories (these are shadow rows of source-inventory hosts) - Restrict the query to only return the name column - Only consider results that are unique - Return the count of this query """ - return self.order_by().exclude(inventory_sources__source='controller').values(name_lower=Lower('name')).distinct().count() + return ( + self.order_by() + .exclude(inventory_sources__source='controller') + .exclude(inventory__kind='constructed') + .values(name_lower=Lower('name')) + .distinct() + .count() + ) def org_active_count(self, org_id): """Return count of active, unique hosts used by an organization. Construction of query involves: - remove any ordering specified in model's Meta - Exclude hosts sourced from another Tower + - Exclude hosts in constructed inventories (these are shadow rows of source-inventory hosts) - Consider only hosts where the canonical inventory is owned by the organization - Restrict the query to only return the name column - Only consider results that are unique - Return the count of this query """ - return self.order_by().exclude(inventory_sources__source='controller').filter(inventory__organization=org_id).values('name').distinct().count() + return ( + self.order_by() + .exclude(inventory_sources__source='controller') + .exclude(inventory__kind='constructed') + .filter(inventory__organization=org_id) + .values('name') + .distinct() + .count() + ) def get_queryset(self): """When the parent instance of the host query set has a `kind=smart` and a `host_filter` diff --git a/awx/main/tests/functional/api/test_dashboard.py b/awx/main/tests/functional/api/test_dashboard.py new file mode 100644 index 0000000000..a321611bed --- /dev/null +++ b/awx/main/tests/functional/api/test_dashboard.py @@ -0,0 +1,34 @@ +import pytest + +from awx.api.versioning import reverse +from awx.main.models import Host, Inventory + + +@pytest.mark.django_db +def test_dashboard_hosts_total_excludes_constructed(get, admin_user, organization): + """ + Constructed inventory hosts are not counted in the dashboard + """ + source_inv = Inventory.objects.create(name='source-inv', organization=organization) + source_host = source_inv.hosts.create(name='host1') + + constructed = Inventory.objects.create(name='constructed-inv', kind='constructed', organization=organization) + Host.objects.create(name='host1', inventory=constructed, instance_id=str(source_host.pk)) + + response = get(reverse('api:dashboard_view'), user=admin_user, expect=200) + assert response.data['hosts']['total'] == 1 + + +@pytest.mark.django_db +def test_host_list_still_returns_constructed(get, admin_user, organization): + """ + Constructed inventory hosts are still visible through the API + """ + source_inv = Inventory.objects.create(name='source-inv', organization=organization) + source_host = source_inv.hosts.create(name='host1') + + constructed = Inventory.objects.create(name='constructed-inv', kind='constructed', organization=organization) + Host.objects.create(name='host1', inventory=constructed, instance_id=str(source_host.pk)) + + response = get(reverse('api:host_list'), user=admin_user, expect=200) + assert response.data['count'] == 2 diff --git a/awx/main/tests/functional/models/test_inventory.py b/awx/main/tests/functional/models/test_inventory.py index 3a739a3b81..c725bfbfe5 100644 --- a/awx/main/tests/functional/models/test_inventory.py +++ b/awx/main/tests/functional/models/test_inventory.py @@ -108,6 +108,28 @@ class TestActiveCount: source.hosts.create(name='remotely-managed-host', inventory=inventory) assert Host.objects.active_count() == 1 + def test_active_count_minus_constructed(self, organization): + """ + Active hosts do not include duplicated hosts from construted inventories. + """ + inv = Inventory.objects.create(name='source-inv', organization=organization) + inv.hosts.create(name='host1') + assert Host.objects.active_count() == 1 + + constructed = Inventory.objects.create(name='constructed-inv', kind='constructed', organization=organization) + Host.objects.create(name='host1', inventory=constructed) + assert Host.objects.active_count() == 1 + + def test_org_active_count_minus_constructed(self, organization): + """Org-scoped count must also exclude constructed-inventory shadow rows.""" + inv = Inventory.objects.create(name='source-inv', organization=organization) + inv.hosts.create(name='host1') + assert Host.objects.org_active_count(organization.id) == 1 + + constructed = Inventory.objects.create(name='constructed-inv', kind='constructed', organization=organization) + Host.objects.create(name='host1', inventory=constructed) + assert Host.objects.org_active_count(organization.id) == 1 + def test_host_case_insensitivity(self, organization): inv1 = Inventory.objects.create(name='inv1', organization=organization) inv2 = Inventory.objects.create(name='inv2', organization=organization)