diff --git a/label_studio/core/settings/base.py b/label_studio/core/settings/base.py index 9ccc80e4c9cf..1a7c6d898b28 100644 --- a/label_studio/core/settings/base.py +++ b/label_studio/core/settings/base.py @@ -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 diff --git a/label_studio/fsm/project_transitions.py b/label_studio/fsm/project_transitions.py index 587d336815c2..2aadeecf132f 100644 --- a/label_studio/fsm/project_transitions.py +++ b/label_studio/fsm/project_transitions.py @@ -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 @@ -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) @@ -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) diff --git a/label_studio/fsm/state_manager.py b/label_studio/fsm/state_manager.py index a8897020088b..c8511d408cc7 100644 --- a/label_studio/fsm/state_manager.py +++ b/label_studio/fsm/state_manager.py @@ -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') diff --git a/label_studio/fsm/utils.py b/label_studio/fsm/utils.py index 03ccbcdac10b..09908a529472 100644 --- a/label_studio/fsm/utils.py +++ b/label_studio/fsm/utils.py @@ -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 diff --git a/label_studio/projects/models.py b/label_studio/projects/models.py index 0096275ff9c6..635c886f2b34 100644 --- a/label_studio/projects/models.py +++ b/label_studio/projects/models.py @@ -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, @@ -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 @@ -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) diff --git a/label_studio/tests/test_fsm_lso_workflows.py b/label_studio/tests/test_fsm_lso_workflows.py index 7cab0b6255db..6fcba8fce06f 100644 --- a/label_studio/tests/test_fsm_lso_workflows.py +++ b/label_studio/tests/test_fsm_lso_workflows.py @@ -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='', + ) + # 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)