Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
420e0da
fix: FIT-1073: Deleted all tasks but one in Done, project still in An…
matt-bernstein Dec 1, 2025
e14f75b
Merge remote-tracking branch 'origin/develop' into fb-FIT-1073
matt-bernstein Dec 1, 2025
ee7ff93
fix test
matt-bernstein Dec 1, 2025
2eea0fe
Merge remote-tracking branch 'origin/develop' into fb-FIT-1073
matt-bernstein Dec 2, 2025
a455d62
move recalc to async job
matt-bernstein Dec 2, 2025
7b15359
Merge remote-tracking branch 'origin/develop' into fb-FIT-1073
matt-bernstein Dec 2, 2025
3b55bb4
Merge branch 'develop' into 'fb-FIT-1073'
matt-bernstein Dec 3, 2025
14df8be
Merge branch 'develop' into 'fb-FIT-1073'
matt-bernstein Dec 3, 2025
7b57502
Merge branch 'develop' into 'fb-FIT-1073'
matt-bernstein Dec 3, 2025
147bc38
make FSM state update a utility fn, plumb user through
matt-bernstein Dec 3, 2025
8f92a5e
pass user to update_tasks_states and update_tasks_counters_and_task_s…
matt-bernstein Dec 3, 2025
0e0bcfd
Apply pre-commit linters
matt-bernstein Dec 3, 2025
fcd0500
bugfix
matt-bernstein Dec 3, 2025
ab671e9
Apply pre-commit linters
matt-bernstein Dec 3, 2025
007ecb0
Merge remote-tracking branch 'origin/develop' into fb-FIT-1073
matt-bernstein Dec 4, 2025
0ba6eb2
Revert "pass user to update_tasks_states and update_tasks_counters_an…
matt-bernstein Dec 4, 2025
02609ee
use CurrentContext for user in _update_tasks_states
matt-bernstein Dec 4, 2025
16594fc
Merge branch 'develop' into 'fb-FIT-1073'
matt-bernstein Dec 4, 2025
0f1c708
Merge branch 'develop' into 'fb-FIT-1073'
matt-bernstein Dec 4, 2025
2076771
Merge branch 'develop' into 'fb-FIT-1073'
matt-bernstein Dec 4, 2025
6957d51
Merge branch 'develop' into 'fb-FIT-1073'
matt-bernstein Dec 4, 2025
c897d2a
Merge branch 'develop' into 'fb-FIT-1073'
matt-bernstein Dec 5, 2025
20b1037
Merge branch 'develop' into 'fb-FIT-1073'
matt-bernstein Dec 5, 2025
ec459b2
making sure we resepect context fsm state
yyassi-heartex Dec 5, 2025
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
1 change: 1 addition & 0 deletions label_studio/core/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,7 @@ def collect_versions_dummy(**kwargs):

# Base FSM (Finite State Machine) Configuration for Label Studio
FSM_CACHE_TTL = 300 # Cache TTL in seconds (5 minutes)
FSM_UPDATE_PROJECT_STATE_AFTER_TASK_CHANGE = 'fsm.project_transitions._update_project_state_after_task_change_lso'

# Used for async migrations. In LSE this is set to a real queue name, including here so we
# can use settings.SERVICE_QUEUE_NAME in async migrations in LSO
Expand Down
9 changes: 8 additions & 1 deletion label_studio/fsm/project_transitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from typing import Any, Dict, Optional

from core.utils.common import load_func
from django.conf import settings
from fsm.registry import register_state_transition
from fsm.state_choices import ProjectStateChoices
from fsm.state_manager import StateManager
Expand Down Expand Up @@ -135,7 +137,7 @@ def transition(self, context: TransitionContext) -> Dict[str, Any]:
}


def update_project_state_after_task_change(project, user=None):
def _update_project_state_after_task_change_lso(project, user=None):
current_state = StateManager.get_current_state_value(project)
inferred_state = infer_entity_state_from_data(project)

Expand All @@ -154,3 +156,8 @@ def update_project_state_after_task_change(project, user=None):
)
elif inferred_state == ProjectStateChoices.COMPLETED:
StateManager.execute_transition(entity=project, transition_name='project_completed', user=user)


def update_project_state_after_task_change(project, user=None):
update_func = load_func(settings.FSM_UPDATE_PROJECT_STATE_AFTER_TASK_CHANGE)
return update_func(project, user)
4 changes: 4 additions & 0 deletions label_studio/fsm/state_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ def transition_state(
if not cls._is_fsm_enabled(user=user):
return True # Feature disabled, silently succeed

# Skip if FSM is temporarily disabled (e.g., during cleanup or bulk operations)
if CurrentContext.is_fsm_disabled():
return True # FSM disabled, silently succeed

state_model = get_state_model_for_entity(entity)
if not state_model:
raise StateManagerError(f'No state model found for {entity._meta.model_name} when transitioning state')
Expand Down
4 changes: 4 additions & 0 deletions label_studio/fsm/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,10 @@ def get_or_initialize_state(entity, user=None, inferred_state=None) -> Optional[
if not is_fsm_enabled(user):
return None

# Skip if FSM is temporarily disabled (e.g., during cleanup or bulk operations)
if CurrentContext.is_fsm_disabled():
return inferred_state # Return inferred state without persisting

try:
from fsm.state_manager import get_state_manager

Expand Down
7 changes: 7 additions & 0 deletions label_studio/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from typing import Any, Mapping, Optional

from annoying.fields import AutoOneToOneField
from core.current_request import CurrentContext
from core.label_config import (
check_control_in_config_by_regex,
check_toname_in_config_by_regex,
Expand Down Expand Up @@ -35,6 +36,7 @@
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from fsm.models import FsmHistoryStateModel
from fsm.project_transitions import update_project_state_after_task_change
from fsm.queryset_mixins import FSMStateQuerySetMixin
from label_studio_sdk._extensions.label_studio_tools.core.label_config import parse_config
from labels_manager.models import Label
Expand Down Expand Up @@ -529,6 +531,11 @@ def _update_tasks_states(
elif tasks_number_changed and self.overlap_cohort_percentage < 100 and self.maximum_annotations > 1:
self._rearrange_overlap_cohort()

if tasks_number_changed:
# FSM: Recalculate project state after task deletion or import
user = CurrentContext.get_user()
update_project_state_after_task_change(self, user=user)

def _batch_update_with_retry(self, queryset, batch_size=500, max_retries=3, **update_fields):
batch_update_with_retry(queryset, batch_size, max_retries, **update_fields)

Expand Down
39 changes: 39 additions & 0 deletions label_studio/tests/test_fsm_lso_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -682,3 +682,42 @@ def test_bulk_task_processing_cold_start(self, django_live_url, business_client)
# Step 4: Verify project is COMPLETED (all tasks completed)
project_state = StateManager.get_current_state_value(project)
assert project_state == ProjectStateChoices.COMPLETED


def test_project_completes_after_deleting_unfinished_tasks(django_live_url, business_client):
"""
Deleting all unfinished tasks should complete the project if the remaining task(s) are completed.
Steps:
- Create project with 4 tasks
- Annotate 1 task (project -> IN_PROGRESS)
- Delete the 3 unannotated tasks via Delete Tasks action
- Expect project -> COMPLETED (only completed task remains)
"""
ls = LabelStudio(base_url=django_live_url, api_key=business_client.api_key)

project = ls.projects.create(
title='Complete after deleting unfinished',
label_config='<View><Text name="text" value="$text"/><Choices name="label" toName="text"><Choice value="positive"/><Choice value="negative"/></Choices></View>',
)
# Create 4 tasks
tasks = [ls.tasks.create(project=project.id, data={'text': f'Task {i}'}) for i in range(4)]
assert len(list(ls.tasks.list(project=project.id))) == 4

# Annotate the first task
ls.annotations.create(
id=tasks[0].id,
result=[{'value': {'choices': ['positive']}, 'from_name': 'label', 'to_name': 'text', 'type': 'choices'}],
lead_time=1.0,
)
# Project should be IN_PROGRESS with mixed completion
assert_project_state(project.id, ProjectStateChoices.IN_PROGRESS)

# Delete remaining 3 unannotated tasks using Data Manager action
ids_to_delete = [t.id for t in tasks[1:]]
ls.actions.create(project=project.id, id='delete_tasks', selected_items={'all': False, 'included': ids_to_delete})

# Only one task should remain
remaining = list(ls.tasks.list(project=project.id))
assert len(remaining) == 1
# Project should now be COMPLETED since all remaining tasks are completed
assert_project_state(project.id, ProjectStateChoices.COMPLETED)
Loading