Repository: nautobot/nautobot

Issue:
IP Address list view not correctly sorted by default
### Environment
* Nautobot version (Docker tag too if applicable): 2.1.4, appears broken as far back as 2.0.0

The `/ipam/ip-addresses/` list view lists IPs in a seemingly random order:

![image](https://github.com/nautobot/nautobot/assets/5603551/67ad6ae6-5c1b-4418-b69d-47e81e72df3f)


### Steps to Reproduce
1. https://demo.nautobot.com/ipam/ip-addresses/

<!-- What did you expect to happen? -->
### Expected Behavior

IPs to be ordered logically by default

<!-- What happened instead? -->
### Observed Behavior

Seemingly random ordering.

Looks to have been broken by #4230; I confirmed locally that reverting 536d75dd802044d5857578748fde58173ca33a26 appears to restore the expected ordering to the table.


Discussion:
Possibly should be using `count_related` instead of `Count` throughout `nautobot.ipam.views`.
https://docs.djangoproject.com/en/3.2/topics/db/aggregation/#interaction-with-order-by

Resolving patch:
diff --git a/nautobot/core/models/querysets.py b/nautobot/core/models/querysets.py
--- a/nautobot/core/models/querysets.py
+++ b/nautobot/core/models/querysets.py
@@ -6,7 +6,7 @@
 from nautobot.core.utils.data import merge_dicts_without_collision
 
 
-def count_related(model, field, *, filter_dict=None):
+def count_related(model, field, *, filter_dict=None, distinct=False):
     """
     Return a Subquery suitable for annotating a child object count.
 
@@ -22,7 +22,12 @@ def count_related(model, field, *, filter_dict=None):
     manager = model.objects
     if hasattr(model.objects, "without_tree_fields"):
         manager = manager.without_tree_fields()
-    subquery = Subquery(manager.filter(**filters).order_by().values(field).annotate(c=Count("*")).values("c"))
+    qs = manager.filter(**filters).order_by().values(field)
+    if distinct:
+        qs = qs.annotate(c=Count("pk", distinct=distinct)).values("c")
+    else:
+        qs = qs.annotate(c=Count("*")).values("c")
+    subquery = Subquery(qs)
 
     return Coalesce(subquery, 0)
 
diff --git a/nautobot/extras/views.py b/nautobot/extras/views.py
--- a/nautobot/extras/views.py
+++ b/nautobot/extras/views.py
@@ -6,7 +6,7 @@
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.db import transaction
-from django.db.models import Count, ProtectedError, Q
+from django.db.models import ProtectedError, Q
 from django.forms.utils import pretty_name
 from django.http import Http404, HttpResponse, HttpResponseForbidden
 from django.shortcuts import get_object_or_404, redirect, render
@@ -31,13 +31,13 @@
 from nautobot.core.views.paginator import EnhancedPaginator, get_paginate_count
 from nautobot.core.views.utils import prepare_cloned_fields
 from nautobot.core.views.viewsets import NautobotUIViewSet
-from nautobot.dcim.models import Device, Rack
+from nautobot.dcim.models import Device, Interface, Rack
 from nautobot.dcim.tables import DeviceTable, RackTable
 from nautobot.extras.tasks import delete_custom_field_data
 from nautobot.extras.utils import get_base_template, get_worker_count
 from nautobot.ipam.models import IPAddress, Prefix, VLAN
 from nautobot.ipam.tables import IPAddressTable, PrefixTable, VLANTable
-from nautobot.virtualization.models import VirtualMachine
+from nautobot.virtualization.models import VirtualMachine, VMInterface
 from nautobot.virtualization.tables import VirtualMachineTable
 
 from . import filters, forms, tables
@@ -1872,11 +1872,12 @@ def get_extra_context(self, request, instance):
                     instance.ip_addresses.select_related("status", "tenant")
                     .restrict(request.user, "view")
                     .annotate(
-                        interface_count=Count("interfaces"),
-                        interface_parent_count=(Count("interfaces__device", distinct=True)),
-                        vm_interface_count=Count("vm_interfaces"),
-                        vm_interface_parent_count=(Count("vm_interfaces__virtual_machine", distinct=True)),
-                        assigned_count=Count("interfaces") + Count("vm_interfaces"),
+                        interface_count=count_related(Interface, "ip_addresses"),
+                        interface_parent_count=count_related(Device, "interfaces__ip_addresses", distinct=True),
+                        vm_interface_count=count_related(VMInterface, "ip_addresses"),
+                        vm_interface_parent_count=count_related(
+                            VirtualMachine, "interfaces__ip_addresses", distinct=True
+                        ),
                     )
                 )
                 ipaddress_table = IPAddressTable(ipaddress)
diff --git a/nautobot/ipam/views.py b/nautobot/ipam/views.py
--- a/nautobot/ipam/views.py
+++ b/nautobot/ipam/views.py
@@ -4,7 +4,7 @@
 from django.contrib import messages
 from django.core.cache import cache
 from django.db import models, transaction
-from django.db.models import Count, Prefetch, ProtectedError, Q
+from django.db.models import Prefetch, ProtectedError, Q
 from django.forms.models import model_to_dict
 from django.shortcuts import get_object_or_404, redirect, render
 from django.templatetags.static import static
@@ -103,10 +103,10 @@ def get_extra_context(self, request, instance):
             instance.ip_addresses.restrict(request.user, "view")
             .select_related("role", "status", "tenant")
             .annotate(
-                interface_count=Count("interfaces"),
-                interface_parent_count=(Count("interfaces__device", distinct=True)),
-                vm_interface_count=Count("vm_interfaces"),
-                vm_interface_parent_count=(Count("vm_interfaces__virtual_machine", distinct=True)),
+                interface_count=count_related(Interface, "ip_addresses"),
+                interface_parent_count=count_related(Device, "interfaces__ip_addresses", distinct=True),
+                vm_interface_count=count_related(VMInterface, "ip_addresses"),
+                vm_interface_parent_count=count_related(VirtualMachine, "interfaces__ip_addresses", distinct=True),
             )
         )
 
@@ -514,10 +514,10 @@ def get_extra_context(self, request, instance):
             .select_related("role", "status", "tenant")
             .prefetch_related("primary_ip4_for", "primary_ip6_for")
             .annotate(
-                interface_count=Count("interfaces"),
-                interface_parent_count=(Count("interfaces__device", distinct=True)),
-                vm_interface_count=Count("vm_interfaces"),
-                vm_interface_parent_count=(Count("vm_interfaces__virtual_machine", distinct=True)),
+                interface_count=count_related(Interface, "ip_addresses"),
+                interface_parent_count=count_related(Device, "interfaces__ip_addresses", distinct=True),
+                vm_interface_count=count_related(VMInterface, "ip_addresses"),
+                vm_interface_parent_count=count_related(VirtualMachine, "interfaces__ip_addresses", distinct=True),
             )
         )
 
@@ -715,11 +715,11 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
 
 class IPAddressListView(generic.ObjectListView):
     queryset = IPAddress.objects.annotate(
-        interface_count=Count("interfaces"),
-        interface_parent_count=(Count("interfaces__device", distinct=True)),
-        vm_interface_count=Count("vm_interfaces"),
-        vm_interface_parent_count=(Count("vm_interfaces__virtual_machine", distinct=True)),
-        assigned_count=Count("interfaces") + Count("vm_interfaces"),
+        interface_count=count_related(Interface, "ip_addresses"),
+        interface_parent_count=count_related(Device, "interfaces__ip_addresses", distinct=True),
+        vm_interface_count=count_related(VMInterface, "ip_addresses"),
+        vm_interface_parent_count=count_related(VirtualMachine, "interfaces__ip_addresses", distinct=True),
+        assigned_count=count_related(Interface, "ip_addresses") + count_related(VMInterface, "ip_addresses"),
     )
     filterset = filters.IPAddressFilterSet
     filterset_form = forms.IPAddressFilterForm
@@ -745,10 +745,10 @@ def get_extra_context(self, request, instance):
             .restrict(request.user, "view")
             .select_related("role", "status", "tenant")
             .annotate(
-                interface_count=Count("interfaces"),
-                interface_parent_count=(Count("interfaces__device", distinct=True)),
-                vm_interface_count=Count("vm_interfaces"),
-                vm_interface_parent_count=(Count("vm_interfaces__virtual_machine", distinct=True)),
+                interface_count=count_related(Interface, "ip_addresses"),
+                interface_parent_count=count_related(Device, "interfaces__ip_addresses", distinct=True),
+                vm_interface_count=count_related(VMInterface, "ip_addresses"),
+                vm_interface_parent_count=count_related(VirtualMachine, "interfaces__ip_addresses", distinct=True),
             )
         )
         related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
@@ -1099,10 +1099,10 @@ class IPAddressBulkImportView(generic.BulkImportView):
 class IPAddressBulkEditView(generic.BulkEditView):
     # queryset = IPAddress.objects.select_related("status", "role", "tenant", "vrf__tenant")
     queryset = IPAddress.objects.select_related("role", "status", "tenant").annotate(
-        interface_count=Count("interfaces"),
-        interface_parent_count=(Count("interfaces__device", distinct=True)),
-        vm_interface_count=Count("vm_interfaces"),
-        vm_interface_parent_count=(Count("vm_interfaces__virtual_machine", distinct=True)),
+        interface_count=count_related(Interface, "ip_addresses"),
+        interface_parent_count=count_related(Device, "interfaces__ip_addresses", distinct=True),
+        vm_interface_count=count_related(VMInterface, "ip_addresses"),
+        vm_interface_parent_count=count_related(VirtualMachine, "interfaces__ip_addresses", distinct=True),
     )
     filterset = filters.IPAddressFilterSet
     table = tables.IPAddressTable
@@ -1112,10 +1112,10 @@ class IPAddressBulkEditView(generic.BulkEditView):
 class IPAddressBulkDeleteView(generic.BulkDeleteView):
     # queryset = IPAddress.objects.select_related("status", "role", "tenant", "vrf__tenant")
     queryset = IPAddress.objects.select_related("role", "status", "tenant").annotate(
-        interface_count=Count("interfaces"),
-        interface_parent_count=(Count("interfaces__device", distinct=True)),
-        vm_interface_count=Count("vm_interfaces"),
-        vm_interface_parent_count=(Count("vm_interfaces__virtual_machine", distinct=True)),
+        interface_count=count_related(Interface, "ip_addresses"),
+        interface_parent_count=count_related(Device, "interfaces__ip_addresses", distinct=True),
+        vm_interface_count=count_related(VMInterface, "ip_addresses"),
+        vm_interface_parent_count=count_related(VirtualMachine, "interfaces__ip_addresses", distinct=True),
     )
     filterset = filters.IPAddressFilterSet
     table = tables.IPAddressTable


Test patch:
diff --git a/nautobot/core/tests/test_views_utils.py b/nautobot/core/tests/test_views_utils.py
--- a/nautobot/core/tests/test_views_utils.py
+++ b/nautobot/core/tests/test_views_utils.py
@@ -104,7 +104,7 @@ def test_check_filter_for_display(self):
 
 class CheckCountRelatedSubquery(TestCase):
     def test_count_related(self):
-        """Assert that InventoryItems with the same Manufacuturers do not cause issues in count_related subquery."""
+        """Assert that InventoryItems with the same Manufacturers do not cause issues in count_related subquery."""
         location = Location.objects.filter(parent__isnull=False).first()
         self.manufacturers = Manufacturer.objects.all()[:3]
         devicetype = DeviceType.objects.first()
@@ -135,6 +135,15 @@ def test_count_related(self):
             device=device1, manufacturer=self.manufacturers[2], name="Inv 4"
         )
         try:
-            list(Manufacturer.objects.annotate(inventory_item_count=count_related(InventoryItem, "manufacturer")))
+            qs = Manufacturer.objects.annotate(inventory_item_count=count_related(InventoryItem, "manufacturer"))
+            list(qs)
+            self.assertEqual(qs.get(pk=self.manufacturers[0].pk).inventory_item_count, 2)
+            self.assertEqual(qs.get(pk=self.manufacturers[1].pk).inventory_item_count, 1)
+            self.assertEqual(qs.get(pk=self.manufacturers[2].pk).inventory_item_count, 2)
         except ProgrammingError:
             self.fail("count_related subquery failed with ProgrammingError")
+
+        qs = Device.objects.annotate(
+            manufacturer_count=count_related(Manufacturer, "inventory_items__device", distinct=True)
+        )
+        self.assertEqual(qs.get(pk=device1.pk).manufacturer_count, 3)