Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
b4dbb77
feat: ROOT-45: Filter by annotator prototype
matt-bernstein Jun 17, 2025
b0fcde4
model changes
matt-bernstein Jun 17, 2025
e3ff613
migration
matt-bernstein Jun 17, 2025
21be28f
update migration
matt-bernstein Jun 17, 2025
57e79b4
Revert "update migration"
matt-bernstein Jun 17, 2025
ae7a9a7
async migration
matt-bernstein Jun 17, 2025
035677f
give up on index
matt-bernstein Jun 17, 2025
ba8e2f7
omit parent when appropriate
matt-bernstein Jun 17, 2025
8abe888
wip FE for child filters
matt-bernstein Jun 18, 2025
91331c0
allow parent index instead of parent id
matt-bernstein Jun 18, 2025
2b47439
use parent_index
matt-bernstein Jun 18, 2025
45230c6
ff
matt-bernstein Jun 18, 2025
cea1fe1
bugfix
matt-bernstein Jun 18, 2025
fccf130
filter counter bugfix
matt-bernstein Jun 18, 2025
89e09b7
don't double-display child filters
matt-bernstein Jun 18, 2025
50dda51
apply child filters correctly
matt-bernstein Jun 18, 2025
a43ab3d
Apply pre-commit linters
matt-bernstein Jun 18, 2025
30c3b2d
extend pydantic data model
matt-bernstein Jun 18, 2025
0918f2a
fix child filter persistence
matt-bernstein Jun 18, 2025
8804d78
wip persist from db
matt-bernstein Jun 18, 2025
1b11cd2
wip persist from db
matt-bernstein Jun 18, 2025
c781a49
wip remove parent_index from BE
matt-bernstein Jun 19, 2025
f34ed29
fix test
matt-bernstein Jun 19, 2025
addbecd
fix serializers
matt-bernstein Jun 20, 2025
7fff391
remove parent_index in favor of parent, id
matt-bernstein Jun 20, 2025
a38c17a
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Jun 23, 2025
a567d62
Sync Follow Merge dependencies
matt-bernstein Jun 23, 2025
7a610b0
hierarchical filters FE
matt-bernstein Jun 23, 2025
cd11f3b
badge styling
matt-bernstein Jun 23, 2025
d141f0c
unstack child filters when applying them to queryset
matt-bernstein Jun 23, 2025
03bd71b
Merge branch 'develop' into fb-ROOT-45
jombooth Jun 23, 2025
f194fc8
consolidate serializing logic
matt-bernstein Jun 23, 2025
aaf1908
Merge branch 'fb-ROOT-45' of github.com:HumanSignal/label-studio into…
jombooth Jun 24, 2025
a1f6656
reduce queries in FilterGroupSerializer, reduce duplication in filter…
jombooth Jun 24, 2025
51978e4
put the index back
jombooth Jun 24, 2025
b76cb68
test POST and PATCH on the views API - effectively covering view dupl…
jombooth Jun 24, 2025
22b8f72
fix broken tests
jombooth Jun 24, 2025
7fbadb6
allowlist completed_by
jombooth Jun 24, 2025
2f060ea
Refactor Filter components for improved layout and styling
ricardoantoniocm Jun 24, 2025
f4707f0
Merge branch 'fb-ROOT-45' of https://github.com/heartexlabs/label-stu…
ricardoantoniocm Jun 24, 2025
33d38ef
Improves spacing.
ricardoantoniocm Jun 24, 2025
d5f0022
Fixes spacing on sidebar.
ricardoantoniocm Jun 24, 2025
3146e62
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Jun 24, 2025
c99793a
revert guards
matt-bernstein Jun 24, 2025
c2141dc
fix guards
matt-bernstein Jun 24, 2025
be3874f
Revert "fix broken tests"
matt-bernstein Jun 24, 2025
5ebd6af
Revert "fix test"
matt-bernstein Jun 24, 2025
0dacb8b
wip
matt-bernstein Jun 24, 2025
e99264b
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Jun 25, 2025
1a97b27
Sync Follow Merge dependencies
matt-bernstein Jun 25, 2025
2eb8e78
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Jul 1, 2025
dfdbd8d
merge migrations
matt-bernstein Jul 1, 2025
4b4b431
syntax
matt-bernstein Jul 1, 2025
5da40be
Sync Follow Merge dependencies
matt-bernstein Jul 1, 2025
9aeb2af
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Jul 2, 2025
8384838
Sync Follow Merge dependencies
matt-bernstein Jul 2, 2025
efb8440
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Jul 2, 2025
abd53a7
add back views
matt-bernstein Jul 2, 2025
1ff201d
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Jul 24, 2025
4f6b370
Apply pre-commit linters
matt-bernstein Jul 24, 2025
261a260
Sync Follow Merge dependencies
matt-bernstein Jul 24, 2025
b532fc6
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Aug 1, 2025
1fa4cc7
Sync Follow Merge dependencies
matt-bernstein Aug 1, 2025
294d128
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Aug 1, 2025
1453390
fix partial child filter saving
matt-bernstein Aug 1, 2025
bcaa4d4
fix style
matt-bernstein Aug 1, 2025
763172f
Sync Follow Merge dependencies
matt-bernstein Aug 1, 2025
4848515
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Aug 4, 2025
8ff38d9
handle annotator filter in child
matt-bernstein Aug 4, 2025
68baec5
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Aug 4, 2025
efd83d5
join_filters -> child_filter
matt-bernstein Aug 4, 2025
e562d14
remove debug logs
matt-bernstein Aug 4, 2025
aec1cc4
make ChildFilterSerializer into a model serializer so that it Spectac…
jombooth Aug 4, 2025
08048a3
Sync Follow Merge dependencies
robot-ci-heartex Aug 4, 2025
bba2023
Merge branch 'develop' into 'fb-ROOT-45'
robot-ci-heartex Aug 4, 2025
8995ef2
fix cutoff connector text
matt-bernstein Aug 5, 2025
0142662
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Aug 5, 2025
fd2ac8a
fix remove button
matt-bernstein Aug 5, 2025
9187272
revert style change
matt-bernstein Aug 5, 2025
24c8961
use update_fields
matt-bernstein Aug 5, 2025
afd65d5
fix margin
matt-bernstein Aug 5, 2025
184bffe
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Aug 5, 2025
301865a
Merge remote-tracking branch 'origin/develop' into fb-ROOT-45
matt-bernstein Aug 6, 2025
53a8062
Merge branch 'develop' into 'fb-ROOT-45'
matt-bernstein Aug 7, 2025
de01ec5
Sync Follow Merge dependencies
robot-ci-heartex Aug 7, 2025
775990e
lockfile
matt-bernstein Aug 7, 2025
f0c9152
Sync Follow Merge dependencies
robot-ci-heartex Aug 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion label_studio/core/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
367 changes: 194 additions & 173 deletions label_studio/data_manager/managers.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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",
),
),
]
Original file line number Diff line number Diff line change
@@ -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 = []
35 changes: 23 additions & 12 deletions label_studio/data_manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions label_studio/data_manager/prepare_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class FilterIn(BaseModel):


class Filter(BaseModel):
child_filter: Optional['Filter'] = None

filter: str
operator: str
type: str
Expand Down
138 changes: 112 additions & 26 deletions label_studio/data_manager/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__'
Expand Down Expand Up @@ -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__'
Expand Down Expand Up @@ -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
Expand All @@ -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', {})
Expand All @@ -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():
Expand All @@ -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:
Expand Down
Loading
Loading