From b4dbb770bcd429a09e6163ed00b45c5b39ca09c1 Mon Sep 17 00:00:00 2001 From: Matt Bernstein Date: Tue, 17 Jun 2025 14:25:28 -0400 Subject: [PATCH 01/25] feat: ROOT-45: Filter by annotator prototype From b0fcde414e00c3c4a16a8a60b8fa999bd2f30b48 Mon Sep 17 00:00:00 2001 From: Matt Bernstein Date: Tue, 17 Jun 2025 14:25:42 -0400 Subject: [PATCH 02/25] model changes --- label_studio/data_manager/models.py | 19 ++++++++++++++++- label_studio/data_manager/serializers.py | 27 ++++++++++++++++++++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/label_studio/data_manager/models.py b/label_studio/data_manager/models.py index 35859d581af1..f1d81f43555a 100644 --- a/label_studio/data_manager/models.py +++ b/label_studio/data_manager/models.py @@ -86,7 +86,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/serializers.py b/label_studio/data_manager/serializers.py index 82cb85af0d39..fad17b1244c7 100644 --- a/label_studio/data_manager/serializers.py +++ b/label_studio/data_manager/serializers.py @@ -121,6 +121,7 @@ def to_internal_value(self, data): 'operator': f.get('operator', ''), 'type': f.get('type', ''), 'value': f.get('value', {}), + 'parent': f.get('parent'), } ) @@ -137,13 +138,17 @@ def to_representation(self, instance): filters.pop('filters', []) filters.pop('id', None) - for f in instance.filter_group.filters.order_by('index'): + # 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): filters['items'].append( { 'filter': f.column, 'operator': f.operator, 'type': f.type, 'value': f.value, + 'parent': f.parent_id, } ) result['data']['filters'] = filters @@ -159,11 +164,25 @@ def to_representation(self, instance): @staticmethod def _create_filters(filter_group, filters_data): - filter_index = 0 + """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. + """ + + next_index = 0 for filter_data in filters_data: - filter_data['index'] = filter_index + is_root = filter_data.get('parent') in (None, '') + + # Assign ordering index only to root filters + filter_data['index'] = next_index if is_root else None + + if is_root: + next_index += 1 + filter_group.filters.add(Filter.objects.create(**filter_data)) - filter_index += 1 def create(self, validated_data): with transaction.atomic(): From e3ff6132e7e85638a672d5abb6f2f6f6d43d0709 Mon Sep 17 00:00:00 2001 From: Matt Bernstein Date: Tue, 17 Jun 2025 14:28:07 -0400 Subject: [PATCH 03/25] migration --- .../0013_filter_parent_alter_filter_index.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py 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..8f6b9033a2bd --- /dev/null +++ b/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py @@ -0,0 +1,37 @@ +# 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", + ), + ), + 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", + ), + ), + ] From 21be28fee861bbd95ca593ff3750e79e85fe3cff Mon Sep 17 00:00:00 2001 From: Matt Bernstein Date: Tue, 17 Jun 2025 14:42:51 -0400 Subject: [PATCH 04/25] update migration --- ..._alter_filter_index.py => 0013_filter_parent.py} | 13 +------------ label_studio/data_manager/models.py | 10 ++-------- label_studio/data_manager/serializers.py | 8 +++++--- 3 files changed, 8 insertions(+), 23 deletions(-) rename label_studio/data_manager/migrations/{0013_filter_parent_alter_filter_index.py => 0013_filter_parent.py} (63%) diff --git a/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py b/label_studio/data_manager/migrations/0013_filter_parent.py similarity index 63% rename from label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py rename to label_studio/data_manager/migrations/0013_filter_parent.py index 8f6b9033a2bd..2173334754d3 100644 --- a/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py +++ b/label_studio/data_manager/migrations/0013_filter_parent.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.10 on 2025-06-17 18:27 +# Generated by Django 5.1.10 on 2025-06-17 18:42 import django.db.models.deletion from django.db import migrations, models @@ -23,15 +23,4 @@ class Migration(migrations.Migration): 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/models.py b/label_studio/data_manager/models.py index f1d81f43555a..eaa58670d47c 100644 --- a/label_studio/data_manager/models.py +++ b/label_studio/data_manager/models.py @@ -96,14 +96,8 @@ class Filter(models.Model): 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', - ) + # Order of the filter inside its group. Not used for child filters. + index = models.IntegerField(_('index'), default=0, help_text='To keep filter order') 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/serializers.py b/label_studio/data_manager/serializers.py index fad17b1244c7..2f807c4ec9b9 100644 --- a/label_studio/data_manager/serializers.py +++ b/label_studio/data_manager/serializers.py @@ -176,11 +176,13 @@ def _create_filters(filter_group, filters_data): for filter_data in filters_data: is_root = filter_data.get('parent') in (None, '') - # Assign ordering index only to root filters - filter_data['index'] = next_index if is_root else None - if is_root: + # Assign ordering index + filter_data['index'] = next_index next_index += 1 + else: + # For children we don't specify index, it'll default to 0 + filter_data.pop('index', None) filter_group.filters.add(Filter.objects.create(**filter_data)) From 57e79b4fffcf583f9bdbc199a9a19f738e25040d Mon Sep 17 00:00:00 2001 From: Matt Bernstein Date: Tue, 17 Jun 2025 15:29:44 -0400 Subject: [PATCH 05/25] Revert "update migration" This reverts commit 21be28fee861bbd95ca593ff3750e79e85fe3cff. --- ....py => 0013_filter_parent_alter_filter_index.py} | 13 ++++++++++++- label_studio/data_manager/models.py | 10 ++++++++-- label_studio/data_manager/serializers.py | 8 +++----- 3 files changed, 23 insertions(+), 8 deletions(-) rename label_studio/data_manager/migrations/{0013_filter_parent.py => 0013_filter_parent_alter_filter_index.py} (63%) diff --git a/label_studio/data_manager/migrations/0013_filter_parent.py b/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py similarity index 63% rename from label_studio/data_manager/migrations/0013_filter_parent.py rename to label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py index 2173334754d3..8f6b9033a2bd 100644 --- a/label_studio/data_manager/migrations/0013_filter_parent.py +++ b/label_studio/data_manager/migrations/0013_filter_parent_alter_filter_index.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.10 on 2025-06-17 18:42 +# Generated by Django 5.1.10 on 2025-06-17 18:27 import django.db.models.deletion from django.db import migrations, models @@ -23,4 +23,15 @@ class Migration(migrations.Migration): 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/models.py b/label_studio/data_manager/models.py index eaa58670d47c..f1d81f43555a 100644 --- a/label_studio/data_manager/models.py +++ b/label_studio/data_manager/models.py @@ -96,8 +96,14 @@ class Filter(models.Model): help_text='Optional parent filter to create one-level hierarchy (child filters are AND-merged with parent)', ) - # Order of the filter inside its group. Not used for child filters. - index = models.IntegerField(_('index'), default=0, help_text='To keep filter order') + # `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/serializers.py b/label_studio/data_manager/serializers.py index 2f807c4ec9b9..fad17b1244c7 100644 --- a/label_studio/data_manager/serializers.py +++ b/label_studio/data_manager/serializers.py @@ -176,13 +176,11 @@ def _create_filters(filter_group, filters_data): for filter_data in filters_data: is_root = filter_data.get('parent') in (None, '') + # Assign ordering index only to root filters + filter_data['index'] = next_index if is_root else None + if is_root: - # Assign ordering index - filter_data['index'] = next_index next_index += 1 - else: - # For children we don't specify index, it'll default to 0 - filter_data.pop('index', None) filter_group.filters.add(Filter.objects.create(**filter_data)) From ae7a9a7c321fa40ea8fe6a772e755fe0a687473e Mon Sep 17 00:00:00 2001 From: Matt Bernstein Date: Tue, 17 Jun 2025 15:43:22 -0400 Subject: [PATCH 06/25] async migration --- .../0013_filter_parent_alter_filter_index.py | 1 + .../migrations/0014_add_filter_parent_idx.py | 78 +++++++++++++++++++ label_studio/data_manager/models.py | 1 + 3 files changed, 80 insertions(+) create mode 100644 label_studio/data_manager/migrations/0014_add_filter_parent_idx.py 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 index 8f6b9033a2bd..98beef65282a 100644 --- 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 @@ -21,6 +21,7 @@ class Migration(migrations.Migration): on_delete=django.db.models.deletion.CASCADE, related_name="children", to="data_manager.filter", + db_index=False, ), ), migrations.AlterField( diff --git a/label_studio/data_manager/migrations/0014_add_filter_parent_idx.py b/label_studio/data_manager/migrations/0014_add_filter_parent_idx.py new file mode 100644 index 000000000000..d68e3370de16 --- /dev/null +++ b/label_studio/data_manager/migrations/0014_add_filter_parent_idx.py @@ -0,0 +1,78 @@ +from django.db import migrations, connection +from core.redis import start_job_async_or_sync +from core.models import AsyncMigrationStatus +import logging + + +logger = logging.getLogger(__name__) + +migration_name = "0014_add_filter_parent_idx" + + +# Actual DDL executed asynchronously +def forward_migration(migration_name): + migration = AsyncMigrationStatus.objects.create( + name=migration_name, + status=AsyncMigrationStatus.STATUS_STARTED, + ) + logger.debug(f"Start async migration {migration_name}") + + if connection.vendor == "postgresql": + sql = """ + CREATE INDEX CONCURRENTLY IF NOT EXISTS "filter_parent_idx" + ON "data_manager_filter" ("parent_id"); + """ + else: + # SQLite / others – use regular index (no CONCURRENTLY support) + sql = """ + CREATE INDEX IF NOT EXISTS "filter_parent_idx" + ON "data_manager_filter" ("parent_id"); + """ + + with connection.cursor() as cursor: + cursor.execute(sql) + + migration.status = AsyncMigrationStatus.STATUS_FINISHED + migration.save() + logger.debug(f"Async migration {migration_name} complete") + + +def reverse_migration(migration_name): + migration = AsyncMigrationStatus.objects.create( + name=migration_name, + status=AsyncMigrationStatus.STATUS_STARTED, + ) + logger.debug(f"Start async migration rollback {migration_name}") + + if connection.vendor == "postgresql": + sql = "DROP INDEX CONCURRENTLY IF EXISTS \"filter_parent_idx\";" + else: + sql = "DROP INDEX IF EXISTS \"filter_parent_idx\";" + + with connection.cursor() as cursor: + cursor.execute(sql) + + migration.status = AsyncMigrationStatus.STATUS_FINISHED + migration.save() + logger.debug(f"Async migration rollback {migration_name} complete") + + +def forwards(apps, schema_editor): + start_job_async_or_sync(forward_migration, migration_name=migration_name) + + +def backwards(apps, schema_editor): + start_job_async_or_sync(reverse_migration, migration_name=migration_name) + + +class Migration(migrations.Migration): + # Must be non-atomic so concurrent index creation isn't wrapped in a transaction + atomic = False + + dependencies = [ + ("data_manager", "0013_filter_parent_alter_filter_index"), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] \ No newline at end of file diff --git a/label_studio/data_manager/models.py b/label_studio/data_manager/models.py index f1d81f43555a..9749b5356355 100644 --- a/label_studio/data_manager/models.py +++ b/label_studio/data_manager/models.py @@ -93,6 +93,7 @@ class Filter(models.Model): related_name='children', null=True, blank=True, + db_index=False, # added in a separate migration help_text='Optional parent filter to create one-level hierarchy (child filters are AND-merged with parent)', ) From 035677f4d53030234b4494aaa4f1244b104e2865 Mon Sep 17 00:00:00 2001 From: Matt Bernstein Date: Tue, 17 Jun 2025 16:00:25 -0400 Subject: [PATCH 07/25] give up on index --- .../migrations/0014_add_filter_parent_idx.py | 78 ------------------- label_studio/data_manager/models.py | 2 +- 2 files changed, 1 insertion(+), 79 deletions(-) delete mode 100644 label_studio/data_manager/migrations/0014_add_filter_parent_idx.py diff --git a/label_studio/data_manager/migrations/0014_add_filter_parent_idx.py b/label_studio/data_manager/migrations/0014_add_filter_parent_idx.py deleted file mode 100644 index d68e3370de16..000000000000 --- a/label_studio/data_manager/migrations/0014_add_filter_parent_idx.py +++ /dev/null @@ -1,78 +0,0 @@ -from django.db import migrations, connection -from core.redis import start_job_async_or_sync -from core.models import AsyncMigrationStatus -import logging - - -logger = logging.getLogger(__name__) - -migration_name = "0014_add_filter_parent_idx" - - -# Actual DDL executed asynchronously -def forward_migration(migration_name): - migration = AsyncMigrationStatus.objects.create( - name=migration_name, - status=AsyncMigrationStatus.STATUS_STARTED, - ) - logger.debug(f"Start async migration {migration_name}") - - if connection.vendor == "postgresql": - sql = """ - CREATE INDEX CONCURRENTLY IF NOT EXISTS "filter_parent_idx" - ON "data_manager_filter" ("parent_id"); - """ - else: - # SQLite / others – use regular index (no CONCURRENTLY support) - sql = """ - CREATE INDEX IF NOT EXISTS "filter_parent_idx" - ON "data_manager_filter" ("parent_id"); - """ - - with connection.cursor() as cursor: - cursor.execute(sql) - - migration.status = AsyncMigrationStatus.STATUS_FINISHED - migration.save() - logger.debug(f"Async migration {migration_name} complete") - - -def reverse_migration(migration_name): - migration = AsyncMigrationStatus.objects.create( - name=migration_name, - status=AsyncMigrationStatus.STATUS_STARTED, - ) - logger.debug(f"Start async migration rollback {migration_name}") - - if connection.vendor == "postgresql": - sql = "DROP INDEX CONCURRENTLY IF EXISTS \"filter_parent_idx\";" - else: - sql = "DROP INDEX IF EXISTS \"filter_parent_idx\";" - - with connection.cursor() as cursor: - cursor.execute(sql) - - migration.status = AsyncMigrationStatus.STATUS_FINISHED - migration.save() - logger.debug(f"Async migration rollback {migration_name} complete") - - -def forwards(apps, schema_editor): - start_job_async_or_sync(forward_migration, migration_name=migration_name) - - -def backwards(apps, schema_editor): - start_job_async_or_sync(reverse_migration, migration_name=migration_name) - - -class Migration(migrations.Migration): - # Must be non-atomic so concurrent index creation isn't wrapped in a transaction - atomic = False - - dependencies = [ - ("data_manager", "0013_filter_parent_alter_filter_index"), - ] - - operations = [ - migrations.RunPython(forwards, backwards), - ] \ No newline at end of file diff --git a/label_studio/data_manager/models.py b/label_studio/data_manager/models.py index 9749b5356355..234bc106ab90 100644 --- a/label_studio/data_manager/models.py +++ b/label_studio/data_manager/models.py @@ -93,7 +93,7 @@ class Filter(models.Model): related_name='children', null=True, blank=True, - db_index=False, # added in a separate migration + db_index=False, help_text='Optional parent filter to create one-level hierarchy (child filters are AND-merged with parent)', ) From ba8e2f7cd1e906b415a1c1f8c54a5aa90e4ce40c Mon Sep 17 00:00:00 2001 From: Matt Bernstein Date: Tue, 17 Jun 2025 16:08:40 -0400 Subject: [PATCH 08/25] omit parent when appropriate --- label_studio/data_manager/serializers.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/label_studio/data_manager/serializers.py b/label_studio/data_manager/serializers.py index fad17b1244c7..5db290fe98ce 100644 --- a/label_studio/data_manager/serializers.py +++ b/label_studio/data_manager/serializers.py @@ -142,15 +142,15 @@ def to_representation(self, instance): 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): - filters['items'].append( - { - 'filter': f.column, - 'operator': f.operator, - 'type': f.type, - 'value': f.value, - 'parent': f.parent_id, - } - ) + item = { + 'filter': f.column, + 'operator': f.operator, + 'type': f.type, + 'value': f.value, + } + if f.parent_id: + item['parent'] = f.parent_id + filters['items'].append(item) result['data']['filters'] = filters selected_items = result.pop('selected_items', {}) From 8abe888f3bfb5b279725c76466557f5e3d6cc6dd Mon Sep 17 00:00:00 2001 From: Matt Bernstein Date: Wed, 18 Jun 2025 07:13:13 -0400 Subject: [PATCH 09/25] wip FE for child filters --- .../Filters/FilterLine/FilterLine.jsx | 20 +++++ web/libs/datamanager/src/stores/Tabs/tab.js | 74 +++++++++++++++++-- .../src/stores/Tabs/tab_column.jsx | 2 + .../datamanager/src/stores/Tabs/tab_filter.js | 21 ++++++ 4 files changed, 111 insertions(+), 6 deletions(-) diff --git a/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx b/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx index 374dfe3dc99e..d0425ae54b30 100644 --- a/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx +++ b/web/libs/datamanager/src/components/Filters/FilterLine/FilterLine.jsx @@ -72,6 +72,26 @@ export const FilterLine = observer(({ filter, availableFilters, index, view, sid + + {/* Render child filters (join filters) inline */} + {filter.view.filters + .filter((f) => (f.parent && f.parent === filter.id) || f.localParent === filter) + .map((child, idx) => { + console.debug("[DM] render child filter", { parent: filter, child }); + return ( + + + {child.field.title} + + + + ); + })}