diff --git a/.github/workflows/oas-check.yml b/.github/workflows/oas-check.yml index 334ca86f..0df85f2d 100644 --- a/.github/workflows/oas-check.yml +++ b/.github/workflows/oas-check.yml @@ -12,13 +12,8 @@ on: jobs: open-api-workflow-check-oas: uses: maykinmedia/open-api-workflows/.github/workflows/oas-check.yml@v1 - strategy: - matrix: - version: - - v2 with: - schema-path: 'src/objects/api/${{ matrix.version }}/openapi.yaml' - schema-options: "--api-version ${{ matrix.version }}" + schema-path: 'src/objects/api/v2/openapi.yaml' python-version: '3.11' django-settings-module: 'objects.conf.ci' apt-packages: 'libgdal-dev gdal-bin' diff --git a/src/objects/api/v2/filters.py b/src/objects/api/v2/filters.py index 442dfa18..6ed36484 100644 --- a/src/objects/api/v2/filters.py +++ b/src/objects/api/v2/filters.py @@ -1,6 +1,7 @@ from datetime import date as date_ from django import forms +from django.db.models import QuerySet from django.utils.translation import gettext_lazy as _ from django_filters import filters @@ -8,11 +9,91 @@ from vng_api_common.filtersets import FilterSet from objects.core.models import ObjectRecord, ObjectType -from objects.utils.filters import ObjectTypeFilter +from objects.utils.filters import ManyCharFilter, ObjectTypeFilter from ..constants import Operators from ..utils import display_choice_values_for_help_text, string_to_value -from ..validators import validate_data_attrs +from ..validators import validate_data_attr, validate_data_attrs + +DATA_ATTR_VALUE_HELP_TEXT = f"""A valid parameter value has the form `key__operator__value`. +`key` is the attribute name, `operator` is the comparison operator to be used and `value` is the attribute value. +Note: Values can be string, numeric, or dates (ISO format; YYYY-MM-DD). + +Valid operator values are: +{display_choice_values_for_help_text(Operators)} + +`value` may not contain double underscore or comma characters. +`key` may not contain comma characters and includes double underscore only if it indicates nested attributes. + +""" + +DATA_ATTRS_HELP_TEXT = ( + _( + """**DEPRECATED: Use 'data_attr' instead**. +Only include objects that have attributes with certain values. +Data filtering expressions are comma-separated and are structured as follows: + +%(value_part_help_text)s + +Example: in order to display only objects with `height` equal to 100, query `data_attrs=height__exact__100` +should be used. If `height` is nested inside `dimensions` attribute, query should look like +`data_attrs=dimensions__height__exact__100` + +`value` may not contain comma, since commas are used as separator between filtering expressions. +If you want to use commas in `value` you can use `data_attr` query parameter. +""" + ) + % {"value_part_help_text": DATA_ATTR_VALUE_HELP_TEXT} +) + +DATA_ATTR_HELP_TEXT = ( + _( + """Only include objects that have attributes with certain values. + +%(value_part_help_text)s + +Example: in order to display only objects with `height` equal to 100, query `data_attr=height__exact__100` +should be used. If `height` is nested inside `dimensions` attribute, query should look like +`data_attr=dimensions__height__exact__100` + +This filter is very similar to the old `data_attrs` filter, but it has two differences: + +* `value` may contain commas +* only one filtering expression is allowed + +If you want to use several filtering expressions, just use this `data_attr` several times in the query string. +Example: `data_attr=height__exact__100&data_attr=naam__icontains__boom` +""" + ) + % {"value_part_help_text": DATA_ATTR_VALUE_HELP_TEXT} +) + + +def filter_data_attr_value_part(value_part: str, queryset: QuerySet) -> QuerySet: + """ + filter one value part for data_attr and data_attrs filters + """ + variable, operator, str_value = value_part.rsplit("__", 2) + real_value = string_to_value(str_value) + + if operator == "exact": + # for exact operator try to filter on string and numeric values + in_vals = [str_value] + if real_value != str_value: + in_vals.append(real_value) + queryset = queryset.filter(**{f"data__{variable}__in": in_vals}) + elif operator == "icontains": + # icontains treats everything like strings + queryset = queryset.filter(**{f"data__{variable}__icontains": str_value}) + elif operator == "in": + # in must be a list + values = str_value.split("|") + queryset = queryset.filter(**{f"data__{variable}__in": values}) + + else: + # gt, gte, lt, lte operators + queryset = queryset.filter(**{f"data__{variable}__{operator}": real_value}) + return queryset class ObjectRecordFilterForm(forms.Form): @@ -58,29 +139,19 @@ class ObjectRecordFilterSet(FilterSet): "date would be between `registrationAt` attributes of different records" ), ) + data_attrs = filters.CharFilter( method="filter_data_attrs", validators=[validate_data_attrs], - help_text=_( - """Only include objects that have attributes with certain values. -Data filtering expressions are comma-separated and are structured as follows: -A valid parameter value has the form `key__operator__value`. -`key` is the attribute name, `operator` is the comparison operator to be used and `value` is the attribute value. -Note: Values can be string, numeric, or dates (ISO format; YYYY-MM-DD). - -Valid operator values are: -%(operator_choices)s - -`value` may not contain double underscore or comma characters. -`key` may not contain comma characters and includes double underscore only if it indicates nested attributes. + help_text=DATA_ATTRS_HELP_TEXT, + ) -Example: in order to display only objects with `height` equal to 100, query `data_attrs=height__exact__100` -should be used. If `height` is nested inside `dimensions` attribute, query should look like -`data_attrs=dimensions__height__exact__100` -""" - ) - % {"operator_choices": display_choice_values_for_help_text(Operators)}, + data_attr = ManyCharFilter( + method="filter_data_attr", + validators=[validate_data_attr], + help_text=DATA_ATTR_HELP_TEXT, ) + data_icontains = filters.CharFilter( method="filter_data_icontains", help_text=_("Search in all `data` values of string properties."), @@ -88,37 +159,20 @@ class ObjectRecordFilterSet(FilterSet): class Meta: model = ObjectRecord - fields = ("type", "data_attrs", "date", "registrationDate") + fields = ("type", "data_attrs", "data_attr", "date", "registrationDate") form = ObjectRecordFilterForm def filter_data_attrs(self, queryset, name, value: str): parts = value.split(",") for value_part in parts: - variable, operator, str_value = value_part.rsplit("__", 2) - real_value = string_to_value(str_value) - - if operator == "exact": - # for exact operator try to filter on string and numeric values - in_vals = [str_value] - if real_value != value: - in_vals.append(real_value) - queryset = queryset.filter(**{f"data__{variable}__in": in_vals}) - elif operator == "icontains": - # icontains treats everything like strings - queryset = queryset.filter( - **{f"data__{variable}__icontains": str_value} - ) - elif operator == "in": - # in must be a list - values = str_value.split("|") - queryset = queryset.filter(**{f"data__{variable}__in": values}) - - else: - # gt, gte, lt, lte operators - queryset = queryset.filter( - **{f"data__{variable}__{operator}": real_value} - ) + queryset = filter_data_attr_value_part(value_part, queryset) + + return queryset + + def filter_data_attr(self, queryset, name, value: list): + for value_part in value: + queryset = filter_data_attr_value_part(value_part, queryset) return queryset diff --git a/src/objects/api/v2/openapi.yaml b/src/objects/api/v2/openapi.yaml index a3274cbd..7fa40c26 100644 --- a/src/objects/api/v2/openapi.yaml +++ b/src/objects/api/v2/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Objects API - version: 2.4.3 (v2) + version: 2.4.3 description: | An API to manage Objects. @@ -88,13 +88,52 @@ paths: description: 'The desired ''Coordinate Reference System'' (CRS) of the response data. According to the GeoJSON spec, WGS84 is the default (EPSG: 4326 is the same as WGS84).' + - in: query + name: data_attr + schema: + type: string + description: | + Only include objects that have attributes with certain values. + + A valid parameter value has the form `key__operator__value`. + `key` is the attribute name, `operator` is the comparison operator to be used and `value` is the attribute value. + Note: Values can be string, numeric, or dates (ISO format; YYYY-MM-DD). + + Valid operator values are: + * `exact` - equal to + * `gt` - greater than + * `gte` - greater than or equal to + * `lt` - lower than + * `lte` - lower than or equal to + * `icontains` - case-insensitive partial match + * `in` - in a list of values separated by `|` + + `value` may not contain double underscore or comma characters. + `key` may not contain comma characters and includes double underscore only if it indicates nested attributes. + + + + Example: in order to display only objects with `height` equal to 100, query `data_attr=height__exact__100` + should be used. If `height` is nested inside `dimensions` attribute, query should look like + `data_attr=dimensions__height__exact__100` + + This filter is very similar to the old `data_attrs` filter, but it has two differences: + + * `value` may contain commas + * only one filtering expression is allowed + + If you want to use several filtering expressions, just use this `data_attr` several times in the query string. + Example: `data_attr=height__exact__100&data_attr=naam__icontains__boom` + explode: true - in: query name: data_attrs schema: type: string description: | + **DEPRECATED: Use 'data_attr' instead**. Only include objects that have attributes with certain values. Data filtering expressions are comma-separated and are structured as follows: + A valid parameter value has the form `key__operator__value`. `key` is the attribute name, `operator` is the comparison operator to be used and `value` is the attribute value. Note: Values can be string, numeric, or dates (ISO format; YYYY-MM-DD). @@ -111,9 +150,15 @@ paths: `value` may not contain double underscore or comma characters. `key` may not contain comma characters and includes double underscore only if it indicates nested attributes. + + Example: in order to display only objects with `height` equal to 100, query `data_attrs=height__exact__100` should be used. If `height` is nested inside `dimensions` attribute, query should look like `data_attrs=dimensions__height__exact__100` + + `value` may not contain comma, since commas are used as separator between filtering expressions. + If you want to use commas in `value` you can use `data_attr` query parameter. + deprecated: true - in: query name: data_icontains schema: @@ -620,8 +665,10 @@ paths: data_attrs: type: string description: | + **DEPRECATED: Use 'data_attr' instead**. Only include objects that have attributes with certain values. Data filtering expressions are comma-separated and are structured as follows: + A valid parameter value has the form `key__operator__value`. `key` is the attribute name, `operator` is the comparison operator to be used and `value` is the attribute value. Note: Values can be string, numeric, or dates (ISO format; YYYY-MM-DD). @@ -638,9 +685,48 @@ paths: `value` may not contain double underscore or comma characters. `key` may not contain comma characters and includes double underscore only if it indicates nested attributes. + + Example: in order to display only objects with `height` equal to 100, query `data_attrs=height__exact__100` should be used. If `height` is nested inside `dimensions` attribute, query should look like `data_attrs=dimensions__height__exact__100` + + `value` may not contain comma, since commas are used as separator between filtering expressions. + If you want to use commas in `value` you can use `data_attr` query parameter. + data_attr: + type: string + description: | + Only include objects that have attributes with certain values. + + A valid parameter value has the form `key__operator__value`. + `key` is the attribute name, `operator` is the comparison operator to be used and `value` is the attribute value. + Note: Values can be string, numeric, or dates (ISO format; YYYY-MM-DD). + + Valid operator values are: + * `exact` - equal to + * `gt` - greater than + * `gte` - greater than or equal to + * `lt` - lower than + * `lte` - lower than or equal to + * `icontains` - case-insensitive partial match + * `in` - in a list of values separated by `|` + + `value` may not contain double underscore or comma characters. + `key` may not contain comma characters and includes double underscore only if it indicates nested attributes. + + + + Example: in order to display only objects with `height` equal to 100, query `data_attr=height__exact__100` + should be used. If `height` is nested inside `dimensions` attribute, query should look like + `data_attr=dimensions__height__exact__100` + + This filter is very similar to the old `data_attrs` filter, but it has two differences: + + * `value` may contain commas + * only one filtering expression is allowed + + If you want to use several filtering expressions, just use this `data_attr` several times in the query string. + Example: `data_attr=height__exact__100&data_attr=naam__icontains__boom` date: type: string format: date diff --git a/src/objects/api/v2/views.py b/src/objects/api/v2/views.py index 9a73f420..ab2e4245 100644 --- a/src/objects/api/v2/views.py +++ b/src/objects/api/v2/views.py @@ -4,8 +4,12 @@ from django.db import models from django.utils.dateparse import parse_date -from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view +from drf_spectacular.utils import ( + OpenApiParameter, + OpenApiTypes, + extend_schema, + extend_schema_view, +) from rest_framework import mixins, viewsets from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 @@ -28,13 +32,32 @@ PermissionSerializer, ) from ..utils import is_date -from .filters import ObjectRecordFilterSet +from .filters import DATA_ATTR_HELP_TEXT, DATA_ATTRS_HELP_TEXT, ObjectRecordFilterSet + +# manually override OAS because of "deprecated" attribute +data_attrs_parameter = OpenApiParameter( + name="data_attrs", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description=DATA_ATTRS_HELP_TEXT, + deprecated=True, +) + +# manually override OAS because of "explode" attribute +data_attr_parameter = OpenApiParameter( + name="data_attr", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + description=DATA_ATTR_HELP_TEXT, + explode=True, +) @extend_schema_view( list=extend_schema( description="Retrieve a list of OBJECTs and their actual RECORD. " - "The actual record is defined as if the query parameter `date=` was given." + "The actual record is defined as if the query parameter `date=` was given.", + parameters=[data_attrs_parameter, data_attr_parameter], ), retrieve=extend_schema( description="Retrieve a single OBJECT and its actual RECORD. " diff --git a/src/objects/api/validators.py b/src/objects/api/validators.py index 11b86567..f0d49797 100644 --- a/src/objects/api/validators.py +++ b/src/objects/api/validators.py @@ -68,32 +68,55 @@ def __call__(self, new_value, serializer_field): raise serializers.ValidationError(self.message, code=self.code) +def validate_data_attr_value_part(value_part: str, code: str): + try: + variable, operator, val = value_part.rsplit("__", 2) + except ValueError: + message = _( + "Filter expression '%(value_part)s' doesn't have the shape 'key__operator__value'" + ) % {"value_part": value_part} + raise serializers.ValidationError(message, code=code) + + if operator not in Operators.values: + message = _("Comparison operator `%(operator)s` is unknown") % { + "operator": operator + } + raise serializers.ValidationError(message, code=code) + + if operator not in ( + Operators.exact, + Operators.icontains, + Operators.in_list, + ) and isinstance(string_to_value(val), str): + message = _( + "Operator `%(operator)s` supports only dates and/or numeric values" + ) % {"operator": operator} + raise serializers.ValidationError(message, code=code) + + def validate_data_attrs(value: str): + # todo remove when 'data_attrs' filter is removed code = "invalid-data-attrs-query" parts = value.split(",") for value_part in parts: - try: - variable, operator, val = value_part.rsplit("__", 2) - except ValueError as exc: - raise serializers.ValidationError(exc.args[0], code=code) from exc - - if operator not in Operators.values: - message = _("Comparison operator `%(operator)s` is unknown") % { - "operator": operator - } - raise serializers.ValidationError(message, code=code) + validate_data_attr_value_part(value_part, code) - if operator not in ( - Operators.exact, - Operators.icontains, - Operators.in_list, - ) and isinstance(string_to_value(val), str): + +def validate_data_attr(value: list): + code = "invalid-data-attr-query" + + for value_part in value: + # check that comma can be only in the value part + if "," in value_part.rsplit("__", 1)[0]: message = _( - "Operator `%(operator)s` supports only dates and/or numeric values" - ) % {"operator": operator} + "Filter expression '%(value_part)s' must have the shape 'key__operator__value', " + "commas can only be present in the 'value'" + ) % {"value_part": value_part} raise serializers.ValidationError(message, code=code) + validate_data_attr_value_part(value_part, code) + class GeometryValidator: code = "geometry-not-allowed" diff --git a/src/objects/tests/v2/test_filters.py b/src/objects/tests/v2/test_filters.py index f0e6eaa1..4b30c381 100644 --- a/src/objects/tests/v2/test_filters.py +++ b/src/objects/tests/v2/test_filters.py @@ -3,6 +3,7 @@ from django.db.utils import ProgrammingError +from furl import furl from rest_framework import status from rest_framework.test import APITestCase @@ -260,7 +261,10 @@ def test_filter_invalid_param(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.json(), ["not enough values to unpack (expected 3, got 2)"] + response.json(), + [ + "Filter expression 'diameter__exact' doesn't have the shape 'key__operator__value'" + ], ) def test_filter_nested_attr(self): @@ -428,6 +432,398 @@ def test_filter_in_string(self): ) +class FilterDataAttrTests(TokenAuthMixin, APITestCase): + url = reverse_lazy("object-list") + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.object_type = ObjectTypeFactory(service__api_root=OBJECT_TYPES_API) + PermissionFactory.create( + object_type=cls.object_type, + mode=PermissionModes.read_only, + token_auth=cls.token_auth, + ) + + def test_filter_exact_string(self): + record = ObjectRecordFactory.create( + data={"name": "demo"}, object__object_type=self.object_type + ) + ObjectRecordFactory.create( + data={"name": "demo2"}, object__object_type=self.object_type + ) + ObjectRecordFactory.create(data={}, object__object_type=self.object_type) + + response = self.client.get(self.url, {"data_attr": "name__exact__demo"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + def test_filter_exact_number(self): + record = ObjectRecordFactory.create( + data={"diameter": 4}, object__object_type=self.object_type + ) + ObjectRecordFactory.create( + data={"diameter": 6}, object__object_type=self.object_type + ) + ObjectRecordFactory.create(data={}, object__object_type=self.object_type) + + response = self.client.get(self.url, {"data_attr": "diameter__exact__4"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + def test_filter_exact_date(self): + record = ObjectRecordFactory.create( + data={"date": "2000-11-01"}, object__object_type=self.object_type + ) + ObjectRecordFactory.create( + data={"date": "2020-11-01"}, object__object_type=self.object_type + ) + ObjectRecordFactory.create(data={}, object__object_type=self.object_type) + + response = self.client.get(self.url, {"data_attr": "date__exact__2000-11-01"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + def test_filter_lte(self): + record1 = ObjectRecordFactory.create( + data={"diameter": 4}, object__object_type=self.object_type + ) + record2 = ObjectRecordFactory.create( + data={"diameter": 5}, object__object_type=self.object_type + ) + ObjectRecordFactory.create( + data={"diameter": 6}, object__object_type=self.object_type + ) + ObjectRecordFactory.create(data={}, object__object_type=self.object_type) + + response = self.client.get(self.url, {"data_attr": "diameter__lte__5"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + data = sorted(data, key=lambda x: x["record"]["data"]["diameter"]) + + self.assertEqual(len(data), 2) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record1.object.uuid])}", + ) + self.assertEqual( + data[1]["url"], + f"http://testserver{reverse('object-detail', args=[record2.object.uuid])}", + ) + + def test_filter_lt(self): + record = ObjectRecordFactory.create( + data={"diameter": 4}, object__object_type=self.object_type + ) + ObjectRecordFactory.create( + data={"diameter": 5}, object__object_type=self.object_type + ) + ObjectRecordFactory.create( + data={"diameter": 6}, object__object_type=self.object_type + ) + ObjectRecordFactory.create(data={}, object__object_type=self.object_type) + + response = self.client.get(self.url, {"data_attr": "diameter__lt__5"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + def test_filter_lte_not_numerical(self): + response = self.client.get(self.url, {"data_attr": "diameter__lt__value"}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), ["Operator `lt` supports only dates and/or numeric values"] + ) + + def test_filter_lte_date(self): + record = ObjectRecordFactory.create( + data={"date": "2000-11-01"}, object__object_type=self.object_type + ) + + response = self.client.get(self.url, {"data_attr": "date__lte__2000-12-01"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + response = self.client.get(self.url, {"data_attr": "date__lte__2000-10-01"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + self.assertEqual(len(data), 0) + + response = self.client.get(self.url, {"data_attr": "date__lte__2000-11-01"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + def test_filter_invalid_operator(self): + response = self.client.get(self.url, {"data_attr": "diameter__not__value"}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.json(), ["Comparison operator `not` is unknown"]) + + def test_filter_invalid_param(self): + response = self.client.get(self.url, {"data_attr": "diameter__exact"}) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + [ + "Filter expression 'diameter__exact' doesn't have the shape 'key__operator__value'" + ], + ) + + def test_filter_nested_attr(self): + record = ObjectRecordFactory.create( + data={"dimensions": {"diameter": 4}}, object__object_type=self.object_type + ) + ObjectRecordFactory.create( + data={"dimensions": {"diameter": 5}}, object__object_type=self.object_type + ) + ObjectRecordFactory.create( + data={"diameter": 4}, object__object_type=self.object_type + ) + ObjectRecordFactory.create(data={}, object__object_type=self.object_type) + + response = self.client.get( + self.url, {"data_attr": "dimensions__diameter__exact__4"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + def test_filter_icontains_string(self): + record = ObjectRecordFactory.create( + data={"name": "Something important"}, object__object_type=self.object_type + ) + ObjectRecordFactory.create( + data={"name": "Nothing important"}, object__object_type=self.object_type + ) + ObjectRecordFactory.create(data={}, object__object_type=self.object_type) + + response = self.client.get(self.url, {"data_attr": "name__icontains__some"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + def test_filter_icontains_numeric(self): + record = ObjectRecordFactory.create( + data={"diameter": 45}, object__object_type=self.object_type + ) + ObjectRecordFactory.create( + data={"diameter": 6}, object__object_type=self.object_type + ) + ObjectRecordFactory.create(data={}, object__object_type=self.object_type) + + response = self.client.get(self.url, {"data_attr": "diameter__icontains__4"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + def test_filter_exclude_old_records(self): + record_old = ObjectRecordFactory.create( + data={"diameter": 45}, + object__object_type=self.object_type, + start_at=date.today() - timedelta(days=10), + end_at=date.today() - timedelta(days=1), + ) + ObjectRecordFactory.create( + data={"diameter": 50}, object=record_old.object, start_at=record_old.end_at + ) + + response = self.client.get(self.url, {"data_attr": "diameter__exact__45"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + self.assertEqual(len(data), 0) + + def test_filter_date_field_gte(self): + ObjectRecordFactory.create( + data={"dateField": "2000-10-10"}, object__object_type=self.object_type + ) + + response = self.client.get( + self.url, {"data_attr": "dateField__gte__2000-10-10"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 1) + + response = self.client.get( + self.url, {"data_attr": "dateField__gte__2000-10-11"} + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 0) + + def test_filter_in_string(self): + record = ObjectRecordFactory.create( + data={"name": "demo1"}, object__object_type=self.object_type + ) + record2 = ObjectRecordFactory.create( + data={"name": "demo2"}, object__object_type=self.object_type + ) + ObjectRecordFactory.create( + data={"name": "demo3"}, object__object_type=self.object_type + ) + + response = self.client.get(self.url, {"data_attr": "name__in__demo1|demo2"}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 2) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record2.object.uuid])}", + ) + self.assertEqual( + data[1]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + def test_filter_icontains_string_with_comma(self): + """ + regression test for https://github.com/maykinmedia/objects-api/issues/472 + """ + ObjectRecordFactory.create( + data={"name": "Something important"}, object__object_type=self.object_type + ) + record = ObjectRecordFactory.create( + data={"name": "Advies, support en kennis om te weten"}, + object__object_type=self.object_type, + ) + + response = self.client.get( + self.url, {"data_attr": "name__icontains__Advies, support en kennis"} + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + def test_filter_two_icontains_with_comma(self): + """ + regression test for https://github.com/maykinmedia/objects-api/issues/472 + """ + ObjectRecordFactory.create( + data={"name": "Something important"}, object__object_type=self.object_type + ) + record = ObjectRecordFactory.create( + data={"name": "Advies, support en kennis om te weten"}, + object__object_type=self.object_type, + ) + url = ( + furl(self.url) + .add({"data_attr": "name__icontains__Advies, support en kennis"}) + .add({"data_attr": "name__icontains__om"}) + .url + ) + + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + data = response.json()["results"] + + self.assertEqual(len(data), 1) + self.assertEqual( + data[0]["url"], + f"http://testserver{reverse('object-detail', args=[record.object.uuid])}", + ) + + def test_filter_comma_separated_invalid(self): + response = self.client.get( + self.url, {"data_attr": "dimensions__diameter__exact__4,name__exact__demo"} + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + [ + "Filter expression 'dimensions__diameter__exact__4,name__exact__demo' " + "must have the shape 'key__operator__value', commas can only be present in " + "the 'value'" + ], + ) + + class FilterDateTests(TokenAuthMixin, APITestCase): @classmethod def setUpTestData(cls): diff --git a/src/objects/utils/filters.py b/src/objects/utils/filters.py index 61364a6a..421c0a2c 100644 --- a/src/objects/utils/filters.py +++ b/src/objects/utils/filters.py @@ -1,3 +1,4 @@ +from django import forms from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ @@ -48,3 +49,27 @@ def to_python(self, value): class ObjectTypeFilter(URLModelChoiceFilter): field_class = ObjectTypeField + + +class ManyWidget(forms.Widget): + def value_from_datadict(self, data, files, name): + if name not in data: + return [] + + return data.getlist(name) + + +class ManyCharField(forms.CharField): + widget = ManyWidget + + def to_python(self, value): + if not value: + return [] + + return value + + +class ManyCharFilter(filters.CharFilter): + # django-filter doesn't support several uses of the same query param out of the box + # so we need to do it ourselves + field_class = ManyCharField