diff --git a/doc/locale/ru/LC_MESSAGES/backend.po b/doc/locale/ru/LC_MESSAGES/backend.po index 3bff1f96..a2161ffa 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-10-24 00:09+0000\n" +"POT-Creation-Date: 2024-11-05 03:35+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -208,8 +208,8 @@ msgid "" "serializer class, but only if they are included in the corresponding " "lists." msgstr "" -"``_override_list_fields`` или ``_override_detail_fields`` — отображение с " -"именами и типами полей, которые будут переопределены в атрибутах " +"``_override_list_fields`` или ``_override_detail_fields`` — отображение с" +" именами и типами полей, которые будут переопределены в атрибутах " "сериализатора (считайте это как объявление полей в DRF ModelSerializer). " "Имейте в виду, что указанные здесь поля не обязательно окажутся в классе " "сериализатора, а только если они включены в соответствующие списки." @@ -444,9 +444,10 @@ msgstr "" "Метод ``get_view_class()`` — это служебный метод в ORM Django моделях, " "предназначенный для облегчения настройки и создания экземпляров " "представлений Django Rest Framework (DRF). Это позволяет разработчикам " -"определить и настроить различные аспекты класса представления DRF." -"Аргументы, переданные в функцию, полностью соответствуют тем, которые были указаны ранее для класса ``Meta`` модели, " -"но без префикса подчёркивания." +"определить и настроить различные аспекты класса представления " +"DRF.Аргументы, переданные в функцию, полностью соответствуют тем, которые" +" были указаны ранее для класса ``Meta`` модели, но без префикса " +"подчёркивания." #: of vstutils.models.BModel:196 msgid "" @@ -2562,6 +2563,112 @@ msgstr "" "Чтобы изменить отображение на странице, используйте " ":const:`vstutils.api.serializers.DisplayModeList`." +#: of vstutils.api.fields.RouterLinkField:1 +msgid "" +"A read-only serializer field that displays a link to another page or a " +"simple text label in the interface." +msgstr "" +"Поле сериализатора, позволяющее отображать ссылку на другую страницу или " +"простой текст в интерфейсе." + +#: of vstutils.api.fields.RouterLinkField:3 +msgid "**Expected Input:**" +msgstr "**Ожидаемый ввод:**" + +#: of vstutils.api.fields.RouterLinkField:5 +msgid "This field expects a dictionary with the following keys:" +msgstr "Этот поле ожидает словарь с следующими ключами:" + +#: of vstutils.api.fields.RouterLinkField:7 +msgid "" +"**link** *(optional)*: The URL or route to another page. If provided, the" +" label will be displayed as a clickable link. If not provided, only the " +"text label is shown. The value should be compatible with the `Vue " +"Router's push method parameter " +"`_. Ensure that" +" the link points to an existing resource in the interface to avoid 404 " +"errors." +msgstr "" +"**link** *(необязательно)*: URL для другой страницы. Если " +"указан, будет отображаться текст как ссылка. Если не указан, будет " +"отображаться просто текст. Значение должно быть совместимым с " +"`параметром метода push Vue Router `_. " +"Убедитесь, что ссылка указывает на существующий ресурс в интерфейсе для " +"избежания ошибок 404." + +#: of vstutils.api.fields.RouterLinkField:11 +msgid "" +"**label**: The text to display. This is required whether or not a link is" +" provided." +msgstr "**label**: Текст для отображения. Обязательное поле." + +#: of vstutils.api.fields.RouterLinkField:14 +msgid "For simpler use cases, you might consider using :class:`.FkField`." +msgstr "Для простых случаев использования см. :class:`.FkField`." + +#: of vstutils.api.fields.RouterLinkField:16 +msgid "**Examples:**" +msgstr "**Примеры:**" + +#: of vstutils.api.fields.RouterLinkField:18 +msgid "*Using a model class method:*" +msgstr "*Использование метода класса модели:*" + +#: of vstutils.api.fields.RouterLinkField:42 +msgid "" +"In this example, the ``get_link`` method in the ``Author`` model returns " +"a dictionary containing the ``link`` and ``label``. The " +"``RouterLinkField`` uses this method to display the author's name as a " +"clickable link to their detail page." +msgstr "" +"В этом примере метод ``get_link`` в модели ``Author`` возвращает словарь " +"с ключами ``link`` и ``label``. Поле ``RouterLinkField`` использует этот " +"метод для отображения имени автора как ссылку на страницу с " +"подробностями." + +#: of vstutils.api.fields.RouterLinkField:45 +msgid "*Using a custom field class:*" +msgstr "*Использование пользовательского класса поля:*" + +#: of vstutils.api.fields.RouterLinkField:70 +msgid "" +"In this example, we create a custom field ``AuthorLinkField`` by " +"subclassing ``RouterLinkField``. We override the ``to_representation`` " +"method to return a dictionary with the ``link`` and ``label`` for each " +"``Author`` instance. This custom field is then used in the viewset to " +"display each author's name as a clickable link." +msgstr "" +"В этом примере мы создаем пользовательский класс ``AuthorLinkField`` " +"подклассом ``RouterLinkField``. Мы переопределяем метод " +"``to_representation`` для возвращения словаря с ключами ``link`` и " +"``label`` для каждого экземпляра ``Author``. Этот пользовательский поле " +"используется в вьюсете для отображения имени автора как кликабельной " +"ссылки." + +#: of vstutils.api.fields.RouterLinkField:75 +msgid "" +"The field is read-only and is intended to display dynamic links based on " +"the instance data." +msgstr "" +"Поле является только для чтения и предназначено для отображения " +"динамических ссылок на основе данных экземпляра." + +#: of vstutils.api.fields.RouterLinkField:76 +msgid "" +"If the ``link`` key is omitted or ``None``, the field will display the " +"``label`` as plain text instead of a link." +msgstr "" +"Если ключ ``link`` отсутствует или имеет значение ``None``, поле " +"отображает текст как обычный текст вместо ссылки." + +#: of vstutils.api.fields.RouterLinkField:79 +msgid "" +"Always ensure that the ``link`` provided points to a valid route within " +"your application to prevent users from encountering 404 errors." +msgstr "" +"Всегда убедитесь, что предоставленная ссылка указывает на действительную" +" страницу в вашем приложении для избежания ошибок 404." + #: of vstutils.api.fields.SecretFileInString:1 msgid "" "This field extends :class:`.FileInStringField` but hides its value in the" @@ -6244,3 +6351,30 @@ msgstr "" "``VE100`` - Код ошибки целостности. Используется при появляении " "``django.db.utils.IntegrityError``." +#~ msgid "Field that shows link to another page. Expected dict with fields:" +#~ msgstr "" +#~ "Поле, которое показывает ссылку на " +#~ "другую страницу. Ожидается словарь с " +#~ "полями:" + +#~ msgid "``link`` - link to another page, if not provided simple text is shown." +#~ msgstr "" +#~ "``link`` - ссылка на другую страницу," +#~ " если не указана то будет показан " +#~ "просто текст." + +#~ msgid "" +#~ "For details see `parameter of Vue " +#~ "Router's push method " +#~ "`_." +#~ msgstr "" +#~ "Подробнее см. `параметр метода Vue " +#~ "Router's push " +#~ "`_." + +#~ msgid "``label`` - text to show." +#~ msgstr "``label`` - текст ссылки." + +#~ msgid "**Usage Tips:**" +#~ msgstr "**Примеры:**" + diff --git a/frontend_src/vstutils/fields/index.ts b/frontend_src/vstutils/fields/index.ts index 45c12a90..962daf32 100644 --- a/frontend_src/vstutils/fields/index.ts +++ b/frontend_src/vstutils/fields/index.ts @@ -63,6 +63,7 @@ import { WYSIWYGField } from './text/WYSIWYGField'; import { UUIDField } from './text/UUIDField'; import { URIField } from './text/URIField'; import type { Field } from './base'; +import { RouterLinkField } from './router-link'; export { FieldLabelIdMixin, ModalWindowAndButtonMixin, TableRowMixin }; export function addDefaultFields(fieldsResolver: FieldsResolver) { @@ -156,6 +157,7 @@ export function addDefaultFields(fieldsResolver: FieldsResolver) { ['json', JSONField], ['namedbinfile', files.namedBinaryFile.NamedBinaryFileField], ['namedbinimage', files.namedBinaryImage.NamedBinaryImageField], + ['router-link', RouterLinkField], ] as [string | typeof FieldsResolver.DEFAULT_FIELD_KEY, new (options: any) => Field][]) { fieldsResolver.registerField(SCHEMA_DATA_TYPE.object, format, field); } diff --git a/frontend_src/vstutils/fields/nested-object/index.ts b/frontend_src/vstutils/fields/nested-object/index.ts index 4e6c0730..e5b1be2e 100644 --- a/frontend_src/vstutils/fields/nested-object/index.ts +++ b/frontend_src/vstutils/fields/nested-object/index.ts @@ -128,7 +128,7 @@ export const NestedObjectFieldMixin = defineComponent({ return () => h( 'div', - { staticClass: 'field-component', class: wrapperClasses }, + { staticClass: 'field-component', class: wrapperClasses.value }, props.type === 'list' ? renderList() : renderDetail(), ); }, diff --git a/frontend_src/vstutils/fields/router-link.ts b/frontend_src/vstutils/fields/router-link.ts new file mode 100644 index 00000000..f5a149f1 --- /dev/null +++ b/frontend_src/vstutils/fields/router-link.ts @@ -0,0 +1,40 @@ +import { computed, h } from 'vue'; +import { BaseField, defineFieldComponent } from './base'; +import FieldWrapper from './base/FieldWrapper.vue'; +import { InnerData } from '#vstutils/utils'; + +type Data = { link?: string; label: string }; + +const RouterLinkFieldComponent = defineFieldComponent((props) => { + const value = computed(() => props.field.getValue(props.data)); + return () => { + const link = value.value; + return h(FieldWrapper, { props }, [ + link?.link + ? h('router-link', { props: { to: link.link } }, [link.label]) + : h('span', [link?.label ?? '']), + ]); + }; +}); + +export class RouterLinkField extends BaseField { + override toRepresent(data: InnerData) { + const value = this.getValue(data); + if (value) { + if (typeof value !== 'object') { + this.error(`Value must be an object, got: ${value}`); + } + if (typeof value.label !== 'string') { + this.error(`Label must be a string, got: ${value.label}`); + } + if (value.link && typeof value.link !== 'string') { + this.error(`Link must be a string, got: ${value.link}`); + } + } + return value; + } + + override getComponent() { + return RouterLinkFieldComponent; + } +} diff --git a/requirements.txt b/requirements.txt index cb89a54d..49921c2b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ django-environ~=0.11.2 # REST API packages djangorestframework~=3.15.2 -drf-yasg==1.21.7 +drf-yasg==1.21.8 django-filter==24.3 drf_orjson_renderer==1.7.1 ormsgpack~=1.5.0 diff --git a/test_src/test_proj/models/fields_testing.py b/test_src/test_proj/models/fields_testing.py index 19ea4f41..d2d18ab5 100644 --- a/test_src/test_proj/models/fields_testing.py +++ b/test_src/test_proj/models/fields_testing.py @@ -152,6 +152,7 @@ class Meta: 'decimal', 'decimal_without_max_digits', 'some_hidden_field', + 'router_link', ] _extra_serializer_classes = { 'serializer_class_update': UpdateAuthorSerializer, @@ -172,6 +173,11 @@ class Meta: 'decimal': DecimalField(default='13.37', decimal_places=2, max_digits=5), 'decimal_without_max_digits': DecimalField(source='decimal', decimal_places=2, max_digits=None, read_only=True), 'some_hidden_field': SomeHiddenFieldSerializer(required=False), + 'router_link': fields.RouterLinkField( + # read_only expected to be True in schema + read_only=False, + source='get_router_link', + ), } _hidden_on_frontend_detail_fields=['some_hidden_field'] _nested = { @@ -205,6 +211,13 @@ class Meta: )(), } + def get_router_link(self): + return { + 'link': f'/author/{self.id}/', + 'label': f'Author: {self.name}', + 'unexpected_key': 'some value', + } + class Category(BModel): name = models.CharField(max_length=256) diff --git a/test_src/test_proj/tests.py b/test_src/test_proj/tests.py index 183d6508..9931a7b4 100644 --- a/test_src/test_proj/tests.py +++ b/test_src/test_proj/tests.py @@ -1671,7 +1671,7 @@ def test_openapi_schema_content(self): # Grouping model properties for GUI self.assertEqual( api['definitions']['OneAuthor']['x-properties-groups'], - {'Main': ['id', 'name'], '': ['registerDate', 'posts', 'phone', 'masked', 'decimal', 'decimal_without_max_digits', 'some_hidden_field']} + {'Main': ['id', 'name'], '': ['registerDate', 'posts', 'phone', 'masked', 'decimal', 'decimal_without_max_digits', 'some_hidden_field', 'router_link']} ) # Check view field name self.assertEqual(api['definitions']['OneExtraPost']['x-view-field-name'], 'title') @@ -1705,6 +1705,22 @@ def test_openapi_schema_content(self): # Check that minLength is set if field allow_null is False self.assertEqual(api['definitions']['OneAuthor']['properties']['decimal']['minLength'], 1) + # Check RouterLinkField + self.assertEqual( + api["definitions"]["OneAuthor"]["properties"]["router_link"], + { + "type": "object", + "x-format": "router-link", + "title": "Router link", + "readOnly": True, + "required": ["label"], + "properties": { + "label": {"type": "string"}, + "link": {"type": "string"}, + }, + }, + ) + # Check properly format for RatingField self.assertEqual( api['definitions']['OneExtraPost']['properties']['rating'], @@ -4218,6 +4234,10 @@ def test_model_related_list_field(self): 'title': post_2.title } ], + 'router_link': { + 'label': 'Author: author_1', + 'link': f'/author/{author_1.id}/', + }, } results = self.bulk([ {'method': 'get', 'path': ['author']}, diff --git a/vstutils/__init__.py b/vstutils/__init__.py index 0695eafa..ffb33c72 100644 --- a/vstutils/__init__.py +++ b/vstutils/__init__.py @@ -1,2 +1,2 @@ # pylint: disable=django-not-available -__version__: str = '5.11.4' +__version__: str = '5.11.5' diff --git a/vstutils/api/fields.py b/vstutils/api/fields.py index 7bab7f8f..58e6f9cd 100644 --- a/vstutils/api/fields.py +++ b/vstutils/api/fields.py @@ -12,8 +12,8 @@ from urllib.parse import quote import orjson -from rest_framework.serializers import CharField, IntegerField, FloatField, ModelSerializer, Serializer -from rest_framework.fields import empty, SkipField, get_error_detail, Field, BooleanField +from rest_framework.serializers import CharField, IntegerField, FloatField, ModelSerializer, Serializer, DictField +from rest_framework.fields import empty, SkipField, get_error_detail, Field, BooleanField, get_attribute from rest_framework.exceptions import ValidationError from django.apps import apps from django.db import models @@ -1737,3 +1737,106 @@ class PlusMinusIntegerField(IntegerField): """ Integer field that renders +/- buttons. """ + + +class RouterLinkField(DictField): + """ + A read-only serializer field that displays a link to another page or a simple text label in the interface. + + **Expected Input:** + + This field expects a dictionary with the following keys: + + - **link** *(optional)*: The URL or route to another page. If provided, the + label will be displayed as a clickable link. + If not provided, only the text label is shown. The value should be compatible + with the `Vue Router's push method parameter + `_. Ensure that the link points + to an existing resource in the interface to avoid 404 errors. + + - **label**: The text to display. This is required whether or not a link is provided. + + .. Note:: + For simpler use cases, you might consider using :class:`.FkField`. + + **Examples:** + + *Using a model class method:* + + .. code-block:: python + + from django.db.models import CharField + from vstutils.api.fields import RouterLinkField + from vstutils.models import BaseModel + + class Author(BaseModel): + name = CharField() + + def get_link(self): + return { + 'link': f'/author/{self.id}/', + 'label': f'Author: {self.name}', + } + + AuthorViewSet = Author.get_view_class( + detail_fields=['name', 'link'], + override_detail_fields={ + 'link': RouterLinkField(source='get_link'), + }, + ) + + In this example, the ``get_link`` method in the ``Author`` model returns + a dictionary containing the ``link`` and ``label``. + The ``RouterLinkField`` uses this method to display the author's name as + a clickable link to their detail page. + + *Using a custom field class:* + + .. code-block:: python + + from django.db.models import CharField + from vstutils.api.fields import RouterLinkField + from vstutils.models import BaseModel + + class Author(BaseModel): + name = CharField() + + class AuthorLinkField(RouterLinkField): + def to_representation(self, instance: Author): + return super().to_representation({ + 'link': f'/author/{instance.id}/', + 'label': f'Author: {instance.name}', + }) + + AuthorViewSet = Author.get_view_class( + detail_fields=['name', 'link'], + override_detail_fields={ + 'link': AuthorLinkField(source='*'), + }, + ) + + In this example, we create a custom field ``AuthorLinkField`` by subclassing ``RouterLinkField``. + We override the ``to_representation`` method to return a dictionary with + the ``link`` and ``label`` for each ``Author`` instance. + This custom field is then used in the viewset to display each author's name as a clickable link. + + .. TIP:: + - The field is read-only and is intended to display dynamic links based on the instance data. + - If the ``link`` key is omitted or ``None``, the field will display the ``label`` + as plain text instead of a link. + + .. WARNING:: + Always ensure that the ``link`` provided points to a valid route within your application + to prevent users from encountering 404 errors. + """ + child = CharField(allow_blank=True) + + def __init__(self, **kwargs): + kwargs['read_only'] = True + super().__init__(**kwargs) + + def to_representation(self, value): + return { + 'link': get_attribute(value, ['link']), + 'label': get_attribute(value, ['label']), + } diff --git a/vstutils/api/schema/inspectors.py b/vstutils/api/schema/inspectors.py index e7455580..267b9be1 100644 --- a/vstutils/api/schema/inspectors.py +++ b/vstutils/api/schema/inspectors.py @@ -727,3 +727,32 @@ def field_to_swagger_object(self, field: Any, swagger_object_type: Any, use_refe if isinstance(field, api_base.ProxyPydanticSerializer): return openapi.Schema(**field.schema_model.model_json_schema()) return NotHandled + + +class RouterLinkFieldInspector(FieldInspector): + def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs): + if not isinstance(field, fields.RouterLinkField): + return NotHandled + + SwaggerType, _ = self._get_partial_types( # pylint: disable=invalid-name + field, + swagger_object_type, + use_references, + **kwargs, + ) + + child_schema = self.probe_field_inspectors( + field.child, + swagger_object_type, + use_references=False, + ) + + return SwaggerType( + type=openapi.TYPE_OBJECT, + x_format='router-link', + required=['label'], + properties={ + 'link': child_schema, + 'label': child_schema, + }, + ) diff --git a/vstutils/api/schema/schema.py b/vstutils/api/schema/schema.py index 815b52cc..a2dac1f6 100644 --- a/vstutils/api/schema/schema.py +++ b/vstutils/api/schema/schema.py @@ -177,6 +177,7 @@ class VSTAutoSchema(ExtendedSwaggerAutoSchema): vst_inspectors.VSTFieldInspector, vst_inspectors.PydanticSerializerInspector, vst_inspectors.ListInspector, + vst_inspectors.RouterLinkFieldInspector, vst_inspectors.VSTReferencingSerializerInspector, vst_inspectors.RelatedListFieldInspector, vst_inspectors.RatingFieldInspector, diff --git a/vstutils/models/base.pyi b/vstutils/models/base.pyi index 49408fac..b5baa4bb 100644 --- a/vstutils/models/base.pyi +++ b/vstutils/models/base.pyi @@ -12,8 +12,7 @@ from ..api.base import GenericViewSet MethodsType = Literal['post', 'get', 'put', 'patch', 'delete', 'options', 'head'] ConstantViewType = Literal['read_only', 'list_only', 'history'] | Type -FilterFieldsListType = Iterable[Text] -FilterFieldsDictType = Dict[Text, Filter | None] +FilterFieldsType = Literal["serializer"] | Iterable[Text] | Dict[Text, Filter | None] DEFAULT_VIEW_FIELD_NAMES: Tuple[Text,Text,Text,Text,Text] LAZY_MODEL: object @@ -48,7 +47,7 @@ class ExtraMetadata(TypedDict, total=False): non_bulk_methods: Optional[MethodsType | Iterable[MethodsType]] properties_groups: Optional[Dict[Text, Sequence[Text]]] extra_serializer_classes: Optional[Dict[Text, Type[Serializer]]] - filterset_fields: Optional[Dict[Text, FilterFieldsListType | FilterFieldsDictType]] + filterset_fields: Optional[FilterFieldsType] search_fields: Optional[Iterable[Text]] copy_attrs: Optional[Dict[Text, Any]] nested: Optional[NestedOptionType] @@ -102,7 +101,7 @@ class ModelBaseClass(ModelBase): def get_view_class( cls, *, - ignore_meta: Optional[bool], + ignore_meta: Optional[bool] = None, view_class: Optional[Tuple | List[Text | ConstantViewType] | Text | ConstantViewType] = None, serializer_class: Optional[Type[Serializer]] = None, @@ -121,7 +120,7 @@ class ModelBaseClass(ModelBase): detail_operations_availability_field_name: Optional[Text] = None, extra_serializer_classes: Optional[Dict[Text, Type[Serializer]]] = None, - filterset_fields: Optional[Dict[Text, FilterFieldsListType | FilterFieldsDictType]] = None, + filterset_fields: Optional[FilterFieldsType] = None, search_fields: Optional[Iterable[Text]] = None, copy_attrs: Optional[Dict[Text, Any]] = None, diff --git a/vstutils/models/model.pyi b/vstutils/models/model.pyi index eb5ac4e5..88dfaae4 100644 --- a/vstutils/models/model.pyi +++ b/vstutils/models/model.pyi @@ -12,7 +12,7 @@ class ObjectDoesNotExist(Exception): class BaseModel(models.Model, metaclass=ModelBaseClass): objects: models.manager.from_queryset(BQuerySet) - DoesNotExist: ObjectDoesNotExist + DoesNotExist: type[ObjectDoesNotExist] generated_view: _t.Type[api_base.GenericViewSet] def __unicode__(self) -> _t.Text: