diff --git a/docs/docs/report/index.md b/docs/docs/report/index.md index b18c0fb5d1..7190757fd8 100644 --- a/docs/docs/report/index.md +++ b/docs/docs/report/index.md @@ -100,6 +100,20 @@ If you enter an invalid option for the filter field, an error message will be di !!! warning "Advanced Users" Report filtering is an advanced topic, and requires a little bit of knowledge of the underlying data structure! +#### List Filtering + +To filter a queryset against a list of ID values, you can use the following "list" syntax: + +``` +item__in=[1,2,3] +``` + +Note that: + +- The list must be enclosed in square brackets `[]` +- The items in the list must be comma-separated +- The key must end with `__in` to indicate that it is a list filter (*note the double underscore*). + ### Metadata A JSON field made available to any [plugins](../plugins/index.md) - but not used by internal code. diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 21cb0f65ab..a6fc33d2d3 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -4,6 +4,7 @@ import datetime import hashlib import inspect import io +import json import os import os.path import re @@ -729,13 +730,14 @@ def extract_serial_numbers( return serials -def validateFilterString(value, model=None): +def validateFilterString(value: str, model=None) -> dict: """Validate that a provided filter string looks like a list of comma-separated key=value pairs. These should nominally match to a valid database filter based on the model being filtered. e.g. "category=6, IPN=12" e.g. "part__name=widget" + e.g. "item=[1,2,3], status=active" The ReportTemplate class uses the filter string to work out which items a given report applies to. For example, an acceptance test report template might only apply to stock items with a given IPN, @@ -753,7 +755,8 @@ def validateFilterString(value, model=None): if not value or len(value) == 0: return results - groups = value.split(',') + # Split by comma, but ignore commas within square brackets + groups = re.split(r',(?![^\[]*\])', value) for group in groups: group = group.strip() @@ -771,6 +774,16 @@ def validateFilterString(value, model=None): if not k or not v: raise ValidationError(f'Invalid group: {group}') + # Account for 'list' support + if v.startswith('[') and v.endswith(']'): + try: + v = json.loads(v) + except json.JSONDecodeError: + raise ValidationError(f'Invalid list value: {v}') + + if not isinstance(v, list): + raise ValidationError(f'Expected a list for key "{k}", got {type(v)}') + results[k] = v # If a model is provided, verify that the provided filters can be used against it diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index c709dce53f..b6ceba959c 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -349,6 +349,47 @@ class LabelTest(InvenTreeAPITestCase): self.assertEqual(output.plugin, 'inventreelabel') self.assertTrue(output.output.name.endswith('.pdf')) + def test_filters(self): + """Test that template filters are correctly validated.""" + from django.core.exceptions import ValidationError + + from InvenTree.helpers import validateFilterString + + invalid = [ + 'name=widget, category=6, invalid_field=123', + 'category__in=[1,', + 'foo=bar', + ] + + valid = [ + 'name=widget, category=6', + 'category__in=[1,2,3]', + 'name=widget , id__in = [99, 199 ] ', + 'pk__in=[1,2,3], active=True', + 'pk__in=[1, 99], category__in=[1,2,3]', + ] + + template = LabelTemplate.objects.filter(enabled=True, model_type='part').first() + + for f in invalid: + with self.assertRaises(ValidationError): + template.filters = f + template.clean() + + for f in valid: + template.filters = f + template.clean() + + # Test a specific example + example = ' location__in =[1,2 , 3 ] , status= 3 , id__in=[4,5,6] , part__active=False' + + result = validateFilterString(example, model=StockItem) + + self.assertEqual(result['location__in'], [1, 2, 3]) + self.assertEqual(result['status'], '3') + self.assertEqual(result['id__in'], [4, 5, 6]) + self.assertEqual(result['part__active'], 'False') + class PrintTestMixins: """Mixin that enables e2e printing tests.""" diff --git a/src/backend/InvenTree/report/validators.py b/src/backend/InvenTree/report/validators.py index ad9e8e0d33..d4cf06d3e4 100644 --- a/src/backend/InvenTree/report/validators.py +++ b/src/backend/InvenTree/report/validators.py @@ -13,7 +13,7 @@ def validate_report_model_type(value): raise ValidationError('Not a valid model type') -def validate_filters(value, model=None): +def validate_filters(value: str, model=None) -> dict: """Validate that the provided model filters are valid.""" from InvenTree.helpers import validateFilterString