diff --git a/seed/migrations/0212_add_filtergroup_labels.py b/seed/migrations/0212_add_filtergroup_labels.py new file mode 100644 index 0000000000..7bde1badd3 --- /dev/null +++ b/seed/migrations/0212_add_filtergroup_labels.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.18 on 2023-12-19 16:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('seed', '0211_auto_20240109_1348'), + ] + + operations = [ + migrations.AddField( + model_name='filtergroup', + name='and_labels', + field=models.ManyToManyField(related_name='and_filter_groups', to='seed.StatusLabel'), + ), + migrations.AddField( + model_name='filtergroup', + name='exclude_labels', + field=models.ManyToManyField(related_name='exclude_filter_groups', to='seed.StatusLabel'), + ), + migrations.AddField( + model_name='filtergroup', + name='or_labels', + field=models.ManyToManyField(related_name='or_filter_groups', to='seed.StatusLabel'), + ), + ] diff --git a/seed/migrations/0213_move_filtergroup_labels.py b/seed/migrations/0213_move_filtergroup_labels.py new file mode 100644 index 0000000000..6c38be2d0f --- /dev/null +++ b/seed/migrations/0213_move_filtergroup_labels.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.18 on 2023-12-19 16:15 + +from django.db import migrations + + +def move_filter_groups(apps, schema_editor): + FilterGroup = apps.get_model('seed', 'FilterGroup') + for filter_group in FilterGroup.objects.all(): + if filter_group.labels.exists() and filter_group.label_logic == 0: # and + filter_group.and_labels.set(filter_group.labels.all()) + elif filter_group.labels.exists() and filter_group.label_logic == 1: # or + filter_group.or_labels.set(filter_group.labels.all()) + elif filter_group.labels.exists() and filter_group.label_logic == 2: # exclude + filter_group.exclude_labels.set(filter_group.labels.all()) + + +def move_filter_groups_back(apps, schema_editor): + # if the filter group only has one set of labels, we can move those back + FilterGroup = apps.get_model('seed', 'FilterGroup') + for filter_group in FilterGroup.objects.all(): + if filter_group.and_labels.exists() and not (filter_group.or_labels.exists() or filter_group.exclude_labels.exists()): + filter_group.labels.set(filter_group.and_labels.all()) + filter_group.label_logic = 0 + filter_group.save() + elif filter_group.or_labels.exists() and not (filter_group.and_labels.exists() or filter_group.exclude_labels.exists()): + filter_group.labels.set(filter_group.or_labels.all()) + filter_group.label_logic = 1 + filter_group.save() + elif filter_group.exclude_labels.exists() and not (filter_group.and_labels.exists() or filter_group.or_labels.exists()): + filter_group.labels.set(filter_group.exclude_labels.all()) + filter_group.label_logic = 2 + filter_group.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('seed', '0212_add_filtergroup_labels'), + ] + + operations = [ + migrations.RunPython(move_filter_groups, move_filter_groups_back) + ] diff --git a/seed/migrations/0214_delete_filtergroup_labels.py b/seed/migrations/0214_delete_filtergroup_labels.py new file mode 100644 index 0000000000..e3dff07818 --- /dev/null +++ b/seed/migrations/0214_delete_filtergroup_labels.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.18 on 2023-12-19 16:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('seed', '0213_move_filtergroup_labels'), + ] + + operations = [ + migrations.RemoveField( + model_name='filtergroup', + name='label_logic', + ), + migrations.RemoveField( + model_name='filtergroup', + name='labels', + ), + ] diff --git a/seed/models/data_views.py b/seed/models/data_views.py index eb4686f0f8..5809d0e526 100644 --- a/seed/models/data_views.py +++ b/seed/models/data_views.py @@ -14,7 +14,6 @@ from seed.models.columns import Column from seed.models.cycles import Cycle from seed.models.filter_group import FilterGroup -from seed.models.models import StatusLabel as Label from seed.models.properties import PropertyState, PropertyView from seed.utils.search import build_view_filters_and_sorts @@ -243,22 +242,24 @@ def _combine_views(self, filter_views, label_views): return list(filter_views) def _get_label_views(self, cycle, filter_group): - if len(filter_group.labels.all()) == 0: + if not (filter_group.and_labels.exists() or filter_group.or_labels.exists() or filter_group.exclude_labels.exists()): return None - logic = filter_group.label_logic - labels = Label.objects.filter(id__in=filter_group.labels.all()) - - if logic == 0: # and - views_all = [] - for label in labels: - views = cycle.propertyview_set.filter(labels__in=[label]) - views_all.append(views) - return list(set.intersection(*map(set, views_all))) - elif logic == 1: # or - return list(cycle.propertyview_set.filter(labels__in=labels)) - elif logic == 2: # exclude - return list(cycle.propertyview_set.exclude(labels__in=labels)) + and_labels = filter_group.and_labels.all() + or_labels = filter_group.or_labels.all() + exclude_labels = filter_group.exclude_labels.all() + views = None + if and_labels.exists(): # and + views = views or cycle.propertyview_set.all() + for label in and_labels: + views = views.filter(labels=label) + if or_labels.exists(): # or + views = views or cycle.propertyview_set.all() + views = views.filter(labels__in=or_labels) + if exclude_labels.exists(): # exclude + views = views or cycle.propertyview_set.all() + views = views.exclude(labels__in=exclude_labels) + return list(views) def _get_filter_group_views(self, cycle, query_dict): org_id = self.organization.id diff --git a/seed/models/filter_group.py b/seed/models/filter_group.py index eb508b9966..96a94f7d51 100644 --- a/seed/models/filter_group.py +++ b/seed/models/filter_group.py @@ -12,13 +12,6 @@ ) from seed.models.models import StatusLabel -AND = 0 -LABEL_LOGIC_TYPE = [ - (AND, 'and'), - (1, 'or'), - (2, 'exclude'), -] - class FilterGroup(models.Model): @@ -26,8 +19,9 @@ class FilterGroup(models.Model): organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name='filter_groups', null=False) inventory_type = models.IntegerField(choices=VIEW_LIST_INVENTORY_TYPE, default=VIEW_LIST_PROPERTY) query_dict = models.JSONField(null=False, default=dict) - labels = models.ManyToManyField(StatusLabel) - label_logic = models.IntegerField(choices=LABEL_LOGIC_TYPE, default=AND) + and_labels = models.ManyToManyField(StatusLabel, related_name='and_filter_groups') + or_labels = models.ManyToManyField(StatusLabel, related_name='or_filter_groups') + exclude_labels = models.ManyToManyField(StatusLabel, related_name='exclude_filter_groups') class Meta: ordering = ['id'] diff --git a/seed/serializers/filter_groups.py b/seed/serializers/filter_groups.py index 3c759d8999..948165d528 100644 --- a/seed/serializers/filter_groups.py +++ b/seed/serializers/filter_groups.py @@ -7,13 +7,12 @@ from rest_framework import serializers from seed.models import VIEW_LIST_INVENTORY_TYPE, FilterGroup -from seed.models.filter_group import LABEL_LOGIC_TYPE class FilterGroupSerializer(serializers.ModelSerializer): class Meta: model = FilterGroup - fields = ('name', 'query_dict', 'inventory_type', 'id', "organization_id", "labels", "label_logic") + fields = ('name', 'query_dict', 'inventory_type', 'id', "organization_id", "and_labels", "or_labels", "exclude_labels") extra_kwargs = { 'user': {'read_only': True}, 'organization': {'read_only': True}, @@ -24,7 +23,8 @@ def to_representation(self, instance): ret = super().to_representation(instance) ret["inventory_type"] = VIEW_LIST_INVENTORY_TYPE[ret["inventory_type"]][1] - ret["label_logic"] = LABEL_LOGIC_TYPE[ret["label_logic"]][1] - ret["labels"] = sorted(ret["labels"]) + ret["and_labels"] = sorted(ret["and_labels"]) + ret["or_labels"] = sorted(ret["or_labels"]) + ret["exclude_labels"] = sorted(ret["exclude_labels"]) return ret diff --git a/seed/static/seed/js/controllers/filter_group_modal_controller.js b/seed/static/seed/js/controllers/filter_group_modal_controller.js index 1defe3d9ca..ce233822b6 100644 --- a/seed/static/seed/js/controllers/filter_group_modal_controller.js +++ b/seed/static/seed/js/controllers/filter_group_modal_controller.js @@ -47,8 +47,9 @@ angular.module('BE.seed.controller.filter_group_modal', []).controller('filter_g name: $scope.newName, query_dict: $scope.data.query_dict, inventory_type: $scope.data.inventory_type, - labels: $scope.data.labels, - label_logic: $scope.data.label_logic + and_labels: $scope.data.and_labels, + or_labels: $scope.data.or_labels, + exclude_labels: $scope.data.exclude_labels, }) .then((result) => { $uibModalInstance.close(result); diff --git a/seed/static/seed/js/controllers/inventory_list_controller.js b/seed/static/seed/js/controllers/inventory_list_controller.js index 9119825e55..a42f361f02 100644 --- a/seed/static/seed/js/controllers/inventory_list_controller.js +++ b/seed/static/seed/js/controllers/inventory_list_controller.js @@ -123,9 +123,10 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li id: -1, name: '-- No filter --', inventory_type: $scope.inventory_type, - labels: [], - label_logic: 'and', - query_dict: {} + and_labels: [], + or_labels: [], + exclude_labels: [], + query_dict: {}, } ]; $scope.filterGroups = $scope.filterGroups.concat(filter_groups); @@ -139,18 +140,18 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li $scope.Modified = false; $scope.new_filter_group = () => { - const label_ids = []; - for (const label of $scope.selected_labels) { - label_ids.push(label.id); - } + const and_label_ids = $scope.selected_and_labels.map(l => l.id); + const or_label_ids = $scope.selected_or_labels.map(l => l.id); + const exclude_label_ids = $scope.selected_exclude_labels.map(l => l.id); const filter_group_inventory_type = $scope.inventory_type === 'properties' ? 'Property' : 'Tax Lot'; const query_dict = inventory_service.get_format_column_filters($scope.column_filters); const filterGroupData = { query_dict, inventory_type: filter_group_inventory_type, - labels: label_ids, - label_logic: $scope.labelLogic + and_labels: and_label_ids, + or_labels: or_label_ids, + exclude_labels: exclude_label_ids }; const modalInstance = $uibModal.open({ @@ -216,9 +217,17 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li }; $scope.save_filter_group = () => { - const label_ids = []; - for (const label of $scope.selected_labels) { - label_ids.push(label.id); + const and_label_ids = []; + const or_label_ids = []; + const exclude_label_ids = []; + for (const label of $scope.selected_and_labels) { + and_label_ids.push(label.id); + } + for (const label of $scope.selected_or_labels) { + or_label_ids.push(label.id); + } + for (const label of $scope.selected_exclude_labels) { + exclude_label_ids.push(label.id); } const filter_group_inventory_type = $scope.inventory_type === 'properties' ? 'Property' : 'Tax Lot'; const query_dict = inventory_service.get_format_column_filters($scope.column_filters); @@ -226,8 +235,9 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li name: $scope.currentFilterGroup.name, query_dict, inventory_type: filter_group_inventory_type, - labels: label_ids, - label_logic: $scope.labelLogic + and_labels: and_label_ids, + or_labels: or_label_ids, + exclude_labels: exclude_label_ids }; filter_groups_service.update_filter_group($scope.currentFilterGroup.id, filterGroupData).then((result) => { $scope.currentFilterGroup = result; @@ -238,29 +248,28 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li }); }; - // compare filters if different then true, then compare labels, and finally label_logic. All must be the same to return false + // compare filters if different then true, then compare labels. All must be the same to return false $scope.isModified = () => { if ($scope.currentFilterGroup == null) return false; if ($scope.filterGroups.length > 0) { const current_filters = inventory_service.get_format_column_filters($scope.column_filters); const saved_filters = $scope.currentFilterGroup.query_dict; - const current_labels = []; - for (const label of $scope.selected_labels) { - current_labels.push(label.id); - } - const saved_labels = $scope.currentFilterGroup.labels; - const current_label_logic = $scope.labelLogic; - const saved_label_logic = $scope.currentFilterGroup.label_logic; - if (!_.isEqual(current_filters, saved_filters)) { - $scope.Modified = true; - } else if (!_.isEqual(current_labels.sort(), saved_labels.sort())) { - $scope.Modified = true; - } else { - $scope.Modified = current_label_logic !== saved_label_logic; - } + const current_and_labels = new Set($scope.selected_and_labels.map(l => l.id)); + const current_or_labels = new Set($scope.selected_or_labels.map(l => l.id)); + const current_exclude_labels = new Set($scope.selected_exclude_labels.map(l => l.id)); + + const saved_and_labels = new Set($scope.currentFilterGroup.and_labels); + const saved_or_labels = new Set($scope.currentFilterGroup.or_labels); + const saved_exclude_labels = new Set($scope.currentFilterGroup.exclude_labels); + + $scope.Modified = !( + _.isEqual(current_filters, saved_filters) && + _.isEqual(current_and_labels, saved_and_labels) && + _.isEqual(current_or_labels, saved_or_labels) && + _.isEqual(current_exclude_labels, saved_exclude_labels) + ) } - return $scope.Modified; }; @@ -293,8 +302,10 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li filter_groups_service.save_last_filter_group($scope.currentFilterGroup.id, $scope.inventory_type); // Update labels - $scope.labelLogicUpdated($scope.currentFilterGroup.label_logic); - $scope.selected_labels = _.filter($scope.labels, (label) => _.includes($scope.currentFilterGroup.labels, label.id)); + $scope.isModified(); + $scope.selected_and_labels = _.filter($scope.labels, label => _.includes($scope.currentFilterGroup.and_labels, label.id)); + $scope.selected_or_labels = _.filter($scope.labels, label => _.includes($scope.currentFilterGroup.or_labels, label.id)); + $scope.selected_exclude_labels = _.filter($scope.labels, label => _.includes($scope.currentFilterGroup.exclude_labels, label.id)); $scope.filterUsingLabels(); // clear table filters @@ -460,13 +471,17 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li }; // Reduce labels to only records found in the current cycle - $scope.selected_labels = []; + $scope.selected_and_labels = []; + $scope.selected_or_labels = []; + $scope.selected_exclude_labels = []; const localStorageKey = `grid.${$scope.inventory_type}`; const localStorageLabelKey = `grid.${$scope.inventory_type}.labels`; - $scope.clear_labels = () => { - $scope.selected_labels = []; + $scope.clear_labels = function (action) { + if (action === 'and') $scope.selected_and_labels = []; + if (action === 'or') $scope.selected_or_labels = []; + if (action === 'exclude') $scope.selected_exclude_labels = []; $scope.filterUsingLabels(); }; @@ -543,19 +558,13 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li }); $scope.filterUsingLabels = () => { - inventory_service.saveSelectedLabels(localStorageLabelKey, _.map($scope.selected_labels, 'id')); + inventory_service.saveSelectedLabels(localStorageLabelKey, _.map($scope.selected_and_labels, 'id'), 'and'); + inventory_service.saveSelectedLabels(localStorageLabelKey, _.map($scope.selected_or_labels, 'id'), 'or'); + inventory_service.saveSelectedLabels(localStorageLabelKey, _.map($scope.selected_exclude_labels, 'id'), 'exclude'); $scope.load_inventory(1); $scope.isModified(); }; - $scope.labelLogic = localStorage.getItem('labelLogic'); - $scope.labelLogic = _.includes(['and', 'or', 'exclude'], $scope.labelLogic) ? $scope.labelLogic : 'and'; - $scope.labelLogicUpdated = (labelLogic) => { - $scope.labelLogic = labelLogic; - localStorage.setItem('labelLogic', $scope.labelLogic); - $scope.isModified(); - }; - /** Opens the update building labels modal. All further actions for labels happen with that modal and its related controller, @@ -1101,16 +1110,27 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li // add label filtering let include_ids; let exclude_ids; - if ($scope.selected_labels.length) { - if ($scope.labelLogic === 'and') { - const intersection = _.intersection.apply(null, _.map($scope.selected_labels, 'is_applied')); - include_ids = intersection.length ? intersection : [0]; - } else if ($scope.labelLogic === 'or') { - include_ids = _.union.apply(null, _.map($scope.selected_labels, 'is_applied')); - } else if ($scope.labelLogic === 'exclude') { - exclude_ids = _.intersection.apply(null, _.map($scope.selected_labels, 'is_applied')); + + if ($scope.selected_and_labels.length) { + let intersection = _.intersection.apply(null, _.map($scope.selected_and_labels, 'is_applied')); + include_ids = intersection.length ? intersection : [0]; + } + if ($scope.selected_or_labels.length) { + let union = _.union.apply(null, _.map($scope.selected_or_labels, 'is_applied')); + if (include_ids != undefined) { + if (_.intersection(include_ids, union).length) { + include_ids = _.intersection(include_ids, union); + } else { + include_ids = [0]; + } + } else { + include_ids = union; } } + if ($scope.selected_exclude_labels.length) { + exclude_ids = _.union.apply(null, _.map($scope.selected_exclude_labels, 'is_applied')); + } + return fn( page, chunk, @@ -1216,8 +1236,12 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li $scope.labels = _.filter(current_labels, (label) => !_.isEmpty(label.is_applied)); // load saved label filter - const ids = inventory_service.loadSelectedLabels(localStorageLabelKey); - $scope.selected_labels = _.filter($scope.labels, (label) => _.includes(ids, label.id)); + let ids = inventory_service.loadSelectedLabels(localStorageLabelKey, 'and'); + $scope.selected_and_labels = _.filter($scope.labels, (label) => _.includes(ids, label.id)); + ids = inventory_service.loadSelectedLabels(localStorageLabelKey, 'or'); + $scope.selected_or_labels = _.filter($scope.labels, (label) => _.includes(ids, label.id)); + ids = inventory_service.loadSelectedLabels(localStorageLabelKey, 'exclude'); + $scope.selected_exclude_labels = _.filter($scope.labels, (label) => _.includes(ids, label.id)); $scope.filterUsingLabels(); $scope.build_labels(); @@ -1385,10 +1409,18 @@ angular.module('BE.seed.controller.inventory_list', []).controller('inventory_li resolve: { ids: () => selectedViewIds, filter_header_string() { - if ($scope.selected_labels.length) { - return ['Filter Method: ""', $scope.labelLogic, '"", Filter Labels: "', $scope.selected_labels.map((label) => label.name).join(' - '), '"'].join(''); + if ($scope.selected_and_labels.length || $scope.selected_or_labels.length || $scope.selected_exclude_labels.length) { + return [ + 'Must Have Filter Labels: "', + $scope.selected_and_labels.map(label => label.name).join(' - '), + '",Include Any Filter Labels: "', + $scope.selected_or_labels.map(label => label.name).join(' - '), + '",Exclude Filter Labels: "', + $scope.selected_exclude_labels.map(label => label.name).join(' - '), + '"' + ].join(''); } - return 'Filter Method: ""none""'; + return 'Filter Labels: ""none""'; }, columns: () => _.map($scope.columns, 'name'), inventory_type: () => $scope.inventory_type, diff --git a/seed/static/seed/js/services/inventory_service.js b/seed/static/seed/js/services/inventory_service.js index 25b1cabde0..ea2e63dddb 100644 --- a/seed/static/seed/js/services/inventory_service.js +++ b/seed/static/seed/js/services/inventory_service.js @@ -1046,13 +1046,13 @@ angular.module('BE.seed.service.inventory', []).factory('inventory_service', [ } }); - inventory_service.saveSelectedLabels = (key, ids) => { - key += `.${user_service.get_organization().id}`; + inventory_service.saveSelectedLabels = (key, ids, action='') => { + key += `.${action}.${user_service.get_organization().id}`; localStorage.setItem(key, JSON.stringify(ids)); }; - inventory_service.loadSelectedLabels = (key) => { - key += `.${user_service.get_organization().id}`; + inventory_service.loadSelectedLabels = (key, action='') => { + key += `.${action}.${user_service.get_organization().id}`; return JSON.parse(localStorage.getItem(key)) || []; }; diff --git a/seed/static/seed/partials/inventory_list.html b/seed/static/seed/partials/inventory_list.html index ee88d5859c..f5869a4a9f 100644 --- a/seed/static/seed/partials/inventory_list.html +++ b/seed/static/seed/partials/inventory_list.html @@ -96,31 +96,30 @@

{$:: (inventory_type === 'taxlots' ? 'Tax Lots' : 'Properties') | translate
- +
- + - +
-
- AND - OR - EXCLUDE +
+
+ +
+ + + + +
+
+
+ +
+ + + +
diff --git a/seed/tests/test_data_view_views.py b/seed/tests/test_data_view_views.py index 17365cd63b..4af85aefe3 100644 --- a/seed/tests/test_data_view_views.py +++ b/seed/tests/test_data_view_views.py @@ -71,7 +71,7 @@ def setUp(self): inventory_type=1, # Tax Lot query_dict={f'year_built_{year_built_id}__lt': ['1950']}, ) - filter_group.labels.add(self.label_1.id) + filter_group.and_labels.add(self.label_1.id) filter_group.save() self.filter_groups.append(filter_group) @@ -826,9 +826,8 @@ def setUp(self): organization_id=self.org.id, inventory_type=1, # Property query_dict={f"extra_col_{self.extra_col.id}__gt": 1}, - label_logic=0 # and, ) - self.fg_and.labels.set([self.label2.id, self.label3.id]) + self.fg_and.and_labels.set([self.label2.id, self.label3.id]) self.fg_and.save() self.fg_or = FilterGroup.objects.create( @@ -836,9 +835,8 @@ def setUp(self): organization_id=self.org.id, inventory_type=1, # Property query_dict={f"extra_col_{self.extra_col.id}__gt": 1}, - label_logic=1 # or, ) - self.fg_or.labels.set([self.label2.id, self.label3.id]) + self.fg_or.or_labels.set([self.label2.id, self.label3.id]) self.fg_or.save() self.fg_exc = FilterGroup.objects.create( @@ -846,9 +844,8 @@ def setUp(self): organization_id=self.org.id, inventory_type=1, # Property query_dict={f"extra_col_{self.extra_col.id}__gt": 1}, - label_logic=2, # exclude ) - self.fg_exc.labels.set([self.label3.id, self.label4.id]) + self.fg_exc.exclude_labels.set([self.label3.id, self.label4.id]) self.fg_exc.save() # filter, labels diff --git a/seed/tests/test_filter_groups.py b/seed/tests/test_filter_groups.py index 3da8bfd607..408e83fb65 100644 --- a/seed/tests/test_filter_groups.py +++ b/seed/tests/test_filter_groups.py @@ -46,7 +46,7 @@ def setUp(self): inventory_type=1, # Tax Lot query_dict={'year_built__lt': ['1950']}, ) - self.filter_group.labels.add(self.status_label.id) + self.filter_group.and_labels.add(self.status_label.id) self.filter_group.save() def test_create_filter_group(self): @@ -57,8 +57,7 @@ def test_create_filter_group(self): "name": "new_filter_group", "inventory_type": "Tax Lot", "query_dict": {'year_built__lt': ['1950']}, - "label_logic": "exclude", - "labels": [self.status_label.id] + "exclude_labels": [self.status_label.id] }), content_type='application/json', **self.headers, @@ -74,8 +73,7 @@ def test_create_filter_group(self): self.assertEqual(self.org.id, data["organization_id"]) self.assertEqual("Tax Lot", data["inventory_type"]) self.assertEqual({'year_built__lt': ['1950']}, data["query_dict"]) - self.assertEqual("exclude", data["label_logic"]) - self.assertEqual([self.status_label.id], data["labels"]) + self.assertEqual([self.status_label.id], data["exclude_labels"]) def test_create_filter_group_no_name(self): # Action @@ -139,23 +137,6 @@ def test_create_filter_group_bad_inventory_type(self): # Assertion self.assertEqual(400, response.status_code) - def test_create_filter_group_bad_label_logic(self): - # Action - response = self.client.post( - reverse('api:v3:filter_groups-list'), - data=json.dumps({ - "name": "new_filter_group", - "inventory_type": "Tax Lot", - "query_dict": {'year_built__lt': ['1950']}, - "label_logic": "bad label logic", - }), - content_type='application/json', - **self.headers, - ) - - # Assertion - self.assertEqual(400, response.status_code) - def test_create_filter_group_label_doesnt_exist(self): # Action response = self.client.post( @@ -163,7 +144,7 @@ def test_create_filter_group_label_doesnt_exist(self): data=json.dumps({ "name": "new_filter_group", "inventory_type": "Tax Lot", - "labels": [-1, -2, self.status_label.id] + "and_labels": [-1, -2, self.status_label.id] }), content_type='application/json', **self.headers, @@ -175,7 +156,7 @@ def test_create_filter_group_label_doesnt_exist(self): self.assertEqual(response.json()["warnings"], 'labels with ids do not exist: -1, -2') data = response.json()["data"] - self.assertEqual([self.status_label.id], data["labels"]) + self.assertEqual([self.status_label.id], data["and_labels"]) def test_get_filter_group(self): # Action @@ -195,8 +176,9 @@ def test_get_filter_group(self): "organization_id": self.org.id, "inventory_type": "Tax Lot", "query_dict": {'year_built__lt': ['1950']}, - "labels": [self.status_label.id], - "label_logic": "and", + "and_labels": [self.status_label.id], + 'or_labels': [], + 'exclude_labels': [], } }, response.json(), @@ -246,8 +228,9 @@ def test_get_all_filter_group(self): 'name': 'test_filter_group', 'organization_id': self.org.id, 'query_dict': {'year_built__lt': ['1950']}, - "labels": [self.status_label.id], - "label_logic": "and", + "and_labels": [self.status_label.id], + 'or_labels': [], + 'exclude_labels': [], }, { 'id': second_filter_group.id, @@ -255,8 +238,9 @@ def test_get_all_filter_group(self): 'name': 'second_test_filter_group', 'organization_id': self.org.id, 'query_dict': {'year_built__lt': ['1950']}, - "labels": [], - "label_logic": "and", + "and_labels": [], + 'or_labels': [], + 'exclude_labels': [], } ], 'pagination': { @@ -296,8 +280,9 @@ def test_update_filter_group_name(self): "organization_id": self.org.id, "inventory_type": "Tax Lot", "query_dict": {'year_built__lt': ['1950']}, - "labels": [self.status_label.id], - "label_logic": "and", + "and_labels": [self.status_label.id], + 'or_labels': [], + 'exclude_labels': [], } }, response.json(), @@ -319,7 +304,7 @@ def test_update_filter_group_labels(self): args=[self.filter_group.id] ), data=json.dumps({ - "labels": [first_label.id, second_label.id], + "and_labels": [first_label.id, second_label.id], }), content_type='application/json', **self.headers @@ -336,14 +321,14 @@ def test_update_filter_group_labels(self): "organization_id": self.org.id, "inventory_type": "Tax Lot", "query_dict": {'year_built__lt': ['1950']}, - "labels": [first_label.id, second_label.id], - "label_logic": "and", + "and_labels": [first_label.id, second_label.id], + 'or_labels': [], + 'exclude_labels': [], } }, response.json(), ) - def test_update_filter_group_label_logic(self): # Action response = self.client.put( reverse( @@ -351,7 +336,8 @@ def test_update_filter_group_label_logic(self): args=[self.filter_group.id] ), data=json.dumps({ - "label_logic": "or", + "or_labels": [first_label.id], + "exclude_labels": [second_label.id] }), content_type='application/json', **self.headers @@ -368,8 +354,57 @@ def test_update_filter_group_label_logic(self): "organization_id": self.org.id, "inventory_type": "Tax Lot", "query_dict": {'year_built__lt': ['1950']}, - "labels": [self.status_label.id], - "label_logic": "or", + "and_labels": [], + "or_labels": [first_label.id], + "exclude_labels": [second_label.id] + } + }, + response.json(), + ) + + def test_update_filter_group_bad_labels(self): + # Setup + first_label = StatusLabel.objects.create( + name='1', super_organization=self.org + ) + + # Action + response = self.client.put( + reverse( + 'api:v3:filter_groups-detail', + args=[self.filter_group.id] + ), + data=json.dumps({ + "and_labels": [first_label.id, -1], + "or_labels": [first_label.id, -1], + }), + content_type='application/json', + **self.headers + ) + + # Assertion + self.assertEqual(400, response.status_code) + + # Action + response = self.client.get( + reverse('api:v3:filter_groups-detail', args=[self.filter_group.id]), + **self.headers + ) + + # Assertion + self.assertEqual(200, response.status_code) + self.assertDictEqual( + { + 'status': 'success', + 'data': { + "id": self.filter_group.id, + "name": "test_filter_group", + "organization_id": self.org.id, + "inventory_type": "Tax Lot", + "query_dict": {'year_built__lt': ['1950']}, + "and_labels": [self.status_label.id], + 'or_labels': [], + 'exclude_labels': [], } }, response.json(), diff --git a/seed/views/v3/filter_group.py b/seed/views/v3/filter_group.py index 97e66fc884..bd9ce4f2f1 100644 --- a/seed/views/v3/filter_group.py +++ b/seed/views/v3/filter_group.py @@ -10,7 +10,6 @@ from seed.decorators import ajax_request_class from seed.models import VIEW_LIST_INVENTORY_TYPE, FilterGroup -from seed.models.filter_group import LABEL_LOGIC_TYPE from seed.models.models import StatusLabel from seed.serializers.filter_groups import FilterGroupSerializer from seed.utils.api_schema import swagger_auto_schema_org_query_param @@ -21,10 +20,6 @@ def _get_inventory_type_int(inventory_type: str) -> int: return next(k for k, v in VIEW_LIST_INVENTORY_TYPE if v == inventory_type) -def _get_label_logic_int(label_logic: str) -> int: - return next(k for k, v in LABEL_LOGIC_TYPE if v == label_logic) - - @method_decorator( name='retrieve', decorator=swagger_auto_schema_org_query_param) @@ -44,8 +39,9 @@ def create(self, request): name = body.get('name') inventory_type = body.get('inventory_type') query_dict = body.get('query_dict', {}) - label_logic = body.get('label_logic', "and") - label_ids = body.get('labels', []) + and_label_ids = body.get('and_labels', []) + or_label_ids = body.get('or_labels', []) + exclude_label_ids = body.get('exclude_labels', []) if not name: return JsonResponse({ @@ -67,21 +63,12 @@ def create(self, request): 'message': 'invalid "inventory_type" must be "Property" or "Tax Lot"' }, status=status.HTTP_400_BAD_REQUEST) - try: - label_logic_int = _get_label_logic_int(label_logic) - except StopIteration: - return JsonResponse({ - 'success': False, - 'message': 'invalid "label_logic" must be "and", "or", or "exclude"' - }, status=status.HTTP_400_BAD_REQUEST) - try: filter_group = FilterGroup.objects.create( name=name, organization_id=org_id, inventory_type=inventory_type_int, query_dict=query_dict, - label_logic=label_logic_int, ) except IntegrityError as e: return JsonResponse({ @@ -89,17 +76,26 @@ def create(self, request): 'message': str(e) }, status=status.HTTP_400_BAD_REQUEST) - good_label_ids, bad_label_ids = self._get_labels(label_ids) - filter_group.labels.add(*good_label_ids) - filter_group.save() + all_bad_label_ids = set() + good_label_ids, bad_label_ids = self._get_labels(and_label_ids) + all_bad_label_ids.update(bad_label_ids) + filter_group.and_labels.add(*good_label_ids) + + good_label_ids, bad_label_ids = self._get_labels(or_label_ids) + all_bad_label_ids.update(bad_label_ids) + filter_group.or_labels.add(*good_label_ids) + + good_label_ids, bad_label_ids = self._get_labels(exclude_label_ids) + all_bad_label_ids.update(bad_label_ids) + filter_group.exclude_labels.add(*good_label_ids) result = { "status": 'success', "data": FilterGroupSerializer(filter_group).data, } - if len(bad_label_ids) > 0: - result["warnings"] = f"labels with ids do not exist: {', '.join([str(id) for id in bad_label_ids])}" + if len(all_bad_label_ids) > 0: + result["warnings"] = f"labels with ids do not exist: {', '.join([str(id) for id in all_bad_label_ids])}" return JsonResponse(result, status=status.HTTP_201_CREATED) @@ -125,19 +121,17 @@ def update(self, request, pk=None): else: filter_group.inventory_type = inventory_type_int - if "label_logic" in request.data: - try: - label_logic_int = _get_label_logic_int(request.data["label_logic"]) - except StopIteration: + if "and_labels" in request.data or "or_labels" in request.data or "exclude_labels" in request.data: + _, bad_label_ids = self._get_labels(request.data.get("and_labels", []) + request.data.get("or_labels", []) + request.data.get("exclude_labels", [])) + if bad_label_ids: return JsonResponse({ 'success': False, - 'message': 'invalid "label_logic" must be "and", "or", or "exclude"' + 'message': f'invalid label ids: {", ".join([str(i) for i in set(bad_label_ids)])}' }, status=status.HTTP_400_BAD_REQUEST) - else: - filter_group.label_logic = label_logic_int - if "labels" in request.data: - filter_group.labels.set(request.data["labels"]) + filter_group.and_labels.set(request.data.get("and_labels", [])) + filter_group.or_labels.set(request.data.get("or_labels", [])) + filter_group.exclude_labels.set(request.data.get("exclude_labels", [])) filter_group.save()