From ec23874bde03b60309ae8d11873b6c8f3c175f17 Mon Sep 17 00:00:00 2001 From: Marcel Canu Date: Tue, 11 Nov 2025 17:29:37 -0300 Subject: [PATCH 01/14] Created history endpoint. Removed limit from state_history method --- label_studio/fsm/README.md | 2 +- label_studio/fsm/api.py | 47 +++++++++++++++++++ label_studio/fsm/serializers.py | 19 ++++++++ label_studio/fsm/state_manager.py | 5 +- label_studio/fsm/state_models.py | 4 +- .../fsm/tests/test_fsm_integration.py | 2 +- .../fsm/tests/test_lso_integration.py | 17 ------- label_studio/fsm/urls.py | 13 +++++ 8 files changed, 85 insertions(+), 24 deletions(-) create mode 100644 label_studio/fsm/api.py create mode 100644 label_studio/fsm/serializers.py create mode 100644 label_studio/fsm/urls.py diff --git a/label_studio/fsm/README.md b/label_studio/fsm/README.md index e4b85681a817..0a33303785f5 100644 --- a/label_studio/fsm/README.md +++ b/label_studio/fsm/README.md @@ -142,7 +142,7 @@ StateManager = get_state_manager() current_state = StateManager.get_current_state_value(order) # Get state history -history = StateManager.get_state_history(order, limit=10) +history = StateManager.get_state_history(order) # Get current state object (full details) current_state_obj = StateManager.get_current_state_object(order) diff --git a/label_studio/fsm/api.py b/label_studio/fsm/api.py new file mode 100644 index 000000000000..de0b79645fd9 --- /dev/null +++ b/label_studio/fsm/api.py @@ -0,0 +1,47 @@ +from core.permissions import all_permissions +from django_filters import CharFilter, DateTimeFilter, FilterSet, NumberFilter +from django_filters.rest_framework import DjangoFilterBackend +from fsm.registry import get_state_model +from fsm.serializers import StateModelSerializer +from rest_framework import generics +from rest_framework.pagination import PageNumberPagination + + +class FSMEntityHistoryPagination(PageNumberPagination): + page_size_query_param = 'page_size' + page_size = 100 + max_page_size = 1000 + + +class FSMEntityHistoryFilterSet(FilterSet): + created_at_from = DateTimeFilter( + field_name='created_at', + lookup_expr='gte', + label='Filter for state history items created at or after the ISO 8601 formatted date (YYYY-MM-DDTHH:MM:SS)', + ) + created_at_to = DateTimeFilter( + field_name='created_at', + lookup_expr='lte', + label='Filter for state history items created at or before the ISO 8601 formatted date (YYYY-MM-DDTHH:MM:SS)', + ) + state = CharFilter(field_name='state', lookup_expr='iexact') + previous_state = CharFilter(field_name='previous_state', lookup_expr='iexact') + transition_name = CharFilter(field_name='transition_name', lookup_expr='iexact') + triggered_by = NumberFilter(field_name='triggered_by', lookup_expr='exact') + + +class FSMEntityHistoryAPI(generics.ListAPIView): + # TODO: Manage permissions per entity correctly, including per object permissions. + # Ex: Task state history only for users with permissions to that task + permission_required = all_permissions.organizations_view + serializer_class = StateModelSerializer + pagination_class = FSMEntityHistoryPagination + filter_backends = [DjangoFilterBackend] # Removes other backends like OrderingFilter + filterset_class = FSMEntityHistoryFilterSet + + def get_queryset(self): + state_model = get_state_model(self.kwargs['entity_name']) + qs = state_model.get_state_history(self.kwargs['entity_id']) + qs = qs.filter(organization_id=self.request.user.active_organization_id) + qs = qs.prefetch_related('triggered_by__om_through') + return qs diff --git a/label_studio/fsm/serializers.py b/label_studio/fsm/serializers.py new file mode 100644 index 000000000000..fa07b6b747e8 --- /dev/null +++ b/label_studio/fsm/serializers.py @@ -0,0 +1,19 @@ +from rest_framework import serializers +from users.serializers import UserSerializer + + +class StateModelSerializer(serializers.Serializer): + """ + Serializer for FSM state models. + + Uses Serializer instead of ModelSerializer because BaseState is abstract. + Works with any concrete state model that inherits from BaseState. + """ + + id = serializers.UUIDField(read_only=True) + state = serializers.CharField(read_only=True) + previous_state = serializers.CharField(read_only=True, allow_null=True) + transition_name = serializers.CharField(read_only=True, allow_null=True) + triggered_by = UserSerializer(read_only=True, allow_null=True, include=['id', 'email']) + created_at = serializers.DateTimeField(read_only=True) + context_data = serializers.JSONField(read_only=True) diff --git a/label_studio/fsm/state_manager.py b/label_studio/fsm/state_manager.py index d19d2b5d740d..ea9ef437e18d 100644 --- a/label_studio/fsm/state_manager.py +++ b/label_studio/fsm/state_manager.py @@ -376,13 +376,12 @@ def transition_state( raise StateManagerError(f'Failed to transition state: {e}') from e @classmethod - def get_state_history(cls, entity: Model, limit: int = 100) -> QuerySet[BaseState]: + def get_state_history(cls, entity: Model) -> QuerySet[BaseState]: """ Get complete state history for an entity. Args: entity: Entity to get history for - limit: Maximum number of state records to return Returns: QuerySet of state records ordered by most recent first @@ -393,7 +392,7 @@ def get_state_history(cls, entity: Model, limit: int = 100) -> QuerySet[BaseStat f'No state model registered for {entity._meta.model_name} when getting state history' ) - return state_model.get_state_history(entity, limit) + return state_model.get_state_history(entity) @classmethod def get_states_in_time_range( diff --git a/label_studio/fsm/state_models.py b/label_studio/fsm/state_models.py index 00cd3258c96d..d9a7fee96521 100644 --- a/label_studio/fsm/state_models.py +++ b/label_studio/fsm/state_models.py @@ -157,10 +157,10 @@ def get_current_state_value(cls, entity) -> Optional[str]: return current_state.state if current_state else None @classmethod - def get_state_history(cls, entity, limit: int = 100) -> QuerySet['BaseState']: + def get_state_history(cls, entity) -> QuerySet['BaseState']: """Get complete state history for an entity""" entity_field = f'{cls._get_entity_field_name()}' - return cls.objects.filter(**{entity_field: entity}).order_by('-id')[:limit] + return cls.objects.filter(**{entity_field: entity}).order_by('-id') @classmethod def get_states_in_range(cls, entity, start_time: datetime, end_time: datetime) -> QuerySet['BaseState']: diff --git a/label_studio/fsm/tests/test_fsm_integration.py b/label_studio/fsm/tests/test_fsm_integration.py index 3af49f9917b6..b321c775b51c 100644 --- a/label_studio/fsm/tests/test_fsm_integration.py +++ b/label_studio/fsm/tests/test_fsm_integration.py @@ -222,7 +222,7 @@ def test_get_state_history(self, mock_flag_set): entity=self.task, new_state=state, user=self.user, transition_name=transition ) - history = self.StateManager.get_state_history(self.task, limit=10) + history = self.StateManager.get_state_history(self.task) # Should have 3 state records assert len(history) == 3 diff --git a/label_studio/fsm/tests/test_lso_integration.py b/label_studio/fsm/tests/test_lso_integration.py index c349f8843eb6..80bd8042f905 100644 --- a/label_studio/fsm/tests/test_lso_integration.py +++ b/label_studio/fsm/tests/test_lso_integration.py @@ -440,23 +440,6 @@ def test_get_states_in_time_range(self): # Should have at least the creation state assert len(states) >= 1 - def test_get_state_history_with_limit(self): - """ - Test get_state_history with limit parameter. - - Validates: - - Limit parameter restricts number of results - - Ordering is correct (most recent first) - """ - project = ProjectFactory(organization=self.org) - - # Get history with limit - history = StateManager.get_state_history(project, limit=10) - - # Should have at least the initial creation state - assert len(history) >= 1 - assert history[0].state == ProjectStateChoices.CREATED - def test_invalidate_cache(self): """ Test cache invalidation for entity state. diff --git a/label_studio/fsm/urls.py b/label_studio/fsm/urls.py new file mode 100644 index 000000000000..3df045f94085 --- /dev/null +++ b/label_studio/fsm/urls.py @@ -0,0 +1,13 @@ +from django.urls import path +from fsm.api import FSMEntityHistoryAPI + +app_name = 'fsm' + + +urlpatterns = [ + path( + 'api/fsm/entities///history', + FSMEntityHistoryAPI.as_view(), + name='fsm-entity-history', + ), +] From dc8761dd7b25b9539c089a24ae7a72fcd18a3844 Mon Sep 17 00:00:00 2001 From: Marcel Canu Date: Tue, 11 Nov 2025 17:35:17 -0300 Subject: [PATCH 02/14] Added url correctly --- label_studio/core/urls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/label_studio/core/urls.py b/label_studio/core/urls.py index 2909f8b65e75..c701e1beb9cd 100644 --- a/label_studio/core/urls.py +++ b/label_studio/core/urls.py @@ -70,6 +70,7 @@ re_path(r'^', include('ml.urls')), re_path(r'^', include('webhooks.urls')), re_path(r'^', include('labels_manager.urls')), + re_path(r'^', include('fsm.urls')), re_path(r'data/local-files/', views.localfiles_data, name='localfiles_data'), re_path(r'version/', views.version_page, name='version'), # html page re_path(r'api/version/', views.version_page, name='api-version'), # json response From e03aa480de4d31c9826d06598a3fb3ee96c9d657 Mon Sep 17 00:00:00 2001 From: Marcel Canu Date: Wed, 12 Nov 2025 15:24:50 -0300 Subject: [PATCH 03/14] Added new permission --- label_studio/fsm/api.py | 36 +++++++++++++++++++++++++++----- label_studio/fsm/state_models.py | 6 ++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/label_studio/fsm/api.py b/label_studio/fsm/api.py index de0b79645fd9..a0a12debad19 100644 --- a/label_studio/fsm/api.py +++ b/label_studio/fsm/api.py @@ -1,9 +1,12 @@ from core.permissions import all_permissions +from django.shortcuts import get_object_or_404 from django_filters import CharFilter, DateTimeFilter, FilterSet, NumberFilter from django_filters.rest_framework import DjangoFilterBackend from fsm.registry import get_state_model from fsm.serializers import StateModelSerializer +from fsm.state_manager import get_state_manager from rest_framework import generics +from rest_framework.exceptions import NotFound, PermissionDenied from rest_framework.pagination import PageNumberPagination @@ -31,17 +34,40 @@ class FSMEntityHistoryFilterSet(FilterSet): class FSMEntityHistoryAPI(generics.ListAPIView): - # TODO: Manage permissions per entity correctly, including per object permissions. - # Ex: Task state history only for users with permissions to that task - permission_required = all_permissions.organizations_view serializer_class = StateModelSerializer pagination_class = FSMEntityHistoryPagination filter_backends = [DjangoFilterBackend] # Removes other backends like OrderingFilter filterset_class = FSMEntityHistoryFilterSet - def get_queryset(self): + permission_map = { + 'task': all_permissions.tasks_view, + 'annotation': all_permissions.annotations_view, + 'project': all_permissions.projects_view, + } + + def get_permission_required(self): + entity_name = self.kwargs['entity_name'] + permission = self.permission_map.get(entity_name) + # This validates the entity name is valid + if not permission: + raise NotFound() + return permission + + def get_entity(self): state_model = get_state_model(self.kwargs['entity_name']) - qs = state_model.get_state_history(self.kwargs['entity_id']) + entity_model = state_model.get_entity_model() + entity = get_object_or_404(entity_model.objects, id=self.kwargs['entity_id']) + try: + self.check_object_permissions(self.request, entity) + except PermissionDenied as e: + # Returning 404 instead of 403 to avoid leaking information about the existence of the entity + raise NotFound() from e + return entity + + def get_queryset(self): + entity = self.get_entity() + state_manager = get_state_manager() + qs = state_manager.get_state_history(entity) qs = qs.filter(organization_id=self.request.user.active_organization_id) qs = qs.prefetch_related('triggered_by__om_through') return qs diff --git a/label_studio/fsm/state_models.py b/label_studio/fsm/state_models.py index d9a7fee96521..ea5e05997935 100644 --- a/label_studio/fsm/state_models.py +++ b/label_studio/fsm/state_models.py @@ -209,6 +209,12 @@ def get_denormalized_fields(cls, entity): """ return {} + @classmethod + def get_entity_model(cls) -> models.Model: + """Get the entity model for the state model""" + field_name = cls._get_entity_field_name() + return cls._meta.get_field(field_name).related_model + @classmethod def _get_entity_field_name(cls) -> str: """Get the foreign key field name for the entity""" From 1815a744879ca5f40a23ac02fccd997178fdcbd7 Mon Sep 17 00:00:00 2001 From: Marcel Canu Date: Wed, 12 Nov 2025 16:25:36 -0300 Subject: [PATCH 04/14] Added tests --- label_studio/fsm/api.py | 11 +- label_studio/fsm/tests/factories.py | 37 ++++ label_studio/fsm/tests/test_api.py | 252 ++++++++++++++++++++++++++++ label_studio/pytest.ini | 1 + 4 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 label_studio/fsm/tests/factories.py create mode 100644 label_studio/fsm/tests/test_api.py diff --git a/label_studio/fsm/api.py b/label_studio/fsm/api.py index a0a12debad19..54ee1b01c641 100644 --- a/label_studio/fsm/api.py +++ b/label_studio/fsm/api.py @@ -2,7 +2,7 @@ from django.shortcuts import get_object_or_404 from django_filters import CharFilter, DateTimeFilter, FilterSet, NumberFilter from django_filters.rest_framework import DjangoFilterBackend -from fsm.registry import get_state_model +from fsm.registry import get_state_model, state_model_registry from fsm.serializers import StateModelSerializer from fsm.state_manager import get_state_manager from rest_framework import generics @@ -48,9 +48,8 @@ class FSMEntityHistoryAPI(generics.ListAPIView): def get_permission_required(self): entity_name = self.kwargs['entity_name'] permission = self.permission_map.get(entity_name) - # This validates the entity name is valid if not permission: - raise NotFound() + raise ValueError(f'Invalid entity name: {entity_name}') return permission def get_entity(self): @@ -71,3 +70,9 @@ def get_queryset(self): qs = qs.filter(organization_id=self.request.user.active_organization_id) qs = qs.prefetch_related('triggered_by__om_through') return qs + + def list(self, request, *args, **kwargs): + entity_name = kwargs['entity_name'] + if entity_name not in state_model_registry.get_all_models(): + raise NotFound() + return super().list(request, *args, **kwargs) diff --git a/label_studio/fsm/tests/factories.py b/label_studio/fsm/tests/factories.py new file mode 100644 index 000000000000..67257193c427 --- /dev/null +++ b/label_studio/fsm/tests/factories.py @@ -0,0 +1,37 @@ +import factory +from fsm.state_choices import AnnotationStateChoices, ProjectStateChoices, TaskStateChoices +from fsm.state_models import AnnotationState, ProjectState, TaskState +from projects.tests.factories import ProjectFactory +from tasks.tests.factories import AnnotationFactory, TaskFactory + + +class ProjectStateFactory(factory.django.DjangoModelFactory): + project = factory.SubFactory(ProjectFactory) + state = factory.Iterator(ProjectStateChoices.values) + created_by_id = factory.SelfAttribute('project.created_by_id') + organization_id = factory.SelfAttribute('project.organization_id') + + class Meta: + model = ProjectState + + +class TaskStateFactory(factory.django.DjangoModelFactory): + task = factory.SubFactory(TaskFactory) + state = factory.Iterator(TaskStateChoices.values) + project_id = factory.SelfAttribute('task.project_id') + organization_id = factory.SelfAttribute('task.project.organization_id') + + class Meta: + model = TaskState + + +class AnnotationStateFactory(factory.django.DjangoModelFactory): + annotation = factory.SubFactory(AnnotationFactory) + state = factory.Iterator(AnnotationStateChoices.values) + task_id = factory.SelfAttribute('annotation.task_id') + project_id = factory.SelfAttribute('annotation.task.project_id') + completed_by_id = factory.SelfAttribute('annotation.completed_by_id') + organization_id = factory.SelfAttribute('annotation.task.project.organization_id') + + class Meta: + model = AnnotationState diff --git a/label_studio/fsm/tests/test_api.py b/label_studio/fsm/tests/test_api.py new file mode 100644 index 000000000000..4870a0f6902b --- /dev/null +++ b/label_studio/fsm/tests/test_api.py @@ -0,0 +1,252 @@ +from urllib.parse import quote + +from fsm.state_choices import AnnotationStateChoices, ProjectStateChoices, TaskStateChoices +from fsm.state_models import AnnotationState, ProjectState, TaskState +from fsm.tests.factories import AnnotationStateFactory, ProjectStateFactory, TaskStateFactory +from projects.tests.factories import ProjectFactory +from rest_framework.test import APITestCase +from tasks.tests.factories import AnnotationFactory, TaskFactory + + +class FSMEntityHistoryAPITests(APITestCase): + @classmethod + def setUpTestData(cls): + cls.project = ProjectFactory() + cls.user = cls.project.created_by + ProjectState.objects.all().delete() # Clean everything just in case + + cls.task = TaskFactory(project=cls.project) + TaskState.objects.all().delete() # Clean everything just in case + + cls.annotation = AnnotationFactory(task=cls.task, completed_by=cls.user) + AnnotationState.objects.all().delete() # Clean everything just in case + + def test_invalid_entity_name(self): + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/fsm/entities/invalid/1/history') + assert response.status_code == 404 + + def test_project_not_found(self): + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/fsm/entities/project/999999/history') + assert response.status_code == 404 + + def test_empty_project_history(self): + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/fsm/entities/project/{self.project.id}/history') + assert response.status_code == 200 + assert response.json()['results'] == [] + + def test_project_history(self): + state_1 = ProjectStateFactory(project=self.project, state=ProjectStateChoices.CREATED) + state_2 = ProjectStateFactory( + project=self.project, + state=ProjectStateChoices.IN_PROGRESS, + previous_state=ProjectStateChoices.CREATED, + triggered_by=self.user, + ) + state_3 = ProjectStateFactory( + project=self.project, + state=ProjectStateChoices.COMPLETED, + previous_state=ProjectStateChoices.IN_PROGRESS, + transition_name='complete_project', + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/fsm/entities/project/{self.project.id}/history') + assert response.status_code == 200 + assert len(response.json()['results']) == 3 + + # Test ordering + assert response.json()['results'][0]['id'] == str(state_3.id) + assert response.json()['results'][1]['id'] == str(state_2.id) + assert response.json()['results'][2]['id'] == str(state_1.id) + + # Test state filtering + response = self.client.get( + f'/api/fsm/entities/project/{self.project.id}/history?state={ProjectStateChoices.COMPLETED}' + ) + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_3.id) + + # Test previous_state filtering + response = self.client.get( + f'/api/fsm/entities/project/{self.project.id}/history?previous_state={ProjectStateChoices.IN_PROGRESS}' + ) + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_3.id) + + # Test transition_name filtering + response = self.client.get( + f'/api/fsm/entities/project/{self.project.id}/history?transition_name=complete_project' + ) + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_3.id) + + # Test triggered_by filtering + response = self.client.get(f'/api/fsm/entities/project/{self.project.id}/history?triggered_by={self.user.id}') + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_2.id) + assert response.json()['results'][0]['triggered_by']['id'] == self.user.id + + # Test date filtering + created_at_from = state_2.created_at.isoformat() + created_at_to = state_3.created_at.isoformat() + response = self.client.get( + f'/api/fsm/entities/project/{self.project.id}/history?created_at_from={quote(created_at_from)}&created_at_to={quote(created_at_to)}' + ) + assert response.status_code == 200 + assert len(response.json()['results']) == 2 + assert response.json()['results'][0]['id'] == str(state_3.id) + assert response.json()['results'][1]['id'] == str(state_2.id) + + def test_task_not_found(self): + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/fsm/entities/task/999999/history') + assert response.status_code == 404 + + def test_empty_task_history(self): + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/fsm/entities/task/{self.task.id}/history') + assert response.status_code == 200 + assert response.json()['results'] == [] + + def test_task_history(self): + state_1 = TaskStateFactory(task=self.task, state=TaskStateChoices.CREATED) + state_2 = TaskStateFactory( + task=self.task, + state=TaskStateChoices.IN_PROGRESS, + previous_state=TaskStateChoices.CREATED, + triggered_by=self.user, + ) + state_3 = TaskStateFactory( + task=self.task, + state=TaskStateChoices.COMPLETED, + previous_state=TaskStateChoices.IN_PROGRESS, + transition_name='complete_task', + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/fsm/entities/task/{self.task.id}/history') + assert response.status_code == 200 + assert len(response.json()['results']) == 3 + + # Test ordering + assert response.json()['results'][0]['id'] == str(state_3.id) + assert response.json()['results'][1]['id'] == str(state_2.id) + assert response.json()['results'][2]['id'] == str(state_1.id) + + # Test state filtering + response = self.client.get(f'/api/fsm/entities/task/{self.task.id}/history?state={TaskStateChoices.COMPLETED}') + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_3.id) + + # Test previous_state filtering + response = self.client.get( + f'/api/fsm/entities/task/{self.task.id}/history?previous_state={TaskStateChoices.IN_PROGRESS}' + ) + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_3.id) + + # Test transition_name filtering + response = self.client.get(f'/api/fsm/entities/task/{self.task.id}/history?transition_name=complete_task') + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_3.id) + + # Test triggered_by filtering + response = self.client.get(f'/api/fsm/entities/task/{self.task.id}/history?triggered_by={self.user.id}') + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_2.id) + assert response.json()['results'][0]['triggered_by']['id'] == self.user.id + + # Test date filtering + created_at_from = state_2.created_at.isoformat() + created_at_to = state_3.created_at.isoformat() + response = self.client.get( + f'/api/fsm/entities/task/{self.task.id}/history?created_at_from={quote(created_at_from)}&created_at_to={quote(created_at_to)}' + ) + assert response.status_code == 200 + assert len(response.json()['results']) == 2 + assert response.json()['results'][0]['id'] == str(state_3.id) + assert response.json()['results'][1]['id'] == str(state_2.id) + + def test_annotation_not_found(self): + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/fsm/entities/annotation/999999/history') + assert response.status_code == 404 + + def test_empty_annotation_history(self): + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/fsm/entities/annotation/{self.annotation.id}/history') + assert response.status_code == 200 + assert response.json()['results'] == [] + + def test_annotation_history(self): + state_1 = AnnotationStateFactory(annotation=self.annotation, state=AnnotationStateChoices.SUBMITTED) + state_2 = AnnotationStateFactory( + annotation=self.annotation, + state=AnnotationStateChoices.COMPLETED, + previous_state=AnnotationStateChoices.SUBMITTED, + triggered_by=self.user, + transition_name='complete_annotation', + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/fsm/entities/annotation/{self.annotation.id}/history') + assert response.status_code == 200 + assert len(response.json()['results']) == 2 + + # Test ordering + assert response.json()['results'][0]['id'] == str(state_2.id) + assert response.json()['results'][1]['id'] == str(state_1.id) + + # Test state filtering + response = self.client.get( + f'/api/fsm/entities/annotation/{self.annotation.id}/history?state={AnnotationStateChoices.COMPLETED}' + ) + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_2.id) + + # Test previous_state filtering + response = self.client.get( + f'/api/fsm/entities/annotation/{self.annotation.id}/history?previous_state={AnnotationStateChoices.SUBMITTED}' + ) + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_2.id) + + # Test transition_name filtering + response = self.client.get( + f'/api/fsm/entities/annotation/{self.annotation.id}/history?transition_name=complete_annotation' + ) + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_2.id) + + # Test triggered_by filtering + response = self.client.get( + f'/api/fsm/entities/annotation/{self.annotation.id}/history?triggered_by={self.user.id}' + ) + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_2.id) + assert response.json()['results'][0]['triggered_by']['id'] == self.user.id + + # Test date filtering + created_at_from = state_1.created_at.isoformat() + created_at_to = state_1.created_at.isoformat() + response = self.client.get( + f'/api/fsm/entities/annotation/{self.annotation.id}/history?created_at_from={quote(created_at_from)}&created_at_to={quote(created_at_to)}' + ) + assert response.status_code == 200 + assert len(response.json()['results']) == 1 + assert response.json()['results'][0]['id'] == str(state_1.id) diff --git a/label_studio/pytest.ini b/label_studio/pytest.ini index 96fe9aff4396..c37d5c7f3dbd 100644 --- a/label_studio/pytest.ini +++ b/label_studio/pytest.ini @@ -1,6 +1,7 @@ [pytest] DJANGO_SETTINGS_MODULE = core.settings.label_studio python_files = tests.py test_*.py *_tests.py +addopts = -p no:warnings tavern-global-cfg= tests/shared_stages.yml env = From 878aadaa5c2c16bba9fc47f76a024769267f08f6 Mon Sep 17 00:00:00 2001 From: Marcel Canu Date: Wed, 12 Nov 2025 16:33:26 -0300 Subject: [PATCH 05/14] Added ordering by id --- label_studio/fsm/api.py | 4 +++- label_studio/fsm/tests/test_api.py | 29 +++++++++++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/label_studio/fsm/api.py b/label_studio/fsm/api.py index 54ee1b01c641..cc44b284735f 100644 --- a/label_studio/fsm/api.py +++ b/label_studio/fsm/api.py @@ -7,6 +7,7 @@ from fsm.state_manager import get_state_manager from rest_framework import generics from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.filters import OrderingFilter from rest_framework.pagination import PageNumberPagination @@ -36,8 +37,9 @@ class FSMEntityHistoryFilterSet(FilterSet): class FSMEntityHistoryAPI(generics.ListAPIView): serializer_class = StateModelSerializer pagination_class = FSMEntityHistoryPagination - filter_backends = [DjangoFilterBackend] # Removes other backends like OrderingFilter + filter_backends = [DjangoFilterBackend, OrderingFilter] filterset_class = FSMEntityHistoryFilterSet + ordering_fields = ['id'] # Only allow ordering by id permission_map = { 'task': all_permissions.tasks_view, diff --git a/label_studio/fsm/tests/test_api.py b/label_studio/fsm/tests/test_api.py index 4870a0f6902b..90b29167c016 100644 --- a/label_studio/fsm/tests/test_api.py +++ b/label_studio/fsm/tests/test_api.py @@ -56,12 +56,18 @@ def test_project_history(self): response = self.client.get(f'/api/fsm/entities/project/{self.project.id}/history') assert response.status_code == 200 assert len(response.json()['results']) == 3 - - # Test ordering assert response.json()['results'][0]['id'] == str(state_3.id) assert response.json()['results'][1]['id'] == str(state_2.id) assert response.json()['results'][2]['id'] == str(state_1.id) + # Test ordering + response = self.client.get(f'/api/fsm/entities/project/{self.project.id}/history?ordering=id') + assert response.status_code == 200 + assert len(response.json()['results']) == 3 + assert response.json()['results'][0]['id'] == str(state_1.id) + assert response.json()['results'][1]['id'] == str(state_2.id) + assert response.json()['results'][2]['id'] == str(state_3.id) + # Test state filtering response = self.client.get( f'/api/fsm/entities/project/{self.project.id}/history?state={ProjectStateChoices.COMPLETED}' @@ -134,12 +140,18 @@ def test_task_history(self): response = self.client.get(f'/api/fsm/entities/task/{self.task.id}/history') assert response.status_code == 200 assert len(response.json()['results']) == 3 - - # Test ordering assert response.json()['results'][0]['id'] == str(state_3.id) assert response.json()['results'][1]['id'] == str(state_2.id) assert response.json()['results'][2]['id'] == str(state_1.id) + # Test ordering + response = self.client.get(f'/api/fsm/entities/task/{self.task.id}/history?ordering=id') + assert response.status_code == 200 + assert len(response.json()['results']) == 3 + assert response.json()['results'][0]['id'] == str(state_1.id) + assert response.json()['results'][1]['id'] == str(state_2.id) + assert response.json()['results'][2]['id'] == str(state_3.id) + # Test state filtering response = self.client.get(f'/api/fsm/entities/task/{self.task.id}/history?state={TaskStateChoices.COMPLETED}') assert response.status_code == 200 @@ -203,11 +215,16 @@ def test_annotation_history(self): response = self.client.get(f'/api/fsm/entities/annotation/{self.annotation.id}/history') assert response.status_code == 200 assert len(response.json()['results']) == 2 - - # Test ordering assert response.json()['results'][0]['id'] == str(state_2.id) assert response.json()['results'][1]['id'] == str(state_1.id) + # Test ordering + response = self.client.get(f'/api/fsm/entities/annotation/{self.annotation.id}/history?ordering=id') + assert response.status_code == 200 + assert len(response.json()['results']) == 2 + assert response.json()['results'][0]['id'] == str(state_1.id) + assert response.json()['results'][1]['id'] == str(state_2.id) + # Test state filtering response = self.client.get( f'/api/fsm/entities/annotation/{self.annotation.id}/history?state={AnnotationStateChoices.COMPLETED}' From 0b73d73e747efc3cae0e3b87abc2b0be28244c22 Mon Sep 17 00:00:00 2001 From: Marcel Canu Date: Thu, 13 Nov 2025 14:53:48 -0300 Subject: [PATCH 06/14] Added API docs --- label_studio/fsm/api.py | 21 +++++++++++++++++++++ label_studio/fsm/serializers.py | 7 ++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/label_studio/fsm/api.py b/label_studio/fsm/api.py index cc44b284735f..1fddc7743edf 100644 --- a/label_studio/fsm/api.py +++ b/label_studio/fsm/api.py @@ -1,7 +1,10 @@ from core.permissions import all_permissions +from core.utils.filterset_to_openapi_params import filterset_to_openapi_params from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator from django_filters import CharFilter, DateTimeFilter, FilterSet, NumberFilter from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema from fsm.registry import get_state_model, state_model_registry from fsm.serializers import StateModelSerializer from fsm.state_manager import get_state_manager @@ -34,6 +37,24 @@ class FSMEntityHistoryFilterSet(FilterSet): triggered_by = NumberFilter(field_name='triggered_by', lookup_expr='exact') +@method_decorator( + name='get', + decorator=extend_schema( + tags=['FSM'], + summary='Get entity state history', + description='Get the state history of an entity', + parameters=filterset_to_openapi_params(FSMEntityHistoryFilterSet), + extensions={ + 'x-fern-sdk-group-name': 'fsm', + 'x-fern-sdk-method-name': 'state_history', + 'x-fern-audiences': ['public'], + 'x-fern-pagination': { + 'offset': '$request.page', + 'results': '$response.results', + }, + }, + ), +) class FSMEntityHistoryAPI(generics.ListAPIView): serializer_class = StateModelSerializer pagination_class = FSMEntityHistoryPagination diff --git a/label_studio/fsm/serializers.py b/label_studio/fsm/serializers.py index fa07b6b747e8..5d674def9d55 100644 --- a/label_studio/fsm/serializers.py +++ b/label_studio/fsm/serializers.py @@ -2,6 +2,11 @@ from users.serializers import UserSerializer +class TriggeredBySerializer(UserSerializer): + class Meta(UserSerializer.Meta): + fields = ['id', 'email'] + + class StateModelSerializer(serializers.Serializer): """ Serializer for FSM state models. @@ -14,6 +19,6 @@ class StateModelSerializer(serializers.Serializer): state = serializers.CharField(read_only=True) previous_state = serializers.CharField(read_only=True, allow_null=True) transition_name = serializers.CharField(read_only=True, allow_null=True) - triggered_by = UserSerializer(read_only=True, allow_null=True, include=['id', 'email']) + triggered_by = TriggeredBySerializer(read_only=True, allow_null=True) created_at = serializers.DateTimeField(read_only=True) context_data = serializers.JSONField(read_only=True) From 85e2aadd4ab53b8f6c820a9048038e53d03dd622 Mon Sep 17 00:00:00 2001 From: Marcel Canu Date: Thu, 13 Nov 2025 15:04:01 -0300 Subject: [PATCH 07/14] Made endpoint internal --- label_studio/fsm/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/label_studio/fsm/api.py b/label_studio/fsm/api.py index 1fddc7743edf..3c3593be5aaa 100644 --- a/label_studio/fsm/api.py +++ b/label_studio/fsm/api.py @@ -47,7 +47,7 @@ class FSMEntityHistoryFilterSet(FilterSet): extensions={ 'x-fern-sdk-group-name': 'fsm', 'x-fern-sdk-method-name': 'state_history', - 'x-fern-audiences': ['public'], + 'x-fern-audiences': ['internal'], 'x-fern-pagination': { 'offset': '$request.page', 'results': '$response.results', From b203139282c646985f23c9e326fe3f334ead6e3d Mon Sep 17 00:00:00 2001 From: Marcel Canu Date: Thu, 13 Nov 2025 16:22:36 -0300 Subject: [PATCH 08/14] Added factories --- label_studio/tasks/models.py | 3 +++ label_studio/tasks/tests/factories.py | 14 +++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/label_studio/tasks/models.py b/label_studio/tasks/models.py index bc22b35b8071..904282b4133b 100644 --- a/label_studio/tasks/models.py +++ b/label_studio/tasks/models.py @@ -880,6 +880,9 @@ class TaskLock(FsmHistoryStateModel): ) created_at = models.DateTimeField(_('created at'), auto_now_add=True, help_text='Creation time', null=True) + def has_permission(self, user): + return self.task.has_permission(user) + class AnnotationDraftQuerySet(models.QuerySet): """Custom QuerySet for AnnotationDraft model""" diff --git a/label_studio/tasks/tests/factories.py b/label_studio/tasks/tests/factories.py index d358b8749d11..3a85864e40ad 100644 --- a/label_studio/tasks/tests/factories.py +++ b/label_studio/tasks/tests/factories.py @@ -1,8 +1,11 @@ +from datetime import timedelta + import factory from core.utils.common import load_func from django.conf import settings +from django.utils import timezone from faker import Faker -from tasks.models import Annotation, AnnotationDraft, Prediction, Task +from tasks.models import Annotation, AnnotationDraft, Prediction, Task, TaskLock class TaskFactory(factory.django.DjangoModelFactory): @@ -72,3 +75,12 @@ class PredictionFactory(factory.django.DjangoModelFactory): class Meta: model = Prediction + + +class TaskLockFactory(factory.django.DjangoModelFactory): + task = factory.SubFactory(TaskFactory) + user = factory.SubFactory(load_func(settings.USER_FACTORY)) + expire_at = factory.LazyFunction(lambda: timezone.now() + timedelta(seconds=10)) + + class Meta: + model = TaskLock From 6b8ebef47968c1d1090134ffdfef5bd60a530bc9 Mon Sep 17 00:00:00 2001 From: robot-ci-heartex Date: Thu, 13 Nov 2025 19:34:53 +0000 Subject: [PATCH 09/14] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/19343509540 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index b97f6fa3a587..6ccd39d52c2b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2137,13 +2137,13 @@ testing = ["pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementat [[package]] name = "label-studio-sdk" -version = "2.0.14" +version = "2.0.15" description = "" optional = false python-versions = ">=3.9,<4" groups = ["main"] files = [ - {file = "fe79d021c09e18f8c7dccce8a43f7ef59d6514dd.zip", hash = "sha256:92f1338242d1530ba7d876e175a394ec84aef2dc5516054f83f5ca1b7efeb358"}, + {file = "815c05813a3d74a55167c730e247ab8f45e411bf.zip", hash = "sha256:98d6ad300213759f5d941c15eb5fec0c6c8dbf9f8eeafa4b1ba79fbbf8a84b7f"}, ] [package.dependencies] @@ -2171,7 +2171,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/fe79d021c09e18f8c7dccce8a43f7ef59d6514dd.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/815c05813a3d74a55167c730e247ab8f45e411bf.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5117,4 +5117,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "88671a7b26e093fca89289a2987f01ce8f5171b4194c92920e04d330341a12cb" +content-hash = "444389e61db4f41d970b1da884cb2960781be984bea0ae134abbfb8906a014ed" diff --git a/pyproject.toml b/pyproject.toml index 69b65b12a05d..c07af94010dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ dependencies = [ "tldextract (>=5.1.3)", "uuid-utils (>=0.11.0,<1.0.0)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/fe79d021c09e18f8c7dccce8a43f7ef59d6514dd.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/815c05813a3d74a55167c730e247ab8f45e411bf.zip", ## HumanSignal repo dependencies :end ] From 16dfff712f21296114883b1ff1048bed96ff62aa Mon Sep 17 00:00:00 2001 From: Marcel Canu Date: Thu, 13 Nov 2025 16:35:58 -0300 Subject: [PATCH 10/14] Updated all_urls.json --- label_studio/core/all_urls.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/label_studio/core/all_urls.json b/label_studio/core/all_urls.json index 7c5079331fb8..9f949cedb51f 100644 --- a/label_studio/core/all_urls.json +++ b/label_studio/core/all_urls.json @@ -1127,6 +1127,12 @@ "name": "labels_manager:api-labels-bulk", "decorators": "" }, + { + "url": "/api/fsm/entities///history", + "module": "fsm.api.FSMEntityHistoryAPI", + "name": "fsm:fsm-entity-history", + "decorators": "" + }, { "url": "/data/local-files/", "module": "core.views.localfiles_data", @@ -1849,19 +1855,13 @@ }, { "url": "/django-rq/", - "module": "django_rq.stats_views.stats", + "module": "django_rq.views.stats", "name": "rq_home", "decorators": "" }, - { - "url": "/django-rq/stats.json/", - "module": "django_rq.stats_views.stats_json", - "name": "rq_home_json", - "decorators": "" - }, { "url": "/django-rq/stats.json//", - "module": "django_rq.stats_views.stats_json", + "module": "django_rq.views.stats_json", "name": "rq_home_json", "decorators": "" }, From e7eb897addf3ad9e6fb3fb58b577cab44e2322b8 Mon Sep 17 00:00:00 2001 From: robot-ci-heartex Date: Thu, 13 Nov 2025 19:43:10 +0000 Subject: [PATCH 11/14] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/19343724668 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index dc98085c4ee5..6b9ea78270e0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "annotated-types" @@ -2143,7 +2143,7 @@ optional = false python-versions = ">=3.9,<4" groups = ["main"] files = [ - {file = "815c05813a3d74a55167c730e247ab8f45e411bf.zip", hash = "sha256:98d6ad300213759f5d941c15eb5fec0c6c8dbf9f8eeafa4b1ba79fbbf8a84b7f"}, + {file = "a3a6bd450bd05b39f989e18c1f38781c5fc22572.zip", hash = "sha256:8ab41140e07554b0465ea743bc9ea54bced3090d637fd8ed7c7eccd55d2ab265"}, ] [package.dependencies] @@ -2171,7 +2171,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/815c05813a3d74a55167c730e247ab8f45e411bf.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/a3a6bd450bd05b39f989e18c1f38781c5fc22572.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5117,4 +5117,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "444389e61db4f41d970b1da884cb2960781be984bea0ae134abbfb8906a014ed" +content-hash = "52003bb0eaf57d91a423b59b8db811bcf9b82537b40bcd9e738832405ee82986" diff --git a/pyproject.toml b/pyproject.toml index c07af94010dc..789366ec8813 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ dependencies = [ "tldextract (>=5.1.3)", "uuid-utils (>=0.11.0,<1.0.0)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/815c05813a3d74a55167c730e247ab8f45e411bf.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/a3a6bd450bd05b39f989e18c1f38781c5fc22572.zip", ## HumanSignal repo dependencies :end ] From 4dcf26b91ee61282278894731da28b3dedda86bf Mon Sep 17 00:00:00 2001 From: Marcel Canu Date: Thu, 13 Nov 2025 16:55:01 -0300 Subject: [PATCH 12/14] Updated all_urls.json --- label_studio/core/all_urls.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/label_studio/core/all_urls.json b/label_studio/core/all_urls.json index 9f949cedb51f..5b07991dd325 100644 --- a/label_studio/core/all_urls.json +++ b/label_studio/core/all_urls.json @@ -1855,13 +1855,19 @@ }, { "url": "/django-rq/", - "module": "django_rq.views.stats", + "module": "django_rq.stats_views.stats", "name": "rq_home", "decorators": "" }, + { + "url": "/django-rq/stats.json/", + "module": "django_rq.stats_views.stats_json", + "name": "rq_home_json", + "decorators": "" + }, { "url": "/django-rq/stats.json//", - "module": "django_rq.views.stats_json", + "module": "django_rq.stats_views.stats_json", "name": "rq_home_json", "decorators": "" }, From 79f575bc9ffd4296d50d9d1a149e167bdf602379 Mon Sep 17 00:00:00 2001 From: Marcel Canu Date: Thu, 13 Nov 2025 17:48:48 -0300 Subject: [PATCH 13/14] Fix test --- label_studio/fsm/tests/test_api.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/label_studio/fsm/tests/test_api.py b/label_studio/fsm/tests/test_api.py index 90b29167c016..758ac3d2871f 100644 --- a/label_studio/fsm/tests/test_api.py +++ b/label_studio/fsm/tests/test_api.py @@ -1,3 +1,4 @@ +from datetime import timedelta from urllib.parse import quote from fsm.state_choices import AnnotationStateChoices, ProjectStateChoices, TaskStateChoices @@ -39,6 +40,8 @@ def test_empty_project_history(self): def test_project_history(self): state_1 = ProjectStateFactory(project=self.project, state=ProjectStateChoices.CREATED) + state_1.created_at = state_1.created_at - timedelta(seconds=10) + state_1.save() state_2 = ProjectStateFactory( project=self.project, state=ProjectStateChoices.IN_PROGRESS, @@ -100,7 +103,7 @@ def test_project_history(self): assert response.json()['results'][0]['triggered_by']['id'] == self.user.id # Test date filtering - created_at_from = state_2.created_at.isoformat() + created_at_from = (state_2.created_at - timedelta(seconds=1)).isoformat() created_at_to = state_3.created_at.isoformat() response = self.client.get( f'/api/fsm/entities/project/{self.project.id}/history?created_at_from={quote(created_at_from)}&created_at_to={quote(created_at_to)}' @@ -123,6 +126,8 @@ def test_empty_task_history(self): def test_task_history(self): state_1 = TaskStateFactory(task=self.task, state=TaskStateChoices.CREATED) + state_1.created_at = state_1.created_at - timedelta(seconds=10) + state_1.save() state_2 = TaskStateFactory( task=self.task, state=TaskStateChoices.IN_PROGRESS, @@ -180,7 +185,7 @@ def test_task_history(self): assert response.json()['results'][0]['triggered_by']['id'] == self.user.id # Test date filtering - created_at_from = state_2.created_at.isoformat() + created_at_from = (state_2.created_at - timedelta(seconds=1)).isoformat() created_at_to = state_3.created_at.isoformat() response = self.client.get( f'/api/fsm/entities/task/{self.task.id}/history?created_at_from={quote(created_at_from)}&created_at_to={quote(created_at_to)}' @@ -203,6 +208,8 @@ def test_empty_annotation_history(self): def test_annotation_history(self): state_1 = AnnotationStateFactory(annotation=self.annotation, state=AnnotationStateChoices.SUBMITTED) + state_1.created_at = state_1.created_at - timedelta(seconds=10) + state_1.save() state_2 = AnnotationStateFactory( annotation=self.annotation, state=AnnotationStateChoices.COMPLETED, @@ -259,11 +266,11 @@ def test_annotation_history(self): assert response.json()['results'][0]['triggered_by']['id'] == self.user.id # Test date filtering - created_at_from = state_1.created_at.isoformat() - created_at_to = state_1.created_at.isoformat() + created_at_from = (state_2.created_at - timedelta(seconds=1)).isoformat() + created_at_to = state_2.created_at.isoformat() response = self.client.get( f'/api/fsm/entities/annotation/{self.annotation.id}/history?created_at_from={quote(created_at_from)}&created_at_to={quote(created_at_to)}' ) assert response.status_code == 200 assert len(response.json()['results']) == 1 - assert response.json()['results'][0]['id'] == str(state_1.id) + assert response.json()['results'][0]['id'] == str(state_2.id) From 6d0572cd95c4e4148d058983d8ce7bb76ed7774b Mon Sep 17 00:00:00 2001 From: robot-ci-heartex Date: Thu, 13 Nov 2025 21:48:24 +0000 Subject: [PATCH 14/14] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/19346821717 --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6b9ea78270e0..4f0eb5326bc2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2143,7 +2143,7 @@ optional = false python-versions = ">=3.9,<4" groups = ["main"] files = [ - {file = "a3a6bd450bd05b39f989e18c1f38781c5fc22572.zip", hash = "sha256:8ab41140e07554b0465ea743bc9ea54bced3090d637fd8ed7c7eccd55d2ab265"}, + {file = "e10bf619a8f6cf691c42f3e840a1c71f76d1ef0c.zip", hash = "sha256:640781116915e4f37522e62eee0cff75ad8e31c5ab0c5c28ab5bd0c216397015"}, ] [package.dependencies] @@ -2171,7 +2171,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/a3a6bd450bd05b39f989e18c1f38781c5fc22572.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/e10bf619a8f6cf691c42f3e840a1c71f76d1ef0c.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5117,4 +5117,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "52003bb0eaf57d91a423b59b8db811bcf9b82537b40bcd9e738832405ee82986" +content-hash = "bbc092f81c65d976a297909de24df258b8cdcbb82ff08b763e35c8efde57beec" diff --git a/pyproject.toml b/pyproject.toml index 789366ec8813..070eca5c7dd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ dependencies = [ "tldextract (>=5.1.3)", "uuid-utils (>=0.11.0,<1.0.0)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/a3a6bd450bd05b39f989e18c1f38781c5fc22572.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/e10bf619a8f6cf691c42f3e840a1c71f76d1ef0c.zip", ## HumanSignal repo dependencies :end ]