diff --git a/label_studio/core/settings/base.py b/label_studio/core/settings/base.py index ee8349a4d6d7..8cee4c21072b 100644 --- a/label_studio/core/settings/base.py +++ b/label_studio/core/settings/base.py @@ -776,7 +776,10 @@ def collect_versions_dummy(**kwargs): # By default, we disallow filters with foreign keys in data manager for security reasons. # Add to this list (either here in code, or via the env) to allow specific filters that rely on foreign keys. DATA_MANAGER_FILTER_ALLOWLIST = list( - set(get_env_list('DATA_MANAGER_FILTER_ALLOWLIST') + ['updated_by__active_organization']) + set( + get_env_list('DATA_MANAGER_FILTER_ALLOWLIST') + + ['updated_by__active_organization', 'annotations__completed_by'] + ) ) if ENABLE_CSP := get_bool_env('ENABLE_CSP', True): diff --git a/label_studio/data_manager/managers.py b/label_studio/data_manager/managers.py index e1f2da9dc2c0..bbfccb338ed6 100644 --- a/label_studio/data_manager/managers.py +++ b/label_studio/data_manager/managers.py @@ -3,6 +3,7 @@ import logging import re from datetime import datetime +from functools import reduce from typing import ClassVar import ujson as json @@ -262,193 +263,213 @@ def apply_filters(queryset, filters, project, request): return queryset # 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 + filter_line_expressions: list[list[Q]] = [] + for parent_filter in filters.items: + filter_line = [parent_filter, parent_filter.child_filter] if parent_filter.child_filter else [parent_filter] + filter_expressions: list[Q] = [] - # we can also have annotations filters - if not _filter.filter.startswith('filter:tasks:') or _filter.value is None: - continue + for _filter in filter_line: + is_child_filter = parent_filter.child_filter is not None and _filter is parent_filter.child_filter - # django orm loop expression attached to column name - preprocess_field_name = load_func(settings.PREPROCESS_FIELD_NAME) - field_name, _ = preprocess_field_name(_filter.filter, project) - - # filter pre-processing, value type conversion, etc.. - preprocess_filter = load_func(settings.DATA_MANAGER_PREPROCESS_FILTER) - _filter = preprocess_filter(_filter, field_name) - - # custom expressions for enterprise - filter_expression = custom_filter_expressions(_filter, field_name, project, request=request) - if filter_expression: - filter_expressions.append(filter_expression) - continue - - # annotators - result = add_user_filter(field_name == 'annotators', 'annotations__completed_by', _filter, filter_expressions) - if result == 'continue': - continue - - # updated_by - result = add_user_filter(field_name == 'updated_by', 'updated_by', _filter, filter_expressions) - if result == 'continue': - continue - - # annotations results & predictions results - if field_name in ['annotations_results', 'predictions_results']: - result = add_result_filter(field_name, _filter, filter_expressions, project) - if result == 'exit': - return queryset.none() - elif result == 'continue': + # we can also have annotations filters + if not _filter.filter.startswith('filter:tasks:') or _filter.value is None: continue - # annotation ids - if field_name == 'annotations_ids': - field_name = 'annotations__id' - if 'contains' in _filter.operator: - # convert string like "1 2,3" => [1,2,3] - _filter.value = [int(value) for value in re.split(',|;| ', _filter.value) if value and value.isdigit()] - _filter.operator = 'in_list' if _filter.operator == 'contains' else 'not_in_list' - elif 'equal' in _filter.operator: - if not _filter.value.isdigit(): - _filter.value = 0 - - # predictions model versions - if field_name == 'predictions_model_versions' and _filter.operator == Operator.CONTAINS: - q = Q() - for value in _filter.value: - q |= Q(predictions__model_version__contains=value) - filter_expressions.append(q) - continue - elif field_name == 'predictions_model_versions' and _filter.operator == Operator.NOT_CONTAINS: - q = Q() - for value in _filter.value: - q &= ~Q(predictions__model_version__contains=value) - filter_expressions.append(q) - continue - elif field_name == 'predictions_model_versions' and _filter.operator == Operator.EMPTY: - value = cast_bool_from_str(_filter.value) - filter_expressions.append(Q(predictions__model_version__isnull=value)) - continue - - # use other name because of model names conflict - if field_name == 'file_upload': - field_name = 'file_upload_field' - - # annotate with cast to number if need - if _filter.type == 'Number' and field_name.startswith('data__'): - json_field = field_name.replace('data__', '') - queryset = queryset.annotate( - **{ - f'filter_{json_field.replace("$undefined$", "undefined")}': Cast( - KeyTextTransform(json_field, 'data'), output_field=FloatField() - ) - } + # django orm loop expression attached to column name + preprocess_field_name = load_func(settings.PREPROCESS_FIELD_NAME) + field_name, _ = preprocess_field_name(_filter.filter, project) + + # filter pre-processing, value type conversion, etc.. + preprocess_filter = load_func(settings.DATA_MANAGER_PREPROCESS_FILTER) + _filter = preprocess_filter(_filter, field_name) + + # custom expressions for enterprise + filter_expression = custom_filter_expressions( + _filter, + field_name, + project, + request=request, + is_child_filter=is_child_filter, ) - clean_field_name = f'filter_{json_field.replace("$undefined$", "undefined")}' - else: - clean_field_name = field_name + if filter_expression: + filter_expressions.append(filter_expression) + continue - # special case: predictions, annotations, cancelled --- for them 0 is equal to is_empty=True - if ( - clean_field_name in ('total_predictions', 'total_annotations', 'cancelled_annotations') - and _filter.operator == 'empty' - ): - _filter.operator = 'equal' if cast_bool_from_str(_filter.value) else 'not_equal' - _filter.value = 0 - - # get type of annotated field - value_type = 'str' - if queryset.exists(): - value_type = type(queryset.values_list(field_name, flat=True)[0]).__name__ - - if (value_type == 'list' or value_type == 'tuple') and 'equal' in _filter.operator: - raise Exception('Not supported filter type') - - # special case: for strings empty is "" or null=True - if _filter.type in ('String', 'Unknown') and _filter.operator == 'empty': - value = cast_bool_from_str(_filter.value) - if value: # empty = true - q = Q(Q(**{field_name: None}) | Q(**{field_name + '__isnull': True})) - if value_type == 'str': - q |= Q(**{field_name: ''}) - if value_type == 'list': - q = Q(**{field_name: [None]}) - - else: # empty = false - q = Q(~Q(**{field_name: None}) & ~Q(**{field_name + '__isnull': True})) - if value_type == 'str': - q &= ~Q(**{field_name: ''}) - if value_type == 'list': - q = ~Q(**{field_name: [None]}) - - filter_expressions.append(q) - continue - - # regex pattern check - elif _filter.operator == 'regex': - try: - re.compile(pattern=str(_filter.value)) - except Exception as e: - logger.info('Incorrect regex for filter: %s: %s', _filter.value, str(e)) - return queryset.none() - - # append operator - field_name = f"{clean_field_name}{operators.get(_filter.operator, '')}" - - # in - if _filter.operator == 'in': - cast_value(_filter) - filter_expressions.append( - Q( - **{ - f'{field_name}__gte': _filter.value.min, - f'{field_name}__lte': _filter.value.max, - } - ), + # annotators + result = add_user_filter( + field_name == 'annotators', 'annotations__completed_by', _filter, filter_expressions ) + if result == 'continue': + continue - # not in - elif _filter.operator == 'not_in': - cast_value(_filter) - filter_expressions.append( - ~Q( + # updated_by + result = add_user_filter(field_name == 'updated_by', 'updated_by', _filter, filter_expressions) + if result == 'continue': + continue + + # annotations results & predictions results + if field_name in ['annotations_results', 'predictions_results']: + result = add_result_filter(field_name, _filter, filter_expressions, project) + if result == 'exit': + return queryset.none() + elif result == 'continue': + continue + + # annotation ids + if field_name == 'annotations_ids': + field_name = 'annotations__id' + if 'contains' in _filter.operator: + # convert string like "1 2,3" => [1,2,3] + _filter.value = [ + int(value) for value in re.split(',|;| ', _filter.value) if value and value.isdigit() + ] + _filter.operator = 'in_list' if _filter.operator == 'contains' else 'not_in_list' + elif 'equal' in _filter.operator: + if not _filter.value.isdigit(): + _filter.value = 0 + + # predictions model versions + if field_name == 'predictions_model_versions' and _filter.operator == Operator.CONTAINS: + q = Q() + for value in _filter.value: + q |= Q(predictions__model_version__contains=value) + filter_expressions.append(q) + continue + elif field_name == 'predictions_model_versions' and _filter.operator == Operator.NOT_CONTAINS: + q = Q() + for value in _filter.value: + q &= ~Q(predictions__model_version__contains=value) + filter_expressions.append(q) + continue + elif field_name == 'predictions_model_versions' and _filter.operator == Operator.EMPTY: + value = cast_bool_from_str(_filter.value) + filter_expressions.append(Q(predictions__model_version__isnull=value)) + continue + + # use other name because of model names conflict + if field_name == 'file_upload': + field_name = 'file_upload_field' + + # annotate with cast to number if need + if _filter.type == 'Number' and field_name.startswith('data__'): + json_field = field_name.replace('data__', '') + queryset = queryset.annotate( **{ - f'{field_name}__gte': _filter.value.min, - f'{field_name}__lte': _filter.value.max, + f'filter_{json_field.replace("$undefined$", "undefined")}': Cast( + KeyTextTransform(json_field, 'data'), output_field=FloatField() + ) } - ), - ) + ) + clean_field_name = f'filter_{json_field.replace("$undefined$", "undefined")}' + else: + clean_field_name = field_name + + # special case: predictions, annotations, cancelled --- for them 0 is equal to is_empty=True + if ( + clean_field_name in ('total_predictions', 'total_annotations', 'cancelled_annotations') + and _filter.operator == 'empty' + ): + _filter.operator = 'equal' if cast_bool_from_str(_filter.value) else 'not_equal' + _filter.value = 0 + + # get type of annotated field + value_type = 'str' + if queryset.exists(): + value_type = type(queryset.values_list(field_name, flat=True)[0]).__name__ + + if (value_type == 'list' or value_type == 'tuple') and 'equal' in _filter.operator: + raise Exception('Not supported filter type') + + # special case: for strings empty is "" or null=True + if _filter.type in ('String', 'Unknown') and _filter.operator == 'empty': + value = cast_bool_from_str(_filter.value) + if value: # empty = true + q = Q(Q(**{field_name: None}) | Q(**{field_name + '__isnull': True})) + if value_type == 'str': + q |= Q(**{field_name: ''}) + if value_type == 'list': + q = Q(**{field_name: [None]}) + + else: # empty = false + q = Q(~Q(**{field_name: None}) & ~Q(**{field_name + '__isnull': True})) + if value_type == 'str': + q &= ~Q(**{field_name: ''}) + if value_type == 'list': + q = ~Q(**{field_name: [None]}) + + filter_expressions.append(q) + continue - # in list - elif _filter.operator == 'in_list': - filter_expressions.append( - Q(**{f'{field_name}__in': _filter.value}), - ) + # regex pattern check + elif _filter.operator == 'regex': + try: + re.compile(pattern=str(_filter.value)) + except Exception as e: + logger.info('Incorrect regex for filter: %s: %s', _filter.value, str(e)) + return queryset.none() + + # append operator + field_name = f"{clean_field_name}{operators.get(_filter.operator, '')}" + + # in + if _filter.operator == 'in': + cast_value(_filter) + filter_expressions.append( + Q( + **{ + f'{field_name}__gte': _filter.value.min, + f'{field_name}__lte': _filter.value.max, + } + ), + ) - # not in list - elif _filter.operator == 'not_in_list': - filter_expressions.append( - ~Q(**{f'{field_name}__in': _filter.value}), - ) + # not in + elif _filter.operator == 'not_in': + cast_value(_filter) + filter_expressions.append( + ~Q( + **{ + f'{field_name}__gte': _filter.value.min, + f'{field_name}__lte': _filter.value.max, + } + ), + ) + + # in list + elif _filter.operator == 'in_list': + filter_expressions.append( + Q(**{f'{field_name}__in': _filter.value}), + ) + + # not in list + elif _filter.operator == 'not_in_list': + filter_expressions.append( + ~Q(**{f'{field_name}__in': _filter.value}), + ) + + # empty + elif _filter.operator == 'empty': + if cast_bool_from_str(_filter.value): + filter_expressions.append(Q(**{field_name: True})) + else: + filter_expressions.append(~Q(**{field_name: True})) - # empty - elif _filter.operator == 'empty': - if cast_bool_from_str(_filter.value): - filter_expressions.append(Q(**{field_name: True})) + # starting from not_ + elif _filter.operator.startswith('not_'): + cast_value(_filter) + filter_expressions.append(~Q(**{field_name: _filter.value})) + + # all others else: - filter_expressions.append(~Q(**{field_name: True})) + cast_value(_filter) + filter_expressions.append(Q(**{field_name: _filter.value})) - # starting from not_ - elif _filter.operator.startswith('not_'): - cast_value(_filter) - filter_expressions.append(~Q(**{field_name: _filter.value})) + filter_line_expressions.append(filter_expressions) - # all others - else: - cast_value(_filter) - filter_expressions.append(Q(**{field_name: _filter.value})) + resolved_filter_lines = [reduce(lambda x, y: x & y, fle) for fle in filter_line_expressions] """WARNING: Stringifying filter_expressions will evaluate the (sub)queryset. Do not use a log in the following manner: @@ -458,12 +479,12 @@ def apply_filters(queryset, filters, project, request): """ if filters.conjunction == ConjunctionEnum.OR: result_filter = Q() - for filter_expression in filter_expressions: - result_filter.add(filter_expression, Q.OR) + for resolved_filter in resolved_filter_lines: + result_filter.add(resolved_filter, Q.OR) queryset = queryset.filter(result_filter) else: - for filter_expression in filter_expressions: - queryset = queryset.filter(filter_expression) + for resolved_filter in resolved_filter_lines: + queryset = queryset.filter(resolved_filter) 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..bcae9ad9c9e7 --- /dev/null +++ b/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.10 on 2025-06-24 03:22 + +import django.db.models.deletion +import django_migration_linter as linter +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("data_manager", "0012_alter_view_user"), + ] + + operations = [ + linter.IgnoreMigration(), + 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", + ), + ), + 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/migrations/0014_merge_20250701_2259.py b/label_studio/data_manager/migrations/0014_merge_20250701_2259.py new file mode 100644 index 000000000000..9247c9211682 --- /dev/null +++ b/label_studio/data_manager/migrations/0014_merge_20250701_2259.py @@ -0,0 +1,13 @@ +# Generated by Django 5.1.10 on 2025-07-01 22:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("data_manager", "0013_cleanup_inconsistent_filtergroup_20250624_2119"), + ("data_manager", "0013_filter_parent_alter_filter_index"), + ] + + operations = [] diff --git a/label_studio/data_manager/models.py b/label_studio/data_manager/models.py index 35859d581af1..ca6d41e0c828 100644 --- a/label_studio/data_manager/models.py +++ b/label_studio/data_manager/models.py @@ -50,20 +50,14 @@ class Meta: class View(ViewBaseModel, ProjectViewMixin): def get_prepare_tasks_params(self, add_selected_items=False): + # Import here to avoid circular imports + from data_manager.serializers import FilterGroupSerializer + # convert filters to PrepareParams structure filters = None if self.filter_group: - items = [] - for f in self.filter_group.filters.all(): - items.append( - dict( - filter=f.column, - operator=f.operator, - type=f.type, - value=f.value, - ) - ) - filters = dict(conjunction=self.filter_group.conjunction, items=items) + serializer = FilterGroupSerializer() + filters = serializer.to_representation(self.filter_group) ordering = self.ordering if not ordering: @@ -86,7 +80,24 @@ 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, + 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 95f8e849ee0e..131daa3ac25d 100644 --- a/label_studio/data_manager/prepare_params.py +++ b/label_studio/data_manager/prepare_params.py @@ -12,6 +12,8 @@ class FilterIn(BaseModel): class Filter(BaseModel): + child_filter: Optional['Filter'] = None + filter: str operator: str type: str diff --git a/label_studio/data_manager/serializers.py b/label_studio/data_manager/serializers.py index 470f40ffa972..d2ab3ad80a1c 100644 --- a/label_studio/data_manager/serializers.py +++ b/label_studio/data_manager/serializers.py @@ -21,7 +21,35 @@ from label_studio.core.utils.common import round_floats +class ChildFilterSerializer(serializers.ModelSerializer): + class Meta: + model = Filter + fields = '__all__' + + def to_representation(self, value): + parent = self.parent # the owning FilterSerializer instance + serializer = parent.__class__(instance=value, context=self.context) + return serializer.data + + def to_internal_value(self, data): + """Allow ChildFilterSerializer 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.__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_filter = ChildFilterSerializer(required=False) + class Meta: model = Filter fields = '__all__' @@ -73,6 +101,35 @@ def validate_column(self, column: str) -> str: class FilterGroupSerializer(serializers.ModelSerializer): filters = FilterSerializer(many=True) + def to_representation(self, instance): + def _build_filter_tree(filter_obj): + """Build hierarchical filter representation.""" + item = { + 'filter': filter_obj.column, + 'operator': filter_obj.operator, + 'type': filter_obj.type, + 'value': filter_obj.value, + } + + # Add child filter if exists (only one level of nesting) + child_filters = filter_obj.children.all() + if child_filters: + child = child_filters[0] # Only support one child + child_item = { + 'filter': child.column, + 'operator': child.operator, + 'type': child.type, + 'value': child.value, + } + item['child_filter'] = child_item + + return item + + # Only process root filters (ordered by index) + roots = instance.filters.filter(parent__isnull=True).prefetch_related('children').order_by('index') + + return {'conjunction': instance.conjunction, 'items': [_build_filter_tree(f) for f in roots]} + class Meta: model = FilterGroup fields = '__all__' @@ -114,15 +171,26 @@ 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 "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', {}), + } + + if child_filter := src_filter.get('child_filter'): + filter_payload['child_filter'] = _convert_filter(child_filter) + + 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 @@ -131,21 +199,10 @@ def to_internal_value(self, data): def to_representation(self, instance): result = super().to_representation(instance) + + # Handle filter_group serialization filters = result.pop('filter_group', {}) if filters: - filters['items'] = [] - 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, - } - ) result['data']['filters'] = filters selected_items = result.pop('selected_items', {}) @@ -159,11 +216,38 @@ 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 + child_filter = data.pop('child_filter', None) + + # 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) + + if child_filter: + _create_recursive(child_filter, parent=obj) + + for index, data in enumerate(filters_data): + _create_recursive(data, index=index) def create(self, validated_data): with transaction.atomic(): @@ -190,6 +274,8 @@ def update(self, instance, validated_data): filter_group = instance.filter_group if filter_group is None: filter_group = FilterGroup.objects.create(**filter_group_data) + instance.filter_group = filter_group + instance.save(update_fields=['filter_group']) conjunction = filter_group_data.get('conjunction') if conjunction and filter_group.conjunction != conjunction: diff --git a/label_studio/tests/data_manager/test_views_api.py b/label_studio/tests/data_manager/test_views_api.py index 317bc1f59e83..f34b3dca71d5 100644 --- a/label_studio/tests/data_manager/test_views_api.py +++ b/label_studio/tests/data_manager/test_views_api.py @@ -190,6 +190,434 @@ def test_views_api_filters(business_client, project_id): assert response.json()['data'] == updated_payload['data'] +def test_views_api_nested_filters(business_client, project_id): + """Test creating views with nested filters using child filters. + + This test validates the nested filter structure where a parent filter + can have child filters that are AND-merged with the parent. This is + similar to the enterprise annotations_results_json filters but uses + regular task data filters. + """ + # Create a project with specific label config for testing + project_response = business_client.post( + '/api/projects/', + data=json.dumps( + { + 'title': 'test_nested_filters', + 'label_config': """ + + + + + + + + + + """, + } + ), + content_type='application/json', + ) + assert project_response.status_code == 201 + project = project_response.json() + + # Create tasks with different data + task1_data = {'text': 'task1', 'category': 'A'} + task1_response = business_client.post( + f'/api/projects/{project["id"]}/tasks', + data=json.dumps({'data': task1_data}), + content_type='application/json', + ) + assert task1_response.status_code == 201 + task1 = task1_response.json() + + task2_data = {'text': 'task2', 'category': 'B'} + task2_response = business_client.post( + f'/api/projects/{project["id"]}/tasks', + data=json.dumps({'data': task2_data}), + content_type='application/json', + ) + assert task2_response.status_code == 201 + task2 = task2_response.json() + + task3_data = {'text': 'task3', 'category': 'A'} + task3_response = business_client.post( + f'/api/projects/{project["id"]}/tasks', + data=json.dumps({'data': task3_data}), + content_type='application/json', + ) + assert task3_response.status_code == 201 + task3 = task3_response.json() + + # Create a view with nested filters + # Parent filter: tasks with category 'A' + # Child filter: tasks with text containing 'task1' + nested_filter_payload = { + 'project': project['id'], + 'data': { + 'filters': { + 'conjunction': 'and', + 'items': [ + { + 'filter': 'filter:tasks:data.category', + 'operator': 'equal', + 'type': 'String', + 'value': 'A', + 'child_filter': { + 'filter': 'filter:tasks:data.text', + 'operator': 'contains', + 'type': 'String', + 'value': 'task1', + }, + } + ], + } + }, + } + + response = business_client.post( + '/api/dm/views/', + data=json.dumps(nested_filter_payload), + content_type='application/json', + ) + assert response.status_code == 201, response.content + view_id = response.json()['id'] + + # Retrieve the created view and verify the nested structure + response = business_client.get(f'/api/dm/views/{view_id}/') + assert response.status_code == 200, response.content + + view_data = response.json()['data'] + filter_data = view_data['filters'] + + # Verify the filter structure + assert filter_data['conjunction'] == 'and' + assert len(filter_data['items']) == 1 + + root_filter = filter_data['items'][0] + assert root_filter['filter'] == 'filter:tasks:data.category' + assert root_filter['operator'] == 'equal' + assert root_filter['type'] == 'String' + assert root_filter['value'] == 'A' + + # Verify child filter structure + assert 'child_filter' in root_filter + child_filter = root_filter['child_filter'] + assert child_filter['filter'] == 'filter:tasks:data.text' + assert child_filter['operator'] == 'contains' + assert child_filter['type'] == 'String' + assert child_filter['value'] == 'task1' + + # Test that the view filters tasks correctly + # Only task1 should match: category='A' AND text contains 'task1' + response = business_client.get(f'/api/tasks?view={view_id}') + assert response.status_code == 200, response.content + + tasks = response.json()['tasks'] + assert len(tasks) == 1 + assert tasks[0]['id'] == task1['id'] + + # Test with a different nested filter structure + # Parent filter: tasks with category 'A' or 'B' + # Child filter: tasks with text containing 'task' + complex_nested_payload = { + 'project': project['id'], + 'data': { + 'filters': { + 'conjunction': 'or', + 'items': [ + { + 'filter': 'filter:tasks:data.category', + 'operator': 'equal', + 'type': 'String', + 'value': 'A', + 'child_filter': { + 'filter': 'filter:tasks:data.text', + 'operator': 'contains', + 'type': 'String', + 'value': 'task', + }, + }, + { + 'filter': 'filter:tasks:data.category', + 'operator': 'equal', + 'type': 'String', + 'value': 'B', + 'child_filter': { + 'filter': 'filter:tasks:data.text', + 'operator': 'contains', + 'type': 'String', + 'value': 'task', + }, + }, + ], + } + }, + } + + response = business_client.post( + '/api/dm/views/', + data=json.dumps(complex_nested_payload), + content_type='application/json', + ) + assert response.status_code == 201, response.content + complex_view_id = response.json()['id'] + + # Test the complex nested filter + response = business_client.get(f'/api/tasks?view={complex_view_id}') + assert response.status_code == 200, response.content + + tasks = response.json()['tasks'] + # Should match all tasks: (category='A' AND text contains 'task') OR (category='B' AND text contains 'task') + assert len(tasks) == 3 + task_ids = [task['id'] for task in tasks] + assert task1['id'] in task_ids + assert task2['id'] in task_ids + assert task3['id'] in task_ids + + +def test_views_api_patch_add_child_filter(business_client, project_id): + """Test creating a view with a non-nested filter, then PATCHing it to add a child filter. + + This test validates the behavior of updating a view's filter structure by adding + child filters to existing filters through PATCH requests. + """ + # Create a project with specific label config for testing + project_response = business_client.post( + '/api/projects/', + data=json.dumps( + { + 'title': 'test_patch_child_filter', + 'label_config': """ + + + + + + + + + + """, + } + ), + content_type='application/json', + ) + assert project_response.status_code == 201 + project = project_response.json() + + # Create tasks with different data + task1_data = {'text': 'task1', 'category': 'A'} + task1_response = business_client.post( + f'/api/projects/{project["id"]}/tasks', + data=json.dumps({'data': task1_data}), + content_type='application/json', + ) + assert task1_response.status_code == 201 + task1 = task1_response.json() + + task2_data = {'text': 'task2', 'category': 'A'} + task2_response = business_client.post( + f'/api/projects/{project["id"]}/tasks', + data=json.dumps({'data': task2_data}), + content_type='application/json', + ) + assert task2_response.status_code == 201 + task2 = task2_response.json() + + task3_data = {'text': 'task3', 'category': 'B'} + task3_response = business_client.post( + f'/api/projects/{project["id"]}/tasks', + data=json.dumps({'data': task3_data}), + content_type='application/json', + ) + assert task3_response.status_code == 201 + task3 = task3_response.json() + + # Step 1: Create a view with a non-nested filter + # Filter: tasks with category 'A' + simple_filter_payload = { + 'project': project['id'], + 'data': { + 'filters': { + 'conjunction': 'and', + 'items': [ + { + 'filter': 'filter:tasks:data.category', + 'operator': 'equal', + 'type': 'String', + 'value': 'A', + } + ], + } + }, + } + + response = business_client.post( + '/api/dm/views/', + data=json.dumps(simple_filter_payload), + content_type='application/json', + ) + assert response.status_code == 201, response.content + view_id = response.json()['id'] + + # Verify the initial view has no child filters + response = business_client.get(f'/api/dm/views/{view_id}/') + assert response.status_code == 200, response.content + + view_data = response.json()['data'] + filter_data = view_data['filters'] + + # Verify the initial filter structure (no child filters) + assert filter_data['conjunction'] == 'and' + assert len(filter_data['items']) == 1 + + root_filter = filter_data['items'][0] + assert root_filter['filter'] == 'filter:tasks:data.category' + assert root_filter['operator'] == 'equal' + assert root_filter['type'] == 'String' + assert root_filter['value'] == 'A' + + # Verify no child filter exists initially + assert 'child_filter' not in root_filter + + # Test that the initial view filters tasks correctly + # Should match task1 and task2 (both have category='A') + response = business_client.get(f'/api/tasks?view={view_id}') + assert response.status_code == 200, response.content + + tasks = response.json()['tasks'] + assert len(tasks) == 2 + task_ids = [task['id'] for task in tasks] + assert task1['id'] in task_ids + assert task2['id'] in task_ids + assert task3['id'] not in task_ids + + # Step 2: PATCH the view to add a child filter + # Add child filter: tasks with text containing 'task1' + patch_payload = { + 'data': { + 'filters': { + 'conjunction': 'and', + 'items': [ + { + 'filter': 'filter:tasks:data.category', + 'operator': 'equal', + 'type': 'String', + 'value': 'A', + 'child_filter': { + 'filter': 'filter:tasks:data.text', + 'operator': 'contains', + 'type': 'String', + 'value': 'task1', + }, + } + ], + } + }, + } + + response = business_client.patch( + f'/api/dm/views/{view_id}/', + data=json.dumps(patch_payload), + content_type='application/json', + ) + assert response.status_code == 200, response.content + + # Step 3: Verify the PATCHed view has the child filter + response = business_client.get(f'/api/dm/views/{view_id}/') + assert response.status_code == 200, response.content + + view_data = response.json()['data'] + filter_data = view_data['filters'] + + # Verify the updated filter structure (now has child filter) + assert filter_data['conjunction'] == 'and' + assert len(filter_data['items']) == 1 + + root_filter = filter_data['items'][0] + assert root_filter['filter'] == 'filter:tasks:data.category' + assert root_filter['operator'] == 'equal' + assert root_filter['type'] == 'String' + assert root_filter['value'] == 'A' + + # Verify child filter was added + assert 'child_filter' in root_filter + child_filter = root_filter['child_filter'] + assert child_filter['filter'] == 'filter:tasks:data.text' + assert child_filter['operator'] == 'contains' + assert child_filter['type'] == 'String' + assert child_filter['value'] == 'task1' + + # Step 4: Test that the PATCHed view filters tasks correctly + # Should now only match task1: category='A' AND text contains 'task1' + response = business_client.get(f'/api/tasks?view={view_id}') + assert response.status_code == 200, response.content + + tasks = response.json()['tasks'] + assert len(tasks) == 1 + assert tasks[0]['id'] == task1['id'] + + # Step 5: PATCH again to modify the child filter + # Change child filter to: tasks with text containing 'task' + patch_payload_2 = { + 'data': { + 'filters': { + 'conjunction': 'and', + 'items': [ + { + 'filter': 'filter:tasks:data.category', + 'operator': 'equal', + 'type': 'String', + 'value': 'A', + 'child_filter': { + 'filter': 'filter:tasks:data.text', + 'operator': 'contains', + 'type': 'String', + 'value': 'task', + }, + } + ], + } + }, + } + + response = business_client.patch( + f'/api/dm/views/{view_id}/', + data=json.dumps(patch_payload_2), + content_type='application/json', + ) + assert response.status_code == 200, response.content + + # Step 6: Verify the child filter was updated + response = business_client.get(f'/api/dm/views/{view_id}/') + assert response.status_code == 200, response.content + + view_data = response.json()['data'] + filter_data = view_data['filters'] + + root_filter = filter_data['items'][0] + child_filter = root_filter['child_filter'] + assert child_filter['value'] == 'task' # Updated value + + # Test that the updated view filters tasks correctly + # Should now match task1 and task2: category='A' AND text contains 'task' + response = business_client.get(f'/api/tasks?view={view_id}') + assert response.status_code == 200, response.content + + tasks = response.json()['tasks'] + assert len(tasks) == 2 + task_ids = [task['id'] for task in tasks] + assert task1['id'] in task_ids + assert task2['id'] in task_ids + assert task3['id'] not in task_ids + + def test_views_ordered_by_id(business_client, project_id): views = [{'view_data': 1}, {'view_data': 2}, {'view_data': 3}] diff --git a/poetry.lock b/poetry.lock index 89dad017f50e..18c9d5fd9211 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2136,7 +2136,7 @@ optional = false python-versions = ">=3.9,<4" groups = ["main"] files = [ - {file = "07ed2fe6387c745e9026e8d650039168721de33e.zip", hash = "sha256:9e47cf8404e86c010e123ed760723717c62be26b6f631e2db873ae81af586fa4"}, + {file = "8ec49aacf26227c1e5956bfbfec907a52e591931.zip", hash = "sha256:b2938e4c79d7b8c36490cf96e26aa30b792dda70d4e121ac4721d642f814b938"}, ] [package.dependencies] @@ -2164,7 +2164,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/07ed2fe6387c745e9026e8d650039168721de33e.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/8ec49aacf26227c1e5956bfbfec907a52e591931.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5037,4 +5037,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "7d774234df518aac5bfada63276f934cf98588a1a888372305be76bbac883d33" +content-hash = "cda57c69bcd1815802029e8f6704729f21a693c57623b12e869263aec296894d" diff --git a/pyproject.toml b/pyproject.toml index 565ceb938be1..8e25ab759df9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ dependencies = [ "djangorestframework-simplejwt[crypto] (>=5.4.0,<6.0.0)", "tldextract (>=5.1.3)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/07ed2fe6387c745e9026e8d650039168721de33e.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/8ec49aacf26227c1e5956bfbfec907a52e591931.zip", ## HumanSignal repo dependencies :end ] diff --git a/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx b/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx index 2b2bc9f15272..00685ef154b1 100644 --- a/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx +++ b/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx @@ -1,5 +1,4 @@ import { observer } from "mobx-react"; -import { Fragment } from "react"; import { BemWithSpecifiContext } from "../../../utils/bem"; import { Button } from "@humansignal/ui"; import { IconClose } from "@humansignal/icons"; @@ -26,14 +25,14 @@ const Conjunction = observer(({ index, view }) => { ); }); -const GroupWrapper = ({ children, wrap = false }) => { - return wrap ? {children} : children; -}; - export const FilterLine = observer(({ filter, availableFilters, index, view, sidebar, dropdownClassName }) => { - return ( - - + const childFilter = filter.child_filter; + + if (sidebar) { + // Sidebar layout uses grid structure like main layout + return ( + + {/* Main filter row */} {index === 0 ? ( Where @@ -41,15 +40,13 @@ export const FilterLine = observer(({ filter, availableFilters, index, view, sid )} + { const original = option?.original ?? option; const title = original?.field?.title ?? original?.title ?? ""; @@ -70,8 +67,7 @@ export const FilterLine = observer(({ filter, availableFilters, index, view, sid disabled={filter.field.disabled} /> - - + - - -