From 9896960bdfeab347c1b9489b80a5c5a31a7ecfd7 Mon Sep 17 00:00:00 2001 From: Sergei Kliuikov Date: Thu, 12 Sep 2024 13:00:33 +1000 Subject: [PATCH] Feature(backend): Provide valid schema array ``collectionFormat`` for filters. --- doc/locale/ru/LC_MESSAGES/backend.po | 10 ++++++++- requirements-rpc.txt | 2 +- requirements-stubs.txt | 2 +- requirements.txt | 2 +- test_src/test_proj/models/fields_testing.py | 24 ++++++++++++++++++++- test_src/test_proj/tests.py | 10 +++++++++ tox.ini | 2 +- vstutils/__init__.py | 2 +- vstutils/api/fields.py | 2 +- vstutils/api/filter_backends.py | 20 ++++++++++++++--- vstutils/api/pagination.py | 4 +++- 11 files changed, 68 insertions(+), 12 deletions(-) diff --git a/doc/locale/ru/LC_MESSAGES/backend.po b/doc/locale/ru/LC_MESSAGES/backend.po index 373644fc..d2925edb 100644 --- a/doc/locale/ru/LC_MESSAGES/backend.po +++ b/doc/locale/ru/LC_MESSAGES/backend.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: VST Utils 5.0.4\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-08-16 02:46+0000\n" +"POT-Creation-Date: 2024-09-12 01:25+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -1216,6 +1216,10 @@ msgstr "" "Список MIME-типов, доступных для выбора пользователем. Поддерживается " "синтаксис с использованием ``*``. По умолчанию ``['text/csv']``" +#: of vstutils.api.fields.CheckboxBooleanField:1 +msgid "Boolean field that renders checkbox." +msgstr "Булево поле, которое отрисовывается как чекбокс." + #: of vstutils.api.fields.CommaMultiSelect:1 msgid "" "Field that allows users to input multiple values, separated by a " @@ -2281,6 +2285,10 @@ msgstr "" "Поле будет отображаться в клиентском приложении в виде поля ввода для " "ввода номера телефона, включая код страны." +#: of vstutils.api.fields.PlusMinusIntegerField:1 +msgid "Integer field that renders +/- buttons." +msgstr "Целочисленное поле, которое отрисовывается как кнопки +/-." + #: of vstutils.api.fields.QrCodeField:1 msgid "A versatile field for encoding various types of data into QR codes." msgstr "Универсальное поле для кодирования различных типов данных в QR-коды." diff --git a/requirements-rpc.txt b/requirements-rpc.txt index afe43c29..11897337 100644 --- a/requirements-rpc.txt +++ b/requirements-rpc.txt @@ -1,3 +1,3 @@ # Packages needed for delayed jobs. celery[redis]==5.4.0 -django-celery-beat~=2.6.0 +django-celery-beat~=2.7.0 diff --git a/requirements-stubs.txt b/requirements-stubs.txt index 46d6bfbd..18d93b0e 100644 --- a/requirements-stubs.txt +++ b/requirements-stubs.txt @@ -1,4 +1,4 @@ -django-stubs[compatible-mypy]~=5.0.2 +django-stubs[compatible-mypy]==5.0.2 djangorestframework-stubs[compatible-mypy]~=3.15.0 celery-stubs~=0.1.3 drf-yasg-stubs~=0.1.4 diff --git a/requirements.txt b/requirements.txt index 8949bd43..d5691ced 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,7 +16,7 @@ uvicorn~=0.30.6 pyuwsgi~=2.0.26 # Restore it if some problems with pyuwsgi # uwsgi==2.0.23 -fastapi-slim~=0.112.1 +fastapi-slim~=0.114.1 aiofiles~=24.1.0 asgiref>=3.8.1 diff --git a/test_src/test_proj/models/fields_testing.py b/test_src/test_proj/models/fields_testing.py index afbfbb11..cee22a61 100644 --- a/test_src/test_proj/models/fields_testing.py +++ b/test_src/test_proj/models/fields_testing.py @@ -3,6 +3,10 @@ from django.db import models from django.http.response import FileResponse +from django.forms.widgets import SelectMultiple +from django.forms.fields import IntegerField +from django.utils import timezone +from django_filters import Filter from vstutils.api import fields, filters, actions from vstutils.models import BModel, BaseModel @@ -20,7 +24,6 @@ WYSIWYGField, CrontabField, ) -from django.utils import timezone from rest_framework.fields import DecimalField, CharField @@ -204,6 +207,24 @@ class SomeDataCsvSerializer(BaseSerializer): some_data = CharField(max_length=300, required=True) +class MultipleSelectField(IntegerField): + widget = SelectMultiple + + def clean(self, value): + return [super(IntegerField, self).clean(v) for v in value] + + +class InFilter(Filter): + field_class = MultipleSelectField + + def __init__( + self, + field_name=None, + lookup_expr='in', + **kwargs): + super().__init__(field_name, lookup_expr, **kwargs) + + class Post(BModel): author = models.ForeignKey(Author, on_delete=models.CASCADE, null=True) title = models.CharField(max_length=255) @@ -229,6 +250,7 @@ class Meta: } _filterset_fields = { '__authors': filters.CharFilter(method=filters.extra_filter, field_name='author'), + 'extra_author': InFilter(field_name='author'), 'author': None, 'author__not': filters.CharFilter(method=filters.FkFilterHandler()), 'title': None, diff --git a/test_src/test_proj/tests.py b/test_src/test_proj/tests.py index 9e130302..7029a8cf 100644 --- a/test_src/test_proj/tests.py +++ b/test_src/test_proj/tests.py @@ -2135,6 +2135,13 @@ def has_deep_parent_filter(params): # Check public centrifugo address when absolute path is provided self.assertEqual(api['info']['x-centrifugo-address'], 'wss://vstutilstestserver/notify/connection/websocket') + param_filter_csv = api['paths']['/post/']['get']['parameters'][1] + self.assertEqual(param_filter_csv['name'], 'extra_author') + self.assertEqual(param_filter_csv['in'], 'query') + self.assertEqual(param_filter_csv['type'], 'array') + self.assertEqual(param_filter_csv['items']['type'], 'integer') + self.assertEqual(param_filter_csv['collectionFormat'], 'multi') + # Check csvfile schema self.assertDictEqual( api['definitions']['OnePost']['properties']['some_data'], @@ -4231,6 +4238,7 @@ def test_pagination_identifiers(self): {"method": "get", "path": ['post'], "query": "__authors=<<2[headers][Pagination-Identifiers]>>"}, {"method": "get", "path": ['author'], "headers": {"Identifiers-List-Name": "id"}, "query": f"id={author_2.id}"}, {"method": "get", "path": ['post'], "query": "__authors=<<4[headers][Pagination-Identifiers]>>"}, + {"method": "get", "path": ['post'], "headers": {"Identifiers-List-Name": "author"}, "query": f"extra_author={author_1.id}&extra_author={author_3.id}"}, ]) self.assertEqual(results[0]['status'], 200) self.assertTrue('Pagination-Identifiers' not in results[0]['headers']) @@ -4243,6 +4251,8 @@ def test_pagination_identifiers(self): self.assertEqual(results[4]['data']['count'], 1) self.assertEqual(results[5]['status'], 200, results[5]) self.assertEqual(results[5]['data']['count'], 0) + self.assertEqual(results[6]['status'], 200) + self.assertEqual(results[6]['headers']['Pagination-Identifiers'], expected_authors_identifiers) def test_model_rating_field(self): date = '2021-01-20T00:26:38Z' diff --git a/tox.ini b/tox.ini index 90890fb3..5dae3432 100644 --- a/tox.ini +++ b/tox.ini @@ -93,7 +93,7 @@ changedir = ./ setenv = DONT_YARN = true deps = - mypy==1.7.1 + mypy==1.10.1 commands = pip uninstall vstutils -y pip install -U -e .[stubs] diff --git a/vstutils/__init__.py b/vstutils/__init__.py index b955cc59..6c0f6cc1 100644 --- a/vstutils/__init__.py +++ b/vstutils/__init__.py @@ -1,2 +1,2 @@ # pylint: disable=django-not-available -__version__: str = '5.10.4' +__version__: str = '5.10.5' diff --git a/vstutils/api/fields.py b/vstutils/api/fields.py index 10c96e15..7bab7f8f 100644 --- a/vstutils/api/fields.py +++ b/vstutils/api/fields.py @@ -852,7 +852,7 @@ def to_internal_value(self, data: _t.Union[models.Model, int]) -> _t.Union[model def to_representation(self, value: _t.Union[int, models.Model]) -> _t.Any: self.model_class = get_if_lazy(self.model_class) - if self.model_class is not None and isinstance(value, self.model_class._meta.pk.model): # type: ignore + if self.model_class is not None and isinstance(value, self.model_class._meta.pk.model): return self.field_type(getattr(value, self.autocomplete_property)) else: # nocv # Uses only if value got from `.values()` diff --git a/vstutils/api/filter_backends.py b/vstutils/api/filter_backends.py index a72c7563..4660f7b8 100644 --- a/vstutils/api/filter_backends.py +++ b/vstutils/api/filter_backends.py @@ -4,6 +4,7 @@ import pydantic from django.db import models +from django.forms import BooleanField, DecimalField, IntegerField from django.utils.encoding import force_str from django_filters import filters, filterset from django_filters.rest_framework.backends import DjangoFilterBackend as BaseDjangoFilterBackend @@ -38,10 +39,13 @@ def get_openapi_field_schema(self, field_name, field, queryset): # pylint: disable=unused-variable,comparison-with-callable field_type: str = openapi.TYPE_STRING kwargs: dict = {} + filter_field = field.field_class - if isinstance(field, filters.NumberFilter): + if issubclass(filter_field, DecimalField): field_type = openapi.TYPE_NUMBER - elif isinstance(field, filters.BooleanFilter): + elif issubclass(filter_field, IntegerField): + field_type = openapi.TYPE_INTEGER + elif issubclass(filter_field, BooleanField): field_type = openapi.TYPE_BOOLEAN elif isinstance(field, (filters.ChoiceFilter, filters.MultipleChoiceFilter)): kwargs['enum'] = tuple(dict(field.field.choices).keys()) @@ -50,7 +54,17 @@ def get_openapi_field_schema(self, field_name, field, queryset): m_field = next((f for f in queryset.model._meta.fields if f.name == search_field), None) field_type, kwargs_update = get_field_type_from_queryset(m_field) - if field.method == extra_filter or isinstance(field, (filters.MultipleChoiceFilter, filters.BaseCSVFilter)): + if getattr(field.field_class.widget, 'allow_multiple_selected', False): + kwargs = { + 'items': { + 'type': field_type, + **kwargs, + }, + 'uniqueItems': True, + 'collectionFormat': 'multi', + } + field_type = openapi.TYPE_ARRAY + elif field.method == extra_filter or isinstance(field, filters.BaseCSVFilter): kwargs = { 'items': { 'type': field_type, diff --git a/vstutils/api/pagination.py b/vstutils/api/pagination.py index 3b071405..8fb5855f 100644 --- a/vstutils/api/pagination.py +++ b/vstutils/api/pagination.py @@ -54,7 +54,9 @@ def get_response_data(self, data): def get_paginated_response(self, data): response = self.response_class(self.get_response_data(data)) if self.identifier is not None: - response.headers['Pagination-Identifiers'] = ','.join(map(str, (d[self.identifier] for d in data))) + response.headers['Pagination-Identifiers'] = ','.join( + sorted(set(map(str, (d[self.identifier] for d in data)))) + ) return response def get_paginated_response_schema(self, schema):