Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions label_studio/core/all_urls.json
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,12 @@
"name": "labels_manager:api-labels-bulk",
"decorators": ""
},
{
"url": "/api/fsm/entities/<str:entity_name>/<int:entity_id>/history",
"module": "fsm.api.FSMEntityHistoryAPI",
"name": "fsm:fsm-entity-history",
"decorators": ""
},
{
"url": "/data/local-files/",
"module": "core.views.localfiles_data",
Expand Down
1 change: 1 addition & 0 deletions label_studio/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion label_studio/fsm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
101 changes: 101 additions & 0 deletions label_studio/fsm/api.py
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 24 additions & 0 deletions label_studio/fsm/serializers.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 2 additions & 3 deletions label_studio/fsm/state_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
10 changes: 8 additions & 2 deletions label_studio/fsm/state_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']:
Expand Down Expand Up @@ -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"""
Expand Down
37 changes: 37 additions & 0 deletions label_studio/fsm/tests/factories.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading