Repository: nautobot/nautobot

Issue:
Allow sorting of Tree like model list views

### Environment
* Nautobot version (Docker tag too if applicable): 2.0
* Python version: 3.11
* Database platform, version: psql
* Middleware(s): N/A


### Steps to Reproduce
1. Go to https://demo.nautobot.com/ipam/prefixes/
2. Click on headers
3.

<!-- What did you expect to happen? -->
### Expected Behavior

To sort based on the column header you created

<!-- What happened instead? -->
### Observed Behavior

No sorting 


Discussion:
Seems like we just need to remove this https://github.com/nautobot/nautobot/blob/70012f7f8db4513e673c2061e07075a36b8f6580/nautobot/ipam/tables.py#L376, but not sure why it was there to begin with? 
Given that prefixes are hierarchical, I'm not sure sorting makes a lot of sense (similar to #1854). Can you give an example of the type of sorting you'd expect to be able to do here?
IIRC in #1854 we chose at the time to disable sorting on tree models primarily due to the odd visual representation of nested data and because we did not have the cycles in 2.0 to properly solve that.

Let's use this issue to build a good story around the presentation and interaction with the Prefix hierarchy alone. I'm sure some of it will meaningfully carry over to other tree models like location, but there will be different use cases.

To start, I can see two primary views into the prefix data.

1. A collapsable, navigable tree. Filtering and sorting here, would only effect top level nodes in the tree.
2. A flat view that ignores the tree relationships in which all traditional filtering and sorting works.

As a user I would want a simple toggle between these two options in the list view.

I'm certain we can get more advanced from there.

In the first case, I care primary about drilling down into the data. In the second case, I care more about filtering and sorting, such as filtering by a location and then sorting by the role.
2 would be what I was looking for, I asked chatgpt for some starter code to describe what I think makes sense

```python
class MyTable(tables.Table):
    my_column = tables.TemplateColumn(template_name='my_column_template.html')

    def __init__(self, *args, **kwargs):
        super(MyTable, self).__init__(*args, **kwargs)
        
        # Check if there's an explicit sorting
        is_sorting = any(value for key, value in self.order_by_field.items() if value)

        # Add context to all columns (or you can target a specific column)
        for column in self.columns:
            column.extra_context.update({'is_sorting': is_sorting})
```

Within the `PREFIX_LINK` variable have something like

```python
PREFIX_LINK = """
{% load helpers %}
{% if not is_sorting %}
{% for i in record.ancestors.count|as_range %}
    <i class="mdi mdi-circle-small"></i>
{% endfor %}
{% endif %}
```
For now, we will allow the sorting and when a user actually does sort, we will dynamically remove the hierarchy indentation.
We had a request today regarding sorting in Locations, and I'm assuming it's missing due to the same reason. @lampwins do you want me to create a parallel issue?
I don't think so. We would support sorting any column that it should be, in this model.

Resolving patch:
diff --git a/nautobot/core/api/filter_backends.py b/nautobot/core/api/filter_backends.py
--- a/nautobot/core/api/filter_backends.py
+++ b/nautobot/core/api/filter_backends.py
@@ -1,4 +1,6 @@
 from django_filters.rest_framework.backends import DjangoFilterBackend
+from rest_framework.filters import OrderingFilter
+from tree_queries.models import TreeNode
 
 
 class NautobotFilterBackend(DjangoFilterBackend):
@@ -28,3 +30,14 @@ def get_filterset_kwargs(self, request, queryset, view):
 
         kwargs["data"] = data
         return kwargs
+
+
+class NautobotOrderingFilter(OrderingFilter):
+    """Custom Ordering Filter backend."""
+
+    def filter_queryset(self, request, queryset, view):
+        filtered_queryset = super().filter_queryset(request, queryset, view)
+        ordering = self.get_ordering(request, queryset, view)
+        if ordering and issubclass(queryset.model, TreeNode):
+            filtered_queryset = filtered_queryset.extra(order_by=ordering)
+        return filtered_queryset
diff --git a/nautobot/core/settings.py b/nautobot/core/settings.py
--- a/nautobot/core/settings.py
+++ b/nautobot/core/settings.py
@@ -193,7 +193,7 @@
     ),
     "DEFAULT_FILTER_BACKENDS": (
         "nautobot.core.api.filter_backends.NautobotFilterBackend",
-        "rest_framework.filters.OrderingFilter",
+        "nautobot.core.api.filter_backends.NautobotOrderingFilter",
     ),
     "DEFAULT_METADATA_CLASS": "nautobot.core.api.metadata.NautobotMetadata",
     "DEFAULT_PAGINATION_CLASS": "nautobot.core.api.pagination.OptionalLimitOffsetPagination",


Test patch:
diff --git a/nautobot/core/tests/test_api.py b/nautobot/core/tests/test_api.py
--- a/nautobot/core/tests/test_api.py
+++ b/nautobot/core/tests/test_api.py
@@ -18,13 +18,16 @@
 from nautobot.core import testing
 from nautobot.core.api.parsers import NautobotCSVParser
 from nautobot.core.api.renderers import NautobotCSVRenderer
+from nautobot.core.api.utils import get_serializer_for_model
 from nautobot.core.api.versioning import NautobotAPIVersioning
 from nautobot.core.constants import COMPOSITE_KEY_SEPARATOR
+from nautobot.core.utils.lookup import get_route_for_model
 from nautobot.dcim import models as dcim_models
 from nautobot.dcim.api import serializers as dcim_serializers
 from nautobot.extras import choices, models as extras_models
 from nautobot.ipam import models as ipam_models
 from nautobot.ipam.api import serializers as ipam_serializers
+from nautobot.tenancy import models as tenancy_models
 
 User = get_user_model()
 
@@ -780,6 +783,24 @@ def setUpTestData(cls):
             "DateTimeField": "created",
         }
 
+    def _validate_sorted_response(self, response, queryset, field_name, is_fk_field=False):
+        self.assertHttpStatus(response, 200)
+
+        # If the field is a foreign key field, we cannot guarantee the relative order of objects with the same value across multiple objects.
+        # Therefore, we directly compare the names of the foreign key objects.
+        if is_fk_field:
+            api_data = list(map(lambda p: p[field_name]["name"] if p[field_name] else None, response.data["results"]))
+            queryset_data = list(queryset.values_list(f"{field_name}__name", flat=True)[:10])
+        else:
+            api_data = list(map(lambda p: p["id"], response.data["results"]))
+            queryset_data = list(
+                map(
+                    lambda p: str(p),  # pylint: disable=unnecessary-lambda
+                    queryset.values_list("id", flat=True)[:10],
+                )
+            )
+        self.assertEqual(api_data, queryset_data)
+
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_ascending_sort(self):
         """Tests that results are returned in the expected ascending order."""
@@ -787,16 +808,7 @@ def test_ascending_sort(self):
         for field_type, field_name in self.field_type_map.items():
             with self.subTest(f"Testing {field_type}"):
                 response = self.client.get(f"{self.url}?sort={field_name}&limit=10", **self.header)
-                self.assertHttpStatus(response, 200)
-                self.assertEqual(
-                    list(map(lambda p: p["id"], response.data["results"])),
-                    list(
-                        map(
-                            lambda p: str(p),  # pylint: disable=unnecessary-lambda
-                            Provider.objects.order_by(field_name).values_list("id", flat=True)[:10],
-                        )
-                    ),
-                )
+                self._validate_sorted_response(response, Provider.objects.all().order_by(field_name), field_name)
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_descending_sort(self):
@@ -805,16 +817,64 @@ def test_descending_sort(self):
         for field_type, field_name in self.field_type_map.items():
             with self.subTest(f"Testing {field_type}"):
                 response = self.client.get(f"{self.url}?sort=-{field_name}&limit=10", **self.header)
-                self.assertHttpStatus(response, 200)
-                self.assertEqual(
-                    list(map(lambda p: p["id"], response.data["results"])),
-                    list(
-                        map(
-                            lambda p: str(p),  # pylint: disable=unnecessary-lambda
-                            Provider.objects.order_by(f"-{field_name}").values_list("id", flat=True)[:10],
-                        )
-                    ),
-                )
+                self._validate_sorted_response(response, Provider.objects.all().order_by(f"-{field_name}"), field_name)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_sorting_tree_node_models(self):
+        location_type = dcim_models.LocationType.objects.get(name="Campus")
+        locations = dcim_models.Location.objects.filter(location_type=location_type)
+        devices = dcim_models.Device.objects.all()
+
+        dcim_models.RackGroup.objects.create(name="Rack Group 0", location=locations[0])
+        dcim_models.InventoryItem.objects.create(
+            device=devices[0], name="Inventory Item 0", manufacturer=devices[0].device_type.manufacturer
+        )
+
+        for i in range(1, 3):
+            dcim_models.InventoryItem.objects.create(
+                name=f"Inventory Item {i}",
+                device=devices[i],
+                manufacturer=devices[i].device_type.manufacturer,
+                parent=dcim_models.InventoryItem.objects.all()[i - 1],
+            )
+            dcim_models.RackGroup.objects.create(
+                name=f"Rack Group {i}",
+                location=locations[i],
+                parent=dcim_models.RackGroup.objects.all()[i - 1],
+            )
+
+        tree_node_models = [
+            dcim_models.Location,
+            dcim_models.LocationType,
+            dcim_models.RackGroup,
+            dcim_models.InventoryItem,
+            tenancy_models.TenantGroup,
+        ]
+        # Each of the table has at-least two sortable field_names in the field_names
+        fk_fields = ["location", "parent", "location_type", "manufacturer"]
+        model_field_names = ["name", *fk_fields]
+        for model_class in tree_node_models:
+            url = reverse(get_route_for_model(model_class, "list", api=True))
+            serializer = get_serializer_for_model(model_class)
+            serializer_avail_fields = set(model_field_names) & set(serializer().fields.keys())
+            for field_name in serializer_avail_fields:
+                with self.subTest(f'Assert sorting "{model_class.__name__}" using "{field_name}" field name.'):
+                    response = self.client.get(f"{url}?sort={field_name}&limit=10&depth=1", **self.header)
+                    self._validate_sorted_response(
+                        response=response,
+                        queryset=model_class.objects.extra(order_by=[field_name]),
+                        field_name=field_name,
+                        is_fk_field=field_name in fk_fields,
+                    )
+
+                with self.subTest(f'Assert inverse sorting "{model_class.__name__}" using "{field_name}" field name.'):
+                    response = self.client.get(f"{url}?sort=-{field_name}&limit=10&depth=1", **self.header)
+                    self._validate_sorted_response(
+                        response=response,
+                        queryset=model_class.objects.extra(order_by=[f"-{field_name}"]),
+                        field_name=field_name,
+                        is_fk_field=field_name in fk_fields,
+                    )
 
 
 class NewUIGetMenuAPIViewTestCase(testing.APITestCase):