diff --git a/label_studio/core/all_urls.json b/label_studio/core/all_urls.json index 7c5079331fb8..5b07991dd325 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", 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 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..3c3593be5aaa --- /dev/null +++ b/label_studio/fsm/api.py @@ -0,0 +1,101 @@ +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 +from rest_framework import generics +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.filters import OrderingFilter +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') + + +@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': ['internal'], + 'x-fern-pagination': { + 'offset': '$request.page', + 'results': '$response.results', + }, + }, + ), +) +class FSMEntityHistoryAPI(generics.ListAPIView): + serializer_class = StateModelSerializer + pagination_class = FSMEntityHistoryPagination + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_class = FSMEntityHistoryFilterSet + ordering_fields = ['id'] # Only allow ordering by id + + 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) + if not permission: + raise ValueError(f'Invalid entity name: {entity_name}') + return permission + + def get_entity(self): + state_model = get_state_model(self.kwargs['entity_name']) + 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 + + 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/serializers.py b/label_studio/fsm/serializers.py new file mode 100644 index 000000000000..5d674def9d55 --- /dev/null +++ b/label_studio/fsm/serializers.py @@ -0,0 +1,24 @@ +from rest_framework import serializers +from users.serializers import UserSerializer + + +class TriggeredBySerializer(UserSerializer): + class Meta(UserSerializer.Meta): + fields = ['id', 'email'] + + +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 = TriggeredBySerializer(read_only=True, allow_null=True) + 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..ea5e05997935 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']: @@ -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""" 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..758ac3d2871f --- /dev/null +++ b/label_studio/fsm/tests/test_api.py @@ -0,0 +1,276 @@ +from datetime import timedelta +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_1.created_at = state_1.created_at - timedelta(seconds=10) + state_1.save() + 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 + 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}' + ) + 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 - 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)}' + ) + 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_1.created_at = state_1.created_at - timedelta(seconds=10) + state_1.save() + 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 + 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 + 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 - 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)}' + ) + 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_1.created_at = state_1.created_at - timedelta(seconds=10) + state_1.save() + 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 + 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}' + ) + 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_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_2.id) 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', + ), +] 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 = 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 diff --git a/poetry.lock b/poetry.lock index bf3ad20b8744..4f0eb5326bc2 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 = "667244ebe01d668133c5e1189a8c5904bc6c36ac.zip", hash = "sha256:3cec6f74c3fc6e9d8337ea26afdd76ec836914c93d92a10d31c7a2f4ec267566"}, + {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/667244ebe01d668133c5e1189a8c5904bc6c36ac.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 = "73c31ebd150a80b6a78554468ae854c08dbceaf3ab27620f724b0e0bb7255ddd" +content-hash = "bbc092f81c65d976a297909de24df258b8cdcbb82ff08b763e35c8efde57beec" diff --git a/pyproject.toml b/pyproject.toml index 6122c27ab8d4..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/667244ebe01d668133c5e1189a8c5904bc6c36ac.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/e10bf619a8f6cf691c42f3e840a1c71f76d1ef0c.zip", ## HumanSignal repo dependencies :end ]