diff --git a/label_studio/data_manager/managers.py b/label_studio/data_manager/managers.py index 85c433a51250..4b866bc8c95b 100644 --- a/label_studio/data_manager/managers.py +++ b/label_studio/data_manager/managers.py @@ -2,7 +2,9 @@ """ import logging import re +from collections import defaultdict from datetime import datetime +from functools import reduce from typing import ClassVar import ujson as json @@ -261,11 +263,15 @@ def apply_filters(queryset, filters, project, request): if not filters: return queryset + parent_id_to_filter_expressions: dict[int, list[Q]] = defaultdict(list) + # convert conjunction to orm statement - filter_expressions = [] custom_filter_expressions = load_func(settings.DATA_MANAGER_CUSTOM_FILTER_EXPRESSIONS) for _filter in filters.items: + # combine child filters with their parent in the same filter expression + parent_id = _filter.parent if _filter.parent is not None else _filter.id + filter_expressions = parent_id_to_filter_expressions[parent_id] # we can also have annotations filters if not _filter.filter.startswith('filter:tasks:') or _filter.value is None: @@ -458,11 +464,13 @@ def apply_filters(queryset, filters, project, request): """ if filters.conjunction == ConjunctionEnum.OR: result_filter = Q() - for filter_expression in filter_expressions: + for filter_expressions in parent_id_to_filter_expressions.values(): + filter_expression = reduce(lambda x, y: x & y, filter_expressions) result_filter.add(filter_expression, Q.OR) queryset = queryset.filter(result_filter) else: - for filter_expression in filter_expressions: + for filter_expressions in parent_id_to_filter_expressions.values(): + filter_expression = reduce(lambda x, y: x & y, filter_expressions) queryset = queryset.filter(filter_expression) return queryset diff --git a/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py b/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py new file mode 100644 index 000000000000..98beef65282a --- /dev/null +++ b/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.10 on 2025-06-17 18:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("data_manager", "0012_alter_view_user"), + ] + + operations = [ + migrations.AddField( + model_name="filter", + name="parent", + field=models.ForeignKey( + blank=True, + help_text="Optional parent filter to create one-level hierarchy (child filters are AND-merged with parent)", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="children", + to="data_manager.filter", + db_index=False, + ), + ), + migrations.AlterField( + model_name="filter", + name="index", + field=models.IntegerField( + blank=True, + default=None, + help_text="Display order among root filters only", + null=True, + verbose_name="index", + ), + ), + ] diff --git a/label_studio/data_manager/models.py b/label_studio/data_manager/models.py index 35859d581af1..ff42144a4da7 100644 --- a/label_studio/data_manager/models.py +++ b/label_studio/data_manager/models.py @@ -57,10 +57,12 @@ def get_prepare_tasks_params(self, add_selected_items=False): for f in self.filter_group.filters.all(): items.append( dict( + id=f.id, filter=f.column, operator=f.operator, type=f.type, value=f.value, + parent_id=f.parent.id if f.parent else None, ) ) filters = dict(conjunction=self.filter_group.conjunction, items=items) @@ -86,7 +88,25 @@ class FilterGroup(models.Model): class Filter(models.Model): - index = models.IntegerField(_('index'), default=0, help_text='To keep filter order') + # Optional reference to a parent filter. We only allow **one** level of nesting. + parent = models.ForeignKey( + 'self', + on_delete=models.CASCADE, + related_name='children', + null=True, + blank=True, + db_index=False, + help_text='Optional parent filter to create one-level hierarchy (child filters are AND-merged with parent)', + ) + + # `index` is now only meaningful for **root** filters (parent is NULL) + index = models.IntegerField( + _('index'), + null=True, + blank=True, + default=None, + help_text='Display order among root filters only', + ) column = models.CharField(_('column'), max_length=1024, help_text='Field name') type = models.CharField(_('type'), max_length=1024, help_text='Field type') operator = models.CharField(_('operator'), max_length=1024, help_text='Filter operator') diff --git a/label_studio/data_manager/prepare_params.py b/label_studio/data_manager/prepare_params.py index 9b1c6b586f70..f97981b8edf9 100644 --- a/label_studio/data_manager/prepare_params.py +++ b/label_studio/data_manager/prepare_params.py @@ -13,6 +13,10 @@ class FilterIn(BaseModel): class Filter(BaseModel): + id: Optional[int] = None # Database ID of this filter + parent: Optional[int] = None # Parent filter DB ID (only for child filters) + children: Optional[List[int]] = None # Child filter DB IDs + filter: str operator: str type: str diff --git a/label_studio/data_manager/serializers.py b/label_studio/data_manager/serializers.py index 82cb85af0d39..e3a8af1ba9c0 100644 --- a/label_studio/data_manager/serializers.py +++ b/label_studio/data_manager/serializers.py @@ -21,7 +21,31 @@ from label_studio.core.utils.common import round_floats +class RecursiveField(serializers.Serializer): + def to_representation(self, value): + parent = self.parent.parent # the owning serializer instance + serializer = parent.__class__(value, context=self.context) + return serializer.data + + def to_internal_value(self, data): + """Allow RecursiveField to be writable. + + We instantiate the *parent* serializer class (which in this case is + ``FilterSerializer``) to validate the nested payload. The validated + data produced by that serializer is returned so that the enclosing + serializer (``FilterSerializer``) can include it in its own + ``validated_data`` structure. + """ + + parent_cls = self.parent.parent.__class__ # FilterSerializer + serializer = parent_cls(data=data, context=self.context) + serializer.is_valid(raise_exception=True) + return serializer.validated_data + + class FilterSerializer(serializers.ModelSerializer): + child_filters = RecursiveField(many=True, required=False) + class Meta: model = Filter fields = '__all__' @@ -114,15 +138,32 @@ def to_internal_value(self, data): if 'filter_group' not in data and conjunction: data['filter_group'] = {'conjunction': conjunction, 'filters': []} if 'items' in filters: + # Support two input formats: + # 1) "flat" list where potential children reference their parent via ``parent`` + # 2) "nested" list where each root item may contain ``child_filters`` + + def _convert_filter(src_filter): + """Convert a single filter JSON object into internal representation.""" + + filter_payload = { + 'column': src_filter.get('filter', ''), + 'operator': src_filter.get('operator', ''), + 'type': src_filter.get('type', ''), + 'value': src_filter.get('value', {}), + } + + # Explicit parent reference by DB id (rare in create requests) + if (parent := src_filter.get('parent')) is not None: + filter_payload['parent'] = parent + + if child_filters := src_filter.get('child_filters'): + filter_payload['child_filters'] = [_convert_filter(child) for child in child_filters] + + return filter_payload + + # Iterate over top-level items (roots) for f in filters['items']: - data['filter_group']['filters'].append( - { - 'column': f.get('filter', ''), - 'operator': f.get('operator', ''), - 'type': f.get('type', ''), - 'value': f.get('value', {}), - } - ) + data['filter_group']['filters'].append(_convert_filter(f)) ordering = _data.pop('ordering', {}) data['ordering'] = ordering @@ -137,15 +178,26 @@ def to_representation(self, instance): filters.pop('filters', []) filters.pop('id', None) - for f in instance.filter_group.filters.order_by('index'): - filters['items'].append( - { - 'filter': f.column, - 'operator': f.operator, - 'type': f.type, - 'value': f.value, - } - ) + # Root filters first (ordered by index), followed by child filters (any order) + roots = instance.filter_group.filters.filter(parent__isnull=True).order_by('index') + children = instance.filter_group.filters.filter(parent__isnull=False) + + for f in list(roots) + list(children): + item = { + 'id': f.id, + 'filter': f.column, + 'operator': f.operator, + 'type': f.type, + 'value': f.value, + } + + # Relationship hints + if f.parent: + item['parent'] = f.parent.id + if children := list(f.children.all().values_list('id', flat=True)): + item['children'] = children + + filters['items'].append(item) result['data']['filters'] = filters selected_items = result.pop('selected_items', {}) @@ -159,11 +211,39 @@ def to_representation(self, instance): @staticmethod def _create_filters(filter_group, filters_data): - filter_index = 0 - for filter_data in filters_data: - filter_data['index'] = filter_index - filter_group.filters.add(Filter.objects.create(**filter_data)) - filter_index += 1 + """Create Filter objects inside the provided ``filter_group``. + + * For **root** filters (``parent`` is ``None``) we enumerate the + ``index`` so that the UI can preserve left-to-right order. + * For **child** filters we leave ``index`` as ``None`` – they are not + shown in the top-level ordering bar. + """ + + def _create_recursive(data, parent=None, index=None): + + # Extract nested children early (if any) and remove them from payload + children = data.pop('child_filters', []) + + # Handle explicit parent reference present in the JSON payload only + # for root elements. For nested structures we rely on the actual + # ``parent`` FK object instead of its primary key. + if parent is not None: + data.pop('parent', None) + + # Assign display order for root filters + if parent is None: + data['index'] = index + + # Persist the filter + obj = Filter.objects.create(parent=parent, **data) + filter_group.filters.add(obj) + + # Recurse into children (if any) + for child in children: + _create_recursive(child, parent=obj) + + for index, data in enumerate(filters_data): + _create_recursive(data, index=index) def create(self, validated_data): with transaction.atomic(): diff --git a/label_studio/tests/data_manager/test_views_api.py b/label_studio/tests/data_manager/test_views_api.py index 317bc1f59e83..55f7ba23d98f 100644 --- a/label_studio/tests/data_manager/test_views_api.py +++ b/label_studio/tests/data_manager/test_views_api.py @@ -149,7 +149,10 @@ def test_views_api_filters(business_client, project_id): ) assert response.status_code == 200, response.content - assert response.json()['data'] == payload['data'] + response_data = response.json()['data'] + for item in response_data['filters']['items']: + item.pop('id') + assert response_data == payload['data'] updated_payload = dict( project=project_id, @@ -187,7 +190,10 @@ def test_views_api_filters(business_client, project_id): ) assert response.status_code == 200, response.content - assert response.json()['data'] == updated_payload['data'] + response_data = response.json()['data'] + for item in response_data['filters']['items']: + item.pop('id') + assert response_data == updated_payload['data'] def test_views_ordered_by_id(business_client, project_id): diff --git a/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx b/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx index 374dfe3dc99e..0fcf03381e42 100644 --- a/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx +++ b/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx @@ -31,6 +31,8 @@ const GroupWrapper = ({ children, wrap = false }) => { }; export const FilterLine = observer(({ filter, availableFilters, index, view, sidebar, dropdownClassName }) => { + const childFilters = filter.view.filters.filter((f) => f.parent === filter.id); + return ( @@ -72,6 +74,24 @@ export const FilterLine = observer(({ filter, availableFilters, index, view, sid + + {/* Render child filters (join filters) inline */} + {childFilters.map((child, idx) => { + console.debug("[DM] render child filter", { parent: filter, child }); + return ( + + + {child.field.title} + + + + ); + })}