import logging
|
|
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, transition_registry
|
from fsm.serializers import (
|
FSMTransitionExecuteRequestSerializer,
|
FSMTransitionExecuteResponseSerializer,
|
StateModelSerializer,
|
)
|
from fsm.state_manager import get_state_manager
|
from fsm.transitions import ModelChangeTransition, TransitionValidationError
|
from pydantic import ValidationError as PydanticValidationError
|
from rest_framework import generics, status
|
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
|
from rest_framework.filters import OrderingFilter
|
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.response import Response
|
|
logger = logging.getLogger(__name__)
|
|
|
class FSMAPIMixin:
|
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'])
|
if not state_model:
|
raise NotFound()
|
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:
|
# Return 404 instead of 403 to avoid leaking entity existence
|
raise NotFound() from e
|
return entity
|
|
|
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(FSMAPIMixin, 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_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)
|
|
|
@method_decorator(
|
name='post',
|
decorator=extend_schema(
|
tags=['FSM'],
|
summary='Execute manual FSM transition',
|
description='Execute a registered manual transition for an entity.',
|
request=FSMTransitionExecuteRequestSerializer,
|
responses={200: FSMTransitionExecuteResponseSerializer},
|
extensions={
|
'x-fern-sdk-group-name': 'fsm',
|
'x-fern-sdk-method-name': 'execute_transition',
|
'x-fern-audiences': ['internal'],
|
},
|
),
|
)
|
class FSMEntityTransitionAPI(FSMAPIMixin, generics.GenericAPIView):
|
"""
|
POST /api/fsm/entities/{entity_type}/{entity_id}/transition/
|
"""
|
|
serializer_class = FSMTransitionExecuteRequestSerializer
|
|
permission_map = {
|
'task': all_permissions.tasks_change,
|
'annotation': all_permissions.annotations_change,
|
'project': all_permissions.projects_change,
|
}
|
|
def post(self, request, *args, **kwargs):
|
entity_name = kwargs['entity_name']
|
if entity_name not in state_model_registry.get_all_models():
|
raise NotFound()
|
|
entity = self.get_entity()
|
|
serializer = self.get_serializer(data=request.data)
|
serializer.is_valid(raise_exception=True)
|
transition_name = serializer.validated_data['transition_name']
|
transition_data = serializer.validated_data.get('transition_data') or {}
|
|
# Validate that transition is registered and manual (not auto-triggered)
|
transition_class = transition_registry.get_transition(entity_name, transition_name)
|
if not transition_class:
|
raise ValidationError({'transition_name': ['Unknown transition for this entity']})
|
|
# If it's a ModelChangeTransition and has any triggers configured, it's not manual
|
if issubclass(transition_class, ModelChangeTransition):
|
triggers_on_create = getattr(transition_class, '_triggers_on_create', False)
|
triggers_on_update = getattr(transition_class, '_triggers_on_update', False)
|
if triggers_on_create or triggers_on_update:
|
raise ValidationError(
|
{'transition_name': ['Transition is auto-triggered and cannot be executed manually']}
|
)
|
|
# Execute transition
|
StateManager = get_state_manager()
|
try:
|
state_record = StateManager.execute_transition(
|
entity=entity,
|
transition_name=transition_name,
|
transition_data=transition_data,
|
user=request.user,
|
organization_id=getattr(request.user, 'active_organization_id', None),
|
)
|
except PydanticValidationError as e:
|
# Pydantic schema validation errors from transition instantiation
|
raise ValidationError({'detail': str(e)})
|
except TransitionValidationError as e:
|
# Explicit validation failure
|
logger.warning(
|
f'Transition validation failed with context: {e.context} and error: {e} for entity: {entity.id}'
|
)
|
raise ValidationError({'detail': str(e)})
|
# Handle feature-flag disabled path (no state record created)
|
if state_record is None:
|
response_payload = {
|
'success': True,
|
'new_state': None,
|
'state_record': None,
|
}
|
else:
|
response_payload = {
|
'success': True,
|
'new_state': state_record.state,
|
# Pass model instance; nested serializer will handle representation
|
'state_record': state_record,
|
}
|
return Response(
|
FSMTransitionExecuteResponseSerializer(response_payload, context={'request': request}).data,
|
status=status.HTTP_200_OK,
|
)
|