Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 13 additions & 5 deletions label_studio/tasks/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": {},
Expand All @@ -536,7 +536,7 @@ def delete(self, request, *args, **kwargs):
"lead_time": 0,
"task": 0
"completed_by": 123
}
}
```
""",
parameters=[
Expand Down Expand Up @@ -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}
Expand Down
24 changes: 24 additions & 0 deletions label_studio/tasks/migrations/0060_add_allow_skip_to_task.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 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,
null=True,
help_text='Whether this task can be skipped. Set to False to make task unskippable.',
verbose_name='allow_skip',
),
),
]

6 changes: 6 additions & 0 deletions label_studio/tasks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ 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,
null=True,
help_text='Whether this task can be skipped. Set to False to make task unskippable.',
)
overlap = models.IntegerField(
_('overlap'),
default=1,
Expand Down
1 change: 1 addition & 0 deletions label_studio/tasks/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
67 changes: 67 additions & 0 deletions label_studio/tasks/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -92,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):
Expand Down Expand Up @@ -123,3 +126,67 @@ 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
9 changes: 9 additions & 0 deletions web/libs/datamanager/src/sdk/lsf-sdk.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions web/libs/datamanager/src/stores/DataStores/tasks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion web/libs/editor/src/components/BottomBar/CurrentTask.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={cn("bottombar").elem("section").toClassName()}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const mockStore = {
settings: {
enableTooltips: true,
},
task: { id: 1, allow_skip: true },
skipTask: jest.fn(),
commentStore: {
currentComment: {
Expand Down Expand Up @@ -119,4 +120,49 @@ describe("Controls", () => {
await expect(mockStore.commentStore.commentFormSubmit).toHaveBeenCalled();
expect(mockStore.skipTask).toHaveBeenCalled();
});

test("Skip button disabled when allow_skip=false", () => {
mockStore.hasInterface = (a: string) => a === "skip";
mockStore.task = { id: 1, allow_skip: false };

const { getByLabelText } = render(
<Provider store={mockStore}>
<Controls history={mockHistory} annotation={mockAnnotation} />
</Provider>,
);

const skipTask = getByLabelText("skip-task");
expect(skipTask).toBeDisabled();
});

test("Skip button enabled when allow_skip=true", () => {
mockStore.hasInterface = (a: string) => a === "skip";
mockStore.task = { id: 1, allow_skip: true };

const { getByLabelText } = render(
<Provider store={mockStore}>
<Controls history={mockHistory} annotation={mockAnnotation} />
</Provider>,
);

const skipTask = getByLabelText("skip-task");
expect(skipTask).not.toBeDisabled();
});

test("Skip action blocked when allow_skip=false", () => {
mockStore.hasInterface = (a: string) => a === "skip";
mockStore.task = { id: 1, allow_skip: false };
mockStore.skipTask.mockClear();

const { getByLabelText } = render(
<Provider store={mockStore}>
<Controls history={mockHistory} annotation={mockAnnotation} />
</Provider>,
);

const skipTask = getByLabelText("skip-task");
fireEvent.click(skipTask);

expect(mockStore.skipTask).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe("CurrentTask", () => {
annotationId: null,
},
],
task: { id: 6616 },
task: { id: 6616, allow_skip: true, allow_postpone: true },
commentStore: {
loading: "list",
comments: [],
Expand Down Expand Up @@ -112,4 +112,60 @@ 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(<CurrentTask store={store} />);

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(<CurrentTask store={store} />);

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(<CurrentTask store={store} />);

expect(getByTestId("next-task").disabled).toBe(false);
});

it("disables postpone button when both allow_skip=false and allow_postpone=false", () => {
store.hasInterface.mockImplementation((interfaceName: string) =>
["skip", "postpone", "topbar:prevnext", "topbar:task-counter"].includes(interfaceName),
);
store.task = { id: 6616, allow_skip: false, allow_postpone: false };

const { getByTestId } = render(<CurrentTask store={store} />);

expect(getByTestId("next-task").disabled).toBe(true);
});

it("enables postpone button when canGoNextTask=true regardless of allow_skip", () => {
store.hasInterface.mockImplementation((interfaceName: string) =>
["skip", "postpone", "topbar:prevnext", "topbar:task-counter"].includes(interfaceName),
);
store.task = { id: 6616, allow_skip: false, allow_postpone: true };
store.canGoNextTask = true;

const { getByTestId } = render(<CurrentTask store={store} />);

expect(getByTestId("next-task").disabled).toBe(false);
});
});
Loading
Loading