diff --git a/seed/static/seed/js/controllers/inventory_list_controller.js b/seed/static/seed/js/controllers/inventory_list_controller.js index a0326bbfc1..48f6be3d72 100644 --- a/seed/static/seed/js/controllers/inventory_list_controller.js +++ b/seed/static/seed/js/controllers/inventory_list_controller.js @@ -1148,16 +1148,8 @@ angular.module('SEED.controller.inventory_list', []).controller('inventory_list_ ); } - // disable sorting (but not filtering) on related data until the backend can filter/sort over two models for (const i in $scope.columns) { const column = $scope.columns[i]; - if (column.related) { - column.enableSorting = false; - // let title = 'Filtering disabled for property columns on the taxlot list.'; - // if ($scope.inventory_type === 'properties') { - // title = 'Filtering disabled for taxlot columns on the property list.'; - // } - } if (column.derived_column != null) { column.enableSorting = false; const title = 'Sorting and filtering disabled for derived columns.'; diff --git a/seed/tests/test_search.py b/seed/tests/test_search.py index 135626e583..b30c5840f2 100644 --- a/seed/tests/test_search.py +++ b/seed/tests/test_search.py @@ -9,7 +9,7 @@ import pytest from django.db import models -from django.db.models import Q +from django.db.models import Min, Q from django.db.models.fields.json import KeyTextTransform from django.db.models.functions import Cast, Coalesce, Collate, Replace from django.http.request import QueryDict @@ -312,6 +312,39 @@ class TestCase: f'Failed "{test_case.name}"; actual: {annotations}; expected: {test_case.expected_annotations}', ) + def test_order_by_related_columns(self): + columns = Column.retrieve_all(self.fake_org, "property", only_used=False, include_related=True) + related_column = Column.objects.get(table_name="TaxLotState", column_name="jurisdiction_tax_lot_id") + query_dict = QueryDict(f"order_by=jurisdiction_tax_lot_id_{related_column.id}") + + _, annotations, order_by = build_view_filters_and_sorts(query_dict, columns, "property") + + annotation_key = f"related_jurisdiction_tax_lot_id_{related_column.id}_sort" + self.assertEqual(len(order_by), 1) + self.assertIsInstance(order_by[0], Collate) + self.assertIn(annotation_key, annotations) + self.assertIsInstance(annotations[annotation_key], Min) + + def test_order_by_related_extra_data_columns(self): + Column.objects.create( + column_name="related_extra", + data_type="string", + is_extra_data=True, + table_name="TaxLotState", + organization=self.fake_org, + ) + columns = Column.retrieve_all(self.fake_org, "property", only_used=False, include_related=True) + related_column = Column.objects.get(table_name="TaxLotState", column_name="related_extra") + query_dict = QueryDict(f"order_by=related_extra_{related_column.id}") + + _, annotations, order_by = build_view_filters_and_sorts(query_dict, columns, "property") + + annotation_key = f"related_related_extra_{related_column.id}_sort" + self.assertEqual(len(order_by), 1) + self.assertIsInstance(order_by[0], Collate) + self.assertIn(annotation_key, annotations) + self.assertIsInstance(annotations[annotation_key], Min) + def test_filter_and_sorts_parser_annotations_works(self): # -- Setup # create extra data column with a number type diff --git a/seed/utils/search.py b/seed/utils/search.py index 0bd4cc7fc0..ad05ddf6bc 100644 --- a/seed/utils/search.py +++ b/seed/utils/search.py @@ -13,7 +13,7 @@ from typing import Any, Union from django.db import models -from django.db.models import Case, IntegerField, Q, Value, When +from django.db.models import Case, IntegerField, Min, Q, Value, When from django.db.models.fields.json import KeyTextTransform from django.db.models.functions import Cast, Coalesce, Collate, Replace from django.http.request import QueryDict @@ -311,6 +311,36 @@ def _build_extra_data_annotations(column_name: str, data_type: str) -> tuple[str return final_field_name, annotations +def _build_related_extra_data_expression(column_name: str, data_type: str, state_prefix: str): + """ + Build a casted expression for a related column's extra_data field. + + This mirrors the conversions in `_build_extra_data_annotations`, but allows + specifying which state relationship to traverse (property vs taxlot). + """ + json_path = f"{state_prefix}__extra_data" + expression = KeyTextTransform(column_name, json_path) + + if data_type == "integer": + expression = Cast( + Replace(expression, models.Value(","), models.Value("")), + output_field=models.IntegerField(), + ) + elif data_type in {"number", "float", "area", "eui", "ghg", "ghg_intensity"}: + expression = Cast( + Replace(expression, models.Value(","), models.Value("")), + output_field=models.FloatField(), + ) + elif data_type in {"date", "datetime"}: + expression = Cast(expression, output_field=models.DateField()) + elif data_type == "boolean": + expression = Cast(expression, output_field=models.BooleanField()) + else: + expression = Coalesce(expression, models.Value(""), output_field=models.TextField()) + + return expression + + def _parse_view_filter( filter_expression: str, filter_value: Union[str, bool], @@ -371,7 +401,11 @@ def _parse_view_filter( def _parse_view_sort( - sort_expression: str, columns_by_name: dict[str, dict], inventory_type: str, access_level_names: list[str] + sort_expression: str, + columns_by_name: dict[str, dict], + related_columns_by_name: dict[str, dict], + inventory_type: str, + access_level_names: list[str], ) -> tuple[Union[None, str, Collate], AnnotationDict]: """Parse a sort expression @@ -401,6 +435,26 @@ def _parse_view_sort( return f"{direction}{new_field_name}", annotations else: return f"{direction}state__{column_name}", {} + elif column_name in related_columns_by_name: + column = related_columns_by_name[column_name] + related_prefix = "taxlotproperty__taxlot_view__" if inventory_type == "property" else "taxlotproperty__property_view__" + state_prefix = f"{related_prefix}state" + annotation_name = f"related_{column['name']}_sort" + + if column["is_extra_data"]: + expression = _build_related_extra_data_expression(column["column_name"], column["data_type"], state_prefix) + else: + expression = models.F(f"{state_prefix}__{column['column_name']}") + + annotations = {annotation_name: Min(expression)} + if column["data_type"] in {"None", "string"}: + order_expression = Collate(annotation_name, "natural_sort") + if direction: + order_expression = order_expression.desc() + else: + order_expression = f"{direction}{annotation_name}" + + return order_expression, annotations elif column_name in access_level_names: return f"{direction}{inventory_type}__access_level_instance__path__{column_name}", {} else: @@ -447,10 +501,12 @@ def build_view_filters_and_sorts( :return: filters, annotations and sorts """ columns_by_name = {} + related_columns_by_name = {} for column in columns: if column["related"]: - continue - columns_by_name[column["name"]] = column + related_columns_by_name[column["name"]] = column + else: + columns_by_name[column["name"]] = column new_filters = Q() annotations = {} @@ -507,7 +563,9 @@ def build_view_filters_and_sorts( order_by = [] for sort_expression in filters.getlist("order_by", ["id"]): - parsed_sort, parsed_annotations = _parse_view_sort(sort_expression, columns_by_name, inventory_type, access_level_names) + parsed_sort, parsed_annotations = _parse_view_sort( + sort_expression, columns_by_name, related_columns_by_name, inventory_type, access_level_names + ) if parsed_sort is not None: order_by.append(parsed_sort) annotations.update(parsed_annotations)