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.
Reopening this issue, needs to be implemented on the Prefix list view, and needs to be implemented when filtering a tree list view as well as sorting

Resolving patch:
diff --git a/nautobot/ipam/tables.py b/nautobot/ipam/tables.py
--- a/nautobot/ipam/tables.py
+++ b/nautobot/ipam/tables.py
@@ -42,28 +42,10 @@
 {% if record.present_in_database %}{% utilization_graph record.get_utilization %}{% else %}&mdash;{% endif %}
 """
 
-PREFIX_LINK = """
-{% load helpers %}
-{% for i in record.ancestors.count|as_range %}
-    <i class="mdi mdi-circle-small"></i>
-{% endfor %}
-    <a href="\
-{% if record.present_in_database %}\
-{% url 'ipam:prefix' pk=record.pk %}\
-{% else %}\
-{% url 'ipam:prefix_add' %}\
-?prefix={{ record }}&namespace={{ object.namespace.pk }}\
-{% for loc in object.locations.all %}&locations={{ loc.pk }}{% endfor %}\
-{% if object.tenant %}&tenant_group={{ object.tenant.tenant_group.pk }}&tenant={{ object.tenant.pk }}{% endif %}\
-{% endif %}\
-">{{ record.prefix }}</a>
-"""
 
 PREFIX_COPY_LINK = """
 {% load helpers %}
-{% for i in record.ancestors.count|as_range %}
-    <i class="mdi mdi-circle-small"></i>
-{% endfor %}
+{% tree_hierarchy_ui_representation record.ancestors.count|as_range table.order_by %}
 <span class="hover_copy">
   <a href="\
 {% if record.present_in_database %}\
@@ -369,7 +351,7 @@ class PrefixTable(StatusTableMixin, RoleTableMixin, BaseTable):
     namespace = tables.Column(linkify=True)
     vlan = tables.Column(linkify=True, verbose_name="VLAN")
     rir = tables.Column(linkify=True)
-    children = tables.Column(accessor="descendants_count")
+    children = tables.Column(accessor="descendants_count", orderable=False)
     date_allocated = tables.DateTimeColumn()
     location_count = LinkedCountColumn(
         viewname="dcim:location_list", url_params={"prefixes": "pk"}, verbose_name="Locations"
@@ -377,7 +359,6 @@ class PrefixTable(StatusTableMixin, RoleTableMixin, BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Prefix
-        orderable = False
         fields = (
             "pk",
             "prefix",


Test patch:
diff --git a/nautobot/ipam/tests/test_tables.py b/nautobot/ipam/tests/test_tables.py
new file mode 100644
--- /dev/null
+++ b/nautobot/ipam/tests/test_tables.py
@@ -0,0 +1,42 @@
+from django.test import TestCase
+
+from nautobot.core.models.querysets import count_related
+from nautobot.dcim.models.locations import Location
+from nautobot.ipam.models import Prefix
+from nautobot.ipam.tables import PrefixTable
+
+
+class PrefixTableTestCase(TestCase):
+    def _validate_sorted_queryset_same_with_table_queryset(self, queryset, table_class, field_name):
+        with self.subTest(f"Assert sorting {table_class.__name__} on '{field_name}'"):
+            table = table_class(queryset, order_by=field_name)
+            table_queryset_data = table.data.data.values_list("pk", flat=True)
+            sorted_queryset = queryset.order_by(field_name).values_list("pk", flat=True)
+            self.assertEqual(list(table_queryset_data), list(sorted_queryset))
+
+    def test_prefix_table_sort(self):
+        """Assert TreeNode model table are orderable."""
+        # Due to MySQL's lack of support for combining 'LIMIT' and 'ORDER BY' in a single query,
+        # hence this approach.
+        pk_list = Prefix.objects.all().values_list("pk", flat=True)[:20]
+        pk_list = [str(pk) for pk in pk_list]
+        queryset = Prefix.objects.filter(pk__in=pk_list)
+
+        # Assets model names
+        table_avail_fields = ["tenant", "vlan", "namespace"]
+        for table_field_name in table_avail_fields:
+            self._validate_sorted_queryset_same_with_table_queryset(queryset, PrefixTable, table_field_name)
+            self._validate_sorted_queryset_same_with_table_queryset(queryset, PrefixTable, f"-{table_field_name}")
+
+        # Assert `prefix`
+        table_queryset_data = PrefixTable(queryset, order_by="prefix").data.data.values_list("pk", flat=True)
+        prefix_queryset = queryset.order_by("network", "prefix_length").values_list("pk", flat=True)
+        self.assertEqual(list(table_queryset_data), list(prefix_queryset))
+        table_queryset_data = PrefixTable(queryset, order_by="-prefix").data.data.values_list("pk", flat=True)
+        prefix_queryset = queryset.order_by("-network", "-prefix_length").values_list("pk", flat=True)
+        self.assertEqual(list(table_queryset_data), list(prefix_queryset))
+
+        # Assets `location_count`
+        location_count_queryset = queryset.annotate(location_count=count_related(Location, "prefixes")).all()
+        self._validate_sorted_queryset_same_with_table_queryset(location_count_queryset, PrefixTable, "location_count")
+        self._validate_sorted_queryset_same_with_table_queryset(location_count_queryset, PrefixTable, "-location_count")