Skip to content

Commit e3f4a5c

Browse files
Merge remote-tracking branch 'origin/develop' into fb-fit-1045-ff-wrap-dm-view
2 parents d14548f + 7c020c9 commit e3f4a5c

File tree

6 files changed

+137
-91
lines changed

6 files changed

+137
-91
lines changed

label_studio/core/utils/filterset_to_openapi_params.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,12 @@ def _get_filter_config(filter_field: Any, overrides: dict) -> dict:
133133
config['enum'] = _get_choice_enum(filter_field)
134134

135135
elif isinstance(filter_field, (MultipleChoiceFilter, TypedMultipleChoiceFilter, AllValuesMultipleFilter)):
136-
config['type'] = OpenApiTypes.ARRAY
137-
config['extra_kwargs']['items'] = {'type': _map_filter_type(filter_field)}
136+
config['type'] = OpenApiTypes.STR
138137
if hasattr(filter_field, 'choices') and filter_field.choices:
139138
config['extra_kwargs']['items']['enum'] = _get_choice_enum(filter_field)
140139

141140
elif isinstance(filter_field, BaseInFilter):
142-
config['type'] = OpenApiTypes.ARRAY
143-
config['extra_kwargs']['items'] = {'type': _map_filter_type(filter_field)}
141+
config['type'] = OpenApiTypes.STR
144142
config['description'] = config.get('description', '') + ' (comma-separated values)'
145143

146144
elif isinstance(
@@ -162,7 +160,7 @@ def _get_filter_config(filter_field: Any, overrides: dict) -> dict:
162160
config['description'] = config.get('description', '') + ' (search term)'
163161

164162
elif isinstance(filter_field, (ModelChoiceFilter, ModelMultipleChoiceFilter)):
165-
config['type'] = OpenApiTypes.INT if isinstance(filter_field, ModelChoiceFilter) else OpenApiTypes.ARRAY
163+
config['type'] = OpenApiTypes.INT if isinstance(filter_field, ModelChoiceFilter) else OpenApiTypes.STR
166164
if isinstance(filter_field, ModelMultipleChoiceFilter):
167165
config['extra_kwargs']['items'] = {'type': OpenApiTypes.INT}
168166

@@ -208,7 +206,7 @@ def _map_filter_type(filter_field: Any) -> str:
208206
elif isinstance(
209207
filter_field, (MultipleChoiceFilter, TypedMultipleChoiceFilter, AllValuesMultipleFilter, BaseInFilter)
210208
):
211-
return OpenApiTypes.ARRAY
209+
return OpenApiTypes.STR
212210

213211
elif isinstance(
214212
filter_field,

label_studio/fsm/project_transitions.py

Lines changed: 16 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
from fsm.registry import register_state_transition
1111
from fsm.state_choices import ProjectStateChoices
12+
from fsm.state_manager import StateManager
1213
from fsm.transitions import ModelChangeTransition, TransitionContext
14+
from fsm.utils import get_or_initialize_state, infer_entity_state_from_data
1315

1416

1517
@register_state_transition('project', 'project_created', triggers_on_create=True, triggers_on_update=False)
@@ -138,85 +140,21 @@ def transition(self, context: TransitionContext) -> Dict[str, Any]:
138140

139141

140142
def update_project_state_after_task_change(project, user=None):
141-
"""
142-
Update project FSM state based on task states.
143-
144-
This helper function is called after any task state change to update the parent project's state.
145-
It handles "cold start" scenarios where tasks or the project may not have state records yet.
146-
147-
State transition logic:
148-
- CREATED -> IN_PROGRESS: When any task becomes COMPLETED
149-
- IN_PROGRESS -> COMPLETED: When ALL tasks are COMPLETED
150-
- COMPLETED -> IN_PROGRESS: When ANY task is not COMPLETED
151-
152-
Args:
153-
project: Project instance to update
154-
user: User triggering the change (for FSM context)
155-
"""
156-
from fsm.state_choices import ProjectStateChoices, TaskStateChoices
157-
from fsm.state_manager import StateManager
158-
from fsm.utils import get_or_initialize_state, infer_entity_state_from_data
159-
160-
# Get task state counts
161-
from tasks.models import Task
162-
163-
tasks = Task.objects.filter(project=project)
164-
total_tasks = tasks.count()
165-
166-
if total_tasks == 0:
167-
# No tasks - ensure project is in CREATED state
168-
current_project_state = get_or_initialize_state(project, user=user)
169-
return
143+
current_state = StateManager.get_current_state_value(project)
144+
inferred_state = infer_entity_state_from_data(project)
170145

171-
# Count completed tasks - handle both tasks with and without state records
172-
completed_tasks_count = 0
173-
174-
for task in tasks:
175-
# Get or initialize task state
176-
task_state = StateManager.get_current_state_value(task)
177-
178-
if task_state is None:
179-
# Task has no state record - infer from data
180-
task_state = infer_entity_state_from_data(task)
181-
182-
# Initialize the task state
183-
if task_state:
184-
get_or_initialize_state(task, user=user, inferred_state=task_state)
185-
186-
# Count completed tasks
187-
if task_state == TaskStateChoices.COMPLETED:
188-
completed_tasks_count += 1
189-
190-
# Determine target project state
191-
if completed_tasks_count == 0:
192-
# No completed tasks -> should be CREATED
193-
target_state = ProjectStateChoices.CREATED
194-
elif completed_tasks_count == total_tasks:
195-
# All tasks completed -> should be COMPLETED
196-
target_state = ProjectStateChoices.COMPLETED
197-
else:
198-
# Some tasks completed -> should be IN_PROGRESS
199-
target_state = ProjectStateChoices.IN_PROGRESS
200-
201-
# Get current project state (initialize if needed)
202-
current_project_state = StateManager.get_current_state_value(project)
203-
204-
if current_project_state is None:
205-
# Project has no state - initialize with target state
206-
get_or_initialize_state(project, user=user, inferred_state=target_state)
146+
if current_state is None:
147+
get_or_initialize_state(project, user=user, inferred_state=inferred_state)
207148
return
208149

209-
# Execute appropriate transition if state should change
210-
if current_project_state != target_state:
211-
if current_project_state == ProjectStateChoices.CREATED and target_state == ProjectStateChoices.IN_PROGRESS:
212-
StateManager.execute_transition(entity=project, transition_name='project_in_progress', user=user)
213-
elif (
214-
current_project_state == ProjectStateChoices.IN_PROGRESS and target_state == ProjectStateChoices.COMPLETED
215-
):
150+
if current_state != inferred_state:
151+
if inferred_state == ProjectStateChoices.IN_PROGRESS:
152+
# Select in progress transition based on current state
153+
if current_state == ProjectStateChoices.CREATED:
154+
StateManager.execute_transition(entity=project, transition_name='project_in_progress', user=user)
155+
elif current_state == ProjectStateChoices.COMPLETED:
156+
StateManager.execute_transition(
157+
entity=project, transition_name='project_in_progress_from_completed', user=user
158+
)
159+
elif inferred_state == ProjectStateChoices.COMPLETED:
216160
StateManager.execute_transition(entity=project, transition_name='project_completed', user=user)
217-
elif (
218-
current_project_state == ProjectStateChoices.COMPLETED and target_state == ProjectStateChoices.IN_PROGRESS
219-
):
220-
StateManager.execute_transition(
221-
entity=project, transition_name='project_in_progress_from_completed', user=user
222-
)

label_studio/fsm/tests/test_transitions.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,3 +856,110 @@ def post_transition_hook(self, context: TransitionContext, state_record) -> None
856856
# Test transition execution
857857
result = transition.transition(context)
858858
assert result['action'] == 'email_sent'
859+
860+
861+
def test_skip_validation_flag():
862+
"""
863+
Test the skip_validation flag in TransitionContext.
864+
865+
This test validates step by step:
866+
- Creating a transition with validation logic that would normally fail
867+
- Verifying that validation runs and fails when skip_validation=False (default)
868+
- Verifying that validation is skipped when skip_validation=True
869+
- Confirming that prepare_and_validate respects the skip_validation flag
870+
- Ensuring the transition can execute successfully when validation is skipped
871+
872+
Critical validation: The skip_validation flag provides a mechanism to bypass
873+
validation checks for special cases like system migrations, data imports, or
874+
administrative operations that need to override normal business rules.
875+
"""
876+
877+
class StrictValidationTransition(BaseTransition):
878+
"""Test transition with strict validation rules"""
879+
880+
action: str = Field(..., description='Action to perform')
881+
882+
@property
883+
def target_state(self) -> str:
884+
return TestStateChoices.COMPLETED
885+
886+
def validate_transition(self, context: TransitionContext) -> bool:
887+
"""Validation that only allows transition from IN_PROGRESS state"""
888+
if context.current_state != TestStateChoices.IN_PROGRESS:
889+
raise TransitionValidationError(
890+
f'Can only complete from IN_PROGRESS state, not {context.current_state}',
891+
{'current_state': context.current_state, 'target_state': self.target_state},
892+
)
893+
return True
894+
895+
def transition(self, context: TransitionContext) -> Dict[str, Any]:
896+
return {'action': self.action, 'completed': True}
897+
898+
# Create mock entity
899+
mock_entity = MockEntity()
900+
901+
# Test 1: Normal validation (skip_validation=False, default behavior)
902+
# This should fail because current_state is CREATED, not IN_PROGRESS
903+
transition = StrictValidationTransition(action='test_action')
904+
context_with_validation = TransitionContext(
905+
entity=mock_entity,
906+
current_state=TestStateChoices.CREATED, # Invalid state for this transition
907+
target_state=transition.target_state,
908+
skip_validation=False, # Explicit False (same as default)
909+
)
910+
911+
# Verify that validation fails as expected
912+
with pytest.raises(TransitionValidationError) as cm:
913+
transition.prepare_and_validate(context_with_validation)
914+
915+
error = cm.value
916+
assert 'Can only complete from IN_PROGRESS state' in str(error)
917+
assert error.context['current_state'] == TestStateChoices.CREATED
918+
919+
# Test 2: Skip validation (skip_validation=True)
920+
# This should succeed even though current_state is CREATED
921+
transition_skip = StrictValidationTransition(action='skip_validation_action')
922+
context_skip_validation = TransitionContext(
923+
entity=mock_entity,
924+
current_state=TestStateChoices.CREATED, # Same invalid state
925+
target_state=transition_skip.target_state,
926+
skip_validation=True, # Validation should be skipped
927+
)
928+
929+
# This should NOT raise an error because validation is skipped
930+
result = transition_skip.prepare_and_validate(context_skip_validation)
931+
932+
# Verify the transition executed successfully
933+
assert result['action'] == 'skip_validation_action'
934+
assert result['completed'] is True
935+
936+
# Test 3: Verify default behavior (skip_validation not specified, defaults to False)
937+
transition_default = StrictValidationTransition(action='default_action')
938+
context_default = TransitionContext(
939+
entity=mock_entity,
940+
current_state=TestStateChoices.CREATED,
941+
target_state=transition_default.target_state,
942+
# skip_validation not specified, should default to False
943+
)
944+
945+
# Verify default is False (validation should run and fail)
946+
assert context_default.skip_validation is False
947+
948+
with pytest.raises(TransitionValidationError) as cm:
949+
transition_default.prepare_and_validate(context_default)
950+
951+
assert 'Can only complete from IN_PROGRESS state' in str(cm.value)
952+
953+
# Test 4: Verify that with correct state and skip_validation=False, it succeeds
954+
transition_valid = StrictValidationTransition(action='valid_action')
955+
context_valid = TransitionContext(
956+
entity=mock_entity,
957+
current_state=TestStateChoices.IN_PROGRESS, # Correct state
958+
target_state=transition_valid.target_state,
959+
skip_validation=False,
960+
)
961+
962+
# This should succeed because state is valid
963+
result_valid = transition_valid.prepare_and_validate(context_valid)
964+
assert result_valid['action'] == 'valid_action'
965+
assert result_valid['completed'] is True

label_studio/fsm/transitions.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ class TransitionContext(BaseModel, Generic[EntityType, StateModelType]):
5454
# Organizational context
5555
organization_id: Optional[int] = Field(None, description='Organization context for the transition')
5656

57+
# Validation context, for cases where we want to skip validation for the transition
58+
skip_validation: Optional[bool] = Field(default=False, description='Whether to skip validation for the transition')
59+
5760
@property
5861
def has_current_state(self) -> bool:
5962
"""Check if entity has a current state"""
@@ -269,7 +272,7 @@ def prepare_and_validate(self, context: TransitionContext[EntityType, StateModel
269272
270273
This method handles the preparation phase of the transition:
271274
1. Set context on the transition instance
272-
2. Validate the transition
275+
2. Validate the transition if not skipped
273276
3. Execute pre-transition hooks
274277
4. Perform the actual transition logic
275278
@@ -290,7 +293,7 @@ def prepare_and_validate(self, context: TransitionContext[EntityType, StateModel
290293

291294
try:
292295
# Validate transition
293-
if not self.validate_transition(context):
296+
if not context.skip_validation and not self.validate_transition(context):
294297
raise TransitionValidationError(
295298
f'Transition validation failed for {self.transition_name}',
296299
{'current_state': context.current_state, 'target_state': self.target_state},

poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ dependencies = [
7474
"tldextract (>=5.1.3)",
7575
"uuid-utils (>=0.11.0,<1.0.0)",
7676
## HumanSignal repo dependencies :start
77-
"label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/a93a9953793d0505aca9f8ea2bf09e9e7af52448.zip",
77+
"label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/9b72a9525c98ecbf36c816d30365380033ee80e1.zip",
7878
## HumanSignal repo dependencies :end
7979
]
8080

0 commit comments

Comments
 (0)