From ae9da638550d187a0bf45378087cc0ef65f61b3b Mon Sep 17 00:00:00 2001 From: Ignacio Velazquez Date: Thu, 27 Nov 2025 16:02:11 +0100 Subject: [PATCH 01/11] feat: UTC-418: Define and enforce 'unskippable' tasks for annotator evaluation --- label_studio/tasks/api.py | 18 +++++++++++---- .../migrations/0060_add_allow_skip_to_task.py | 23 +++++++++++++++++++ label_studio/tasks/models.py | 5 ++++ label_studio/tasks/serializers.py | 1 + web/libs/datamanager/src/sdk/lsf-sdk.js | 9 ++++++++ .../src/stores/DataStores/tasks.js | 1 + .../src/components/BottomBar/CurrentTask.jsx | 8 ++++++- .../src/components/BottomBar/buttons.tsx | 10 ++++++-- .../src/components/Controls/Controls.jsx | 10 +++++--- .../editor/src/components/TopBar/Controls.jsx | 9 ++++++-- .../src/components/TopBar/CurrentTask.jsx | 8 ++++++- web/libs/editor/src/stores/AppStore.js | 7 ++++++ 12 files changed, 95 insertions(+), 14 deletions(-) create mode 100644 label_studio/tasks/migrations/0060_add_allow_skip_to_task.py diff --git a/label_studio/tasks/api.py b/label_studio/tasks/api.py index 27b62bf1cb70..22da1db69667 100644 --- a/label_studio/tasks/api.py +++ b/label_studio/tasks/api.py @@ -21,7 +21,7 @@ from projects.functions.stream_history import fill_history_annotation from projects.models import Project from rest_framework import generics, viewsets -from rest_framework.exceptions import PermissionDenied +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from tasks.models import Annotation, AnnotationDraft, Prediction, Task @@ -524,10 +524,10 @@ def delete(self, request, *args, **kwargs): tags=['Annotations'], summary='Create annotation', description=""" - Add annotations to a task like an annotator does. The content of the result field depends on your - labeling configuration. For example, send the following data as part of your POST + Add annotations to a task like an annotator does. The content of the result field depends on your + labeling configuration. For example, send the following data as part of your POST request to send an empty annotation with the ID of the user who completed the task: - + ```json { "result": {}, @@ -536,7 +536,7 @@ def delete(self, request, *args, **kwargs): "lead_time": 0, "task": 0 "completed_by": 123 - } + } ``` """, parameters=[ @@ -598,6 +598,14 @@ def perform_create(self, ser): # annotator has write access only to annotations and it can't be checked it after serializer.save() user = self.request.user + # Check if task is being skipped and if it's allowed + was_cancelled_get = bool_from_request(self.request.GET, 'was_cancelled', False) + was_cancelled_data = self.request.data.get('was_cancelled', False) + is_skipping = was_cancelled_get or was_cancelled_data + + if is_skipping and not task.allow_skip: + raise ValidationError({'detail': 'This task cannot be skipped.'}) + # updates history result = ser.validated_data.get('result') extra_args = {'task_id': self.kwargs['pk'], 'project_id': task.project_id} diff --git a/label_studio/tasks/migrations/0060_add_allow_skip_to_task.py b/label_studio/tasks/migrations/0060_add_allow_skip_to_task.py new file mode 100644 index 000000000000..dd054f20e1db --- /dev/null +++ b/label_studio/tasks/migrations/0060_add_allow_skip_to_task.py @@ -0,0 +1,23 @@ +# Generated migration for adding allow_skip field to Task model + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0059_task_completion_id_updated_at_idx_async'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='allow_skip', + field=models.BooleanField( + default=True, + help_text='Whether this task can be skipped. Set to False to make task unskippable.', + verbose_name='allow_skip', + ), + ), + ] + diff --git a/label_studio/tasks/models.py b/label_studio/tasks/models.py index 904282b4133b..3f537a7f7c65 100644 --- a/label_studio/tasks/models.py +++ b/label_studio/tasks/models.py @@ -97,6 +97,11 @@ class Task(TaskMixin, FsmHistoryStateModel): help_text='True if the number of annotations for this task is greater than or equal ' 'to the number of maximum_completions for the project', ) + allow_skip = models.BooleanField( + _('allow_skip'), + default=True, + help_text='Whether this task can be skipped. Set to False to make task unskippable.', + ) overlap = models.IntegerField( _('overlap'), default=1, diff --git a/label_studio/tasks/serializers.py b/label_studio/tasks/serializers.py index e6c328f3aca4..e15262aa46dd 100644 --- a/label_studio/tasks/serializers.py +++ b/label_studio/tasks/serializers.py @@ -668,6 +668,7 @@ def add_tasks(self, task_annotations, task_predictions, validated_tasks): total_predictions=len(task_predictions[i]), total_annotations=total_annotations, cancelled_annotations=cancelled_annotations, + allow_skip=task.get('allow_skip', True), # Default to True for backward compatibility ) db_tasks.append(t) diff --git a/web/libs/datamanager/src/sdk/lsf-sdk.js b/web/libs/datamanager/src/sdk/lsf-sdk.js index 1adcb05d83e7..bd156470e7c4 100644 --- a/web/libs/datamanager/src/sdk/lsf-sdk.js +++ b/web/libs/datamanager/src/sdk/lsf-sdk.js @@ -812,6 +812,15 @@ export class LSFWrapper { }; onSkipTask = async (_, { comment } = {}) => { + // Check if task can be skipped + const task = this.task; + const allowSkip = task?.allow_skip !== false; // Default to true if undefined + if (!allowSkip) { + console.warn("Task cannot be skipped: allow_skip is false"); + this.showOperationToast(400, null, "This task cannot be skipped", { error: "Task cannot be skipped" }); + return; + } + const result = await this.submitCurrentAnnotation( "skipTask", async (taskID, body) => { diff --git a/web/libs/datamanager/src/stores/DataStores/tasks.js b/web/libs/datamanager/src/stores/DataStores/tasks.js index ba59890b2aa3..261f9e3bc684 100644 --- a/web/libs/datamanager/src/stores/DataStores/tasks.js +++ b/web/libs/datamanager/src/stores/DataStores/tasks.js @@ -34,6 +34,7 @@ export const create = (columns) => { // annotation to select on rejected queue default_selected_annotation: types.maybeNull(types.number), allow_postpone: types.maybeNull(types.boolean), + allow_skip: types.optional(types.maybeNull(types.boolean), true), unique_lock_id: types.maybeNull(types.string), updated_by: types.optional(types.array(Assignee), []), ...(isFF(FF_DISABLE_GLOBAL_USER_FETCHING) diff --git a/web/libs/editor/src/components/BottomBar/CurrentTask.jsx b/web/libs/editor/src/components/BottomBar/CurrentTask.jsx index 0d46aa59cfe9..d14695df8410 100644 --- a/web/libs/editor/src/components/BottomBar/CurrentTask.jsx +++ b/web/libs/editor/src/components/BottomBar/CurrentTask.jsx @@ -15,12 +15,18 @@ export const CurrentTask = observer(({ store }) => { const historyEnabled = store.hasInterface("topbar:prevnext"); // @todo some interface? + const task = store.task; + const allowPostpone = task?.allow_postpone !== false; // Default to true if undefined + const allowSkip = task?.allow_skip !== false; // Default to true if undefined + // If task cannot be skipped, also disable postpone to prevent bypassing the restriction const canPostpone = !isDefined(store.annotationStore.selected.pk) && !store.canGoNextTask && (!isFF(FF_LEAP_1173) || store.hasInterface("skip")) && !store.hasInterface("review") && - store.hasInterface("postpone"); + store.hasInterface("postpone") && + allowPostpone && + allowSkip; // Disable postpone if task cannot be skipped return (
diff --git a/web/libs/editor/src/components/BottomBar/buttons.tsx b/web/libs/editor/src/components/BottomBar/buttons.tsx index d7d99ad2ea91..e3573fdd30c6 100644 --- a/web/libs/editor/src/components/BottomBar/buttons.tsx +++ b/web/libs/editor/src/components/BottomBar/buttons.tsx @@ -95,14 +95,20 @@ type SkipButtonProps = { export const SkipButton = memo( observer(({ disabled, store, onSkipWithComment }: SkipButtonProps) => { + const task = store.task; + const allowSkip = task?.allow_skip !== false; // Default to true if undefined + const isDisabled = disabled || !allowSkip; + const tooltip = allowSkip ? "Cancel (skip) task [ Ctrl+Space ]" : "This task cannot be skipped"; + return ( + ); + }), + }; +}); + +const createMockStore = (overrides: any = {}) => ({ + task: { id: 1, allow_skip: true, ...overrides.task }, + skipTask: jest.fn(), + hasInterface: jest.fn((name: string) => overrides.interfaces?.includes(name) ?? false), + annotationStore: { + selected: { + submissionInProgress: jest.fn(), + }, + }, + commentStore: { + commentFormSubmit: jest.fn(), + }, + ...overrides, +}); + +describe("SkipButton", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("Skip button disabled when allow_skip=false", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: false }, + }); + const onSkipWithComment = jest.fn(); + + const { getByTestId } = render( + + + , + ); + + const button = getByTestId("skip-button"); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("title", "This task cannot be skipped"); + }); + + test("Skip button enabled when allow_skip=true", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: true }, + }); + const onSkipWithComment = jest.fn(); + + const { getByTestId } = render( + + + , + ); + + const button = getByTestId("skip-button"); + expect(button).not.toBeDisabled(); + expect(button).toHaveAttribute("title", "Cancel (skip) task [ Ctrl+Space ]"); + }); + + test("Skip button enabled when allow_skip is undefined (default behavior)", () => { + const mockStore = createMockStore({ + task: { id: 1 }, // no allow_skip property + }); + const onSkipWithComment = jest.fn(); + + const { getByTestId } = render( + + + , + ); + + const button = getByTestId("skip-button"); + expect(button).not.toBeDisabled(); + expect(button).toHaveAttribute("title", "Cancel (skip) task [ Ctrl+Space ]"); + }); + + test("Skip button enabled when allow_skip=null", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: null }, + }); + const onSkipWithComment = jest.fn(); + + const { getByTestId } = render( + + + , + ); + + const button = getByTestId("skip-button"); + expect(button).not.toBeDisabled(); + expect(button).toHaveAttribute("title", "Cancel (skip) task [ Ctrl+Space ]"); + }); + + test("Skip button onClick doesn't trigger when allow_skip=false", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: false }, + interfaces: ["skip"], + }); + const onSkipWithComment = jest.fn(); + + const { getByTestId } = render( + + + , + ); + + const button = getByTestId("skip-button"); + fireEvent.click(button); + + expect(onSkipWithComment).not.toHaveBeenCalled(); + expect(mockStore.skipTask).not.toHaveBeenCalled(); + }); + + test("Skip button onClick triggers when allow_skip=true", async () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: true }, + interfaces: ["skip", "comments:skip"], + }); + const onSkipWithComment = jest.fn(); + + const { getByTestId } = render( + + + , + ); + + const button = getByTestId("skip-button"); + fireEvent.click(button); + + expect(onSkipWithComment).toHaveBeenCalled(); + }); + + test("Skip button respects other disabled conditions", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: true }, + }); + const onSkipWithComment = jest.fn(); + + const { getByTestId } = render( + + + , + ); + + const button = getByTestId("skip-button"); + expect(button).toBeDisabled(); + }); +}); diff --git a/web/libs/editor/src/components/Controls/__tests__/Controls.test.tsx b/web/libs/editor/src/components/Controls/__tests__/Controls.test.tsx new file mode 100644 index 000000000000..29ad35359caa --- /dev/null +++ b/web/libs/editor/src/components/Controls/__tests__/Controls.test.tsx @@ -0,0 +1,130 @@ +import { render, fireEvent } from "@testing-library/react"; +import { Provider } from "mobx-react"; +import Controls from "../Controls"; + +jest.mock("@humansignal/ui", () => { + const { forwardRef } = jest.requireActual("react"); + return { + Button: forwardRef(({ children, disabled, tooltip, onClick, ...props }: any, ref: any) => { + return ( + + ); + }), + }; +}); + +const createMockStore = (overrides: any = {}) => ({ + task: { id: 1, allow_skip: true, ...overrides.task }, + skipTask: jest.fn(), + isSubmitting: false, + hasInterface: jest.fn((name: string) => overrides.interfaces?.includes(name) ?? false), + settings: { + enableHotkeys: true, + enableTooltips: true, + }, + annotationStore: { + predictSelect: null, + }, + explore: true, // Set to true so component renders + ...overrides, +}); + +describe("Controls", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("Skip button disabled when allow_skip=false", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: false }, + interfaces: ["skip"], + }); + const item = { + userGenerate: false, + sentUserGenerate: false, + versions: {}, + }; + + const { container, getByText } = render( + + + , + ); + + const skipButton = getByText(/Skip/i).closest("button"); + expect(skipButton).toBeDisabled(); + expect(skipButton).toHaveAttribute("title", "This task cannot be skipped"); + }); + + test("Skip button enabled when allow_skip=true", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: true }, + interfaces: ["skip"], + }); + const item = { + userGenerate: false, + sentUserGenerate: false, + versions: {}, + }; + + const { getByText } = render( + + + , + ); + + const skipButton = getByText(/Skip/i).closest("button"); + expect(skipButton).not.toBeDisabled(); + expect(skipButton).toHaveAttribute("title", "Cancel (skip) task: [ Ctrl+Space ]"); + }); + + test("Skip button onClick is undefined when allow_skip=false", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: false }, + interfaces: ["skip"], + }); + const item = { + userGenerate: false, + sentUserGenerate: false, + versions: {}, + }; + + const { getByText } = render( + + + , + ); + + const skipButton = getByText(/Skip/i).closest("button"); + expect(skipButton).toBeDisabled(); + // When disabled and onClick is undefined, clicking should not trigger skipTask + fireEvent.click(skipButton!); + expect(mockStore.skipTask).not.toHaveBeenCalled(); + }); + + test("Skip button onClick triggers skipTask when allow_skip=true", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: true }, + interfaces: ["skip"], + }); + const item = { + userGenerate: false, + sentUserGenerate: false, + versions: {}, + }; + + const { getByText } = render( + + + , + ); + + const skipButton = getByText(/Skip/i).closest("button"); + fireEvent.click(skipButton!); + + expect(mockStore.skipTask).toHaveBeenCalled(); + }); +}); + diff --git a/web/libs/editor/src/components/TopBar/__tests__/Controls.test.tsx b/web/libs/editor/src/components/TopBar/__tests__/Controls.test.tsx new file mode 100644 index 000000000000..291c4d63825a --- /dev/null +++ b/web/libs/editor/src/components/TopBar/__tests__/Controls.test.tsx @@ -0,0 +1,119 @@ +import { render, fireEvent } from "@testing-library/react"; +import { Provider } from "mobx-react"; +import { Controls } from "../Controls"; + +jest.mock("@humansignal/ui", () => { + const { forwardRef } = jest.requireActual("react"); + return { + Button: forwardRef(({ children, disabled, ...props }: any, ref: any) => { + return ( + + ); + }), + Tooltip: ({ children, title }: any) => { + return ( +
+ {children} +
+ ); + }, + }; +}); + +const createMockStore = (overrides: any = {}) => ({ + task: { id: 1, allow_skip: true, ...overrides.task }, + skipTask: jest.fn(), + isSubmitting: false, + settings: { + enableTooltips: true, + ...overrides.settings, + }, + hasInterface: jest.fn((name: string) => overrides.interfaces?.includes(name) ?? false), + annotationStore: { + selectedHistory: undefined, + selected: { + history: { + canUndo: false, + }, + }, + ...overrides.annotationStore, + }, + commentStore: { + commentFormSubmit: jest.fn(), + addedCommentThisSession: false, + currentComment: {}, + inputRef: { current: null }, + setTooltipMessage: jest.fn(), + ...overrides.commentStore, + }, + rejectAnnotation: jest.fn(), + ...overrides, +}); + +describe("TopBar Controls", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("Skip button disabled when allow_skip=false", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: false }, + interfaces: ["skip"], + }); + const annotation = { id: "test", skipped: false, userGenerate: false, sentUserGenerate: false, versions: {}, results: [], editable: true }; + + const { getByLabelText } = render( + + + , + ); + + const skipButton = getByLabelText("Skip current task"); + expect(skipButton).toBeDisabled(); + + const tooltip = skipButton.closest('[data-testid="tooltip"]'); + expect(tooltip).toHaveAttribute("title", "This task cannot be skipped"); + }); + + test("Skip button enabled when allow_skip=true", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: true }, + interfaces: ["skip"], + }); + const annotation = { id: "test", skipped: false, userGenerate: false, sentUserGenerate: false, versions: {}, results: [], editable: true }; + + const { getByLabelText } = render( + + + , + ); + + const skipButton = getByLabelText("Skip current task"); + expect(skipButton).not.toBeDisabled(); + + const tooltip = skipButton.closest('[data-testid="tooltip"]'); + expect(tooltip).toHaveAttribute("title", "Cancel (skip) task: [ Ctrl+Space ]"); + }); + + test("Skip action blocked when allow_skip=false", () => { + const mockStore = createMockStore({ + task: { id: 1, allow_skip: false }, + interfaces: ["skip"], + }); + const annotation = { id: "test", skipped: false, userGenerate: false, sentUserGenerate: false, versions: {}, results: [], editable: true }; + + const { getByLabelText } = render( + + + , + ); + + const skipButton = getByLabelText("Skip current task"); + fireEvent.click(skipButton); + + expect(mockStore.skipTask).not.toHaveBeenCalled(); + }); +}); + diff --git a/web/libs/editor/src/components/TopBar/__tests__/CurrentTask.test.tsx b/web/libs/editor/src/components/TopBar/__tests__/CurrentTask.test.tsx index 77c8c4df96d5..a3c491adc9e7 100644 --- a/web/libs/editor/src/components/TopBar/__tests__/CurrentTask.test.tsx +++ b/web/libs/editor/src/components/TopBar/__tests__/CurrentTask.test.tsx @@ -32,11 +32,12 @@ describe("CurrentTask", () => { annotationId: null, }, ], - task: { id: 6616 }, + task: { id: 6616, allow_skip: true, allow_postpone: true }, commentStore: { loading: "list", comments: [], setAddedCommentThisSession: jest.fn(), + addedCommentThisSession: false, }, queuePosition: 1, prevTask: jest.fn(), @@ -112,4 +113,37 @@ describe("CurrentTask", () => { expect(getByTestId("next-task").disabled).toBe(true); }); + + it("disables postpone button when allow_skip=false", () => { + store.hasInterface.mockImplementation((interfaceName: string) => + ["skip", "postpone", "topbar:prevnext", "topbar:task-counter"].includes(interfaceName), + ); + store.task = { id: 6616, allow_skip: false, allow_postpone: true }; + + const { getByTestId } = render(); + + expect(getByTestId("next-task").disabled).toBe(true); + }); + + it("enables postpone button when allow_skip=true", () => { + store.hasInterface.mockImplementation((interfaceName: string) => + ["skip", "postpone", "topbar:prevnext", "topbar:task-counter"].includes(interfaceName), + ); + store.task = { id: 6616, allow_skip: true, allow_postpone: true }; + + const { getByTestId } = render(); + + expect(getByTestId("next-task").disabled).toBe(false); + }); + + it("enables postpone button when allow_skip is undefined", () => { + store.hasInterface.mockImplementation((interfaceName: string) => + ["skip", "postpone", "topbar:prevnext", "topbar:task-counter"].includes(interfaceName), + ); + store.task = { id: 6616, allow_postpone: true }; // no allow_skip property + + const { getByTestId } = render(); + + expect(getByTestId("next-task").disabled).toBe(false); + }); }); From c4ff4b07c080855ee9cdbfa55285fe00a732ccee Mon Sep 17 00:00:00 2001 From: Ignacio Velazquez Date: Fri, 28 Nov 2025 10:34:34 +0100 Subject: [PATCH 05/11] added tests --- label_studio/tasks/tests/test_api.py | 117 +++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/label_studio/tasks/tests/test_api.py b/label_studio/tasks/tests/test_api.py index e9689d481827..ff3b7e36af45 100644 --- a/label_studio/tasks/tests/test_api.py +++ b/label_studio/tasks/tests/test_api.py @@ -1,6 +1,7 @@ from organizations.tests.factories import OrganizationFactory from projects.tests.factories import ProjectFactory from rest_framework.test import APITestCase +from tasks.models import Task from tasks.tests.factories import TaskFactory @@ -50,6 +51,7 @@ def test_get_task(self): 'comment_count': 0, 'last_comment_updated_at': None, 'unresolved_comment_count': 0, + 'allow_skip': True, } def test_patch_task(self): @@ -123,3 +125,118 @@ def test_create_task_with_project_id_succeeds(self): response_data = response.json() assert response_data['project'] == self.project.id assert response_data['data'] == {'text': 'test task'} + + def test_get_task_includes_allow_skip(self): + """Test that GET task API includes allow_skip field""" + task = TaskFactory(project=self.project, allow_skip=False) + + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/') + assert response.status_code == 200 + response_data = response.json() + assert 'allow_skip' in response_data + assert response_data['allow_skip'] is False + + def test_create_task_with_allow_skip(self): + """Test that creating a task with allow_skip field succeeds""" + payload = { + 'project': self.project.id, + 'data': {'text': 'test task'}, + 'meta': {}, + 'allow_skip': False, + } + + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/tasks/', data=payload, format='json') + + assert response.status_code == 201 + response_data = response.json() + assert response_data['allow_skip'] is False + task = Task.objects.get(id=response_data['id']) + assert task.allow_skip is False + + def test_skip_unskippable_task_fails(self): + """Test that skipping a task with allow_skip=False fails""" + task = TaskFactory(project=self.project, allow_skip=False) + + self.client.force_authenticate(user=self.user) + response = self.client.post( + f'/api/tasks/{task.id}/annotations/', + data={'result': [], 'was_cancelled': True}, + format='json' + ) + + assert response.status_code == 400 + response_data = response.json() + assert 'cannot be skipped' in str(response_data).lower() + + def test_skip_skippable_task_succeeds(self): + """Test that skipping a task with allow_skip=True succeeds""" + task = TaskFactory(project=self.project, allow_skip=True) + + self.client.force_authenticate(user=self.user) + response = self.client.post( + f'/api/tasks/{task.id}/annotations/', + data={'result': [], 'was_cancelled': True}, + format='json' + ) + + assert response.status_code == 201 + + def test_skip_task_with_default_allow_skip_succeeds(self): + """Test that skipping a task without explicit allow_skip (defaults to True) succeeds""" + task = TaskFactory(project=self.project) # allow_skip defaults to True + + self.client.force_authenticate(user=self.user) + response = self.client.post( + f'/api/tasks/{task.id}/annotations/', + data={'result': [], 'was_cancelled': True}, + format='json' + ) + + assert response.status_code == 201 + + def test_bulk_import_tasks_with_allow_skip(self): + """Test that bulk importing tasks with allow_skip field works correctly""" + payload = [ + { + 'project': self.project.id, + 'data': {'text': 'skippable task'}, + 'meta': {}, + 'allow_skip': True, + }, + { + 'project': self.project.id, + 'data': {'text': 'unskippable task'}, + 'meta': {}, + 'allow_skip': False, + }, + { + 'project': self.project.id, + 'data': {'text': 'default task'}, + 'meta': {}, + # allow_skip not specified, should default to True + }, + ] + + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/tasks/', data=payload, format='json') + + assert response.status_code == 201 + response_data = response.json() + assert len(response_data) == 3 + + # Check first task (explicitly skippable) + assert response_data[0]['allow_skip'] is True + task1 = Task.objects.get(id=response_data[0]['id']) + assert task1.allow_skip is True + + # Check second task (explicitly unskippable) + assert response_data[1]['allow_skip'] is False + task2 = Task.objects.get(id=response_data[1]['id']) + assert task2.allow_skip is False + + # Check third task (defaults to True) + assert response_data[2]['allow_skip'] is True + task3 = Task.objects.get(id=response_data[2]['id']) + assert task3.allow_skip is True From 68eeec9ab1f7fae7ee3df03979829db54f8168fd Mon Sep 17 00:00:00 2001 From: Ignacio Velazquez Date: Fri, 28 Nov 2025 10:46:59 +0100 Subject: [PATCH 06/11] linter --- label_studio/tasks/tests/test_api.py | 12 ++----- .../Controls/__tests__/Controls.test.tsx | 1 - .../TopBar/__tests__/Controls.test.tsx | 31 ++++++++++++++++--- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/label_studio/tasks/tests/test_api.py b/label_studio/tasks/tests/test_api.py index ff3b7e36af45..86d081a051c2 100644 --- a/label_studio/tasks/tests/test_api.py +++ b/label_studio/tasks/tests/test_api.py @@ -161,9 +161,7 @@ def test_skip_unskippable_task_fails(self): self.client.force_authenticate(user=self.user) response = self.client.post( - f'/api/tasks/{task.id}/annotations/', - data={'result': [], 'was_cancelled': True}, - format='json' + f'/api/tasks/{task.id}/annotations/', data={'result': [], 'was_cancelled': True}, format='json' ) assert response.status_code == 400 @@ -176,9 +174,7 @@ def test_skip_skippable_task_succeeds(self): self.client.force_authenticate(user=self.user) response = self.client.post( - f'/api/tasks/{task.id}/annotations/', - data={'result': [], 'was_cancelled': True}, - format='json' + f'/api/tasks/{task.id}/annotations/', data={'result': [], 'was_cancelled': True}, format='json' ) assert response.status_code == 201 @@ -189,9 +185,7 @@ def test_skip_task_with_default_allow_skip_succeeds(self): self.client.force_authenticate(user=self.user) response = self.client.post( - f'/api/tasks/{task.id}/annotations/', - data={'result': [], 'was_cancelled': True}, - format='json' + f'/api/tasks/{task.id}/annotations/', data={'result': [], 'was_cancelled': True}, format='json' ) assert response.status_code == 201 diff --git a/web/libs/editor/src/components/Controls/__tests__/Controls.test.tsx b/web/libs/editor/src/components/Controls/__tests__/Controls.test.tsx index 29ad35359caa..4476be551952 100644 --- a/web/libs/editor/src/components/Controls/__tests__/Controls.test.tsx +++ b/web/libs/editor/src/components/Controls/__tests__/Controls.test.tsx @@ -127,4 +127,3 @@ describe("Controls", () => { expect(mockStore.skipTask).toHaveBeenCalled(); }); }); - diff --git a/web/libs/editor/src/components/TopBar/__tests__/Controls.test.tsx b/web/libs/editor/src/components/TopBar/__tests__/Controls.test.tsx index 291c4d63825a..b5876e535b3e 100644 --- a/web/libs/editor/src/components/TopBar/__tests__/Controls.test.tsx +++ b/web/libs/editor/src/components/TopBar/__tests__/Controls.test.tsx @@ -62,7 +62,15 @@ describe("TopBar Controls", () => { task: { id: 1, allow_skip: false }, interfaces: ["skip"], }); - const annotation = { id: "test", skipped: false, userGenerate: false, sentUserGenerate: false, versions: {}, results: [], editable: true }; + const annotation = { + id: "test", + skipped: false, + userGenerate: false, + sentUserGenerate: false, + versions: {}, + results: [], + editable: true, + }; const { getByLabelText } = render( @@ -82,7 +90,15 @@ describe("TopBar Controls", () => { task: { id: 1, allow_skip: true }, interfaces: ["skip"], }); - const annotation = { id: "test", skipped: false, userGenerate: false, sentUserGenerate: false, versions: {}, results: [], editable: true }; + const annotation = { + id: "test", + skipped: false, + userGenerate: false, + sentUserGenerate: false, + versions: {}, + results: [], + editable: true, + }; const { getByLabelText } = render( @@ -102,7 +118,15 @@ describe("TopBar Controls", () => { task: { id: 1, allow_skip: false }, interfaces: ["skip"], }); - const annotation = { id: "test", skipped: false, userGenerate: false, sentUserGenerate: false, versions: {}, results: [], editable: true }; + const annotation = { + id: "test", + skipped: false, + userGenerate: false, + sentUserGenerate: false, + versions: {}, + results: [], + editable: true, + }; const { getByLabelText } = render( @@ -116,4 +140,3 @@ describe("TopBar Controls", () => { expect(mockStore.skipTask).not.toHaveBeenCalled(); }); }); - From 9b869d744254d0ac7802cbd7260de0ed84b1bb28 Mon Sep 17 00:00:00 2001 From: Ignacio Velazquez Date: Fri, 28 Nov 2025 11:58:27 +0100 Subject: [PATCH 07/11] fixed migration --- label_studio/tasks/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/label_studio/tasks/models.py b/label_studio/tasks/models.py index 3f537a7f7c65..90f843731243 100644 --- a/label_studio/tasks/models.py +++ b/label_studio/tasks/models.py @@ -100,6 +100,7 @@ class Task(TaskMixin, FsmHistoryStateModel): allow_skip = models.BooleanField( _('allow_skip'), default=True, + null=True, help_text='Whether this task can be skipped. Set to False to make task unskippable.', ) overlap = models.IntegerField( From 0582e8895eff3f3fae0bd564ac6612c34cce8ffa Mon Sep 17 00:00:00 2001 From: Ignacio Velazquez Date: Fri, 28 Nov 2025 12:26:02 +0100 Subject: [PATCH 08/11] fixed tests --- label_studio/tasks/tests/test_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/label_studio/tasks/tests/test_api.py b/label_studio/tasks/tests/test_api.py index 86d081a051c2..d95433c1817b 100644 --- a/label_studio/tasks/tests/test_api.py +++ b/label_studio/tasks/tests/test_api.py @@ -94,6 +94,7 @@ def test_patch_task(self): 'comment_count': 0, 'last_comment_updated_at': None, 'unresolved_comment_count': 0, + 'allow_skip': True, } def test_create_task_without_project_id_fails(self): From 571753dc3a3a350aac23f01d460692eef218454b Mon Sep 17 00:00:00 2001 From: Ignacio Velazquez Date: Fri, 28 Nov 2025 12:40:42 +0100 Subject: [PATCH 09/11] fixed tests --- label_studio/tasks/tests/test_api.py | 45 ---------------------------- 1 file changed, 45 deletions(-) diff --git a/label_studio/tasks/tests/test_api.py b/label_studio/tasks/tests/test_api.py index d95433c1817b..9ee7c4935f11 100644 --- a/label_studio/tasks/tests/test_api.py +++ b/label_studio/tasks/tests/test_api.py @@ -190,48 +190,3 @@ def test_skip_task_with_default_allow_skip_succeeds(self): ) assert response.status_code == 201 - - def test_bulk_import_tasks_with_allow_skip(self): - """Test that bulk importing tasks with allow_skip field works correctly""" - payload = [ - { - 'project': self.project.id, - 'data': {'text': 'skippable task'}, - 'meta': {}, - 'allow_skip': True, - }, - { - 'project': self.project.id, - 'data': {'text': 'unskippable task'}, - 'meta': {}, - 'allow_skip': False, - }, - { - 'project': self.project.id, - 'data': {'text': 'default task'}, - 'meta': {}, - # allow_skip not specified, should default to True - }, - ] - - self.client.force_authenticate(user=self.user) - response = self.client.post('/api/tasks/', data=payload, format='json') - - assert response.status_code == 201 - response_data = response.json() - assert len(response_data) == 3 - - # Check first task (explicitly skippable) - assert response_data[0]['allow_skip'] is True - task1 = Task.objects.get(id=response_data[0]['id']) - assert task1.allow_skip is True - - # Check second task (explicitly unskippable) - assert response_data[1]['allow_skip'] is False - task2 = Task.objects.get(id=response_data[1]['id']) - assert task2.allow_skip is False - - # Check third task (defaults to True) - assert response_data[2]['allow_skip'] is True - task3 = Task.objects.get(id=response_data[2]['id']) - assert task3.allow_skip is True From 52e7c399d5f60bacc3a80a05e0425b0809430902 Mon Sep 17 00:00:00 2001 From: hakan458 Date: Mon, 1 Dec 2025 15:42:13 -0800 Subject: [PATCH 10/11] support allow_skip in cloud import --- label_studio/io_storages/base_models.py | 3 +++ .../io_storages/tests/test_multitask_import.py | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/label_studio/io_storages/base_models.py b/label_studio/io_storages/base_models.py index 137618e86321..e62884507ff8 100644 --- a/label_studio/io_storages/base_models.py +++ b/label_studio/io_storages/base_models.py @@ -448,6 +448,8 @@ def add_task(cls, project, maximum_annotations, max_inner_id, storage, link_obje link_kwargs = asdict(link_object) data = link_kwargs.pop('task_data', None) + allow_skip = data.get('allow_skip', None) + # predictions predictions = data.get('predictions') or [] if predictions: @@ -483,6 +485,7 @@ def add_task(cls, project, maximum_annotations, max_inner_id, storage, link_obje total_annotations=len(annotations) - cancelled_annotations, cancelled_annotations=cancelled_annotations, inner_id=max_inner_id, + allow_skip=(allow_skip if allow_skip is not None else True), ) # Save with skip_fsm flag to bypass FSM during bulk import task.save(skip_fsm=True) diff --git a/label_studio/io_storages/tests/test_multitask_import.py b/label_studio/io_storages/tests/test_multitask_import.py index 49c0be619c00..d1c8233c3e23 100644 --- a/label_studio/io_storages/tests/test_multitask_import.py +++ b/label_studio/io_storages/tests/test_multitask_import.py @@ -423,3 +423,15 @@ def test_list_jsonl_with_datetimes(storage): assert list(output) == expected_output create_tasks(storage, list(output)) + + +def test_allow_skip_false_is_saved(storage): + project, s3_storage = storage + task_data = { + 'data': {'text': 'Task with disallowed skip'}, + 'allow_skip': False, + } + params = StorageObject(key='test.json', task_data=task_data) + # Create one task via cloud import pathway + task = S3ImportStorage.add_task(project, 1, 1, s3_storage, params, S3ImportStorageLink) + assert task.allow_skip is False From 9f8c590abc49f74261ea4d971fe657f5661a1479 Mon Sep 17 00:00:00 2001 From: nass600 Date: Tue, 2 Dec 2025 09:14:48 +0000 Subject: [PATCH 11/11] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/19853196309 --- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index aeba24c9ba05..b2d06270dee7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "annotated-types" @@ -2131,7 +2131,7 @@ optional = false python-versions = ">=3.9,<4" groups = ["main"] files = [ - {file = "9b72a9525c98ecbf36c816d30365380033ee80e1.zip", hash = "sha256:95d6b0b968a779b6216a6c5bf94ae7461c3decf53066ebeee1442627f7ff51a1"}, + {file = "c6857e92b44143f1aba234471632891490777856.zip", hash = "sha256:6b8ca9a1c3c93c1d8726e0f1434f3faac410a6f98aee222419dd9e6b704e6605"}, ] [package.dependencies] @@ -2159,7 +2159,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/9b72a9525c98ecbf36c816d30365380033ee80e1.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/c6857e92b44143f1aba234471632891490777856.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5105,4 +5105,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "46f607014fe7c51f64b807475fac8edd5a2c7211d81da09fd1c99cf72f5a5b60" +content-hash = "b3a445d40bb867cedd338d0cc2e0042323754f1a41bf96f4cddba2332a0fff1d" diff --git a/pyproject.toml b/pyproject.toml index 56706357fd42..17737e6a6124 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "tldextract (>=5.1.3)", "uuid-utils (>=0.11.0,<1.0.0)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/9b72a9525c98ecbf36c816d30365380033ee80e1.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/c6857e92b44143f1aba234471632891490777856.zip", ## HumanSignal repo dependencies :end ]